{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"prompt-suggestions","type":"registry:component","title":"Prompt Suggestions","description":"Suggested prompt cards or chips (`PromptSuggestions`) that stagger in on first reveal, filter and highlight as the composer text changes via `query`, reflow with spring layout, move with arrow keys and hand the chosen prompt to `onSelect` — built for empty-chat welcome screens above a PromptInput.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/agents/prompt-suggestions.tsx","type":"registry:component","target":"@components/agents/prompt-suggestions.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/agents/prompt-suggestions\n\nimport { ArrowUpRight } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type ReactNode,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_PANEL } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface PromptSuggestion {\n  id: string;\n  title: string;\n  subtitle?: string;\n  /** Text handed to `onSelect`. Defaults to `title`. */\n  prompt?: string;\n  icon?: ReactNode;\n}\n\nexport type PromptSuggestionsVariant = \"card\" | \"chip\";\n\nexport interface PromptSuggestionsProps {\n  suggestions: PromptSuggestion[];\n  /** Current composer text; suggestions filter to those matching every word. */\n  query?: string;\n  onSelect?: (prompt: string, suggestion: PromptSuggestion) => void;\n  /** `card` shows icon + title + subtitle in a grid; `chip` is a wrapping pill row. */\n  variant?: PromptSuggestionsVariant;\n  /** Card grid columns from the `sm` breakpoint. Default 2. */\n  columns?: 1 | 2 | 3;\n  /** Maximum suggestions shown at once. */\n  limit?: number;\n  /** Highlight the matched words. Default true. */\n  highlight?: boolean;\n  /** Shown when nothing matches; pass `null` to collapse instead. */\n  emptyMessage?: ReactNode;\n  /** Seconds between each item's entrance. Default 0.05. */\n  stagger?: number;\n  \"aria-label\"?: string;\n  className?: string;\n  itemClassName?: string;\n}\n\nfunction tokenize(query: string) {\n  return query.toLowerCase().split(/\\s+/).filter(Boolean);\n}\n\nfunction Highlighted({ text, tokens }: { text: string; tokens: string[] }) {\n  if (!tokens.length) return <>{text}</>;\n  const escaped = tokens.map((token) =>\n    token.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\"),\n  );\n  const parts = text.split(new RegExp(`(${escaped.join(\"|\")})`, \"gi\"));\n  return (\n    <>\n      {parts.map((part, index) =>\n        index % 2 === 1 ? (\n          <mark\n            // biome-ignore lint/suspicious/noArrayIndexKey: split order is stable for a given text.\n            key={index}\n            className=\"rounded-sm bg-primary/12 px-px text-foreground\"\n          >\n            {part}\n          </mark>\n        ) : (\n          part\n        ),\n      )}\n    </>\n  );\n}\n\nconst GRID_COLUMNS = {\n  1: \"grid-cols-1\",\n  2: \"grid-cols-1 sm:grid-cols-2\",\n  3: \"grid-cols-1 sm:grid-cols-3\",\n} as const;\n\nexport function PromptSuggestions({\n  suggestions,\n  query = \"\",\n  onSelect,\n  variant = \"card\",\n  columns = 2,\n  limit,\n  highlight = true,\n  emptyMessage = \"No suggestions match — press Enter to send your own.\",\n  stagger = 0.05,\n  \"aria-label\": ariaLabel = \"Suggested prompts\",\n  className,\n  itemClassName,\n}: PromptSuggestionsProps) {\n  const reduce = useReducedMotion() ?? false;\n  const listRef = useRef<HTMLUListElement>(null);\n  // Only the first reveal staggers; filtering afterwards should feel instant.\n  const [settled, setSettled] = useState(false);\n\n  const tokens = useMemo(() => tokenize(query), [query]);\n  const visible = useMemo(() => {\n    const filtered = suggestions.filter((suggestion) => {\n      const haystack = [suggestion.title, suggestion.subtitle, suggestion.prompt]\n        .filter(Boolean)\n        .join(\" \")\n        .toLowerCase();\n      return tokens.every((token) => haystack.includes(token));\n    });\n    return limit ? filtered.slice(0, limit) : filtered;\n  }, [suggestions, tokens, limit]);\n\n  useEffect(() => {\n    const timer = window.setTimeout(\n      () => setSettled(true),\n      (suggestions.length * stagger + 0.4) * 1000,\n    );\n    return () => window.clearTimeout(timer);\n  }, [suggestions.length, stagger]);\n\n  const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {\n    const items = Array.from(\n      listRef.current?.querySelectorAll<HTMLButtonElement>(\n        \"[data-prompt-suggestion]\",\n      ) ?? [],\n    );\n    const index = items.indexOf(event.currentTarget);\n    if (index < 0) return;\n    const cols =\n      variant === \"card\" && listRef.current\n        ? getComputedStyle(listRef.current)\n            .gridTemplateColumns.split(\" \")\n            .filter(Boolean).length\n        : 1;\n    let next = index;\n    switch (event.key) {\n      case \"ArrowRight\":\n        next = index + 1;\n        break;\n      case \"ArrowLeft\":\n        next = index - 1;\n        break;\n      case \"ArrowDown\":\n        next = index + (variant === \"card\" ? cols : 1);\n        break;\n      case \"ArrowUp\":\n        next = index - (variant === \"card\" ? cols : 1);\n        break;\n      case \"Home\":\n        next = 0;\n        break;\n      case \"End\":\n        next = items.length - 1;\n        break;\n      default:\n        return;\n    }\n    event.preventDefault();\n    items[Math.max(0, Math.min(items.length - 1, next))]?.focus();\n  };\n\n  const hidden = reduce\n    ? { opacity: 0 }\n    : { opacity: 0, y: 8, scale: 0.97, filter: \"blur(4px)\" };\n  const shown = { opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\" };\n\n  return (\n    <div className={cn(\"w-full\", className)}>\n      <ul\n        ref={listRef}\n        aria-label={ariaLabel}\n        className={cn(\n          \"m-0 list-none p-0\",\n          variant === \"card\"\n            ? cn(\"grid gap-2\", GRID_COLUMNS[columns])\n            : \"flex flex-wrap justify-center gap-2\",\n        )}\n      >\n        <AnimatePresence initial={!reduce} mode=\"popLayout\">\n          {visible.map((suggestion, index) => (\n            <motion.li\n              key={suggestion.id}\n              layout={reduce ? false : \"position\"}\n              initial={hidden}\n              animate={shown}\n              exit={{\n                ...hidden,\n                transition: { duration: 0.12, ease: EASE_OUT },\n              }}\n              transition={\n                reduce\n                  ? { duration: 0.15 }\n                  : {\n                      ...SPRING_PANEL,\n                      filter: { duration: 0.25, ease: EASE_OUT },\n                      layout: SPRING_LAYOUT,\n                      delay: settled ? 0 : index * stagger,\n                    }\n              }\n              className=\"min-w-0\"\n            >\n              <button\n                type=\"button\"\n                data-prompt-suggestion=\"\"\n                onClick={() =>\n                  onSelect?.(suggestion.prompt ?? suggestion.title, suggestion)\n                }\n                onKeyDown={onKeyDown}\n                className={cn(\n                  \"group/suggestion w-full text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring\",\n                  variant === \"card\"\n                    ? \"flex h-full items-start gap-3 rounded-xl border border-border bg-card p-3 hover:border-border-strong hover:bg-muted/50\"\n                    : \"inline-flex h-8 items-center gap-1.5 rounded-full border border-border bg-card px-3 text-sm text-foreground hover:bg-muted\",\n                  itemClassName,\n                )}\n              >\n                {suggestion.icon ? (\n                  <span\n                    aria-hidden=\"true\"\n                    className={cn(\n                      \"grid shrink-0 place-items-center text-muted-foreground transition-colors group-hover/suggestion:text-foreground\",\n                      variant === \"card\"\n                        ? \"size-8 rounded-lg bg-muted [&_svg]:size-4\"\n                        : \"[&_svg]:size-3.5\",\n                    )}\n                  >\n                    {suggestion.icon}\n                  </span>\n                ) : null}\n                {variant === \"card\" ? (\n                  <>\n                    <span className=\"min-w-0 flex-1\">\n                      <span className=\"block truncate text-sm font-medium text-foreground\">\n                        {highlight ? (\n                          <Highlighted text={suggestion.title} tokens={tokens} />\n                        ) : (\n                          suggestion.title\n                        )}\n                      </span>\n                      {suggestion.subtitle ? (\n                        <span className=\"mt-0.5 block truncate text-xs text-muted-foreground\">\n                          {highlight ? (\n                            <Highlighted\n                              text={suggestion.subtitle}\n                              tokens={tokens}\n                            />\n                          ) : (\n                            suggestion.subtitle\n                          )}\n                        </span>\n                      ) : null}\n                    </span>\n                    <ArrowUpRight\n                      aria-hidden=\"true\"\n                      className=\"mt-0.5 size-3.5 shrink-0 text-muted-foreground/0 transition-[color,translate] duration-200 group-hover/suggestion:translate-x-0.5 group-hover/suggestion:-translate-y-0.5 group-hover/suggestion:text-muted-foreground group-focus-visible/suggestion:text-muted-foreground motion-reduce:transition-none motion-reduce:group-hover/suggestion:translate-x-0 motion-reduce:group-hover/suggestion:translate-y-0\"\n                    />\n                  </>\n                ) : (\n                  <span className=\"truncate\">\n                    {highlight ? (\n                      <Highlighted text={suggestion.title} tokens={tokens} />\n                    ) : (\n                      suggestion.title\n                    )}\n                  </span>\n                )}\n              </button>\n            </motion.li>\n          ))}\n        </AnimatePresence>\n      </ul>\n      <AnimatePresence initial={false}>\n        {visible.length === 0 && emptyMessage !== null ? (\n          <motion.p\n            key=\"empty\"\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1, transition: { delay: 0.1, duration: 0.2 } }}\n            exit={{ opacity: 0, transition: { duration: 0.1 } }}\n            className=\"py-3 text-center text-xs text-muted-foreground\"\n          >\n            {emptyMessage}\n          </motion.p>\n        ) : null}\n      </AnimatePresence>\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {tokens.length\n          ? `${visible.length} suggestion${visible.length === 1 ? \"\" : \"s\"}`\n          : \"\"}\n      </p>\n    </div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}