AI Prompt Input
The composer: auto-growing textarea, send-on-Enter, and a send/stop control.
Installation
pnpm dlx @dowel-ui/cli add ai-prompt-inputNo npm packages are needed beyond what Dowel already requires.
Accessibility
Enter sends and Shift+Enter inserts a newline, but never while an IME composition is active — for Japanese, Chinese and Korean input Enter confirms a candidate, and sending there truncates the sentence mid-word. The submit control renames itself to "Stop generating" while busy, so its accessible name always matches what it does. The character counter stays silent until it nears the limit; announcing every keystroke would be unusable.
Props
PromptInput
| Prop | Type | Default |
|---|---|---|
busyA response is in flight: submission is blocked and Stop is shown instead. | boolean | false |
disabledBlocks submission and dims the composer. | boolean | false |
Plus every attribute of <form> except onSubmit.
PromptInputTextarea
| Prop | Type | Default |
|---|---|---|
maxRows | number | DEFAULT_MAX_ROWS |
submitOnEnterSend on Enter, newline on Shift+Enter. Turn it off for a composer where Enter should always insert a newline and sending is only ever an explicit button press. | boolean | true |
Plus every attribute of <textarea> except rows.
PromptInputToolbar
Plus every attribute of <div>.
PromptInputSubmit
| Prop | Type | Default |
|---|---|---|
labelLabel while idle. | string | "Send message" |
onStopCalled instead of submitting when busy. | () => void | — |
stopLabelLabel while a response is streaming. | string | "Stop generating" |
Plus every attribute of <button>.
PromptInputCounter
| Prop | Type | Default |
|---|---|---|
max (required) | number | — |
value (required) | number | — |
warnAtFraction of the limit past which the counter starts warning. | number | 0.9 |
Plus every attribute of <div>.
Quality
10/10 checks, measured from the source and its tests
- Tested — passes
- axe assertion — passes
- Keyboard tested — passes
- Storybook examples — passes
- Accessibility documented — passes
- Semantic tokens only — passes
- Motion from tokens — passes
- className merged — passes
- Visible focus — passes
- No fixed widths — passes
Used in
Whole screens assembled from this component. Installing one brings this and everything else it needs with it.
Source
This is exactly what dowel add ai-prompt-input writes into your project, with imports rewritten to your own path alias.
"use client";
// Motion from SmoothUI AI Prompt Input (MIT, © 2024 Eduardo Calvo). See THIRD_PARTY_NOTICES.md.
import {
createContext,
useContext,
useId,
useLayoutEffect,
useMemo,
useRef,
type ComponentPropsWithRef,
type FormEvent,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { focusRing, focusRingInset } from "@/lib/styles";
import { cn } from "@/lib/utils";
/**
* The composer.
*
* Handles the parts of a chat input that are easy to get subtly wrong:
* auto-growing to fit the text up to a ceiling, sending on Enter without
* breaking multi-line input, and — the one almost everybody misses —
* not sending mid-composition when someone is typing with an IME.
*/
interface PromptInputContextValue {
textareaId: string;
submit: () => void;
disabled: boolean;
busy: boolean;
}
const PromptInputContext = createContext<PromptInputContextValue | null>(null);
function usePromptInput(component: string): PromptInputContextValue {
const context = useContext(PromptInputContext);
if (!context) {
throw new Error(`${component} must be rendered inside <PromptInput>.`);
}
return context;
}
export interface PromptInputProps extends Omit<ComponentPropsWithRef<"form">, "onSubmit"> {
onSubmit?: (event: FormEvent<HTMLFormElement>) => void;
/** Blocks submission and dims the composer. */
disabled?: boolean;
/** A response is in flight: submission is blocked and Stop is shown instead. */
busy?: boolean;
}
export function PromptInput({
className,
onSubmit,
disabled = false,
busy = false,
children,
...props
}: PromptInputProps) {
const uid = useId();
const formRef = useRef<HTMLFormElement | null>(null);
const context = useMemo<PromptInputContextValue>(
() => ({
textareaId: `${uid}-textarea`,
disabled,
busy,
submit: () => {
formRef.current?.requestSubmit();
},
}),
[uid, disabled, busy],
);
return (
<PromptInputContext.Provider value={context}>
<form
ref={formRef}
data-slot="prompt-input"
data-busy={busy || undefined}
onSubmit={(event) => {
if (disabled || busy) {
event.preventDefault();
return;
}
onSubmit?.(event);
}}
className={cn(
"rounded-xl border border-input bg-background shadow-xs",
"transition-[border-color,box-shadow] duration-[var(--duration-fast)]",
"focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/55",
disabled && "opacity-60",
className,
)}
{...props}
>
{children}
</form>
</PromptInputContext.Provider>
);
}
/** Rows the textarea will grow to before it starts scrolling. */
const DEFAULT_MAX_ROWS = 8;
export interface PromptInputTextareaProps extends Omit<
ComponentPropsWithRef<"textarea">,
"rows"
> {
maxRows?: number;
/**
* Send on Enter, newline on Shift+Enter.
*
* Turn it off for a composer where Enter should always insert a newline and
* sending is only ever an explicit button press.
*/
submitOnEnter?: boolean;
}
export function PromptInputTextarea({
className,
maxRows = DEFAULT_MAX_ROWS,
submitOnEnter = true,
onKeyDown,
onChange,
...props
}: PromptInputTextareaProps) {
const { textareaId, submit, disabled, busy } = usePromptInput("PromptInputTextarea");
const ref = useRef<HTMLTextAreaElement | null>(null);
// Grows with the content up to maxRows, then scrolls. Measured after layout
// so the height is right on the first paint rather than one frame late.
useLayoutEffect(() => {
const textarea = ref.current;
if (!textarea) return;
textarea.style.height = "auto";
const lineHeight = Number.parseFloat(getComputedStyle(textarea).lineHeight) || 20;
const maxHeight = lineHeight * maxRows;
textarea.style.height = `${String(Math.min(textarea.scrollHeight, maxHeight))}px`;
textarea.style.overflowY = textarea.scrollHeight > maxHeight ? "auto" : "hidden";
});
function handleKeyDown(event: ReactKeyboardEvent<HTMLTextAreaElement>) {
onKeyDown?.(event);
if (event.defaultPrevented || !submitOnEnter) return;
if (event.key !== "Enter" || event.shiftKey) return;
// Mid-composition Enter confirms the candidate an IME is offering — for
// Japanese, Chinese, Korean and others it is part of typing a word, not a
// request to send. Sending here truncates the sentence and is the single
// most common way a chat composer breaks for those users.
if (event.nativeEvent.isComposing) return;
event.preventDefault();
submit();
}
return (
<textarea
ref={ref}
id={textareaId}
data-slot="prompt-input-textarea"
rows={1}
disabled={disabled}
aria-disabled={busy || undefined}
onKeyDown={handleKeyDown}
onChange={onChange}
className={cn(
"block w-full resize-none bg-transparent px-4 py-3 text-sm outline-none",
"placeholder:text-muted-foreground disabled:cursor-not-allowed",
focusRingInset,
"focus-visible:ring-0",
className,
)}
{...props}
/>
);
}
/** The row beneath the textarea: attachments, model picker, send. */
export function PromptInputToolbar({ className, ...props }: ComponentPropsWithRef<"div">) {
return (
<div
data-slot="prompt-input-toolbar"
className={cn("flex items-center gap-2 px-2 pb-2", className)}
{...props}
/>
);
}
export interface PromptInputSubmitProps extends ComponentPropsWithRef<"button"> {
/** Label while idle. */
label?: string;
/** Label while a response is streaming. */
stopLabel?: string;
/** Called instead of submitting when busy. */
onStop?: () => void;
}
/**
* Send, or stop.
*
* One control with two jobs, because that is what the space affords and what
* people expect. The accessible name changes with the state, so it is never
* "Send" while it actually stops.
*/
export function PromptInputSubmit({
className,
label = "Send message",
stopLabel = "Stop generating",
onStop,
...props
}: PromptInputSubmitProps) {
const { busy, disabled } = usePromptInput("PromptInputSubmit");
// Both glyphs stay mounted in one grid cell and cross-fade, so the swap is a
// scale-and-fade rather than a jump. The accessible name carries the state;
// the glyphs are decoration.
const glyph =
"col-start-1 row-start-1 transition-[opacity,scale] duration-[var(--duration-normal)] ease-[var(--ease-out-quint)]";
return (
<button
type={busy ? "button" : "submit"}
data-slot="prompt-input-submit"
data-state={busy ? "stop" : "send"}
aria-label={busy ? stopLabel : label}
disabled={disabled}
onClick={busy ? onStop : undefined}
className={cn(
"ms-auto grid size-8 shrink-0 place-items-center rounded-lg",
"bg-primary text-primary-foreground",
"transition-[color,background-color,scale] duration-[var(--duration-fast)] ease-[var(--ease-out-quint)]",
"hover:scale-105 hover:bg-primary-hover active:scale-95",
"disabled:pointer-events-none disabled:scale-100 disabled:opacity-55",
focusRing,
className,
)}
{...props}
>
<svg
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
data-glyph="send"
className={cn(glyph, "size-4", busy ? "scale-60 opacity-0" : "scale-100 opacity-100")}
>
<path
d="M12 19V5m0 0-6 6m6-6 6 6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<svg
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
data-glyph="stop"
className={cn(glyph, "size-3.5", busy ? "scale-100 opacity-100" : "scale-60 opacity-0")}
>
<rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor" />
</svg>
</button>
);
}
const PREFIX = "dowel-ai-prompt-input";
/* The "value / max" reading rises in once, when the limit first matters. */
const STYLES = `
@keyframes ${PREFIX}-counter-in{from{opacity:0;translate:0 4px}}
[data-slot=prompt-input-counter-value][data-warning]{display:inline-block;animation:${PREFIX}-counter-in calc(200ms * var(--motion-scale,1)) var(--ease-out-quint) both}
`;
export interface PromptInputCounterProps extends ComponentPropsWithRef<"div"> {
value: number;
max: number;
/** Fraction of the limit past which the counter starts warning. */
warnAt?: number;
}
/**
* Characters used against a limit.
*
* The visible count updates on every keystroke; the live region does not.
*
* The naive version toggles `aria-live` on once the count matters, but a region
* only announces changes that happen *while* it is live — switching it on at
* the same moment the content changes means the one update worth hearing is the
* one that is missed. So the region is live from the start and simply has
* nothing in it until the limit is close, which is also what keeps ordinary
* typing silent.
*/
export function PromptInputCounter({
className,
value,
max,
warnAt = 0.9,
...props
}: PromptInputCounterProps) {
const warning = value >= max * warnAt;
const over = value > max;
const remaining = max - value;
return (
<div
data-slot="prompt-input-counter"
data-warning={warning || undefined}
data-over={over || undefined}
className={cn(
"text-xs tabular-nums",
over ? "text-destructive" : warning ? "text-warning" : "text-muted-foreground",
className,
)}
{...props}
>
<style href={PREFIX} precedence="dowel">
{STYLES}
</style>
{/* Keyed so crossing the threshold remounts the visible reading, which is
what plays its entrance. Never key the status span below: it has to
stay the same live region from first paint (ADR 0009). */}
<span
key={warning ? "limit" : "count"}
aria-hidden="true"
data-slot="prompt-input-counter-value"
data-warning={warning || undefined}
>
{warning ? `${String(value)} / ${String(max)}` : String(value)}
</span>
<span role="status" aria-live="polite" className="sr-only">
{over
? `Over the limit by ${String(-remaining)} characters`
: warning
? `${String(remaining)} characters remaining`
: ""}
</span>
</div>
);
}