Prompt box
AIThe chat entry box for an AI product — a blue-to-purple gradient border, a textarea that grows with the prompt, a tool row, a live character count, and a border that pulses once when the prompt goes.
Preview
Replies can be wrong. Check anything that matters.
Controls
Notes
The textarea grows without measuring anything. The wrapper is a one-cell grid whose ::after carries a copy of the value, so the cell is always exactly as tall as the text — no scrollHeight read, no layout thrash on every keystroke, and it is right on the first paint rather than one frame later.
Two layers, and the reason is performance rather than looks: a band at inset -1.5px that is always visible and IS the border, and a blurred copy of it at the same inset that rests at opacity 0 and fades in on send. Collapsing them into one element that animates its own inset means every frame does a layout pass and re-rasterises a conic gradient, which is already the most expensive thing on the element.
The pulse is a blurred copy, not a wider band. Expressing it as extra width reads as too heavy at any width, because a hard-edged band that grows is a change in the box's shape and the eye reads shape changes as movement. A blurred copy at the same inset bleeds softly outward with no edge of its own, so the border lights up rather than swelling — and its inward bleed is covered by the opaque box painting on top.
Only opacity animates. The blur is a static raster and the gradient's angle is a registered @property, so both stay on the compositor and nothing here touches a property that can force layout.
Both layers are keyed by the send count and remount in the same commit. A remount is the only thing that replays a CSS animation — re-adding a class the element already carries does nothing — and remounting together keeps their angles in lockstep so the seam between band and blur stays invisible.
Spin and pulse are two separate signals and keeping them separate is the point. The spin says "still working"; the pulse says "sent", so it fires once and settles rather than sitting fat for the whole wait, which reads as heavy and makes the box shout while nothing is happening.
The reduced-motion block lists all three selectors, including the compound one, at matching specificity. A bare .prompt-glow-spin { animation: none } is (0,1,0) and loses to the (0,2,0) compound rule, so the spin would keep running for exactly the readers who asked it not to. Being inside a media query grants no extra weight.
The pulse is never the only confirmation. Under prefers-reduced-motion it does not run at all, so the field clearing and the status line are what actually say the prompt went. If you drop this into an app where the sent prompt lands in a transcript, delete the status line — two confirmations is one too many. What you must not do is delete it and keep the pulse.
The blue-to-purple border is a token pair — --ai-gradient-from, --ai-gradient-to and --on-ai for what sits on it — defined for both palettes in globals.css rather than pasted in as hex. Two fixed hex values would look right on the dark site and fail contrast the moment the component landed on a light one. Both ends clear 3:1 against paper and paper-raised in both themes, which is what lets the gradient BE the control boundary instead of sitting over one.
White on the dark-theme gradient clears only 2.64:1, so --on-ai inverts between palettes exactly as --on-accent does. An icon on a gradient button never hardcodes its own colour.
The band carries a solid --accent underneath its gradient. If the component is copied into a project without the AI tokens the gradient declaration is invalid and drops, and the box keeps a plain clay boundary rather than losing its edge entirely.
Focus is the outline on the textarea, where the keyboard actually is — tightened to sit inside the box, never removed. --focus is re-pointed to the gradient's first stop so the ring matches the component: same rule, same width, same offset, only the hue moves. The border itself does not thicken on focus, for the same reason the pulse does not.
The tone knob swaps the whole treatment to the site's clay accent through one indirection, --prompt-from and --prompt-to on the shell. The band, the blurred ring and the send button all read those, so there is no second copy of the CSS to keep in step.
Every button inside the form declares its type. A bare <button> in a form is a submit button, so the attach control would fire the prompt.
The character count is plain text, deliberately not a live region. A count that announces on every keystroke makes the box unusable with a screen reader.
Source
ShowHide
"use client";
import { useId, useState } from "react";
import { cn } from "@/lib/cn";
/**
* The chat entry box for an AI product.
*
* Four things here are load-bearing and easy to get wrong.
*
* The textarea grows without MEASURING anything. The wrapper is a one-cell
* grid and its ::after carries a copy of the value in the same cell, so the
* cell is always exactly as tall as the text. No scrollHeight read, no layout
* thrash on every keystroke, and it is correct on the first paint rather than
* one frame later.
*
* The pulse element is KEYED by the send count, so React remounts it and the
* animation replays. Toggling a class cannot do it — the second send re-adds a
* class the element already carries and nothing runs.
*
* The pulse is never the only confirmation. Under prefers-reduced-motion it
* does not run at all, so the field clearing and the status line are what
* actually say the prompt went.
*
* The blue-to-purple border is a TOKEN PAIR, --ai-gradient-from and
* --ai-gradient-to, defined for both palettes in globals.css — not two hex
* values pasted in here. Hardcoding them would look right in the dark theme
* and fail contrast the moment the component was dropped on a light one. The
* component reads them through --prompt-from / --prompt-to, set on the shell,
* which is also how the clay `tone` gets the same CSS with no second copy.
*/
const css = `
.prompt-grow { display: grid; max-height: 9rem; overflow-y: auto; }
.prompt-grow::after {
content: attr(data-value) " ";
visibility: hidden;
}
.prompt-grow > textarea { resize: none; overflow: hidden; }
.prompt-grow > textarea,
.prompt-grow::after {
grid-area: 1 / 1;
font: inherit;
line-height: 1.55;
padding: 0;
border: 0;
background: none;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* Tightened so it sits inside the shell rather than over the tool row.
Tightened — not removed. The keyboard is in the textarea, so the ring
belongs on the textarea. */
.prompt-grow > textarea:focus-visible { outline-offset: 1px; }
@property --prompt-angle {
syntax: "<angle>";
inherits: false;
initial-value: 270deg;
}
@keyframes prompt-spin {
to { --prompt-angle: 630deg; } /* 270 + 360 — one turn, back where it started */
}
.prompt-shell { position: relative; isolation: isolate; }
/* TWO LAYERS, AND THE REASON IS PERFORMANCE, NOT LOOKS.
Do not collapse these into one element that animates its own inset. \`inset\`
is a LAYOUT property and \`border-radius\` is a PAINT property; neither is
compositor-accelerated, so animating them means every frame does a layout
pass AND re-rasterises a conic gradient — which is already the most
expensive thing on this element.
So the thickening is done with OPACITY, which the compositor animates
without touching layout or paint at all:
.prompt-glow the band, inset -1.5px, always visible — it IS the border
.prompt-glow-ring a blurred copy at the SAME inset, opacity 0, faded in on send
THE RING IS BLURRED, NOT WIDER, and that is the whole trick. Expressing the
pulse as extra width reads as too heavy, because a hard-edged band that
grows is a change in the box's shape and the eye reads shape changes as
movement. A blurred copy bleeds softly outward with no edge of its own, so
the border lights up rather than swelling. Its inward bleed is simply
covered by the opaque box, which paints on top of both layers. */
.prompt-glow,
.prompt-glow-ring {
/* THE TWO NUMBERS TO TUNE. --prompt-band is the resting border width; the
radii derive from it, so changing it needs no other edit.
--prompt-pulse-blur is how far the glow spreads — a BLUR RADIUS, not a
width, so raising it makes the glow softer and broader rather than making
the border fatter. Turn the glow down with the peak opacity in
@keyframes prompt-pulse; turn its reach up or down here. */
--prompt-band: 1.5px;
--prompt-pulse-blur: 4px;
/* The box's own rounded-2xl (1rem) plus this layer's bleed, so both stay
concentric with the box and with each other. */
--prompt-radius: 1rem;
position: absolute;
inset: calc(-1 * var(--prompt-band));
border-radius: calc(var(--prompt-radius) + var(--prompt-band));
pointer-events: none;
/* A solid base under the gradient. If a project lacks the tokens the
gradient drops and the band still draws a boundary. */
background-color: var(--accent);
/* Conic, and it loops back to the first stop at 100% so a spin has no seam. */
background-image: conic-gradient(
from var(--prompt-angle, 270deg),
var(--prompt-from) 0%,
var(--prompt-to) 50%,
var(--prompt-from) 100%
);
}
/* Same geometry as the band it copies — the spread comes entirely from the
blur, so the box's outline never changes shape. The blur is a static raster
(it does not change over the pulse), so only opacity animates and the layer
stays composited. */
.prompt-glow-ring {
filter: blur(var(--prompt-pulse-blur));
opacity: 0;
will-change: opacity;
}
/* TWO SEPARATE SIGNALS, and keeping them separate is the point. The SPIN says
"still working" and runs while the turn does. The PULSE says "sent" — an
acknowledgement of the keystroke, so it fires once and settles back rather
than sitting fat for the whole wait, which reads as heavy and makes the box
shout while nothing is happening. */
@keyframes prompt-pulse {
0% { opacity: 0; }
22% { opacity: 1; }
100% { opacity: 0; }
}
.prompt-glow-spin {
/* Finite: three turns and it settles. An infinite spin here would claim a
request is still running when nothing is. */
animation: prompt-spin 1.4s linear 3;
}
.prompt-glow-pulse {
animation: prompt-pulse 1s ease-out;
}
/* Both at once — the ring on a send that also spins. The compound selector
(0,2,0) outranks either single class, so this is what actually applies, and
it has to restate both: listing one animation on an element REPLACES the
other rather than adding to it, so without this rule the ring would spin and
never fade in. */
.prompt-glow-spin.prompt-glow-pulse {
animation: prompt-spin 1.4s linear 3, prompt-pulse 1s ease-out;
}
.prompt-send {
/* A solid base under the gradient, so a project missing the tokens still
gets a filled button rather than a transparent one. */
background-color: var(--accent);
background-image: linear-gradient(105deg, var(--prompt-from), var(--prompt-to));
color: var(--prompt-on);
transition: filter 0.18s ease;
}
.prompt-send:hover { filter: brightness(1.08); }
/* The gradient border stays, it simply does not move. All three selectors are
listed, and the compound one is repeated at matching specificity — a bare
.prompt-glow-spin { animation: none } is (0,1,0) and would lose to the
(0,2,0) rule above, so the spin would keep running for exactly the readers
who asked it not to. Being inside a media query grants no extra weight. */
@media (prefers-reduced-motion: reduce) {
.prompt-glow-spin,
.prompt-glow-pulse,
.prompt-glow-spin.prompt-glow-pulse {
animation: none;
}
.prompt-send { transition: none; }
}
`;
/** Fixed locale: the count renders on the server too, and "3,000" has to match. */
const number = new Intl.NumberFormat("en-US");
function SendIcon() {
return (
<svg width="17" height="17" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path
d="M2.5 9 15.5 2.5 12 15.5 9.2 10.3 2.5 9Z"
stroke="currentColor"
strokeWidth="1.4"
strokeLinejoin="round"
/>
</svg>
);
}
function ClipIcon() {
return (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M10.5 5 6 9.5a1.7 1.7 0 0 0 2.4 2.4l4.6-4.6a3.2 3.2 0 0 0-4.5-4.5L3.6 7.7a4.7 4.7 0 0 0 6.6 6.6l3.3-3.3"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function WaveIcon() {
return (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<g stroke="currentColor" strokeWidth="1.3" strokeLinecap="round">
<path d="M2 6.5v3M5 4v8M8 2.5v11M11 4.5v7M14 6.5v3" />
</g>
</svg>
);
}
function PromptsIcon() {
return (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<g stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round">
<path d="M13.5 8.2V3.2a1.7 1.7 0 0 0-1.7-1.7H3.7A1.7 1.7 0 0 0 2 3.2v9.6a1.7 1.7 0 0 0 1.7 1.7h4.6" />
<circle cx="11" cy="11" r="2.4" />
<path d="m13 13 1.5 1.5" />
</g>
</svg>
);
}
/**
* type="button" is not optional. A bare <button> inside a form is a submit
* button, so the attach control would fire the prompt.
*/
function ToolButton({ icon, label }: { icon: React.ReactNode; label: string }) {
return (
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-[13px] text-ink-muted transition-colors hover:bg-paper-sunken hover:text-ink"
>
<span className="text-ink-faint">{icon}</span>
{label}
</button>
);
}
/** What happens on send. "spin" also turns the gradient for a few beats. */
export type PromptPulse = "glow" | "spin" | "none";
/** "gradient" is the AI blue-to-purple; "accent" is the site's clay. */
export type PromptTone = "gradient" | "accent";
export function PromptBox({
label = "Your prompt",
placeholder = "Ask anything",
maxLength = 3000,
tone = "gradient",
tools = true,
counter = true,
pulse = "glow",
disclaimer,
onSend,
className,
}: {
/** Rendered visually hidden. A placeholder is not a name — it leaves. */
label?: string;
placeholder?: string;
maxLength?: number;
tone?: PromptTone;
tools?: boolean;
counter?: boolean;
pulse?: PromptPulse;
disclaimer?: string;
onSend?: (value: string) => void;
className?: string;
}) {
const id = useId();
const [value, setValue] = useState("");
const [sends, setSends] = useState(0);
const [lastSent, setLastSent] = useState("");
const empty = value.trim().length === 0;
const gradient = tone === "gradient";
// Nothing animates before the first send: a box that spins on page load is
// claiming a request is running when none is.
const pulsing = pulse !== "none" && sends > 0;
const spinning = pulse === "spin" && sends > 0;
function send() {
if (empty) return;
const text = value.trim();
setValue("");
setLastSent(text);
setSends((n) => n + 1);
onSend?.(text);
}
return (
<div className={cn("w-full", className)}>
{/* React 19 hoists this and dedupes on href, so N boxes ship one copy. */}
<style href="prompt-box" precedence="default">
{css}
</style>
<form
onSubmit={(event) => {
event.preventDefault();
send();
}}
>
<label htmlFor={id} className="sr-only">
{label}
</label>
<div
className="prompt-shell"
style={
{
// One indirection, set once: the band, the blurred ring and the
// send button all read these, so both tones share the same CSS.
"--prompt-from": gradient ? "var(--ai-gradient-from)" : "var(--accent)",
"--prompt-to": gradient ? "var(--ai-gradient-to)" : "var(--accent-hover)",
"--prompt-on": gradient ? "var(--on-ai)" : "var(--on-accent)",
// The site's focus ring, re-pointed to this component's accent.
// Same rule, same width, same offset — only the hue moves, and
// both ends clear 3:1 on paper in both palettes.
"--focus": "var(--prompt-from)",
} as React.CSSProperties
}
>
{/* Both layers are keyed by the send count so they remount in the
SAME commit. A remount is the only thing that replays a CSS
animation — re-adding a class the element already carries does
nothing — and remounting together keeps their gradient angles in
lockstep, so the seam between band and blur stays invisible. */}
<span
key={`band-${sends}`}
aria-hidden="true"
className={cn("prompt-glow", spinning && "prompt-glow-spin")}
/>
<span
key={`ring-${sends}`}
aria-hidden="true"
className={cn(
"prompt-glow-ring",
spinning && "prompt-glow-spin",
pulsing && "prompt-glow-pulse",
)}
/>
{/* Opaque AND positioned. It has to paint on top of both layers to
cover the blur's inward bleed, and a static box would paint under
the absolutely positioned layers no matter the DOM order. */}
<div className="relative rounded-2xl bg-paper-raised">
<div className="flex items-start gap-3 px-4 pt-3.5 pb-3">
<div
className="prompt-grow min-w-0 flex-1 text-[15px] text-ink"
data-value={value}
>
<textarea
id={id}
rows={1}
value={value}
maxLength={maxLength}
placeholder={placeholder}
onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => {
// Enter sends, Shift+Enter breaks the line. The submit
// button below is real, so the shortcut stays a shortcut.
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
send();
}
}}
className="w-full text-ink placeholder:text-ink-faint"
/>
</div>
<button
type="submit"
disabled={empty}
aria-label="Send prompt"
className={cn(
"mt-0.5 inline-flex size-9 shrink-0 items-center justify-center rounded-full",
// The one honest use of disabled: the reason is the field the
// reader just left blank, which is already on screen.
empty ? "text-ink-faint opacity-45" : "prompt-send",
)}
>
<SendIcon />
</button>
</div>
{tools && (
<div className="flex flex-wrap items-center gap-x-1 gap-y-1 border-t border-rule px-2.5 py-1.5">
<ToolButton icon={<ClipIcon />} label="Attach" />
<ToolButton icon={<WaveIcon />} label="Voice message" />
<ToolButton icon={<PromptsIcon />} label="Browse prompts" />
{counter && (
// Plain text, deliberately not a live region: a count that
// announces on every keystroke makes the box unusable with AT.
<span className="ml-auto pr-2 font-mono text-[11px] tabular-nums text-ink-faint">
{number.format(value.length)} / {number.format(maxLength)}
</span>
)}
</div>
)}
</div>
</div>
</form>
{disclaimer && (
<p className="mt-3 text-center text-[12.5px] leading-relaxed text-ink-faint">
{disclaimer}
</p>
)}
{/* Drop this line if the sent prompt lands in a transcript — two
confirmations is one too many. What you must not do is drop it and
keep only the pulse. */}
<p role="status" className="mt-2 min-h-5 text-center text-[12.5px] text-ink-muted">
{lastSent && `Sent “${lastSent}”`}
</p>
</div>
);
}
export default function Demo({
placeholder = "Summarize the latest",
disclaimer = "Replies can be wrong. Check anything that matters.",
tone = "gradient",
tools = true,
counter = true,
pulse = "glow",
}: {
placeholder?: string;
disclaimer?: string;
tone?: PromptTone;
tools?: boolean;
counter?: boolean;
pulse?: PromptPulse;
}) {
return (
<PromptBox
className="max-w-xl"
placeholder={placeholder}
disclaimer={disclaimer}
tone={tone}
tools={tools}
counter={counter}
pulse={pulse}
/>
);
}
Next
Streamed replyNext step
Tell me where the business is losing time.
No pitch deck and no discovery-call funnel. Describe the bottleneck and I will tell you plainly whether it is worth building something for, and roughly what that would take.