{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "prompt-bar", "type": "registry:component", "title": "Prompt Bar", "description": "Composer with @ sources, / commands, model picker, and dictation.", "dependencies": [ "glimm" ], "registryDependencies": [ "https://www.beautifului.dev/r/foundation.json" ], "files": [ { "path": "components/primitives/PromptBar.tsx", "content": "\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { createShader, playSweep, accentChain, ACCENTS } from \"glimm\";\n\n/* The built-in \"prism\" palette is only cyan→indigo→magenta, so a sweep\n * reads as blue/purple. Build a true full-spectrum rainbow instead. */\nconst RAINBOW = accentChain([\n ACCENTS.red,\n ACCENTS.orange,\n ACCENTS.yellow,\n ACCENTS.green,\n ACCENTS.cyan,\n ACCENTS.blue,\n ACCENTS.purple,\n]);\n\n/* ─────────────────────────────────────────────────────────\n * PROMPT BAR\n * A composer with real controls: attach, @ data sources,\n * / commands, a model picker, dictation, and send.\n * Type @ or / to open the menus; ↑↓ + Enter to pick.\n * Variants: Rounded (card radius) · Pill (full radius).\n * ───────────────────────────────────────────────────────── */\n\nfunction Icon({ children, size = 15, strokeWidth = 1.8 }: { children: React.ReactNode; size?: number; strokeWidth?: number }) {\n return (\n \n {children}\n \n );\n}\n\nconst GLYPHS: Record = {\n clip: ,\n chart: ,\n layers: ,\n globe: ,\n};\n\n/* real product marks, inline so the file stays self-contained */\nconst BRANDS: Record = {\n figma: (\n \n \n \n \n \n \n \n ),\n slack: (\n \n \n \n \n \n \n ),\n gmail: (\n \n \n \n \n \n \n \n ),\n};\n\ntype Source = {\n key: string;\n name: string;\n desc: string;\n glyph?: string;\n brand?: string;\n attach?: boolean;\n connect?: boolean;\n};\n\nconst SOURCES: Source[] = [\n { key: \"attach\", name: \"Add photos & files\", desc: \"Upload from your computer\", glyph: \"clip\", attach: true },\n { key: \"scoop\", name: \"Scoop Data\", desc: \"Sales & churn metrics\", glyph: \"chart\" },\n { key: \"flavors\", name: \"Flavor records\", desc: \"26 makers, tags, links\", glyph: \"layers\" },\n { key: \"web\", name: \"Web search\", desc: \"Real-time news and info\", glyph: \"globe\" },\n { key: \"figma\", name: \"Figma\", desc: \"Design-to-code workflows\", brand: \"figma\" },\n { key: \"slack\", name: \"Slack\", desc: \"Read and manage Slack\", brand: \"slack\" },\n { key: \"gmail\", name: \"Gmail\", desc: \"Read and manage Gmail\", brand: \"gmail\", connect: true },\n];\n\nconst COMMANDS = [\n { key: \"compare\", name: \"/compare\", desc: \"Flavor vs. last summer\" },\n { key: \"churn-plan\", name: \"/churn-plan\", desc: \"Draft a churn schedule\" },\n { key: \"restock\", name: \"/restock\", desc: \"Build a reorder list\" },\n { key: \"draft-email\", name: \"/draft-email\", desc: \"Write a supplier email\" },\n { key: \"summarize\", name: \"/summarize\", desc: \"Digest the thread so far\" },\n];\n\nconst MODELS = [\n { key: \"sprinkles-5\", name: \"Sprinkles 5\", tag: \"Flagship\" },\n { key: \"vanilla-1\", name: \"Vanilla 1\", tag: \"Basic\" },\n { key: \"freezer-burn\", name: \"Freezer Burn 0.4\", tag: \"Stale\" },\n];\n\nconst FILES = [\"flavor-chart.png\", \"summer-menu.pdf\", \"pos-export.csv\"];\nconst DICTATION = \"Compare pistachio weekends to last summer\";\n\n/* self-running demo: walk the @ menu, then the / menu, and repeat.\n * Any pointer or key interaction hands control to the user. */\nconst AUTO_STEPS: {\n draft: string;\n active?: number;\n connect?: boolean;\n modelOpen?: boolean;\n model?: string;\n hold: number;\n}[] = [\n { draft: \"\", connect: false, model: \"vanilla-1\", hold: 1100 },\n { draft: \"@\", active: 0, hold: 900 },\n { draft: \"@\", active: 1, hold: 620 },\n { draft: \"@\", active: 4, hold: 620 },\n { draft: \"@\", active: 6, hold: 700 },\n { draft: \"@\", active: 6, connect: true, hold: 1000 },\n { draft: \"\", hold: 700 },\n { draft: \"/\", active: 0, hold: 900 },\n { draft: \"/\", active: 1, hold: 620 },\n { draft: \"/\", active: 3, hold: 1000 },\n { draft: \"\", hold: 800 },\n // open the model picker and upgrade to the flagship → rainbow sweep\n { draft: \"\", modelOpen: true, hold: 1200 },\n { draft: \"\", model: \"sprinkles-5\", hold: 2400 },\n { draft: \"\", hold: 900 },\n];\n\n/* the last @word or /word being typed, if any */\nfunction parseToken(draft: string): { kind: \"at\" | \"slash\"; query: string; start: number } | null {\n const match = /(^|\\s)([@/])([\\w-]*)$/.exec(draft);\n if (!match) return null;\n return {\n kind: match[2] === \"@\" ? \"at\" : \"slash\",\n query: match[3].toLowerCase(),\n start: match.index + match[1].length,\n };\n}\n\nexport default function PromptBar({\n variant = \"Rounded\",\n demo = true,\n tall = false,\n placeholder,\n onSend,\n}: {\n variant?: string;\n /** the self-running walkthrough; turn off when embedding in a real surface */\n demo?: boolean;\n /** hero sizing: a multi-line input with controls on their own row */\n tall?: boolean;\n placeholder?: string;\n onSend?: (text: string) => void;\n}) {\n const pill = variant === \"Pill\";\n const [draft, setDraft] = useState(\"\");\n const [dismissed, setDismissed] = useState(false);\n const [plusOpen, setPlusOpen] = useState(false);\n const [modelOpen, setModelOpen] = useState(false);\n const [model, setModel] = useState(MODELS[1]);\n const [attachments, setAttachments] = useState([]);\n const [connected, setConnected] = useState(false);\n const [active, setActive] = useState(0);\n const [listening, setListening] = useState(false);\n const [auto, setAuto] = useState(demo);\n const [autoStep, setAutoStep] = useState(0);\n const [expanded, setExpanded] = useState(false);\n const wide = expanded || tall;\n const [rowBox, setRowBox] = useState<{ top: number; height: number } | null>(null);\n const [engaged, setEngaged] = useState(false);\n const [modelBox, setModelBox] = useState<{ top: number; height: number } | null>(null);\n const [modelHovered, setModelHovered] = useState(null);\n const [modelMenuLeft, setModelMenuLeft] = useState(0);\n const [modelMenuBottom, setModelMenuBottom] = useState(0);\n const composerAnchorRef = useRef(null);\n const controlsRef = useRef(null);\n const inputRef = useRef(null);\n const measureRef = useRef(null);\n const modelRef = useRef(null);\n const rowRefs = useRef<(HTMLButtonElement | null)[]>([]);\n const modelRowRefs = useRef<(HTMLButtonElement | null)[]>([]);\n const glimmRef = useRef(null);\n const shaderRef = useRef | null>(null);\n const sweepingRef = useRef(false);\n\n /* hand control to the user: stop the demo loop, and when they aim at\n * the input itself, clear the demo's leftover draft for a clean start */\n const takeOver = (event: { target: EventTarget | null }) => {\n setAuto(false);\n if (auto && event.target === inputRef.current) setDraft(\"\");\n };\n\n const token = dismissed ? null : parseToken(draft);\n const menu: \"at\" | \"slash\" | null = plusOpen ? \"at\" : token?.kind ?? null;\n const query = plusOpen ? \"\" : token?.query ?? \"\";\n\n const rows: { key: string; name: string; desc: string }[] =\n menu === \"at\"\n ? SOURCES.filter((s) => s.name.toLowerCase().includes(query))\n : menu === \"slash\"\n ? COMMANDS.filter((c) => c.name.slice(1).startsWith(query))\n : [];\n\n useEffect(() => {\n setActive(0);\n setEngaged(false);\n }, [menu, query]);\n\n /* a single highlight glides to the active row instead of each row\n * toggling its own background — matches the gliding pill in the nav */\n useLayoutEffect(() => {\n const target = rowRefs.current[active];\n if (target) setRowBox({ top: target.offsetTop, height: target.offsetHeight });\n }, [menu, query, active, connected, rows.length]);\n\n /* same gliding highlight in the model menu — floats to the hovered\n * row, falling back to the currently-selected model */\n const modelIndex = MODELS.findIndex((m) => m.key === model.key);\n useLayoutEffect(() => {\n if (!modelOpen) return;\n const target = modelRowRefs.current[modelHovered ?? modelIndex];\n if (target) setModelBox({ top: target.offsetTop, height: target.offsetHeight });\n }, [modelOpen, modelHovered, modelIndex]);\n\n /* The menu is outside the clipped composer, so align it to the model\n * trigger by measurement instead of pinning it to the far-right edge. */\n useLayoutEffect(() => {\n if (!modelOpen || !composerAnchorRef.current || !modelRef.current) return;\n const anchorRect = composerAnchorRef.current.getBoundingClientRect();\n const triggerRect = modelRef.current.getBoundingClientRect();\n setModelMenuLeft(Math.max(0, Math.min(triggerRect.left - anchorRect.left, anchorRect.width - 176)));\n setModelMenuBottom(anchorRect.bottom - triggerRect.top + 8);\n }, [modelOpen, wide, model.name]);\n\n useEffect(() => {\n if (!modelOpen) setModelHovered(null);\n }, [modelOpen]);\n\n /* Build the shader with a pinned hue phase. createShader seeds its\n * internal hueShift from Math.random(), which made the sweep a different\n * colour on every reload — pin it so the rainbow is identical each time. */\n const makeShader = () => {\n const canvas = glimmRef.current;\n if (!canvas) return null;\n const random = Math.random;\n Math.random = () => 0;\n try {\n return createShader({\n canvas,\n palette: RAINBOW,\n direction: \"ltr\",\n bandTight: 10,\n swellAmount: 0.85,\n });\n } finally {\n Math.random = random;\n }\n };\n\n /* Glimm shader lives inside the composer, invisible at rest. Selecting\n * the flagship model fires a one-shot rainbow sweep across the interior. */\n useEffect(() => {\n shaderRef.current = makeShader();\n return () => {\n shaderRef.current?.destroy();\n shaderRef.current = null;\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n const celebrate = () => {\n if (sweepingRef.current) return;\n if (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) return;\n // Recreate the shader per sweep so uTime restarts at 0 — the hue phase\n // (which drifts with time) is then identical on every trigger.\n shaderRef.current?.destroy();\n const shader = makeShader();\n shaderRef.current = shader;\n if (!shader) return;\n sweepingRef.current = true;\n const sweep = playSweep(shader, {\n palette: RAINBOW,\n direction: \"ltr\",\n sweepMs: 570,\n outroMs: 80,\n peakAlpha: 1.3,\n bandTight: 10,\n brightness: 1.4,\n swellAmount: 1,\n waveSpeed: 1.8,\n easing: \"easeOutExpo\",\n });\n sweep.done.finally(() => {\n sweepingRef.current = false;\n });\n };\n\n const selectModel = (next: (typeof MODELS)[number]) => {\n setModel(next);\n setModelOpen(false);\n if (next.key === \"sprinkles-5\") celebrate();\n };\n\n /* autoplay: apply the current step, then advance after its hold */\n useEffect(() => {\n if (!auto) return;\n const step = AUTO_STEPS[autoStep % AUTO_STEPS.length];\n setDraft(step.draft);\n if (step.active !== undefined) setActive(step.active);\n if (step.connect !== undefined) setConnected(step.connect);\n if (step.modelOpen !== undefined) setModelOpen(step.modelOpen);\n if (step.model) {\n const next = MODELS.find((m) => m.key === step.model);\n if (next) selectModel(next);\n }\n const t = setTimeout(() => setAutoStep((s) => s + 1), step.hold);\n return () => clearTimeout(t);\n }, [auto, autoStep]);\n\n /* dictation resolves after a beat, like a real transcript landing */\n useEffect(() => {\n if (!listening) return;\n const t = setTimeout(() => {\n setDraft((current) => (current ? `${current.trimEnd()} ${DICTATION}` : DICTATION));\n setListening(false);\n inputRef.current?.focus();\n }, 2200);\n return () => clearTimeout(t);\n }, [listening]);\n\n /* Move wrapped text above the controls, then grow to a compact maximum. */\n useLayoutEffect(() => {\n const input = inputRef.current;\n const controls = controlsRef.current;\n const measure = measureRef.current;\n const modelButton = modelRef.current;\n if (!input || !controls || !measure || !modelButton) return;\n\n const fixedControlsWidth = 28 * 3 + modelButton.offsetWidth;\n const inlineGaps = 4 * 4;\n const inlineInputWidth = controls.clientWidth - fixedControlsWidth - inlineGaps;\n const needsFullWidth = draft.includes(\"\\n\") || measure.offsetWidth + 8 > inlineInputWidth;\n if (needsFullWidth !== expanded) {\n setExpanded(needsFullWidth);\n }\n\n const minHeight = 28;\n const maxHeight = 100;\n input.style.height = \"0px\";\n const contentHeight = input.scrollHeight;\n input.style.height = `${Math.min(Math.max(contentHeight, minHeight), maxHeight)}px`;\n input.style.overflowY = contentHeight > maxHeight ? \"auto\" : \"hidden\";\n }, [draft, expanded]);\n\n /* clicking anywhere outside the composer closes the open menus */\n useEffect(() => {\n if (!modelOpen && !plusOpen) return;\n const close = (event: PointerEvent) => {\n if (!(event.target as Element).closest(\"[data-promptbar]\")) {\n setModelOpen(false);\n setPlusOpen(false);\n }\n };\n document.addEventListener(\"pointerdown\", close);\n return () => document.removeEventListener(\"pointerdown\", close);\n }, [modelOpen, plusOpen]);\n\n const closeMenus = () => {\n setPlusOpen(false);\n setModelOpen(false);\n };\n\n const pick = (row: { key: string; name: string }) => {\n const source = SOURCES.find((s) => s.key === row.key);\n if (source?.attach) {\n setAttachments((current) => [...current, FILES[current.length % FILES.length]]);\n if (token) setDraft(draft.slice(0, token.start));\n } else if (menu === \"at\") {\n setDraft(`${token ? draft.slice(0, token.start) : draft}@${row.name} `);\n } else {\n setDraft(`${token ? draft.slice(0, token.start) : draft}${row.name} `);\n }\n setPlusOpen(false);\n setDismissed(false);\n inputRef.current?.focus();\n };\n\n const canSend = draft.trim().length > 0 || attachments.length > 0;\n const send = () => {\n if (!canSend) return;\n onSend?.(draft.trim());\n setDraft(\"\");\n setAttachments([]);\n closeMenus();\n };\n\n return (\n \n {/* composer is the anchor — menus grow up from its top edge */}\n
\n {/* ── @ / slash menu ─────────────────────────────── */}\n {menu && (\n setEngaged(false)}\n className=\"absolute inset-x-0 bottom-full z-10 mb-2 rounded-[10px] bg-surface p-1 shadow-raised\"\n style={{ animation: \"pop-in 180ms cubic-bezier(0.23,1,0.32,1) both\", transformOrigin: \"bottom center\" }}\n >\n {/* single gliding highlight — appears once a row is hovered */}\n 0 ? 1 : 0,\n transition:\n \"top 220ms cubic-bezier(0.23,1,0.32,1), height 220ms cubic-bezier(0.23,1,0.32,1), opacity 150ms ease\",\n }}\n />\n {rows.map((row, i) => {\n const source = menu === \"at\" ? SOURCES.find((s) => s.key === row.key) : undefined;\n return (\n {\n rowRefs.current[i] = el;\n }}\n onMouseDown={(event) => event.preventDefault()}\n onMouseEnter={() => {\n setActive(i);\n setEngaged(true);\n }}\n onClick={() => pick(row)}\n className=\"relative z-10 flex h-9 w-full items-center gap-2.5 rounded-[6px] px-2 text-left\"\n >\n {source && (\n \n {source.brand ? BRANDS[source.brand] : {GLYPHS[source.glyph ?? \"clip\"]}}\n \n )}\n \n {row.name}\n \n {row.desc}\n {source?.connect && (\n {\n event.stopPropagation();\n setConnected((current) => !current);\n }}\n className={`shrink-0 text-[12px] font-medium transition-colors duration-100 ${\n connected ? \"text-green\" : \"text-accent-ink hover:underline\"\n }`}\n >\n {connected ? \"Connected\" : \"Connect\"}\n \n )}\n \n );\n })}\n {rows.length === 0 && (\n
\n No matches for “{query}”\n
\n )}\n
\n {menu === \"at\" ? \"Type to search sources & files\" : \"Type to search commands\"}\n
\n
\n )}\n\n {/* ── model menu ─────────────────────────────────── */}\n {modelOpen && (\n setModelHovered(null)}\n className=\"absolute z-10 w-44 rounded-[10px] bg-surface p-1 shadow-raised\"\n style={{ left: modelMenuLeft, bottom: modelMenuBottom, animation: \"pop-in 180ms cubic-bezier(0.23,1,0.32,1) both\", transformOrigin: \"bottom left\" }}\n >\n {/* single gliding highlight — floats to the hovered / selected row */}\n \n {MODELS.map((m, i) => (\n {\n modelRowRefs.current[i] = el;\n }}\n onMouseDown={(event) => event.preventDefault()}\n onMouseEnter={() => setModelHovered(i)}\n onClick={() => {\n selectModel(m);\n inputRef.current?.focus();\n }}\n className=\"relative z-10 flex h-7.5 w-full items-center gap-2 rounded-[6px] px-2 text-left\"\n >\n {m.name}\n {m.tag}\n \n \n \n \n ))}\n \n )}\n\n {/* ── composer ───────────────────────────────────── */}\n 0 || wide ? \"rounded-[24px]\" : \"rounded-full\") : tall ? \"rounded-[22px]\" : \"rounded-[14px]\"\n }`}\n >\n {/* rainbow glimm sweep — plays across the interior on model change.\n explicit w/h: a is a replaced element and won't stretch\n to inset-0 alone, which feeds back into the shader's ResizeObserver. */}\n \n \n {draft}\n \n\n {attachments.length > 0 && (\n
\n {attachments.map((file, i) => (\n \n \n {file}\n setAttachments((current) => current.filter((_, j) => j !== i))}\n className={`-my-1 flex size-6 items-center justify-center text-ink-3 transition-colors duration-100 hover:bg-line/70 hover:text-ink ${\n pill ? \"rounded-full\" : \"rounded-[5px]\"\n }`}\n >\n \n \n \n ))}\n
\n )}\n\n \n {\n setModelOpen(false);\n setPlusOpen((current) => !current);\n inputRef.current?.focus();\n }}\n className={`flex size-7 shrink-0 items-center justify-center justify-self-start text-ink-3 transition-[background-color,color,transform] duration-150 hover:bg-hover hover:text-ink active:scale-[0.94] ${\n pill ? \"rounded-full\" : \"rounded-[8px]\"\n } ${plusOpen ? \"bg-hover text-ink\" : \"\"} ${wide ? \"col-start-1 row-start-2\" : \"col-start-1 row-start-1\"}`}\n >\n \n \n\n {\n setDraft(event.target.value);\n setDismissed(false);\n setPlusOpen(false);\n }}\n onKeyDown={(event) => {\n if (menu && rows.length > 0) {\n if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n event.preventDefault();\n setEngaged(true);\n setActive((current) => (current + (event.key === \"ArrowDown\" ? 1 : rows.length - 1)) % rows.length);\n return;\n }\n if ((event.key === \"Enter\" && !event.shiftKey) || event.key === \"Tab\") {\n event.preventDefault();\n pick(rows[active]);\n return;\n }\n }\n if (event.key === \"Escape\") {\n setDismissed(true);\n closeMenus();\n return;\n }\n if (event.key === \"Enter\" && !event.shiftKey && !event.nativeEvent.isComposing) {\n event.preventDefault();\n send();\n }\n }}\n placeholder={listening ? \"Listening…\" : placeholder ?? \"Write a message…\"}\n aria-label=\"Prompt\"\n className={`${tall ? \"min-h-[68px] px-2 py-2 text-[14px] leading-5\" : \"min-h-7 px-1 py-[5px] text-[13px] leading-[18px]\"} min-w-0 w-full resize-none bg-transparent text-ink outline-none [overflow-wrap:anywhere] placeholder:text-ink-3 ${\n wide ? \"col-span-full col-start-1 row-start-1\" : \"col-start-2 row-start-1\"\n }`}\n />\n\n {/* model picker */}\n {\n setPlusOpen(false);\n setModelOpen((current) => !current);\n }}\n className={`flex h-7 shrink-0 items-center gap-1 px-1.5 text-[12px] font-medium text-ink-2 transition-colors duration-150 hover:bg-hover hover:text-ink ${\n pill ? \"rounded-full\" : \"rounded-[8px]\"\n } ${wide ? \"col-start-2 row-start-2 justify-self-start\" : \"col-start-3 row-start-1\"}`}\n >\n {model.name}\n \n \n \n \n\n {/* dictation */}\n setListening((current) => !current)}\n className={`flex size-7 shrink-0 items-center justify-center transition-[background-color,color,transform] duration-150 active:scale-[0.94] ${\n pill ? \"rounded-full\" : \"rounded-[8px]\"\n } ${listening ? \"bg-accent-tint text-accent-ink\" : \"text-ink-3 hover:bg-hover hover:text-ink\"} ${wide ? \"col-start-4 row-start-2\" : \"col-start-4 row-start-1\"}`}\n >\n {listening ? (\n \n {[0, 1, 2].map((i) => (\n \n ))}\n \n ) : (\n \n )}\n \n\n {/* send — tactile square (round in the pill variant) */}\n \n \n \n \n \n \n \n );\n}\n", "type": "registry:component", "target": "components/primitives/PromptBar.tsx" } ] }