Skip to content

Expandable Search Bar

A search icon that springs open into a full field with a suggestions dropdown: recent searches, live filtering, arrow-key navigation, / and Cmd+K to open, Escape to collapse. A real ARIA combobox, no dependencies.

DOM + CSS0 deps1 file · 16 KB TSXreduced-motion aware

Freeminimalglass
Preview

Props

240
No runtime dependencies
"use client";

import { Fragment, useEffect, useId, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";

type Tone = "dark" | "light";
type Item = { label: string; kind: "recent" | "suggestion" | "query" };

const NO_ITEMS: string[] = [];

// Damped spring (stiffness 320, damping 28, mass 1) sampled into a CSS linear() easing.
function springEasing(): string {
  const w0 = Math.sqrt(320);
  const z = 28 / (2 * w0);
  const wd = w0 * Math.sqrt(1 - z * z);
  const pts: string[] = [];
  const n = 40;
  for (let i = 0; i <= n; i++) {
    const t = (i / n) * 0.6;
    const v = 1 - Math.exp(-z * w0 * t) * (Math.cos(wd * t) + ((z * w0) / wd) * Math.sin(wd * t));
    pts.push((i === n ? 1 : v).toFixed(3));
  }
  return "linear(" + pts.join(", ") + ")";
}

const CSS = [
  ".esb-w{transition:width 520ms cubic-bezier(.3,1.25,.5,1)}",
  "@supports (transition-timing-function:linear(0,1)){.esb-w{transition-timing-function:" + springEasing() + "}}",
  '.esb-w[data-open="false"]{transition-duration:340ms;transition-timing-function:cubic-bezier(.3,.7,.2,1)}',
  ".esb-pill{box-shadow:inset 0 0 0 1px color-mix(in srgb,currentColor 16%,transparent);transition:box-shadow 200ms ease,background-color 200ms ease}",
  ".esb-pill:hover{box-shadow:inset 0 0 0 1px color-mix(in srgb,currentColor 28%,transparent)}",
  ".esb-pill:focus-within{box-shadow:inset 0 0 0 1px color-mix(in srgb,currentColor 48%,transparent),0 0 0 4px color-mix(in srgb,currentColor 11%,transparent)}",
  ".esb-in{opacity:0;transition:opacity 120ms ease}",
  '.esb-w[data-open="true"] .esb-in{opacity:1;transition:opacity 220ms ease 130ms}',
  ".esb-pop{transform-origin:top left;animation:esb-pop 220ms cubic-bezier(.2,.8,.2,1) both}",
  ".esb-row{animation:esb-row 240ms cubic-bezier(.2,.8,.2,1) both;animation-delay:calc(var(--i,0)*24ms + 40ms)}",
  "@keyframes esb-pop{from{opacity:0;transform:translateY(-6px) scale(.97)}to{opacity:1;transform:none}}",
  "@keyframes esb-row{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}",
  "@keyframes esb-fade{from{opacity:0}to{opacity:1}}",
  "@media (prefers-reduced-motion:reduce){.esb-w,.esb-w[data-open]{transition:none}.esb-in,.esb-w[data-open=true] .esb-in{transition:none}.esb-pop,.esb-row{animation-name:esb-fade;animation-duration:140ms;animation-delay:0s}}",
].join("\n");

const TONES: Record<Tone, { bg: string; fg: string; shadow: string }> = {
  dark: {
    bg: "#0c0d10",
    fg: "#eceef2",
    shadow: "inset 0 0 0 1px rgba(255,255,255,0.09), 0 24px 48px -12px rgba(0,0,0,0.6), 0 2px 6px rgba(0,0,0,0.4)",
  },
  light: {
    bg: "#ffffff",
    fg: "#16181d",
    shadow: "inset 0 0 0 1px rgba(0,0,0,0.08), 0 20px 40px -12px rgba(15,20,30,0.22), 0 2px 6px rgba(15,20,30,0.08)",
  },
};

const MIX = (pct: number) => "color-mix(in srgb, currentColor " + pct + "%, transparent)";

function Icon({ d, size = 16, className }: { d: string; size?: number; className?: string }) {
  return (
    <svg
      aria-hidden
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
    >
      <path d={d} />
    </svg>
  );
}
const SEARCH = "M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16ZM21 21l-4.3-4.3";
const CLOSE = "M18 6 6 18M6 6l12 12";
const ENTER = "M20 5v6a3 3 0 0 1-3 3H5M9 10l-4 4 4 4";
const CLOCK ="M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18ZM12 7v5l3 2";

function Match({ text, q }: { text: string; q: string }) {
  const i = q ? text.toLowerCase().indexOf(q) : -1;
  if (i < 0) return <>{text}</>;
  return (
    <>
      {text.slice(0, i)}
      <span style={{ color: "currentColor" }} className="font-medium">
        {text.slice(i, i + q.length)}
      </span>
      {text.slice(i + q.length)}
    </>
  );
}

export function ExpandableSearchBar({
  placeholder = "Search...",
  width = 240,
  suggestions = NO_ITEMS,
  recent = NO_ITEMS,
  onSearch,
  onQueryChange,
  defaultOpen = false,
  shortcut = false,
  panel = "dark",
  className = "",
}: {
  placeholder?: string;
  width?: number;
  /** Suggestions filtered as you type; shown under the field. */
  suggestions?: string[];
  /** Recent searches shown first while the field is empty (submitted searches are added). */
  recent?: string[];
  onSearch?: (query: string) => void;
  onQueryChange?: (query: string) => void;
  defaultOpen?: boolean;
  /** Press / or Cmd/Ctrl+K anywhere on the page to open and focus the field. */
  shortcut?: boolean;
  /** Surface of the suggestions panel. The field itself inherits currentColor. */
  panel?: Tone;
  className?: string;
}) {
  const [open, setOpen] = useState(defaultOpen);
  const [query, setQuery] = useState("");
  const [recents, setRecents] = useState<string[]>(recent);
  const [listOpen, setListOpen] = useState(defaultOpen);
  const [active, setActive] = useState(-1);
  const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
  const listId = "esb" + uid + "list";
  const rootRef = useRef<HTMLDivElement>(null);
  const fieldRef = useRef<HTMLDivElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const triggerRef = useRef<HTMLButtonElement>(null);
  const wantFocus = useRef(false);
  const refocusTrigger = useRef(false);

  useEffect(() => {
    fieldRef.current?.toggleAttribute("inert", !open);
    if (open && wantFocus.current) {
      wantFocus.current = false;
      inputRef.current?.focus({ preventScroll: true });
    }
    if (!open && refocusTrigger.current) {
      refocusTrigger.current = false;
      triggerRef.current?.focus({ preventScroll: true });
    }
  }, [open]);

  useEffect(() => {
    if (!shortcut) return;
    const onKey = (e: KeyboardEvent) => {
      const t = e.target as HTMLElement | null;
      const typing = !!t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName));
      const mod = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k";
      const slash = e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey && !typing;
      if (!mod && !slash) return;
      e.preventDefault();
      setListOpen(true);
      if (fieldRef.current && !fieldRef.current.hasAttribute("inert")) {
        inputRef.current?.focus({ preventScroll: true });
        inputRef.current?.select();
        return;
      }
      wantFocus.current = true;
      setOpen(true);
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [shortcut]);

  const q = query.trim().toLowerCase();
  const items: Item[] = [];
  if (!q) {
    for (const r of recents.slice(0, 3)) items.push({ label: r, kind: "recent" });
    for (const s of suggestions) {
      if (items.length >= 5) break;
      if (!recents.includes(s)) items.push({ label: s, kind: "suggestion" });
    }
  } else {
    const seen = new Set<string>();
    for (const r of recents) {
      if (r.toLowerCase().includes(q) && !seen.has(r)) {
        seen.add(r);
        items.push({ label: r, kind: "recent" });
      }
    }
    for (const s of suggestions) {
      if (s.toLowerCase().includes(q) && !seen.has(s)) {
        seen.add(s);
        items.push({ label: s, kind: "suggestion" });
      }
    }
    items.splice(5);
    items.push({ label: query.trim(), kind: "query" });
  }
  const shown = open && listOpen && items.length > 0;
  const act = active < items.length ? active : -1;
  const tone = TONES[panel] ?? TONES.dark;

  function commit(label: string) {
    const v = label.trim();
    if (!v) return;
    setQuery(v);
    setRecents((r) => [v, ...r.filter((x) => x !== v)].slice(0, 5));
    setListOpen(false);
    setActive(-1);
    onSearch?.(v);
  }

  function collapse(refocus: boolean) {
    setOpen(false);
    setQuery("");
    setListOpen(false);
    setActive(-1);
    refocusTrigger.current = refocus;
    onQueryChange?.("");
  }

  function onKeyDown(e: ReactKeyboardEvent<HTMLInputElement>) {
    if (e.nativeEvent.isComposing) return;
    if (e.key === "ArrowDown" || e.key === "ArrowUp") {
      e.preventDefault();
      if (!shown) {
        setListOpen(true);
        return;
      }
      const n = items.length;
      setActive(e.key === "ArrowDown" ? (act + 1) % n : act <= 0 ? n - 1 : act - 1);
    } else if (e.key === "Enter") {
      e.preventDefault();
      const it = act >= 0 ? items[act] : undefined;
      commit(it ? it.label : query);
    } else if (e.key === "Escape") {
      e.preventDefault();
      collapse(true);
    }
  }

  const headerStyle = { color: MIX(48) };
  let lastKind = "";

  return (
    <div
      ref={rootRef}
      role="search"
      data-open={open}
      className={"esb-w relative h-11 shrink-0 " + className}
      style={{ width: open ? width : 44 }}
      onBlur={(e) => {
        if (!rootRef.current?.contains(e.relatedTarget as Node | null)) setListOpen(false);
      }}
    >
      <style>{CSS}</style>
      <div className="esb-pill absolute inset-0 overflow-hidden rounded-full" style={{ background: MIX(5) }}>
        {open ? (
          <span aria-hidden className="pointer-events-none absolute left-[14px] top-1/2 -mt-2 opacity-70">
            <Icon d={SEARCH} />
          </span>
        ) : (
          <button
            ref={triggerRef}
            type="button"
            title="Search"
            aria-label="Open search"
            aria-expanded={false}
            aria-keyshortcuts={shortcut ? "/ Control+K Meta+K" : undefined}
            onClick={() => {
              wantFocus.current = true;
              setListOpen(true);
              setOpen(true);
            }}
            className="group absolute inset-0 flex cursor-pointer items-center rounded-full bg-transparent pl-[14px] text-inherit focus-visible:!rounded-full focus-visible:!outline-2 focus-visible:!outline-offset-2 focus-visible:!outline-current"
          >
            <Icon
              d={SEARCH}
              className="opacity-70 transition-[opacity,transform] duration-200 group-hover:opacity-100 group-active:scale-90"
            />
          </button>
        )}
        <div ref={fieldRef} className="esb-in absolute inset-y-0 flex items-center" style={{ left: 40, width: Math.max(0, width - 40) }}>
          <input
            ref={inputRef}
            type="text"
            role="combobox"
            aria-label={placeholder}
            aria-expanded={shown}
            aria-controls={listId}
            aria-autocomplete="list"
            aria-activedescendant={shown && act >= 0 ? "esb" + uid + "o" + act : undefined}
            enterKeyHint="search"
            autoComplete="off"
            spellCheck={false}
            placeholder={placeholder}
            value={query}
            onChange={(e) => {
              setQuery(e.target.value);
              setListOpen(true);
              setActive(-1);
              onQueryChange?.(e.target.value);
            }}
            onFocus={() => setListOpen(true)}
            onKeyDown={onKeyDown}
            style={{ color: "inherit" }}
            className="h-full min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-current placeholder:opacity-50 focus-visible:!outline-none [@media(pointer:coarse)]:text-[16px]"
          />
          <button
            type="button"
            aria-label={query ? "Clear search" : "Close search"}
            onClick={() => {
              if (query) {
                setQuery("");
                setActive(-1);
                onQueryChange?.("");
                inputRef.current?.focus({ preventScroll: true });
              } else collapse(true);
            }}
            className="mr-[6px] flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full bg-transparent text-inherit opacity-60 transition-[opacity,background-color] duration-150 hover:opacity-100 focus-visible:!rounded-full focus-visible:!opacity-100 focus-visible:!outline-2 focus-visible:!outline-offset-0 focus-visible:!outline-current [@media(pointer:coarse)]:size-10"
            onMouseEnter={(e) => (e.currentTarget.style.background = MIX(12))}
            onMouseLeave={(e) => (e.currentTarget.style.background = "")}
          >
            <Icon d={CLOSE} size={14} />
          </button>
        </div>
      </div>

      {shown ? (
        <div
          className="esb-pop absolute left-0 top-full z-20 mt-2 w-full min-w-[240px] max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl"
          style={{ background: tone.bg, color: tone.fg, boxShadow: tone.shadow }}
        >
          <ul id={listId} role="listbox" aria-label="Search suggestions" className="m-0 list-none p-1.5">
            {items.map((it, i) => {
              const head = !q && it.kind !== lastKind && it.kind !== "query";
              lastKind = it.kind;
              return (
                <Fragment key={it.kind + it.label}>
                  {head ? (
                    <div
                      aria-hidden
                      className="esb-row px-2.5 pb-1 pt-2 text-[11px] font-medium uppercase tracking-[0.14em]"
                      style={{ ...headerStyle, ["--i" as string]: i }}
                    >
                      {it.kind === "recent" ? "Recent" : "Suggested"}
                    </div>
                  ) : null}
                  <div
                    role="option"
                    id={"esb" + uid + "o" + i}
                    aria-selected={act === i}
                    onMouseDown={(e) => e.preventDefault()}
                    onMouseMove={() => {
                      if (act !== i) setActive(i);
                    }}
                    onClick={() => commit(it.label)}
                    className="esb-row flex h-9 cursor-pointer items-center gap-2.5 rounded-[10px] px-2.5 text-[13.5px] transition-colors duration-100"
                    style={{ background: act === i ? MIX(9) : undefined, ["--i" as string]: i }}
                  >
                    <Icon d={it.kind === "recent" ? CLOCK : SEARCH} size={14} className="shrink-0 opacity-50" />
                    <span className="min-w-0 flex-1 truncate" style={{ color: MIX(it.kind === "query" ? 70 : 62) }}>
                      {it.kind === "query" ? (
                        <>
                          Search for <span style={{ color: "currentColor" }}>“{it.label}”</span>
                        </>
                      ) : (
                        <Match text={it.label} q={q} />
                      )}
                    </span>
                    {act === i ? (
                      <kbd
                        aria-hidden
                        className="shrink-0 rounded-[5px] px-1.5 font-sans text-[11px] leading-[18px] opacity-70 [@media(pointer:coarse)]:hidden"
                        style={{ boxShadow: "inset 0 0 0 1px " + MIX(20) }}
                      >
                        <Icon d={ENTER} size={11} className="inline" />
                      </kbd>
                    ) : null}
                  </div>
                </Fragment>
              );
            })}
          </ul>
          <div
            aria-hidden
            className="flex items-center gap-4 px-3.5 py-2 text-[11px] [@media(pointer:coarse)]:hidden"
            style={{ color: MIX(46), boxShadow: "inset 0 1px 0 " + MIX(8) }}
          >
            <span className="inline-flex items-center gap-1.5">
              <Icon d="M12 19V5M5 12l7-7 7 7" size={11} />
              <Icon d="M12 5v14M19 12l-7 7-7-7" size={11} />
              navigate
            </span>
            <span className="inline-flex items-center gap-1.5">
              <Icon d={ENTER} size={11} />
              select
            </span>
            <span>esc close</span>
          </div>
        </div>
      ) : null}
      <span role="status" className="sr-only">
        {shown ? items.length + (items.length === 1 ? " suggestion" : " suggestions") + " available" : ""}
      </span>
    </div>
  );
}

Free. Pro unlocks everything, from $27/month.

Details

About this component

A 44px circle holding a search icon. Click it, press / or press Cmd/Ctrl+K and the box widens on a damped spring (stiffness 320, damping 28, sampled into a CSS linear() easing, so it overshoots a few pixels and settles) while the field fades in behind it. The input keeps the same width inside the clipping pill, so nothing reflows during the animation, and it is inert while collapsed, so a hidden field can never be tabbed to. Below the field a suggestions panel opens: while the field is empty it shows your recent searches and a few suggestions; as you type it filters both, highlights the matching text, and ends with a “Search for …” row. Arrow keys move through the rows, Enter picks one (or submits what you typed), Escape collapses and returns focus to the icon. The right-hand button clears the text first and closes on the second press. Submitted searches are remembered as recents for the session and reported through onSearch. The field inherits currentColor, so it works on light and dark pages; the panel is dark by default and panel=“light” flips it. Pass suggestions, recent, defaultOpen and shortcut to wire it up; with none of them it behaves like the original icon-to-input control.

Curator’s note

Expandable Search Bar, rebuilt in October. The first version animated a pill with Framer's layout prop; this one is plain React and CSS, adds a real suggestions combobox, and opens from the keyboard.

Pairs well with

Pro