Command Palette with Spring Highlight and Live Filtering

A command palette shown over a warm, editorial app screen. Typing filters the list live: rows that no longer match fade out and the rest slide up into place. One dark highlight moves between rows on a spring as you press the arrow keys or move the pointer, matched letters are underlined, and running a command turns the row green with a tick. An idle demo types three searches until someone presses a key. On phones the palette fills the width and the shortcut keys are hidden.

Code

import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "framer-motion";
import { ArrowDown, ArrowUp, Check, Clock, CornerDownLeft, Download, FilePlus, FileText, Inbox, LogOut, MapPin, Moon, Search, UserPlus, Users } from "lucide-react";

const commands = [
  { id: "create-invoice", title: "Create invoice", group: "Billing", icon: FilePlus, keys: ["C", "I"] },
  { id: "invite", title: "Invite a teammate", group: "Team", icon: UserPlus, keys: ["I", "T"] },
  { id: "open-invoices", title: "Open invoices", group: "Go to", icon: FileText, keys: ["G", "I"] },
  { id: "open-inbox", title: "Open inbox", group: "Go to", icon: Inbox, keys: ["G", "N"] },
  { id: "dark", title: "Switch to dark theme", group: "Appearance", icon: Moon, keys: ["T", "D"] },
  { id: "clients", title: "Search clients", group: "Go to", icon: Users, keys: ["G", "C"] },
  { id: "recent", title: "View invoice 1048, Brightwater Dental", group: "Recent", icon: Clock, keys: [] },
  { id: "export", title: "Export this month as a spreadsheet", group: "Reports", icon: Download, keys: ["E"] },
  { id: "address", title: "Change billing address", group: "Settings", icon: MapPin, keys: [] },
  { id: "logout", title: "Log out", group: "Account", icon: LogOut, keys: [] },
];

// The idle demo: [wait in ms, then set this query | "down" | "run"]. It stops at the first real key or click.
const demoSteps: [number, { query: string } | "down" | "run"][] = [
  [650, { query: "i" }], [110, { query: "in" }], [120, { query: "inv" }], [420, "down"], [520, "down"], [620, "run"],
  [1500, { query: "" }], [600, { query: "d" }], [100, { query: "da" }], [100, { query: "dar" }], [110, { query: "dark" }], [800, "run"],
  [1500, { query: "" }], [600, { query: "o" }], [110, { query: "op" }], [110, { query: "ope" }], [110, { query: "open" }], [500, "down"], [700, "run"],
  [1500, { query: "" }], [500, "down"], [350, "down"], [350, "down"], [350, "down"], [900, { query: "" }],
];

const rowSpring = { type: "spring", stiffness: 520, damping: 40 } as const;

export default function CommandPaletteWithSpringHighlightAndLiveFiltering() {
  const reduce = useReducedMotion();
  const rootRef = useRef<HTMLElement>(null);
  const listRef = useRef<HTMLUListElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const inView = useInView(rootRef, { amount: 0.3 });
  const [open, setOpen] = useState(true);
  const [query, setQuery] = useState("");
  const [active, setActive] = useState(0);
  const [ran, setRan] = useState<string | null>(null);
  const [demo, setDemo] = useState(true);
  const [step, setStep] = useState(0);

  const needle = query.trim().toLowerCase();
  const results = commands.filter((c) => c.title.toLowerCase().includes(needle));
  const current = results[Math.min(active, results.length - 1)];

  const move = (by: number) => results.length && setActive((i) => (Math.min(i, results.length - 1) + by + results.length) % results.length);
  const run = () => current && setRan(current.id);
  const type = (text: string) => {
    setQuery(text);
    setActive(0);
    setRan(null);
  };

  // Idle demo.
  useEffect(() => {
    if (!demo || !inView || reduce || !open) return;
    const [wait, action] = demoSteps[step % demoSteps.length];
    const timer = setTimeout(() => {
      if (action === "down") move(1);
      else if (action === "run") run();
      else type(action.query);
      setStep((s) => s + 1);
    }, wait);
    return () => clearTimeout(timer);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [demo, inView, reduce, open, step]);

  // A real run clears its "done" state after a moment.
  useEffect(() => {
    if (!ran || demo) return;
    const timer = setTimeout(() => setRan(null), 1400);
    return () => clearTimeout(timer);
  }, [ran, demo]);

  // Keep the highlighted row inside the scrolling list (without scrolling the page).
  useEffect(() => {
    const list = listRef.current;
    const row = list?.children[Math.min(active, results.length - 1)] as HTMLElement | undefined;
    if (!list || !row) return;
    if (row.offsetTop < list.scrollTop) list.scrollTop = row.offsetTop;
    else if (row.offsetTop + row.offsetHeight > list.scrollTop + list.clientHeight) list.scrollTop = row.offsetTop + row.offsetHeight - list.clientHeight;
  }, [active, results.length]);

  const onKeyDown = (e: ReactKeyboardEvent) => {
    setDemo(false);
    if (e.key === "ArrowDown") (e.preventDefault(), move(1));
    else if (e.key === "ArrowUp") (e.preventDefault(), move(-1));
    else if (e.key === "Enter") (e.preventDefault(), run());
    else if (e.key === "Escape") setOpen(false);
  };

  return (
    <section ref={rootRef} className="bg-amber-50 px-4 py-12 text-stone-900 sm:px-6 sm:py-16">
      <style>{`
        @keyframes command-palette-caret { 50% { opacity: 0; } }
        .command-palette-caret { animation: command-palette-caret 1s steps(1) infinite; }
        @media (prefers-reduced-motion: reduce) { .command-palette-caret { animation: none; } }
      `}</style>
      <div className="relative mx-auto h-[620px] max-w-5xl overflow-hidden rounded-3xl bg-gradient-to-br from-amber-300 via-orange-200 to-rose-300">
        {/* A quiet app behind the palette */}
        <div className="flex items-center justify-between px-5 py-4 sm:px-8">
          <p className="flex items-center gap-2 text-sm font-semibold tracking-tight">
            <span className="h-5 w-5 rounded-md bg-stone-900" />
            Paperboat
          </p>
          <button type="button" aria-expanded={open} onClick={() => (setDemo(false), setOpen(true), type(""))} className="flex h-10 items-center gap-2 rounded-full bg-white/70 px-4 text-sm text-stone-700 shadow-sm transition hover:bg-white focus:outline-none focus-visible:ring-2 focus-visible:ring-stone-900">
            <Search aria-hidden="true" className="h-4 w-4" />
            <span className="hidden sm:inline">Search or run a command</span>
            <kbd className="rounded-md bg-stone-900/10 px-1.5 py-0.5 font-sans text-xs font-medium text-stone-700">Ctrl K</kbd>
          </button>
        </div>
        <div className="px-5 pt-6 sm:px-8">
          <h2 className="font-serif text-4xl tracking-tight text-stone-900 sm:text-6xl">Good morning, Priya.</h2>
          <p className="mt-3 max-w-sm text-stone-700">Three invoices went out yesterday and two were paid overnight.</p>
          <div aria-hidden="true" className="mt-8 grid gap-4 sm:grid-cols-3">
            {[0, 1, 2].map((card) => (
              <div key={card} className="h-40 rounded-2xl bg-white/50 p-5">
                <div className="h-3 w-1/3 rounded-full bg-stone-900/10" />
                <div className="mt-4 h-8 w-2/3 rounded-lg bg-stone-900/10" />
                <div className="mt-6 h-3 w-full rounded-full bg-stone-900/5" />
                <div className="mt-2 h-3 w-4/5 rounded-full bg-stone-900/5" />
              </div>
            ))}
          </div>
        </div>

        <AnimatePresence>
          {open && (
            <motion.div key="scrim" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute inset-0 bg-orange-950/10 backdrop-blur-sm" onClick={() => setOpen(false)} />
          )}
          {open && (
            <motion.div
              key="palette"
              role="dialog"
              aria-modal="true"
              aria-label="Command palette"
              initial={reduce ? false : { opacity: 0, scale: 0.94, y: -16 }}
              animate={{ opacity: 1, scale: 1, y: 0 }}
              exit={{ opacity: 0, scale: 0.96, y: -8, transition: { duration: 0.15 } }}
              transition={{ type: "spring", stiffness: 380, damping: 28 }}
              onKeyDown={onKeyDown}
              onPointerDown={() => setDemo(false)}
              className="absolute inset-x-3 top-16 mx-auto max-w-xl overflow-hidden rounded-2xl bg-white shadow-2xl shadow-orange-950/25 ring-1 ring-stone-900/5 sm:top-20"
            >
              <div className="relative flex items-center gap-3 border-b border-stone-100 px-4">
                <Search aria-hidden="true" className="h-5 w-5 flex-none text-stone-500" />
                <div className="relative min-w-0 flex-1">
                  <label htmlFor="command-palette-input" className="sr-only">
                    Search commands
                  </label>
                  <input
                    id="command-palette-input"
                    ref={inputRef}
                    role="combobox"
                    aria-expanded="true"
                    aria-controls="command-palette-list"
                    aria-activedescendant={current ? `command-palette-${current.id}` : undefined}
                    autoComplete="off"
                    value={query}
                    onChange={(e) => type(e.target.value)}
                    placeholder="Type a command or search..."
                    className="h-14 w-full bg-transparent text-base text-stone-900 placeholder:text-stone-500 focus:outline-none"
                  />
                  {demo && !reduce && (
                    <span aria-hidden="true" className="pointer-events-none absolute inset-0 flex items-center whitespace-pre text-base text-transparent">
                      {query}
                      <span className="command-palette-caret h-5 w-0.5 bg-orange-600" />
                    </span>
                  )}
                </div>
                <kbd className="flex-none rounded-md bg-stone-100 px-1.5 py-0.5 font-sans text-xs font-medium text-stone-600">esc</kbd>
              </div>

              <motion.ul ref={listRef} layoutScroll id="command-palette-list" role="listbox" aria-label="Commands" initial={false} animate={{ height: Math.min(6, Math.max(results.length, 2)) * 52 + 16 }} transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 380, damping: 34 }} className="relative overflow-y-auto p-2">
                <AnimatePresence initial={false} mode="popLayout">
                  {results.map((command, i) => {
                    const on = current?.id === command.id;
                    const done = ran === command.id;
                    const at = needle ? command.title.toLowerCase().indexOf(needle) : -1;
                    const Icon = command.icon;
                    return (
                      <motion.li
                        key={command.id}
                        id={`command-palette-${command.id}`}
                        role="option"
                        aria-selected={on}
                        layout={reduce ? false : "position"}
                        initial={reduce ? false : { opacity: 0, y: 10 }}
                        animate={{ opacity: 1, y: 0 }}
                        exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, transition: { duration: 0.12 } }}
                        transition={rowSpring}
                        onPointerMove={() => !on && setActive(i)}
                        onClick={() => (setActive(i), setRan(command.id))}
                        className="relative flex h-[52px] cursor-pointer items-center gap-3 rounded-xl px-3"
                      >
                        {on && <motion.span layoutId="command-palette-highlight" aria-hidden="true" animate={{ scale: done ? [1, 0.98, 1] : 1 }} transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 480, damping: 34 }} className={`absolute inset-0 rounded-xl transition-colors duration-300 ${done ? "bg-emerald-700" : "bg-stone-900"}`} />}
                        <span className={`relative grid h-8 w-8 flex-none place-items-center rounded-lg transition-colors duration-200 ${on ? "bg-white/15 text-white" : "bg-stone-100 text-stone-700"}`}>
                          {done ? <Check aria-hidden="true" className="h-4 w-4" strokeWidth={3} /> : <Icon aria-hidden="true" className="h-4 w-4" />}
                        </span>
                        <span className={`relative min-w-0 flex-1 truncate text-sm transition-colors duration-200 ${on ? "text-white" : "text-stone-700"}`}>
                          {at < 0 ? (
                            command.title
                          ) : (
                            <>
                              {command.title.slice(0, at)}
                              <mark className={`bg-transparent font-semibold underline decoration-2 underline-offset-4 ${on ? "text-amber-300 decoration-amber-300" : "text-stone-950 decoration-orange-500"}`}>{command.title.slice(at, at + needle.length)}</mark>
                              {command.title.slice(at + needle.length)}
                            </>
                          )}
                        </span>
                        <span className={`relative hidden flex-none text-xs transition-colors duration-200 min-[420px]:block ${on ? "text-stone-300" : "text-stone-500"}`}>{done ? "Done" : command.group}</span>
                        <span className="relative hidden flex-none gap-1 sm:flex">
                          {command.keys.map((key) => (
                            <kbd key={key} className={`grid h-6 min-w-[24px] place-items-center rounded-md px-1 font-sans text-xs font-medium transition-colors duration-200 ${on ? "bg-white/15 text-white" : "bg-stone-100 text-stone-600"}`}>
                              {key}
                            </kbd>
                          ))}
                        </span>
                      </motion.li>
                    );
                  })}
                </AnimatePresence>
                {results.length === 0 && <li className="grid h-full place-items-center px-4 text-center text-sm text-stone-600">No commands match "{query}". Try "invoice" or "theme".</li>}
              </motion.ul>

              <div className="flex items-center justify-between gap-3 border-t border-stone-100 bg-stone-50 px-4 py-2.5 text-xs text-stone-600">
                <p className="flex items-center gap-3">
                  <span className="flex items-center gap-1">
                    <kbd className="grid h-5 w-5 place-items-center rounded bg-white shadow-sm ring-1 ring-stone-200"><ArrowUp aria-hidden="true" className="h-3 w-3" /></kbd>
                    <kbd className="grid h-5 w-5 place-items-center rounded bg-white shadow-sm ring-1 ring-stone-200"><ArrowDown aria-hidden="true" className="h-3 w-3" /></kbd>
                    Move
                  </span>
                  <span className="flex items-center gap-1">
                    <kbd className="grid h-5 w-5 place-items-center rounded bg-white shadow-sm ring-1 ring-stone-200"><CornerDownLeft aria-hidden="true" className="h-3 w-3" /></kbd>
                    Run
                  </span>
                </p>
                <p aria-live="polite" className="tabular-nums">
                  {ran ? `Ran: ${commands.find((c) => c.id === ran)?.title}` : `${results.length} ${results.length === 1 ? "command" : "commands"}`}
                </p>
              </div>
            </motion.div>
          )}
        </AnimatePresence>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/command-palette-with-spring-highlight-and-live-filtering.json

When to use it

How to customize it

Accessibility

Questions

How does the highlight slide between rows?

Only the active row renders the highlight element, and it carries a framer-motion `layoutId`. When the active row changes, the element is removed from one row and added to another, and framer-motion animates it from the old position to the new one on a spring.

How is the filtering animated?

Rows sit inside `AnimatePresence` with `mode="popLayout"` and each row has `layout="position"`. A row that stops matching fades out and is taken out of the flow at once, and the remaining rows animate to their new positions.

How do I open it with a keyboard shortcut?

Add a `keydown` listener on `window` in an effect. When the key is K and `metaKey` or `ctrlKey` is held, call `preventDefault`, set `open` to true and focus the input. Remove the listener in the effect's cleanup.

How do I run real actions?

Add an `onRun` function to each command and call it in `run` and in the row's click handler. Close the palette afterwards with `setOpen(false)`, or keep the short green confirmation first.

Why does the list scroll by itself?

An effect compares the highlighted row with the visible part of the list and adjusts `scrollTop` so the row stays in view. It scrolls only the list, never the page.

Related: Suggested Prompt Cards That Fill the Prompt Box, Assistant Side Panel with Thinking Shimmer, Search Field with Grouped Results Dropdown.

More ai prompt and chat ui or all free UI components. Start for free to have a full site built around it.

More in AI Chat

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap