Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Prompt bar

New

The composer of a chat with an agent: @ to mention a source, / for a command, a model picker, dictation and send, rounded or pill-shaped.

prompt-bar/prompt-bar.tsx
Live · interactive

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

When to use

  • The composer of a chat with an agent: one line to write in, with send, dictation and a model picker beside it.
  • When a message can point at sources: @ lists the data, files and apps the agent can read, / the commands it knows.
  • Products that offer a few models and want the choice next to the message, not buried in settings.
  • Short prompts that sometimes grow: the bar stays one row until the text would wrap, then gives the message its own row.

When not to use

  • Long-form writing or editing documents. Use a full editor with formatting.
  • A plain search field with no agent behind it. Use a search input or a command palette.
  • Chats between people: the model picker and the commands mean nothing there. Use a simpler message field.
  • Picking from many models with prices and limits to compare. Give that a settings page; the picker holds a handful.
  • Places where @ and / are ordinary text, like code or handles: the menus would keep opening.

You can add this component with the shadcn CLI or by hand:

Into components/prompt-bar of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/prompt-bar.json

Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

Usage

agent-composer.tsx
import { PromptBar, type PromptSource } from './prompt-bar';const sources: PromptSource[] = [  { id: 'files', label: 'Add photos & files', detail: 'Upload from your computer', attach: true },  { id: 'docs', label: 'Team docs', detail: 'Specs, notes and plans' },  { id: 'mail', label: 'Mail', detail: 'Read and manage mail', connect: true },];export function AgentComposer({ onSend }: { onSend: (message: string, model: string) => void }) {  return (    <PromptBar      sources={sources}      commands={[{ name: 'summarize', detail: 'Digest the thread so far' }]}      onSend={onSend}      onAttach={() => document.querySelector<HTMLInputElement>('#upload')?.click()}      shapeSwitch={false}      autoplay={false}    />  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

A one-line composer that grows into two rows once the message would wrap. Typing @ lists the sources to mention and / the commands, both filtered as you type; the + button opens the sources too, with a Connect link for one not yet connected. The model picker opens above its button, and choosing the flagship sweeps a band of colour across the bar. Dictation listens with three bouncing bars and writes what it heard. A switch under the bar turns its corners from rounded to a pill.

  • The composer is one grid. While the message fits on its line the + button, the text box, the model picker, dictation and send sit in one row; an invisible copy of the message in the same type measures it, and once it is wider than the room the row leaves the text box, the message takes the whole first row and the buttons drop below it, the model picker beside the +. A ResizeObserver keeps the choice right as the bar changes width, and the text box grows with its lines up to seven.
  • @ and / are read from the word that ends at the caret, wherever it is in the message: an @word opens the sources and a /word the commands, filtered by what follows the sign at the start of any word ("fla" finds Flavor records). Picking an entry replaces the word with "@Name " or "/name " and puts the caret after it.
  • Every menu pops in from the edge it opens from, 180 ms from 95 % and transparent on a fast-out curve, and closes at once. The source and command menus span the bar; the model menu is 176 px wide, opens 8 px above its button and is kept inside the bar.
  • Each menu has one highlight under its rows that glides to the row under the pointer or the keyboard (top and height over 220 ms, fading in over 150 ms). Rows are a fixed height, so its place is a sum, not a measurement. The demo moves it unseen, as a script would, and a new query hides it again.
  • Choosing a flagship model plays a flash on a canvas behind the bar’s content: a gradient whose hue runs from red at the left edge to violet at the right, and whose stops’ alpha is redrawn every frame. A front crosses the bar in about 110 ms; each point it passes holds its brightest, paler colour for 55 ms, dims to a faint tint, and the tint fades out by 900 ms.
  • Dictation tints the microphone button and swaps its icon for three bars that bounce by scaleY (900 ms each, 150 ms apart), with “Listening…” in the empty box and send disabled. Without onDictate it hears a sample sentence after 2.25 s; pressing the button again stops.
  • Send is a quiet grey square until there is something to send, then turns to ink (200 ms). Enter sends, Shift+Enter starts a new line, and the message clears.
  • While on screen and untouched, a demo replays a 13.5 s round: it types @ and walks the sources, connects the last one, types / and walks the commands, opens the model menu and picks the flagship, flash included, then puts the model back. Its changes never reach the callbacks and are not announced; any key or press hands the bar to the reader.
  • Every colour is a light-dark() token on the root, so the bar follows the page’s color-scheme or the colorScheme prop. Each shadow is written once with all its layers, those a scheme does not use being transparent: long soft lifts by day, short dark ones at night.
PropTypeDefaultDescription
sourcesPromptSource[]seven sample sourcesWhat @ and + list: { id, label, detail?, icon?, connect?, attach? }. connect shows a Connect link until it is connected; attach calls onAttach instead of mentioning it.
commandsPromptCommand[]five sample commandsWhat / lists: { name, detail? }, the name without its slash.
modelsPromptModel[]three sample modelsWhat the picker offers: { id, label, note?, flagship? }. Choosing a flagship model plays the flash.
modelstring—The chosen model’s id, when the parent keeps it (with onModelChange).
defaultModelstring'vanilla-1'The model chosen at first, when the bar keeps it.
onModelChange(id: string) => void—Called when the reader chooses another model.
shape'rounded' | 'pill'—The bar’s corners, when the parent keeps them (with onShapeChange).
defaultShape'rounded' | 'pill''rounded'The corners at first: 14 px, or a pill (24 px once the message wraps).
onShapeChange(shape: 'rounded' | 'pill') => void—Called when the reader switches the shape.
shapeSwitchbooleantrueShows the Rounded | Pill switch under the bar.
defaultStatePromptBarState—Opens the bar mid-way: value (the message; an @ or / word at its end opens that menu), menu ('plus' or 'model'), active (the lit row), connected (source ids) and listening. A menu it opens doesn’t take the focus. Setting it turns the demo off.
onSend(message: string, model: string) => void—Called with the trimmed message and the model’s id when the reader sends.
onAttach(source: PromptSource) => void—Called when a source marked attach is picked, to open a file picker, say.
onConnect(source: PromptSource) => void—Called when a source marked connect is connected.
onDictate() => Promise<string>—Listens and resolves to what was heard, added to the message. Without it, a sample sentence arrives after 2.25 s.
placeholderstring'Write a message…'The empty box’s words.
listeningLabelstring'Listening…'The empty box’s words while dictation listens.
autoplaybooleantrueWhile on screen and untouched, a demo plays the @ and / menus and the model picker by itself.
colorScheme'light' | 'dark' | 'system'—Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
classNamestring—Extra class for the root.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

prompt-bar.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useEffectEvent,  useId,  useLayoutEffect,  useRef,  useState,  type ChangeEvent,  type KeyboardEvent as ReactKeyboardEvent,  type ReactNode,} from 'react';import {  COMMANDS,  MODELS,  SAMPLE_TRANSCRIPT,  type PromptCommand,  type PromptModel,  type PromptShape,  type PromptSource,} from './data';
prompt-bar.module.css
/* Prompt bar (prompt-bar.tsx): a soft grey stage with the composer near its foot, 420 px wide at   most, its menus opening above it; at night a graphite stage with the composer a step lighter.   Colours are the tokens below, light-dark(light, dark): the component takes the color-scheme it   inherits from the page (light unless the page says otherwise), or the one its colorScheme prop   sets (data-color-scheme). Each shadow is one list for both schemes, each layer coloured for one   of them and clear in the other.   Motion: the menus pop in (opacity and scale), the highlight glides by top, the shape eases its   corners, the dictation bars bounce by scaleY. The flagship flash is drawn on a canvas. */.root {  /* The stage, the composer and menus, the highlight, and the shape switch's track. */  --canvas: light-dark(oklch(96.1% 0.002 247.84), oklch(23.1% 0.004 264.487));  --surface: light-dark(oklch(100% 0 0), oklch(26% 0.006 271.191));  --hover: light-dark(oklch(97% 0.002 247.839), oklch(28.9% 0.006 271.22));  --field: light-dark(oklch(96.1% 0.001 286.375), oklch(29.3% 0.006 271.223));  /* Words: strong, for buttons, and quiet. */  --ink: light-dark(oklch(24.7% 0.006 258.361), oklch(96.4% 0.002 247.839));  --ink-2: light-dark(oklch(50.6% 0.01 264.477), oklch(73.1% 0.008 260.731));  --ink-3: light-dark(oklch(69.5% 0.009 264.505), oklch(54.1% 0.01 264.484));  /* Edges: the composer's border, and the same focused (also the idle send button). */  --line: light-dark(oklch(94.6% 0.003 264.542), oklch(30.8% 0.006 258.354));  --line-strong: light-dark(oklch(91.2% 0.005 258.326), oklch(35.6% 0.007 264.474));
glow.ts
// Copyright (c) 2026 Bruno Delorence. MIT License./* * The flash that greets a flagship model: a band of spectrum colours that sweeps across the * composer from left to right in about 110 ms, lighting each point as it passes, and leaves a dim * tint behind that fades out by 900 ms. * * Each point across the width has its own hue, red at the left edge to violet at the right, so * the whole flash is a single horizontal gradient redrawn every frame: only its stops' alpha * changes. The canvas sits behind the composer's content and is cleared when the flash ends. *//** How long the flash lasts, in ms. */export const GLOW_DURATION = 900;/** Where the sweeping front is at 0 ms, in widths, and how far it travels per ms. */const FRONT_START = 0.08;const FRONT_SPEED = 0.0085;/** * How long a point stays at its brightest once the front has passed (ms), how fast it dims after * (ms), and the softness of the front (ms). */const HOLD = 55;const DECAY = 35;const LEAD = 25;
data.ts
// Copyright (c) 2026 Bruno Delorence. MIT License.import type { ReactNode } from 'react';/** Something a message can draw on: picked from the @ menu or the + menu. */export type PromptSource = {  id: string;  label: string;  /** The quieter words after the label. */  detail?: string;  /** A 22 px icon (decorative). */  icon?: ReactNode;  /** Shows a Connect button until it is connected. */  connect?: boolean;  /** Picking it calls onAttach instead of mentioning it (a file picker, say). */  attach?: boolean;};/** A / command. Its name is written without the slash. */export type PromptCommand = { name: string; detail?: string };export type PromptModel = {  id: string;  label: string;
icons.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License./* * The prompt bar's icons, drawn on a 24-unit grid and stroked with the text colour, except the * three coloured marks of the sample sources. All are decorative: the buttons carry the names. */const stroke = {  fill: 'none',  stroke: 'currentColor',  strokeLinecap: 'round',  strokeLinejoin: 'round',} as const;export function PlusIcon() {  return (    <svg width="16" height="16" viewBox="0 0 24 24" {...stroke} strokeWidth={2} aria-hidden="true">      <line x1="12" y1="5" x2="12" y2="19" />      <line x1="5" y1="12" x2="19" y2="12" />    </svg>  );}export function ArrowUpIcon() {
use-autoplay.ts
'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) {  const ref = useRef<T>(null);  const [visible, setVisible] = useState(false);  const [reduced, setReduced] = useState(true);  const [taken, setTaken] = useState(false);  const timer = useRef(0);  useEffect(() => {    const element = ref.current;    if (!element) return;    let onScreen = false;    const update = () => setVisible(onScreen && !document.hidden);    const observer = new IntersectionObserver(      ([entry]) => {        onScreen = entry.isIntersecting;
  • The text box is a combobox named “Prompt”. While an @ or / menu is open it controls a listbox of options and points at the highlighted one with aria-activedescendant, so focus never leaves the text: the up and down arrows move, Enter or Tab picks, Escape closes.
  • The + and model buttons say they open menus and whether they are expanded. Their menus take the focus when they open: arrows, Home and End move, Enter or Space picks, Escape closes and returns to the button, Tab closes. Models are menuitemradio entries with aria-checked.
  • A source that needs connecting says so in its name (“Mail, Read and manage mail, not connected”); Enter on it connects it, and Enter again mentions it. The Connect link itself is for the pointer.
  • The dictation button is a toggle (aria-pressed) whose name changes from “Start dictation” to “Stop dictation”; Send is disabled while there is nothing to send. The shape switch is a group of two toggle buttons.
  • A polite live region says what changed: the model chosen, a source connected, listening, what was heard, a message sent. Focus rings are visible on every button; the text box shows focus by its border.
  • Under reduced motion the menus appear without popping, the highlight jumps, the flash is skipped, the bars stand still and the demo never plays.