Teseria
All elements

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

Terminal
npm i framer-motion lucide-react

2. Files

PromptBar.tsx240 lines
"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.css171 lines
/* 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);
}
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 { PromptBar } from "./PromptBar";
import "./PromptBar.css";

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