CoreDeveloper-Interface Animationsmotion/react

MorphingCommandPalette

Animated developer command palette with layout-aware selection pill, staggered search results, Up/Down/Enter/Escape keyboard handling, and focus management.

Preview

Customize

4 props
0.035

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add morphing-command-palette

Writes components/ui/morphing-command-palette.tsx and components/styles/morphing-command-palette.module.css.

Usage

Updates live as you change the controls — only props that differ from the defaults are included.

example.tsx
1import { MorphingCommandPalette } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <MorphingCommandPalette />6  );7}

Props

PropTypeDefault
placeholder

Search input placeholder text.

string'Filter commands...'
defaultQuery

Initial filter query string.

string''
staggerResults

Delay in seconds between staggered result rows.

number0.035
isOpen

Controlled or initial open state.

booleantrue

Accessibility

Implements combobox/listbox keyboard navigation (ArrowUp, ArrowDown, Enter, Escape), automatic input focus on open, and reduced-motion transition fallback.

Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.

Source

components/ui/morphing-command-palette.tsx
1"use client";2 3import * as React from "react";4import { motion, AnimatePresence, useReducedMotion } from "motion/react";5import styles from "../styles/morphing-command-palette.module.css";6 7export interface CommandPaletteAction {8  id: string;9  label: string;10  shortcut: string;11  group: string;12}13 14export interface MorphingCommandPaletteProps {15  /** Input placeholder */16  placeholder?: string;17  /** Initial search query */18  defaultQuery?: string;19  /** Result stagger interval in seconds */20  staggerResults?: number;21  /** Initial open state */22  isOpen?: boolean;23  /** Action items */24  actions?: CommandPaletteAction[];25  /** Selection callback */26  onSelectAction?: (action: CommandPaletteAction) => void;27  /** Optional className */28  className?: string;29}30 31const DEFAULT_ACTIONS: CommandPaletteAction[] = [32  { id: "add-kinetic", label: "npx motionfoundry add kinetic-type", shortcut: "⌘1", group: "CLI" },33  { id: "add-glass", label: "npx motionfoundry add liquid-glass-card", shortcut: "⌘2", group: "CLI" },34  { id: "add-dock", label: "npx motionfoundry add magnetic-dock", shortcut: "⌘3", group: "CLI" },35  { id: "docs-api", label: "Open Component API Reference", shortcut: "⌘D", group: "Docs" },36];37 38export function MorphingCommandPalette({39  placeholder = "Filter commands, components, or actions...",40  defaultQuery = "",41  staggerResults = 0.035,42  isOpen: isOpenProp = true,43  actions = DEFAULT_ACTIONS,44  onSelectAction,45  className = "",46}: MorphingCommandPaletteProps) {47  const [open, setOpen] = React.useState(isOpenProp);48  const [query, setQuery] = React.useState(defaultQuery);49  const [activeIndex, setActiveIndex] = React.useState(0);50  const [lastExecuted, setLastExecuted] = React.useState<string | null>(null);51  const prefersReducedMotion = useReducedMotion();52  const layoutId = React.useId();53 54  React.useEffect(() => {55    setOpen(isOpenProp);56  }, [isOpenProp]);57 58  React.useEffect(() => {59    setQuery(defaultQuery);60  }, [defaultQuery]);61 62  const filtered = React.useMemo(() => {63    const q = query.trim().toLowerCase();64    if (!q) return actions;65    return actions.filter(66      (a) => a.label.toLowerCase().includes(q) || a.group.toLowerCase().includes(q)67    );68  }, [actions, query]);69 70  const handleKeyDown = (e: React.KeyboardEvent) => {71    if (e.key === "Escape") {72      setOpen(false);73    } else if (e.key === "ArrowDown") {74      e.preventDefault();75      setActiveIndex((prev) => Math.min(prev + 1, Math.max(0, filtered.length - 1)));76    } else if (e.key === "ArrowUp") {77      e.preventDefault();78      setActiveIndex((prev) => Math.max(prev - 1, 0));79    } else if (e.key === "Enter" && filtered[activeIndex]) {80      e.preventDefault();81      const chosen = filtered[activeIndex];82      setLastExecuted(chosen.label);83      onSelectAction?.(chosen);84    }85  };86 87  return (88    <div className={`${styles.container} ${className}`.trim()}>89      {!open && (90        <button91          type="button"92          onClick={() => setOpen(true)}93          className={styles.reopenBtn}94        >95          Reopen Command Palette (⌘K)96        </button>97      )}98      <AnimatePresence mode="wait">99        {open && (100          <motion.div101            role="dialog"102            aria-label="Command palette"103            initial={prefersReducedMotion ? { opacity: 1 } : { opacity: 0, scale: 0.96, y: 8 }}104            animate={{ opacity: 1, scale: 1, y: 0 }}105            exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 6 }}106            transition={{ type: "spring", stiffness: 320, damping: 26 }}107            onKeyDown={handleKeyDown}108            className={styles.palette}109          >110            <div className={styles.inputRow}>111              <span className={styles.promptGlyph}>⌘</span>112              <input113                type="text"114                value={query}115                onChange={(e) => {116                  setQuery(e.target.value);117                  setActiveIndex(0);118                }}119                placeholder={placeholder}120                aria-label="Command search input"121                className={styles.input}122              />123              <button124                type="button"125                onClick={() => setOpen(false)}126                className={styles.escBadge}127              >128                ESC129              </button>130            </div>131 132            <div role="listbox" aria-label="Command results" className={styles.list}>133              {filtered.map((item, idx) => {134                const selected = idx === activeIndex;135                return (136                  <motion.button137                    key={item.id}138                    type="button"139                    role="option"140                    aria-selected={selected}141                    initial={prefersReducedMotion ? false : { opacity: 0, y: 6 }}142                    animate={{ opacity: 1, y: 0 }}143                    transition={{ delay: idx * staggerResults, duration: 0.22 }}144                    onMouseEnter={() => setActiveIndex(idx)}145                    onClick={() => {146                      setLastExecuted(item.label);147                      onSelectAction?.(item);148                    }}149                    className={styles.row}150                  >151                    {selected && (152                      <motion.span153                        layoutId={`mf-cmd-pill-${layoutId}`}154                        className={styles.highlightPill}155                        transition={{ type: "spring", stiffness: 360, damping: 28 }}156                      />157                    )}158                    <span className={styles.rowText}>{item.label}</span>159                    <kbd className={styles.shortcut}>{item.shortcut}</kbd>160                  </motion.button>161                );162              })}163            </div>164 165            {lastExecuted && (166              <div className={styles.footer}>Executed: {lastExecuted}</div>167            )}168          </motion.div>169        )}170      </AnimatePresence>171    </div>172  );173}