AI input
Prompt composer
The simple prompt box for a chat: it grows with the text, takes files, and changes Send into a spinner and then Stop in place, so the person always knows if the answer is on its way.
Live preview
Loading preview
A composer with an add files button and a 500 character limit. Typing "Summarize the notes from the Tuesday design review" and pressing Enter shows Sending, then Stop while a fake answer streams, then Ready again; pasting a long text opens the counter and turns it red past the limit. A second demo is disabled with the reason "You reached today's message limit. You can send more tomorrow." Replay runs the send flow again.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add prompt-composerUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { type ModelOption, ModelPicker } from '../model-picker/model-picker';
import { type ComposerStatus, PromptComposer } from './prompt-composer';
const PROMPT = 'Summarize the notes from the Tuesday design review';
const MODELS: ModelOption[] = [
{
id: 'pro',
name: 'Lumen 2 Pro',
description: 'Best for hard, long tasks',
speed: 1,
intelligence: 3,
reasoning: true,
},
{
id: 'atlas',
name: 'Atlas Fast',
description: 'Quick answers to simple questions',
speed: 3,
intelligence: 1,
},
];
const MARKS: Record<string, string> = {
pro: 'M6.667 2 7.6 5.067 10.667 6 7.6 6.933 6.667 10 5.733 6.933 2.667 6l3.066-.933zM11.667 9.333l.5 1.5 1.5.5-1.5.5-.5 1.5-.5-1.5-1.5-.5 1.5-.5z',
atlas: 'M8.667 1.333 2.667 9.333H8l-.667 5.334 6-8H8z',
};
const REASON = "You reached today's message limit. You can send more tomorrow.";
/** A plain file chip for the demo; apps put their own attachment chips in the slot. */
function FileChip({ name, onRemove }: { name: string; onRemove: () => void }) {
return (
<span className="inline-flex h-8 max-w-60 items-center gap-1 rounded-lg border border-secondary bg-primary_alt ps-2.5 pe-1 text-xs font-medium text-secondary">
<span className="truncate">{name}</span>
<button
type="button"
aria-label={`Remove ${name}`}
className="inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-fg-quaternary outline-none hover:bg-tertiary hover:text-fg-secondary focus-visible:shadow-focus-ring"
onClick={onRemove}
>
<svg
aria-hidden="true"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
className="size-3.5"
>
<path d="M12 4 4 12M4 4l8 8" />
</svg>
</button>
</span>
);
}
export default function PromptComposerDemo({ replay = 0 }: { replay?: number }) {
const [text, setText] = React.useState('');
const [status, setStatus] = React.useState<ComposerStatus>('ready');
const [model, setModel] = React.useState('pro');
const [effort, setEffort] = React.useState('High');
const [files, setFiles] = React.useState<{ id: number; name: string }[]>([]);
const fileIds = React.useRef(0);
const timers = React.useRef<number[]>([]);
const clear = React.useCallback(() => {
for (const timer of timers.current) window.clearTimeout(timer);
timers.current = [];
}, []);
React.useEffect(() => clear, [clear]);
// A fake answer: wait, stream, then ready again.
const submit = React.useCallback(() => {
clear();
setText('');
setFiles([]);
setStatus('submitted');
timers.current.push(window.setTimeout(() => setStatus('streaming'), 900));
timers.current.push(window.setTimeout(() => setStatus('ready'), 3200));
}, [clear]);
React.useEffect(() => {
if (replay === 0) return;
clear();
setStatus('ready');
setText('');
timers.current.push(window.setTimeout(() => setText(PROMPT), 300));
timers.current.push(window.setTimeout(submit, 1000));
}, [replay, clear, submit]);
return (
<div className="flex w-full max-w-md flex-col gap-6">
<PromptComposer
value={text}
status={status}
maxLength={500}
onValueChange={setText}
onSubmit={submit}
onStop={() => {
clear();
setStatus('ready');
}}
onFilesSelected={(picked) =>
setFiles((list) => [...list, ...picked.map((f) => ({ id: ++fileIds.current, name: f.name }))])
}
toolbar={
<ModelPicker
models={MODELS}
value={model}
effort={effort}
onValueChange={setModel}
onEffortChange={setEffort}
renderIcon={(option) => (
<svg
aria-hidden="true"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d={MARKS[option.id]} />
</svg>
)}
/>
}
attachments={files.map((file) => (
<FileChip
key={file.id}
name={file.name}
onRemove={() => setFiles((list) => list.filter((f) => f.id !== file.id))}
/>
))}
/>
<PromptComposer disabled disabledReason={REASON} />
</div>
);
}
API
Parts: card (form), disabled reason bar (folds open above the prompt), attachments row (folds open above the prompt), prompt (textarea), toolbar, add files button, toolbar slot (for example a mode switcher), character counter (number roll, opens near the limit), send button (send, wait, stop icons), drop layer.
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | undefined | Controlled prompt text. |
| defaultValue | string | "" | Prompt text at first when not controlled. |
| status | "ready" | "submitted" | "streaming" | "error" | "ready" | Request status; takes the chat hook status as is. Error behaves like ready, so the person can send again. |
| placeholder | string | "Write a message" | Placeholder and accessible name of the prompt. |
| disabled | boolean | false | Turns off the prompt, the add files button and Send. |
| disabledReason | string | undefined | Why the composer is off, for example a daily limit; folds open above the prompt while disabled. |
| accept | string | undefined | File types for the file picker, paste and drop. |
| maxLength | number | undefined | Character limit; the counter opens at 80% of it, and Send turns off above it. |
| allowEmpty | boolean | false | Lets the person send with no text, for example when files are attached. |
| autoFocus | boolean | false | Moves focus to the prompt when it first shows. |
| Event | Description |
|---|---|
| valueChange | Prompt text changed. Payload: the text. |
| submit | Enter or the send button sent the prompt. Payload: the text. Uncontrolled text is cleared after it. |
| stop | Stop was pressed while the answer streams. Without this listener the button shows the wait spinner until the answer ends. |
| filesSelected | Files were picked, pasted or dropped. Payload: the files. Setting this listener turns on the add files button, paste and drop. |
| Slot | Description |
|---|---|
| attachments | Attachment chips (attachment-chip) shown above the prompt. |
| toolbar | Controls at the start of the toolbar, after the add files button. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| PCM1 | Status changes (send, wait, stop) | send icons | opacity, blur | 0, blur-swap → 1, 0 (the leaving icon does the reverse) | duration-md (260)ease-out | Swaps at once |
| PCM2 | Status changes (send, wait, stop) | send icons | scale | scale-swap → 1 (the leaving icon does the reverse) | duration-xl (380)ease-out | Swaps at once |
| PCM3 | While sending | wait spinner | rotate | 0deg → 360deg, repeats | duration-6xl (900)ease-linear | No loop |
| PCM4 | Send pressed | send button | scale | 1 → scale-press | duration-sm (200)ease-out | Stays still |
| PCM5 | Prompt filled or emptied | send button | opacity | 0.35 → 1 | duration-sm (200)ease-out | Shows at once |
| PCM6 | Card gets focus | card border | border color | border-secondary → border-primary | duration-xs (160)ease-out | Swaps at once |
| PCM7 | Files dragged over the card, or away | drop layer | opacity | 0 → 1 (back to 0 on leave or drop) | duration-sm (200)ease-out | Shows at once |
| PCM8 | Files dragged over the card, or away | card border | border color, style | border-secondary, solid → border-brand, dashed | duration-xs (160)ease-out | Swaps at once |
| PCM9 | Disabled with a reason, or enabled again | disabled reason bar | height (grid rows) | 0fr → 1fr (back to 0fr when enabled) | duration-xl (380)ease-out | Resizes at once |
| PCM10 | Disabled with a reason | disabled reason bar | opacity | 0 → 1 | duration-md (260)ease-out | Shows at once |
| PCM11 | Enabled again | disabled reason bar | opacity | 1 → 0 | duration-xs (160)ease-out | Hides at once |
| PCM12 | First file added, or last file removed | attachments row | height (grid rows) | 0fr → 1fr (back to 0fr when empty) | duration-xl (380)ease-out | Resizes at once |
| PCM13 | First file added | attachments row | opacity | 0 → 1 | duration-md (260)ease-out | Shows at once |
| PCM14 | Last file removed | attachments row | opacity | 1 → 0 | duration-xs (160)ease-out | Hides at once |
| PCM15 | Text passes 80% of maxLength, or drops below it | character counter | column width (grid columns) | 0fr → 1fr | duration-xl (380)ease-out | Resizes at once |
| PCM16 | Text passes 80% of maxLength, or drops below it | character counter | opacity | 0 → 1 | duration-md (260)ease-out | Shows at once |
| PCM17 | Text added or removed near the limit | counter number | number roll | 412 → 413 | duration-lg (320)ease-out | Swaps at once |
| PCM18 | Text goes over maxLength, or back under it | character counter | text color | text-tertiary → text-error-primary | duration-lg (320)ease-out | Swaps at once |
| PCM19 | Hover the add files button | add files button | background color, icon color | transparent, fg-tertiary → bg-tertiary, fg-primary | duration-xs (160)ease-out | Swaps at once |
| PCM20 | Text added or removed | prompt | height | old height → grows with the text, up to 200px, then scrolls | 0ease-linear | Resizes at once |
Accessibility
- The card is a form; the prompt is a textarea named by the placeholder text.
- Enter sends, Shift+Enter adds a line, and Enter during text composition (IME) does not send.
- The send button name follows its state: "Send", "Sending" or "Stop"; icons inside it are aria-hidden. While Sending it is disabled; while the answer streams without a stop listener it stays on Sending.
- The add files button is named "Add files"; paste and drop also add files, so the drop layer (aria-hidden) is never the only way.
- The disabled reason is linked to the prompt with aria-describedby while it shows, so the person hears why the prompt is off.
- Once the character counter shows, a visually hidden text such as "413 of 500 characters" is linked to the prompt with aria-describedby; over the limit it adds "Too long", so the state is not shown by red color alone.
- Folded parts (reason bar, attachments row, counter) are aria-hidden and inert while closed.
- All buttons are 32px with a visible focus ring (Focus rings/focus-ring).