AI
Prompt composer
The box where a person writes to an assistant: grows with the text, holds attachments, sends and stops.
- Built on
<form><textarea>field-sizing: content:has()- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 3.7 kB
- Built from
- Button
Code
"use client";
import { useState } from "react";
import { PromptComposer } from "@/components/equip/prompt-composer";
export default function Demo() {
const [files, setFiles] = useState([{ id: "log", name: "deploy-412.log" }]);
return (
<PromptComposer
style={{ width: 224 }}
placeholder="Ask about this deploy"
attachments={files}
onRemoveAttachment={(id) => setFiles((list) => list.filter((file) => file.id !== id))}
/>
);
}Examples
Send and stop
While busy the send button becomes stop, Escape stops too, and the box stays editable so the next question can be drafted.
Nothing sent yet.
Code
"use client";
import { useEffect, useState } from "react";
import { PromptComposer } from "@/components/equip/prompt-composer";
export default function Example() {
const [busy, setBusy] = useState(false);
const [sent, setSent] = useState<string | null>(null);
useEffect(() => {
if (!busy) return;
const timer = setTimeout(() => setBusy(false), 4000);
return () => clearTimeout(timer);
}, [busy]);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8, maxWidth: 480 }}>
<PromptComposer
placeholder="Ask about the failed health check"
busy={busy}
onSubmit={(text) => {
setSent(text);
setBusy(true);
}}
onStop={() => setBusy(false)}
/>
<p style={{ margin: 0, fontSize: 13, color: "var(--eq-ink-2)" }} aria-live="polite">
{sent == null ? "Nothing sent yet." : busy ? `Answering "${sent}"` : `Last sent: "${sent}"`}
</p>
</div>
);
}Tools, attachments and a hint
The box grows with its text up to maxRows lines, then scrolls.
Code
"use client";
import { useRef, useState } from "react";
import { Button } from "@/components/equip/button";
import { PromptComposer } from "@/components/equip/prompt-composer";
const clip = (
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="m13 7.5-5.2 5.2a3 3 0 0 1-4.3-4.2l5.6-5.6a2 2 0 0 1 2.8 2.8L6.4 11.2a1 1 0 0 1-1.4-1.4l4.9-4.9"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
const names = ["error-rate.png", "values.prod.yaml", "rollout-notes.md"];
export default function Example() {
const added = useRef(0);
const [files, setFiles] = useState([
{ id: "a", name: "deploy-412.log" },
{ id: "b", name: "api-gateway.yaml" },
]);
return (
<PromptComposer
style={{ maxWidth: "min(520px, 100%)" }}
placeholder="Ask about this deploy"
maxRows={6}
attachments={files}
onRemoveAttachment={(id) => setFiles((list) => list.filter((file) => file.id !== id))}
hint="Enter to send, Shift+Enter for a new line"
tools={
<Button
variant="ghost"
size="s"
aria-label="Attach a file"
lead={clip}
onClick={() => {
const n = added.current++;
setFiles((list) => [...list, { id: `added-${n}`, name: names[n % names.length] }]);
}}
/>
}
/>
);
}Disabled
Use while the conversation is read only, for example after the session expires.
Code
import { PromptComposer } from "@/components/equip/prompt-composer";
export default function Example() {
return <PromptComposer style={{ maxWidth: "min(480px, 100%)" }} placeholder="This conversation is archived" disabled />;
}Focus from outside
inputRef reaches the text box, so a suggested prompt can fill it and put the caret at the end, ready to edit before sending.
Code
"use client";
import { useRef, useState } from "react";
import { Button } from "@/components/equip/button";
import { PromptComposer } from "@/components/equip/prompt-composer";
const suggestions = ["Write a status page update", "Draft a reply to the customer", "List the follow-up tasks"];
export default function Example() {
const [text, setText] = useState("");
const box = useRef<HTMLTextAreaElement>(null);
function pick(suggestion: string) {
setText(suggestion);
const el = box.current;
if (!el) return;
el.focus();
// After React has written the new value, move the caret to its end.
requestAnimationFrame(() => el.setSelectionRange(el.value.length, el.value.length));
}
return (
<div style={{ display: "grid", gap: 8, maxWidth: 480 }}>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{suggestions.map((s) => (
<Button key={s} variant="outline" size="s" onClick={() => pick(s)}>
{s}
</Button>
))}
</div>
<PromptComposer
inputRef={box}
label="Ask about incident INC-4182"
placeholder="Ask about this incident"
value={text}
onValueChange={setText}
onSubmit={() => setText("")}
/>
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
value | string | none | Controlled text. Pair with onValueChange. |
defaultValue | string | "" | Starting text when uncontrolled. Cleared after each send. |
onValueChange | (value: string) => void | none | Called on every edit. |
onSubmit | (value: string) => void | none | Called with the text on send. Empty or whitespace-only text never sends. |
onStop | () => void | none | Called by the stop button or Escape while busy. |
busy | boolean | false | A response is streaming. The send button becomes stop and Enter does not send. |
placeholder | string | none | Placeholder text in the box. |
label | string | "Message" | Accessible name of the text box. |
maxRows | number | 8 | Lines the box grows to before it scrolls. |
attachments | { id: string; name: string }[] | [] | Files shown as chips above the text. |
onRemoveAttachment | (id: string) => void | none | Adds a remove button to each chip. |
tools | ReactNode | none | Buttons for attaching files or picking tools, left of the send button. |
hint | ReactNode | none | Short help in the toolbar, announced as the box's description. |
disabled | boolean | false | Disables the box, the chips and the send button. |
inputRef | Ref<HTMLTextAreaElement> | none | Ref to the text box, for focusing it from outside, such as after a suggested prompt is picked. The root ref still goes to the form. |
...rest | ComponentProps<"form"> | none | Every native form attribute except onSubmit, which is replaced by the text-based one. |
Anatomy
| root | .eq-prompt-composer | The form. Carries the border and the focus ring. |
|---|---|---|
| attachments | .eq-prompt-composer-attachments | |
| chip | .eq-prompt-composer-chip | |
| chip name | .eq-prompt-composer-chip-name | |
| chip remove | .eq-prompt-composer-chip-remove | 16px to see, 24px to hit. |
| input | .eq-prompt-composer-input | |
| toolbar | .eq-prompt-composer-bar | |
| tools | .eq-prompt-composer-tools | |
| hint | .eq-prompt-composer-hint | |
| send | .eq-prompt-composer-send | An accent Button that turns into stop while busy. |
| icon | .eq-prompt-composer-icon | Holds both icons; they swap with the spring. |
| send icon | .eq-prompt-composer-icon-send | |
| stop icon | .eq-prompt-composer-icon-stop |
Keyboard
| Enter | Sends the message. Does nothing while busy, while an input method is composing, or when the box is empty. |
|---|---|
| ShiftEnter | Inserts a new line. |
| Escape | Stops the response while busy. |
| Tab | Moves through the chips' remove buttons, the box, the tools and the send button. |
Notes
- The send button is labelled Send message or Stop response, so its changing icon is never the only cue.
- After a send, a stop or a chip removal, focus returns to the box so typing can continue.
- Uncontrolled, the box clears itself after a send; controlled, clear value yourself in onSubmit.
- The box stays editable while busy, so the next message can be drafted during a response.
- field-sizing: content is not yet in Firefox or Safari. There the part measures the box in an effect and sets its height, up to maxRows lines.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/prompt-composer.tsx225 lines
"use client";
import {
useCallback,
useEffect,
useId,
useRef,
useState,
type ComponentProps,
type CSSProperties,
type FormEvent,
type KeyboardEvent,
type ReactNode,
type Ref,
} from "react";
import { Button } from "./button";
import "./prompt-composer.css";
export type PromptComposerProps = Omit<ComponentProps<"form">, "onSubmit" | "defaultValue"> & {
/** Controlled text. Pair with onValueChange. */
value?: string;
/** Starting text when uncontrolled. Cleared after each send. */
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Called with the text when the person sends. Empty or whitespace-only text never sends. */
onSubmit?: (value: string) => void;
/** Called when the person presses stop or Escape while busy. */
onStop?: () => void;
/** A response is streaming: the send button becomes a stop button. */
busy?: boolean;
placeholder?: string;
/** Accessible name of the text box. */
label?: string;
/** Lines the box grows to before it scrolls. */
maxRows?: number;
attachments?: { id: string; name: string }[];
onRemoveAttachment?: (id: string) => void;
/** Buttons for attachments or tools, placed left of the send button. */
tools?: ReactNode;
/** Short help under the box, e.g. "Enter to send, Shift+Enter for a new line". */
hint?: ReactNode;
disabled?: boolean;
/** Ref to the text box, for focusing it from outside. The root ref still goes to the form. */
inputRef?: Ref<HTMLTextAreaElement>;
};
function setRefs<T>(node: T, ...refs: (Ref<T> | undefined)[]) {
for (const ref of refs) {
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
}
}
export function PromptComposer({
value,
defaultValue = "",
onValueChange,
onSubmit,
onStop,
busy = false,
placeholder,
label = "Message",
maxRows = 8,
attachments = [],
onRemoveAttachment,
tools,
hint,
disabled = false,
inputRef,
className,
style,
onKeyDown,
...rest
}: PromptComposerProps) {
const [inner, setInner] = useState(defaultValue);
const text = value ?? inner;
const empty = text.trim() === "";
const boxRef = useRef<HTMLTextAreaElement | null>(null);
const mergedInputRef = useCallback((node: HTMLTextAreaElement | null) => setRefs(node, boxRef, inputRef), [inputRef]);
const hintId = useId();
const setText = (next: string) => {
if (value === undefined) setInner(next);
onValueChange?.(next);
};
// Browsers without field-sizing: content grow the box by measuring it.
useEffect(() => {
const el = boxRef.current;
if (!el || CSS.supports("field-sizing", "content")) return;
const css = getComputedStyle(el);
const line = parseFloat(css.lineHeight) || parseFloat(css.fontSize) * 1.5;
const chrome = parseFloat(css.paddingTop) + parseFloat(css.paddingBottom);
const max = line * maxRows + chrome;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, max)}px`;
el.style.overflowY = el.scrollHeight > max ? "auto" : "hidden";
}, [text, maxRows]);
const send = () => {
if (busy || disabled || empty) return;
onSubmit?.(text);
if (value === undefined) setInner("");
boxRef.current?.focus();
};
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
send();
};
const handleInputKey = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key !== "Enter" || event.shiftKey) return;
if (event.nativeEvent.isComposing || event.keyCode === 229) return;
event.preventDefault();
send();
};
const handleFormKey = (event: KeyboardEvent<HTMLFormElement>) => {
onKeyDown?.(event);
if (event.key === "Escape" && busy) {
event.preventDefault();
event.stopPropagation();
onStop?.();
}
};
return (
<form
{...rest}
className={["eq-prompt-composer", className].filter(Boolean).join(" ")}
style={{ "--_max-rows": maxRows, ...style } as CSSProperties}
data-busy={busy || undefined}
data-disabled={disabled || undefined}
onSubmit={handleSubmit}
onKeyDown={handleFormKey}
>
{attachments.length > 0 && (
<ul className="eq-prompt-composer-attachments" aria-label="Attachments">
{attachments.map((file) => (
<li key={file.id} className="eq-prompt-composer-chip">
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" aria-hidden="true">
<path
d="M9 1.75H4.5a1 1 0 0 0-1 1v10.5a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1v-8M9 1.75l3.5 3.5M9 1.75v3.5h3.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span className="eq-prompt-composer-chip-name">{file.name}</span>
{onRemoveAttachment && (
<button
type="button"
className="eq-prompt-composer-chip-remove"
aria-label={`Remove ${file.name}`}
disabled={disabled}
onClick={() => {
onRemoveAttachment(file.id);
boxRef.current?.focus();
}}
>
<svg viewBox="0 0 16 16" width="10" height="10" fill="none" aria-hidden="true">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
)}
</li>
))}
</ul>
)}
<textarea
ref={mergedInputRef}
className="eq-prompt-composer-input"
rows={1}
value={text}
placeholder={placeholder}
aria-label={label}
aria-describedby={hint != null ? hintId : undefined}
disabled={disabled}
onChange={(event) => setText(event.target.value)}
onKeyDown={handleInputKey}
/>
<div className="eq-prompt-composer-bar">
{tools != null && <div className="eq-prompt-composer-tools">{tools}</div>}
{hint != null && (
<p className="eq-prompt-composer-hint" id={hintId}>
{hint}
</p>
)}
<Button
variant="accent"
className="eq-prompt-composer-send"
type={busy ? "button" : "submit"}
aria-label={busy ? "Stop response" : "Send message"}
disabled={disabled || (!busy && empty)}
onClick={
busy
? () => {
onStop?.();
boxRef.current?.focus();
}
: undefined
}
lead={
<span className="eq-prompt-composer-icon" aria-hidden="true">
<svg className="eq-prompt-composer-icon-send" viewBox="0 0 16 16" fill="none">
<path
d="M8 13V3.5M3.75 7.5 8 3.25l4.25 4.25"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<svg className="eq-prompt-composer-icon-stop" viewBox="0 0 16 16" fill="none">
<rect x="4.25" y="4.25" width="7.5" height="7.5" rx="1.5" fill="currentColor" />
</svg>
</span>
}
/>
</div>
</form>
);
}components/equip/prompt-composer.css233 lines
@layer equip {
.eq-prompt-composer {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: var(--eq-space-1);
min-width: 0;
max-width: 100%;
margin: 0;
padding: var(--eq-space-2);
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-l);
background: var(--eq-raised);
color: var(--eq-ink);
font-family: var(--eq-font);
transition: border-color var(--eq-dur) var(--eq-ease);
}
@media (hover: hover) {
.eq-prompt-composer:hover:where(:not([data-disabled])) {
border-color: color-mix(in srgb, var(--eq-line-strong), var(--eq-ink) 30%);
}
}
/* The whole box carries the ring, so the toolbar reads as part of the field. */
.eq-prompt-composer:has(.eq-prompt-composer-input:focus-visible) {
outline: 2px solid var(--eq-focus);
outline-offset: -1px;
}
.eq-prompt-composer[data-disabled] {
opacity: 0.45;
cursor: not-allowed;
}
.eq-prompt-composer-attachments {
display: flex;
flex-wrap: wrap;
gap: var(--eq-space-1);
margin: 0;
padding: 0 0 var(--eq-space-1);
list-style: none;
}
.eq-prompt-composer-chip {
display: inline-flex;
align-items: center;
gap: var(--eq-space-1);
max-inline-size: 100%;
height: 20px;
padding-inline: var(--eq-space-2) 2px;
border-radius: var(--eq-radius-s);
background: var(--eq-tint-strong);
color: var(--eq-ink);
font: var(--eq-weight-strong) var(--eq-text-xs) / 1 var(--eq-font);
cursor: default;
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur-move) var(--eq-spring);
}
@starting-style {
.eq-prompt-composer-chip {
opacity: 0;
scale: 0.96;
}
}
.eq-prompt-composer-chip > svg {
flex: none;
color: var(--eq-ink-2);
}
.eq-prompt-composer-chip-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.eq-prompt-composer-chip-remove {
position: relative;
flex: none;
display: inline-grid;
place-items: center;
width: 16px;
height: 16px;
margin: 0;
padding: 0;
border: 0;
border-radius: var(--eq-radius-s);
background: transparent;
color: var(--eq-ink-2);
cursor: pointer;
transition:
background-color var(--eq-dur) var(--eq-ease),
color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
/* Visible at 16px, hit target at 24px. */
.eq-prompt-composer-chip-remove::before {
content: "";
position: absolute;
inset: -4px;
}
@media (hover: hover) {
.eq-prompt-composer-chip-remove:hover:not(:disabled) {
background: var(--eq-tint-strong);
color: var(--eq-ink);
}
}
.eq-prompt-composer-chip-remove:active:not(:disabled) {
scale: var(--eq-press);
}
.eq-prompt-composer-chip-remove:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 1px;
}
.eq-prompt-composer-chip-remove:disabled {
cursor: not-allowed;
}
.eq-prompt-composer-input {
box-sizing: border-box;
display: block;
width: 100%;
min-height: calc(1lh + 2 * var(--eq-space-1));
margin: 0;
padding: var(--eq-space-1) var(--eq-space-1);
border: 0;
background: transparent;
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1.45 var(--eq-font);
resize: none;
overflow-y: hidden;
}
@supports (field-sizing: content) {
.eq-prompt-composer-input {
field-sizing: content;
max-block-size: calc(var(--_max-rows, 8) * 1lh + 2 * var(--eq-space-1));
overflow-y: auto;
}
}
.eq-prompt-composer-input::placeholder {
color: var(--eq-ink-3);
opacity: 1;
}
.eq-prompt-composer-input:focus-visible {
outline: none;
}
.eq-prompt-composer-input:disabled {
cursor: not-allowed;
}
.eq-prompt-composer-bar {
display: flex;
align-items: center;
gap: var(--eq-space-2);
min-width: 0;
}
.eq-prompt-composer-tools {
display: flex;
align-items: center;
gap: var(--eq-space-1);
min-width: 0;
}
.eq-prompt-composer-hint {
flex: 1;
min-width: 0;
margin: 0;
overflow: hidden;
color: var(--eq-ink-3);
font: var(--eq-weight) var(--eq-text-xs) / 1.3 var(--eq-font);
text-overflow: ellipsis;
white-space: nowrap;
}
.eq-prompt-composer .eq-prompt-composer-send {
flex: none;
width: var(--eq-h-m);
margin-inline-start: auto;
padding: 0;
}
/* The disabled box already dims everything; do not dim the button twice. */
.eq-prompt-composer[data-disabled] .eq-prompt-composer-send:disabled {
opacity: 1;
}
.eq-prompt-composer-icon {
display: grid;
width: 16px;
height: 16px;
}
.eq-prompt-composer-icon > svg {
grid-area: 1 / 1;
width: 16px;
height: 16px;
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur-move) var(--eq-spring),
rotate var(--eq-dur-move) var(--eq-spring);
}
.eq-prompt-composer-icon-stop {
opacity: 0;
scale: 0.4;
rotate: -90deg;
}
.eq-prompt-composer[data-busy] .eq-prompt-composer-icon-send {
opacity: 0;
scale: 0.4;
rotate: 90deg;
}
.eq-prompt-composer[data-busy] .eq-prompt-composer-icon-stop {
opacity: 1;
scale: 1;
rotate: 0deg;
}
}