Teseria
All elements

Command palette

⌘K opens a sheet that pops out of a blur. Type and rows re-sort; the highlight slides from row to row; the letters you typed are inked darker in each match.

Code

1. Install

Terminal
npm i framer-motion lucide-react

2. Files

CommandPalette.tsx353 lines
"use client";
// CommandPalette: ⌘K opens a sheet that pops out of a blur. Type and rows re-sort; the highlight slides from row to row;
// the letters you typed are inked darker in each match. The sheet is portalled to the body. By default the block draws
// its own trigger button; pass `trigger={null}` to open it only with ⌘K or the `open` prop.
import { useEffect, useId, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, MotionConfig, motion } from "framer-motion";
import { Bell, Folder, Home, Search, Settings, Share2, Sparkles, User, Zap } from "lucide-react";

export type Command = {
  id: string;
  label: string;
  group?: string;
  icon?: ReactNode;
  /** Shortcut hint shown at the right of the row. */
  kbd?: string;
  /** Small grey note after the label. */
  hint?: string;
  /** Called when this command is chosen. */
  run?: () => void;
};
export type CommandPaletteCue = "unfold" | "fold" | "push" | "pip";

export type CommandPaletteProps = {
  /** The commands, grouped by `group` while the query is empty. */
  commands?: Command[];
  /** Open state (controlled). */
  open?: boolean;
  /** Called when the sheet opens or closes. */
  onOpenChange?: (o: boolean) => void;
  /** Called with the command when one is chosen. */
  onRun?: (c: Command) => void;
  /** ⌘K / Ctrl+K toggles it. */
  hotkey?: boolean;
  /** Placeholder of the search field. */
  placeholder?: string;
  /** Text of the built-in trigger button. */
  triggerLabel?: string;
  /** Replace the built-in trigger with your own node, or pass null for none. */
  trigger?: ReactNode;
  /** Sound hook: "unfold"/"fold" on open/close, "push" on run, "pip" on arrow keys. */
  onSound?: (cue: CommandPaletteCue) => void;
  className?: string;
  style?: CSSProperties;
};

const DEFAULT_COMMANDS: Command[] = [
  { id: "new", label: "New project", group: "Actions", icon: <Zap />, kbd: "N" },
  { id: "invoice", label: "Send invoice", group: "Actions", icon: <Share2 /> },
  { id: "invite", label: "Invite teammate", group: "Actions", icon: <User /> },
  { id: "home", label: "Go to Home", group: "Navigate", icon: <Home />, kbd: "G H" },
  { id: "projects", label: "Go to Projects", group: "Navigate", icon: <Folder />, kbd: "G P" },
  { id: "settings", label: "Open settings", group: "Navigate", icon: <Settings />, kbd: "," },
  { id: "theme", label: "Toggle theme", group: "Preferences", icon: <Sparkles />, hint: "Light / dark" },
  { id: "sound", label: "Toggle sound", group: "Preferences", icon: <Bell /> },
];
const join = (...parts: (string | false | undefined)[]) => parts.filter(Boolean).join(" ");

const ROW_HIGHLIGHT = { type: "spring", stiffness: 420, damping: 34, mass: 0.6 } as const;
const SHEET_SPRING = { type: "spring", stiffness: 500, damping: 30, mass: 0.8 } as const;
const EXIT_EASE = [0.4, 0, 1, 1] as const;
const TOCK_GAP_MS = 30;

type Match = { command: Command; matched: number[]; score: number };

/** Subsequence match of `query` in `label`: the matched character positions (for inking) and a score, or null. */
function fuzzyMatch(query: string, label: string): { matched: number[]; score: number } | null {
  if (!query) return { matched: [], score: 0 };
  const wanted = query.toLowerCase();
  const text = label.toLowerCase();
  const matched: number[] = [];
  let next = 0;
  let run = 0;
  let score = 0;
  for (let at = 0; at < text.length && next < wanted.length; at++) {
    if (text[at] === wanted[next]) {
      matched.push(at);
      run++;
      const startsWord = at === 0 || text[at - 1] === " ";
      score += run * 2 + (startsWord ? 3 : 0);
      next++;
    } else {
      run = 0;
    }
  }
  return next === wanted.length ? { matched, score: score - text.length * 0.02 } : null;
}

/** The matching commands, best first while searching, otherwise grouped in their given order. */
function filterCommands(commands: Command[], query: string): { name: string; matches: Match[] }[] {
  const matches: Match[] = [];
  for (const command of commands) {
    const found = fuzzyMatch(query, command.label);
    if (found) matches.push({ command, ...found });
  }
  if (query) return [{ name: "", matches: matches.sort((a, b) => b.score - a.score) }];
  const groups = new Map<string, Match[]>();
  for (const match of matches) {
    const name = match.command.group ?? "";
    groups.set(name, [...(groups.get(name) ?? []), match]);
  }
  return [...groups].map(([name, grouped]) => ({ name, matches: grouped }));
}

type SheetProps = {
  commands: Command[];
  placeholder: string;
  onRun: (command: Command) => void;
  onClose: () => void;
  onSound?: (cue: CommandPaletteCue) => void;
};

/** The sheet itself: search field, grouped results, keyboard highlight. */
function PaletteSheet({ commands, placeholder, onRun, onClose, onSound }: SheetProps) {
  const id = useId();
  const [query, setQuery] = useState("");
  const [hot, setHot] = useState(0);
  const input = useRef<HTMLInputElement>(null);
  const list = useRef<HTMLDivElement>(null);
  const lastTockAt = useRef(0);

  const groups = useMemo(() => filterCommands(commands, query), [commands, query]);
  const flat = groups.flatMap((group) => group.matches);
  const hotCommand = flat[hot]?.command;

  const tock = () => {
    const now = performance.now();
    if (now - lastTockAt.current < TOCK_GAP_MS) return;
    lastTockAt.current = now;
    onSound?.("pip");
  };

  const search = (next: string) => {
    setQuery(next);
    setHot(0);
  };

  // focus goes into the sheet on open and back to whatever opened it on close
  useEffect(() => {
    const opener = document.activeElement as HTMLElement | null;
    input.current?.focus();
    return () => opener?.focus?.({ preventScroll: true });
  }, []);

  useEffect(() => {
    list.current?.querySelector<HTMLElement>(`[data-i="${hot}"]`)?.scrollIntoView({ block: "nearest" });
  }, [hot]);

  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    switch (event.key) {
      case "ArrowDown":
        event.preventDefault();
        setHot((current) => Math.min(flat.length - 1, current + 1));
        tock();
        break;
      case "ArrowUp":
        event.preventDefault();
        setHot((current) => Math.max(0, current - 1));
        tock();
        break;
      case "Home":
        event.preventDefault();
        setHot(0);
        break;
      case "End":
        event.preventDefault();
        setHot(flat.length - 1);
        break;
      case "Enter":
        if (!hotCommand) break;
        event.preventDefault();
        onRun(hotCommand);
        break;
      case "Escape":
        event.preventDefault();
        onClose();
        break;
      case "Tab": // the field is the sheet's only tab stop
        event.preventDefault();
        break;
    }
  };

  return (
    <motion.div
      className="tcmd-veil"
      initial={{ opacity: 0 }}
      animate={{ opacity: 1 }}
      exit={{ opacity: 0, transition: { duration: 0.14 } }}
      transition={{ duration: 0.18 }}
      onPointerDown={(event) => {
        if (event.target === event.currentTarget) onClose();
      }}
    >
      <motion.div
        className="tcmd-box"
        role="dialog"
        aria-modal
        aria-label="Command palette"
        initial={{ opacity: 0, scale: 0.94, y: -10, filter: "blur(8px)" }}
        animate={{ opacity: 1, scale: 1, y: 0, filter: "blur(0px)", transitionEnd: { filter: "none" } }}
        exit={{ opacity: 0, scale: 0.96, y: -6, filter: "blur(6px)", transition: { duration: 0.14, ease: EXIT_EASE } }}
        transition={{ ...SHEET_SPRING, opacity: { duration: 0.16 }, filter: { duration: 0.22 } }}
        onKeyDown={onKeyDown}
      >
        <div className="tcmd-head">
          <Search size={16} strokeWidth={2} aria-hidden />
          <input
            ref={input}
            className="tcmd-in"
            value={query}
            placeholder={placeholder}
            role="combobox"
            aria-label={placeholder}
            aria-autocomplete="list"
            aria-expanded
            aria-controls={`${id}-list`}
            aria-activedescendant={hotCommand ? `${id}-${hotCommand.id}` : undefined}
            autoComplete="off"
            spellCheck={false}
            onChange={(event) => search(event.target.value)}
          />
          <kbd className="tcmd-esc">esc</kbd>
        </div>
        <div ref={list} id={`${id}-list`} role="listbox" className="tcmd-list">
          {flat.length === 0 && <div className="tcmd-none">Nothing matches “{query}”</div>}
          {groups.map((group) => (
            <div key={group.name || "ungrouped"} className="tcmd-group" role="group" aria-label={group.name || undefined}>
              {group.name && <div className="tcmd-gname" aria-hidden>{group.name}</div>}
              {group.matches.map((match) => {
                const { command } = match;
                const index = flat.indexOf(match);
                const isHot = index === hot;
                return (
                  <div
                    key={command.id}
                    id={`${id}-${command.id}`}
                    role="option"
                    aria-selected={isHot}
                    data-i={index}
                    data-hot={isHot || undefined}
                    className="tcmd-row"
                    onPointerMove={() => setHot(index)}
                    onClick={() => onRun(command)}
                  >
                    {isHot && <motion.span className="tcmd-hl" layoutId={`${id}-highlight`} transition={ROW_HIGHLIGHT} />}
                    {command.icon && <span className="tcmd-ico">{command.icon}</span>}
                    <span className="tcmd-label">
                      {[...command.label].map((character, at) => (
                        <span key={at} data-ink={match.matched.includes(at) || undefined}>
                          {character}
                        </span>
                      ))}
                      {command.hint && <span className="tcmd-hint">{command.hint}</span>}
                    </span>
                    {command.kbd && <kbd className="tcmd-kbd">{command.kbd}</kbd>}
                  </div>
                );
              })}
            </div>
          ))}
        </div>
      </motion.div>
    </motion.div>
  );
}

export function CommandPalette({
  commands = DEFAULT_COMMANDS,
  open: controlledOpen,
  onOpenChange,
  onRun,
  hotkey = true,
  placeholder = "Type a command…",
  triggerLabel = "Command palette",
  trigger,
  onSound,
  className,
  style,
}: CommandPaletteProps) {
  const [ownOpen, setOwnOpen] = useState(false);
  const open = controlledOpen ?? ownOpen;

  const setOpen = (next: boolean, quiet = false) => {
    if (next === open) return;
    if (!quiet) onSound?.(next ? "unfold" : "fold");
    if (controlledOpen === undefined) setOwnOpen(next);
    onOpenChange?.(next);
  };
  const toggleRef = useRef(() => setOpen(!open));
  toggleRef.current = () => setOpen(!open);

  useEffect(() => {
    if (!hotkey) return;
    const onKeyDown = (event: globalThis.KeyboardEvent) => {
      if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "k") return;
      event.preventDefault();
      toggleRef.current();
    };
    window.addEventListener("keydown", onKeyDown);
    return () => window.removeEventListener("keydown", onKeyDown);
  }, [hotkey]);

  const run = (command: Command) => {
    onSound?.("push");
    setOpen(false, true);
    command.run?.();
    onRun?.(command);
  };

  const button =
    trigger === undefined ? (
      <button
        type="button"
        className={join("tcmd-trigger", className)}
        style={style}
        aria-haspopup="dialog"
        aria-expanded={open}
        onClick={() => setOpen(true)}
      >
        <span>{triggerLabel}</span>
        <span className="tcmd-trigger-kbd">
          <kbd>⌘K</kbd>
        </span>
      </button>
    ) : (
      trigger
    );

  if (typeof document === "undefined") return button;
  return (
    <>
      {button}
      {createPortal(
        <MotionConfig reducedMotion="user">
          <AnimatePresence>
            {open && (
              <PaletteSheet
                key="sheet"
                commands={commands}
                placeholder={placeholder}
                onRun={run}
                onClose={() => setOpen(false)}
                onSound={onSound}
              />
            )}
          </AnimatePresence>
        </MotionConfig>,
        document.body,
      )}
    </>
  );
}
CommandPalette.css223 lines
/* basics: the host app may have none */
:where(.tcmd-trigger, .tcmd-veil),
:where(.tcmd-trigger, .tcmd-veil) *,
:where(.tcmd-trigger, .tcmd-veil) *::before,
:where(.tcmd-trigger, .tcmd-veil) *::after {
  box-sizing: border-box;
}
.tcmd-trigger, .tcmd-veil {
  font-family: var(--ts-font, system-ui, sans-serif);
}
:where(.tcmd-trigger button, .tcmd-veil button) {
  font: inherit;
  color: inherit;
  background: none;
  border: 0;
  padding: 0;
  margin: 0;
}
:where(.tcmd-trigger input, .tcmd-veil input) {
  font: inherit;
  color: inherit;
}
:where(.tcmd-trigger, .tcmd-veil) :focus-visible {
  outline: 2px solid var(--ts-focus);
  outline-offset: 2px;
}

.tcmd-veil {
  position: fixed;
  inset: 0;
  z-index: 60;
  display: flex;
  justify-content: center;
  align-items: flex-start;
  padding-top: 14vh;
  background: color-mix(in srgb, var(--ts-page) 40%, transparent);
  backdrop-filter: blur(6px);
}
.tcmd-box {
  width: min(520px, calc(100vw - 32px));
  max-height: 60vh;
  display: flex;
  flex-direction: column;
  border-radius: var(--ts-r-pop);
  background: var(--ts-raised);
  color: var(--ts-fg);
  box-shadow: var(--ts-edge);
  overflow: hidden;
}
.tcmd-head {
  flex: none; /* a long list must scroll, not squeeze the field */
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 0 14px;
  height: 52px;
  border-bottom: 1px solid var(--ts-border);
  color: var(--ts-fg-muted);
}
.tcmd-in {
  flex: 1;
  min-width: 0;
  height: 100%;
  border: 0;
  padding: 0;
  background: transparent;
  color: var(--ts-fg);
  font: inherit;
  font-size: 14px;
  letter-spacing: -0.01em;
  outline: 0;
}
.tcmd-in::placeholder {
  color: var(--ts-fg-faint);
}
.tcmd-esc,
.tcmd-kbd {
  font: inherit;
  font-size: 11px;
  font-weight: 500;
  padding: 2px 6px;
  border-radius: 6px;
  color: var(--ts-fg-muted);
  background: rgba(var(--ts-fg-rgb), 0.06);
}
.tcmd-list {
  overflow-y: auto;
  padding: 6px;
  overscroll-behavior: contain;
}
.tcmd-group + .tcmd-group {
  margin-top: 4px;
}
.tcmd-gname {
  padding: 8px 10px 4px;
  font-size: 12px;
  font-weight: 500;
  color: var(--ts-fg-faint);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}
.tcmd-row {
  position: relative;
  display: flex;
  align-items: center;
  gap: 10px;
  height: 40px;
  padding: 0 10px;
  border-radius: calc(var(--ts-r-pop) - 6px);
  cursor: pointer;
  color: var(--ts-fg-secondary);
  transition:
    color 0.16s cubic-bezier(.22, 1, .36, 1),
    scale 0.22s cubic-bezier(.22, 1, .36, 1);
}
.tcmd-row:active {
  scale: 0.985;
}
.tcmd-row[data-hot] {
  color: var(--ts-fg);
}
.tcmd-hl {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: rgba(var(--ts-fg-rgb), 0.07);
}
.tcmd-ico,
.tcmd-label,
.tcmd-kbd {
  position: relative;
}
.tcmd-ico {
  display: inline-grid;
  color: var(--ts-fg-muted);
}
.tcmd-ico svg {
  width: 16px;
  height: 16px;
}
.tcmd-label {
  flex: 1;
  min-width: 0;
  display: inline-flex;
  align-items: baseline;
  font-size: 14px;
  letter-spacing: -0.01em;
  white-space: pre;
  overflow: hidden;
  text-overflow: ellipsis;
}
.tcmd-label > span {
  color: var(--ts-fg-tertiary);
  transition: color 0.16s cubic-bezier(.22, 1, .36, 1);
}
.tcmd-label > span[data-ink] {
  color: var(--ts-fg);
  font-weight: 600;
}
.tcmd-row[data-hot] .tcmd-label > span {
  color: var(--ts-fg-secondary);
}
.tcmd-row[data-hot] .tcmd-label > span[data-ink] {
  color: var(--ts-fg);
}
.tcmd-hint {
  margin-left: 8px;
  font-size: 12px;
  color: var(--ts-fg-faint) !important;
  font-weight: 400 !important;
}
.tcmd-none {
  padding: 22px 10px;
  text-align: center;
  font-size: 13px;
  color: var(--ts-fg-muted);
}

/* built-in trigger: a filled button with the hotkey hint */
.tcmd-trigger {
  position: relative;
  height: 40px;
  padding: 0 18px 0 20px;
  border: 0;
  border-radius: 999px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 16px; /* room between the label and the hotkey hint */
  font-family: var(--ts-font, system-ui, sans-serif);
  font-size: 14px;
  font-weight: 500;
  letter-spacing: -0.01em;
  white-space: nowrap;
  cursor: pointer;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
  background: var(--ts-raised);
  color: var(--ts-fg);
  box-shadow: var(--ts-edge);
  transition: scale 0.22s cubic-bezier(.22, 1, .36, 1), background 0.16s cubic-bezier(.22, 1, .36, 1), box-shadow 0.16s cubic-bezier(.22, 1, .36, 1);
}
.tcmd-trigger:hover {
  background: rgba(var(--ts-fg-rgb), 0.05);
}
.tcmd-trigger:active {
  scale: 0.96;
}
.tcmd-trigger:focus-visible {
  outline: 0;
  box-shadow: 0 0 0 2px var(--ts-page), 0 0 0 4px var(--ts-accent);
}
.tcmd-trigger-kbd {
  display: grid;
  place-items: center;
  min-width: 1.15em;
  height: 1.15em;
}
.tcmd-trigger-kbd kbd {
  font: inherit;
  font-size: 11px;
  opacity: 0.6;
}
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 { CommandPalette } from "./CommandPalette";
import "./CommandPalette.css";

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