Teseria
All elements

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

Terminal
npm i framer-motion lucide-react

2. Files

SearchBar.tsx194 lines
"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>
  );
}
SearchBar.css144 lines
/* 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);
}
tokens.cssshared · add once
/*
 * 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

Demo.tsx
import { SearchBar } from "./SearchBar";
import "./SearchBar.css";

export default function Demo() {
  return <SearchBar />;
}