Search bar
A pill that grows slightly while focused. An idle placeholder cycles through the given phrases, the hotkey (⌘K or Ctrl+K) focuses it from anywhere, and a clear button replaces the hotkey hint once there is text.
Code
1. Install
npm i framer-motion lucide-react
2. Files
"use client";
// SearchBar: a pill that grows slightly while focused. An idle placeholder cycles through the given phrases, the
// hotkey (⌘K or Ctrl+K) focuses it from anywhere, and a clear button replaces the hotkey hint once there is text.
import { useEffect, useRef, useState, useSyncExternalStore, type CSSProperties, type KeyboardEvent, type RefObject } from "react";
import { AnimatePresence, MotionConfig, motion } from "framer-motion";
import { Search, X } from "lucide-react";
export type SearchBarCue = "push" | "crumple";
export type SearchBarProps = {
/** Controlled text. */
value?: string;
/** Initial text when uncontrolled. */
defaultValue?: string;
/** Called on every edit and on clear. */
onChange?: (v: string) => void;
/** Called with the trimmed text on Enter. */
onSubmit?: (v: string) => void;
/** Phrases the placeholder cycles through while the field is empty and idle. */
placeholders?: string[];
/** Show the hotkey hint and focus the field on ⌘K / Ctrl+K. */
kbd?: boolean;
/** Field width in px. */
width?: number;
/** Accessible name of the field. */
"aria-label"?: string;
/** Accessible name of the clear button. */
clearLabel?: string;
/** Sound hook: "push" on focus and on submit, "crumple" on clear. */
onSound?: (cue: SearchBarCue) => void;
className?: string;
style?: CSSProperties;
};
const PHRASES = ["Search projects…", "Try “invoices”", "Jump to a workspace"];
const CYCLE_MS = 2600;
const settle = [0.22, 1, 0.36, 1] as const;
const join = (...parts: (string | false | undefined)[]) => parts.filter(Boolean).join(" ");
const onMac = () => /Mac|iPhone|iPad/.test(navigator.platform);
const never = () => () => {};
/** Reports whether an element is within 160px of the viewport while the tab is visible. */
function useVisible(target: RefObject<Element | null>) {
const [seen, setSeen] = useState(false);
useEffect(() => {
const node = target.current;
if (!node) return;
if (!("IntersectionObserver" in window)) return setSeen(true);
let inside = false;
const update = () => setSeen(inside && !document.hidden);
const watcher = new IntersectionObserver((list) => {
inside = list[list.length - 1].isIntersecting;
update();
}, { rootMargin: "160px" });
watcher.observe(node);
document.addEventListener("visibilitychange", update);
return () => {
watcher.disconnect();
document.removeEventListener("visibilitychange", update);
};
}, [target]);
return seen;
}
export function SearchBar({ value, defaultValue = "", onChange, onSubmit, placeholders = PHRASES, kbd = true, width = 280, "aria-label": label = "Search", clearLabel = "Clear search", onSound, className, style }: SearchBarProps) {
const [draft, setDraft] = useState(defaultValue);
const text = value ?? draft;
const [focused, setFocused] = useState(false);
const [phrase, setPhrase] = useState(0);
const field = useRef<HTMLInputElement>(null);
const shell = useRef<HTMLDivElement>(null);
const visible = useVisible(shell);
const mac = useSyncExternalStore(never, onMac, () => false);
const soundRef = useRef(onSound);
soundRef.current = onSound;
const refocus = useRef(false); // clearing re-focuses the field: that focus stays silent
const write = (next: string) => {
if (value === undefined) setDraft(next);
onChange?.(next);
};
const clear = () => {
write("");
soundRef.current?.("crumple");
};
// rotate the placeholder only while it can be seen and nobody is typing
const rotating = visible && !text && !focused && placeholders.length > 1;
useEffect(() => {
if (!rotating) return;
const timer = setInterval(() => setPhrase((n) => (n + 1) % placeholders.length), CYCLE_MS);
return () => clearInterval(timer);
}, [rotating, placeholders.length]);
useEffect(() => {
if (!kbd) return;
const listen = (e: globalThis.KeyboardEvent) => {
if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== "k") return;
e.preventDefault();
field.current?.focus();
field.current?.select();
};
window.addEventListener("keydown", listen);
return () => window.removeEventListener("keydown", listen);
}, [kbd]);
const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
const trimmed = text.trim();
switch (e.key) {
case "Enter":
if (trimmed) {
soundRef.current?.("push");
onSubmit?.(trimmed);
}
break;
case "Escape":
if (text) clear();
else field.current?.blur();
break;
}
};
return (
<MotionConfig reducedMotion="user">
<div ref={shell} className={join("tsch-root", className)} data-focus={focused || undefined} data-filled={text ? "" : undefined} style={{ ...style, width }} onClick={() => field.current?.focus()}>
<Search className="tsch-glass" size={16} strokeWidth={2} aria-hidden />
<div className="tsch-well">
<input
ref={field}
className="tsch-in"
type="search"
role="searchbox"
aria-label={label}
value={text}
autoComplete="off"
spellCheck={false}
onChange={(e) => write(e.target.value)}
onFocus={() => {
setFocused(true);
if (!refocus.current) soundRef.current?.("push");
}}
onBlur={() => setFocused(false)}
onKeyDown={onKey}
/>
{!text && placeholders.length > 0 && (
<span className="tsch-ph" aria-hidden>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={phrase}
initial={{ opacity: 0, y: 10, filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)", transitionEnd: { filter: "none" } }}
exit={{ opacity: 0, y: -10, filter: "blur(4px)" }}
transition={{ duration: 0.22, ease: settle }}
>
{placeholders[phrase % placeholders.length]}
</motion.span>
</AnimatePresence>
</span>
)}
</div>
<AnimatePresence initial={false}>
{text ? (
<motion.button
key="clear"
type="button"
className="tsch-x"
aria-label={clearLabel}
initial={{ opacity: 0, scale: 0.6, filter: "blur(3px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)", transitionEnd: { filter: "none" } }}
exit={{ opacity: 0, scale: 0.6, filter: "blur(3px)", transition: { duration: 0.12 } }}
transition={{ type: "spring", stiffness: 500, damping: 22, mass: 0.6, opacity: { duration: 0.14 } }}
onPointerDown={(e) => e.preventDefault()}
onClick={() => {
clear();
refocus.current = true;
field.current?.focus();
refocus.current = false;
}}
>
<X size={13} strokeWidth={2.4} />
</motion.button>
) : (
kbd && (
<motion.kbd key="hint" className="tsch-kbd" initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.8, transition: { duration: 0.12 } }} transition={{ duration: 0.16 }}>
{mac ? "⌘" : "Ctrl"} K
</motion.kbd>
)
)}
</AnimatePresence>
</div>
</MotionConfig>
);
}
/* basics: the host app may have none */
:where(.tsch-root),
:where(.tsch-root) *,
:where(.tsch-root) *::before,
:where(.tsch-root) *::after {
box-sizing: border-box;
}
.tsch-root {
font-family: var(--ts-font, system-ui, sans-serif);
}
:where(.tsch-root button) {
font: inherit;
color: inherit;
background: none;
border: 0;
padding: 0;
margin: 0;
}
:where(.tsch-root input) {
font: inherit;
color: inherit;
}
:where(.tsch-root) :focus-visible {
outline: 2px solid var(--ts-focus, #3446eb);
outline-offset: 2px;
}
.tsch-root {
position: relative;
display: flex;
align-items: center;
gap: 9px;
height: 44px;
padding: 0 10px 0 14px;
border-radius: 999px;
background: var(--ts-raised);
color: var(--ts-fg);
box-shadow: var(--ts-edge);
cursor: text;
transition:
box-shadow 0.22s cubic-bezier(.22, 1, .36, 1),
scale 0.42s cubic-bezier(.22, 1, .36, 1);
}
.tsch-root[data-focus] {
box-shadow: 0 0 0 1.5px var(--ts-fg);
scale: 1.02;
}
.tsch-glass {
flex: none;
color: color-mix(
in srgb,
var(--ts-fg) 46%,
var(--ts-raised)
);
transition:
color 0.16s cubic-bezier(.22, 1, .36, 1),
rotate 0.42s cubic-bezier(.24, 1.34, .38, 1);
}
.tsch-root[data-focus] .tsch-glass {
color: var(--ts-fg);
rotate: -12deg;
}
.tsch-well {
position: relative;
flex: 1;
min-width: 0;
height: 100%;
display: flex;
align-items: center;
}
.tsch-in {
width: 100%;
height: 100%;
border: 0;
padding: 0;
background: transparent;
color: inherit;
font: inherit;
font-size: 14px;
letter-spacing: -0.01em;
outline: 0;
appearance: none;
}
.tsch-in::-webkit-search-cancel-button,
.tsch-in::-webkit-search-decoration {
display: none;
}
.tsch-ph {
position: absolute;
left: 0;
top: 0;
bottom: 0;
display: grid;
align-items: center;
pointer-events: none;
font-size: 14px;
letter-spacing: -0.01em;
color: color-mix(
in srgb,
var(--ts-fg) 42%,
var(--ts-raised)
);
overflow: hidden;
white-space: nowrap;
}
.tsch-ph > span {
grid-area: 1 / 1;
display: block;
}
.tsch-kbd {
flex: none;
height: 24px;
padding: 0 7px;
border-radius: 7px;
display: inline-flex;
align-items: center;
gap: 2px;
font: inherit;
font-size: 12px;
font-weight: 500;
color: color-mix(
in srgb,
var(--ts-fg) 52%,
var(--ts-raised)
);
background: rgba(var(--ts-fg-rgb), 0.06);
}
.tsch-x {
flex: none;
width: 24px;
height: 24px;
border: 0;
border-radius: 999px;
padding: 0;
display: grid;
place-items: center;
background: rgba(var(--ts-fg-rgb), 0.08);
color: var(--ts-fg);
cursor: pointer;
transition: background 0.16s cubic-bezier(.22, 1, .36, 1);
}
.tsch-x:hover {
background: rgba(var(--ts-fg-rgb), 0.14);
}
/*
* Teseria tokens — the only colours, fonts and corner radii a block reads.
*
* Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
* the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
* any ancestor, or the OS setting unless [data-theme="light"] is set.
*
* Surfaces page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
* Text fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
* Lines border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
* dark; composes with other shadows)
* Signals accent · focus · success · warning · danger
* Corners controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
* (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
*/
:root {
--ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ts-r-panel: 24px;
--ts-r-pop: 18px;
--ts-r-inner: 12px;
--ts-page: #ffffff;
--ts-raised: #ffffff;
--ts-sunken: #f3f2f0;
--ts-tint-1: #f5f4f2;
--ts-tint-2: #ecebe8;
--ts-tint-3: #e3e1dd;
--ts-fg: #161514;
--ts-fg-rgb: 22, 21, 20;
--ts-fg-secondary: #3a3836;
--ts-fg-tertiary: #585552;
--ts-fg-muted: #6d6a66;
--ts-fg-faint: #89857f;
--ts-fg-inverse: #ffffff;
--ts-border: rgba(22, 21, 20, 0.1);
--ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);
--ts-accent: #3446eb;
--ts-focus: #3446eb;
--ts-success: #13804a;
--ts-warning: #a9650b;
--ts-danger: #df474e;
}
:root[data-theme="dark"] {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
}
3. Usage
import { SearchBar } from "./SearchBar";
import "./SearchBar.css";
export default function Demo() {
return <SearchBar />;
}