AI prompt bar
An AI prompt box that grows with the text. Attachments pop in as chips, the mic turns into a send arrow once you type.
Code
1. Install
npm i framer-motion lucide-react
2. Files
"use client";
// PromptBar: an AI prompt box that grows with the text. Attachments pop in as chips, the mic turns into a send arrow once you type.
import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { ArrowUp, Mic, Paperclip, Sparkles, X, Zap } from "lucide-react";
export type PromptBarCue = "send" | "blip" | "crumple" | "next" | "enable" | "disable" | "hold";
export type PromptSend = { text: string; model: string; deep: boolean; files: string[] };
export type PromptBarProps = {
/** The prompt text. Controlled. */
value?: string;
/** Initial text when uncontrolled. */
defaultValue?: string;
/** Called as the text changes. */
onChange?: (value: string) => void;
/** Model names the chip cycles through. */
models?: string[];
/** Chosen model index. Controlled. */
model?: number;
/** Initial model index when uncontrolled. */
defaultModel?: number;
/** Called with the model index when the chip is pressed. */
onModelChange?: (index: number) => void;
/** Deep mode on or off. Controlled. */
deep?: boolean;
/** Initial deep mode when uncontrolled. */
defaultDeep?: boolean;
/** Called when Deep is toggled. */
onDeepChange?: (deep: boolean) => void;
/** Label of the Deep toggle. */
deepLabel?: string;
/** Rotating placeholder hints shown while the box is empty. */
hints?: string[];
/** Milliseconds between hints. */
hintMs?: number;
/** File names the paperclip attaches in turn (a demo; wire onAttach to a real picker). */
files?: string[];
/** Called with the name when a file chip is added. */
onAttach?: (name: string) => void;
/** Called with the prompt, model, deep flag and files when it is sent (Enter, or the arrow). */
onSend?: (send: PromptSend) => void;
/** Aria label of the text box. */
label?: string;
/** Sound hook: "send" on send, "blip" on attach, "crumple" on remove, "enable" / "disable" on Deep, "next" on model change, "hold" on the mic. */
onSound?: (cue: PromptBarCue) => void;
className?: string;
style?: CSSProperties;
};
const SNAP = { type: "spring", stiffness: 380, damping: 34, mass: 0.8 } as const;
const POP = { type: "spring", stiffness: 500, damping: 18, mass: 0.7 } as const;
const MORPH = { type: "spring", stiffness: 340, damping: 30, mass: 0.8 } as const;
function useControlled<T>(value: T | undefined, def: T, onChange?: (v: T) => void) {
const [inner, setInner] = useState(def);
const update = (next: T) => {
if (value === undefined) setInner(next);
onChange?.(next);
};
return [value ?? inner, update] as const;
}
function useOnScreen(ref: { current: Element | null }) {
const [near, setNear] = useState(false);
const [tab, setTab] = useState(true);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (typeof IntersectionObserver === "undefined") return setNear(true);
const io = new IntersectionObserver(([e]) => setNear(e.isIntersecting), { rootMargin: "160px" });
io.observe(el);
return () => io.disconnect();
}, [ref]);
useEffect(() => {
const syncVisibility = () => setTab(!document.hidden);
syncVisibility();
document.addEventListener("visibilitychange", syncVisibility);
return () => document.removeEventListener("visibilitychange", syncVisibility);
}, []);
return near && tab;
}
export function PromptBar({
value,
defaultValue = "",
onChange,
models = ["Fable", "Opus", "Haiku"],
model: modelProp,
defaultModel = 0,
onModelChange,
deep: deepProp,
defaultDeep = false,
onDeepChange,
deepLabel = "Deep",
hints = ["Ask anything…", "Summarise this thread…", "Draft a reply to Alex…", "Explain the diff…"],
hintMs = 2800,
files: fileNames = ["spec.pdf", "mood.png", "notes.md"],
onAttach,
onSend,
label = "Prompt",
onSound,
className,
style,
}: PromptBarProps) {
const [text, setText] = useControlled(value, defaultValue, onChange);
const [modelRaw, setModel] = useControlled(modelProp, defaultModel, onModelChange);
const [deep, setDeep] = useControlled(deepProp, defaultDeep, onDeepChange);
const model = models.length ? ((modelRaw % models.length) + models.length) % models.length : 0;
const [files, setFiles] = useState<string[]>([]);
const [hint, setHint] = useState(0);
const [sent, setSent] = useState(false);
const ta = useRef<HTMLTextAreaElement>(null);
const sentTimer = useRef(0);
useEffect(() => () => window.clearTimeout(sentTimer.current), []);
const root = useRef<HTMLDivElement>(null);
const on = useOnScreen(root); // hints only rotate while someone can see them
useEffect(() => {
if (!on || text || hints.length < 2) return;
const id = setInterval(() => setHint((h) => (h + 1) % hints.length), hintMs);
return () => clearInterval(id);
}, [on, text, hints.length, hintMs]);
const grow = () => {
const el = ta.current;
if (!el) return;
el.style.height = "0px";
el.style.height = `${Math.min(120, el.scrollHeight)}px`;
};
useEffect(grow, [text]);
const send = () => {
if (!text.trim()) return;
onSound?.("send");
onSend?.({ text, model: models[model] ?? "", deep, files });
setText("");
setFiles([]);
ta.current?.focus();
setSent(true);
window.clearTimeout(sentTimer.current);
sentTimer.current = window.setTimeout(() => setSent(false), 500);
};
const attach = () => {
const next = fileNames.find((f) => !files.includes(f));
if (!next) return;
onSound?.("blip");
setFiles((f) => [...f, next]);
onAttach?.(next);
};
const removeFile = (name: string) => {
onSound?.("crumple");
setFiles((current) => current.filter((file) => file !== name));
ta.current?.focus();
};
const nextModel = () => {
onSound?.("next");
setModel((model + 1) % models.length);
};
const toggleDeep = () => {
onSound?.(deep ? "disable" : "enable");
setDeep(!deep);
};
const submitOnEnter = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key !== "Enter" || e.shiftKey || e.nativeEvent.isComposing) return;
e.preventDefault();
send();
};
const canSend = text.trim().length > 0;
const hintText = hints[hint % Math.max(1, hints.length)];
return (
<div ref={root} className={className ? `pbar-root ${className}` : "pbar-root"} style={style}>
<motion.div layout className="pbar-bar" data-sent={sent || undefined} transition={MORPH}>
<AnimatePresence initial={false}>
{files.length > 0 && (
<motion.div key="files" layout className="pbar-files" initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }} transition={SNAP}>
<AnimatePresence mode="popLayout" initial={false}>
{files.map((f) => (
<motion.span key={f} layout className="pbar-chip" initial={{ opacity: 0, scale: 0.7, filter: "blur(4px)" }} animate={{ opacity: 1, scale: 1, filter: "blur(0px)", transitionEnd: { filter: "none" } }} exit={{ opacity: 0, scale: 0.7, filter: "blur(4px)" }} transition={POP}>
<Paperclip size={11} /> {f}
<button type="button" aria-label={`Remove ${f}`} onClick={() => removeFile(f)}>
<X size={11} />
</button>
</motion.span>
))}
</AnimatePresence>
</motion.div>
)}
</AnimatePresence>
<div className="pbar-field">
<textarea
ref={ta}
className="pbar-ta"
rows={1}
value={text}
aria-label={label}
onChange={(e) => setText(e.target.value)}
onKeyDown={submitOnEnter}
/>
<AnimatePresence mode="popLayout" initial={false}>
{!text && hintText && (
<motion.span key={hint} className="pbar-hint" aria-hidden initial={{ opacity: 0, y: 6, filter: "blur(4px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)", transitionEnd: { filter: "none" } }} exit={{ opacity: 0, y: -6, filter: "blur(4px)" }} transition={{ duration: 0.22 }}>
{hintText}
</motion.span>
)}
</AnimatePresence>
</div>
<div className="pbar-row">
<button type="button" className="pbar-ib" aria-label="Attach file" onClick={attach}>
<Paperclip size={15} />
</button>
{models.length > 0 && (
<button type="button" className="pbar-chipbtn" aria-label={`Model: ${models[model]}. Press to change.`} onClick={nextModel}>
<Sparkles size={12} />
<span className="pbar-swap">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span key={model} initial={{ opacity: 0, y: 8, filter: "blur(4px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)", transitionEnd: { filter: "none" } }} exit={{ opacity: 0, y: -8, filter: "blur(4px)" }} transition={{ duration: 0.18 }}>
{models[model]}
</motion.span>
</AnimatePresence>
</span>
</button>
)}
<button type="button" className="pbar-chipbtn" data-on={deep || undefined} aria-pressed={deep} onClick={toggleDeep}>
<Zap size={12} /> {deepLabel}
</button>
<span className="pbar-grow" />
<motion.button type="button" className="pbar-send" data-ready={canSend || undefined} aria-label={canSend ? "Send" : "Voice"} onClick={canSend ? send : () => onSound?.("hold")} whileTap={{ scale: 0.92 }} transition={SNAP}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span key={canSend ? "send" : "mic"} className="pbar-glyph" initial={{ opacity: 0, scale: 0.5, rotate: canSend ? 90 : -90, filter: "blur(3px)" }} animate={{ opacity: 1, scale: 1, rotate: 0, filter: "blur(0px)", transitionEnd: { filter: "none" } }} exit={{ opacity: 0, scale: 0.5, rotate: canSend ? -90 : 90, filter: "blur(3px)" }} transition={{ ...POP, filter: { duration: 0.15 } }}>
{canSend ? <ArrowUp size={16} strokeWidth={2.6} /> : <Mic size={15} strokeWidth={2.2} />}
</motion.span>
</AnimatePresence>
</motion.button>
</div>
</motion.div>
</div>
);
}
/* PromptBar. Prefix pbar-. */
.pbar-root, .pbar-root * { box-sizing: border-box; }
:where(.pbar-root) button { font: inherit; color: inherit; background: none; border: 0; padding: 0; margin: 0; }
/* The block reserves its tallest state (two rows of chips + a 120px textarea) and the bar grows from its centre
inside it, so it sits centred at rest and typing or attaching never changes the block's height. */
.pbar-root {
--pbar-on: var(--ts-fg, #14161b);
--pbar-on-rgb: var(--ts-fg-rgb, 20, 22, 27);
--pbar-panel: var(--ts-raised, #fff);
font-family: var(--ts-font, system-ui, sans-serif);
width: 276px;
min-height: 240px;
display: flex;
flex-direction: column;
justify-content: center;
color: var(--pbar-on);
}
.pbar-bar {
display: flex;
flex-direction: column;
gap: 6px;
padding: 11px 14px 8px;
border-radius: var(--ts-r-panel);
background: var(--pbar-panel);
box-shadow: var(--ts-edge);
transition: box-shadow 0.22s cubic-bezier(.22, 1, .36, 1);
}
.pbar-bar:focus-within {
box-shadow: 0 0 0 1px rgba(var(--pbar-on-rgb), 0.3);
}
.pbar-bar[data-sent] {
box-shadow: 0 0 0 2px var(--ts-accent, #3446eb);
}
.pbar-files {
display: flex;
flex-wrap: wrap;
gap: 4px;
overflow: hidden;
padding: 0 2px;
}
.pbar-chip {
display: inline-flex;
align-items: center;
gap: 5px;
height: 24px;
padding: 0 4px 0 8px;
border-radius: 999px;
background: rgba(var(--pbar-on-rgb), 0.06);
font-size: 12px;
font-weight: 500;
}
.pbar-chip button {
width: 16px;
height: 16px;
border: 0;
border-radius: 50%;
background: none;
color: rgba(var(--pbar-on-rgb), 0.6);
cursor: pointer;
display: grid;
place-items: center;
}
.pbar-chip button:hover {
background: rgba(var(--pbar-on-rgb), 0.08);
color: var(--pbar-on);
}
.pbar-field {
position: relative;
padding: 2px 4px;
}
.pbar-ta {
display: block;
width: 100%;
min-height: 24px;
max-height: 120px;
padding: 0;
border: 0;
background: none;
color: var(--pbar-on);
font: inherit;
font-size: 14px;
line-height: 24px;
resize: none;
outline: 0;
}
.pbar-hint {
position: absolute;
left: 4px;
top: 2px;
line-height: 24px;
font-size: 14px;
color: rgba(var(--pbar-on-rgb), 0.5);
pointer-events: none;
}
.pbar-row {
display: flex;
align-items: center;
gap: 4px;
}
.pbar-ib,
.pbar-chipbtn,
.pbar-send {
border: 0;
background: none;
color: rgba(var(--pbar-on-rgb), 0.68);
cursor: pointer;
font: inherit;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
-webkit-tap-highlight-color: transparent;
}
.pbar-ib {
width: 30px;
height: 30px;
border-radius: 50%;
transition: background 0.16s cubic-bezier(.22, 1, .36, 1), color 0.16s cubic-bezier(.22, 1, .36, 1);
}
.pbar-ib:hover {
background: rgba(var(--pbar-on-rgb), 0.06);
color: var(--pbar-on);
}
.pbar-chipbtn {
height: 28px;
padding: 0 9px;
border-radius: 999px;
font-size: 12px;
font-weight: 500;
background: rgba(var(--pbar-on-rgb), 0.06);
transition: background 0.16s cubic-bezier(.22, 1, .36, 1), color 0.16s cubic-bezier(.22, 1, .36, 1), scale 0.22s cubic-bezier(.22, 1, .36, 1);
}
.pbar-chipbtn:active {
scale: 0.96;
}
.pbar-chipbtn[data-on] {
background: var(--pbar-on);
color: var(--pbar-panel);
}
.pbar-swap {
display: inline-grid;
min-width: 36px;
text-align: left;
}
.pbar-grow {
flex: 1;
}
.pbar-send {
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(var(--pbar-on-rgb), 0.06);
color: rgba(var(--pbar-on-rgb), 0.82);
transition: background 0.22s cubic-bezier(.22, 1, .36, 1), color 0.22s cubic-bezier(.22, 1, .36, 1);
overflow: hidden;
}
.pbar-send[data-ready] {
background: var(--pbar-on);
color: var(--pbar-panel);
}
.pbar-glyph {
display: grid;
place-items: center;
}
.pbar-ib:focus-visible,
.pbar-chip button:focus-visible,
.pbar-chipbtn:focus-visible,
.pbar-send:focus-visible {
outline: 0;
box-shadow: 0 0 0 2px var(--pbar-panel), 0 0 0 4px var(--ts-accent, #3446eb);
}
/*
* 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
import { PromptBar } from "./PromptBar";
import "./PromptBar.css";
export default function Demo() {
return <PromptBar />;
}