Lunia DS

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.

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-composer

Usage

The demo above, as code.

demo.tsx
'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.

PropTypeDefaultDescription
valuestringundefinedControlled prompt text.
defaultValuestring""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.
placeholderstring"Write a message"Placeholder and accessible name of the prompt.
disabledbooleanfalseTurns off the prompt, the add files button and Send.
disabledReasonstringundefinedWhy the composer is off, for example a daily limit; folds open above the prompt while disabled.
acceptstringundefinedFile types for the file picker, paste and drop.
maxLengthnumberundefinedCharacter limit; the counter opens at 80% of it, and Send turns off above it.
allowEmptybooleanfalseLets the person send with no text, for example when files are attached.
autoFocusbooleanfalseMoves focus to the prompt when it first shows.
EventDescription
valueChangePrompt text changed. Payload: the text.
submitEnter or the send button sent the prompt. Payload: the text. Uncontrolled text is cleared after it.
stopStop was pressed while the answer streams. Without this listener the button shows the wait spinner until the answer ends.
filesSelectedFiles were picked, pasted or dropped. Payload: the files. Setting this listener turns on the add files button, paste and drop.
SlotDescription
attachmentsAttachment chips (attachment-chip) shown above the prompt.
toolbarControls 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.

IDTriggerElementPropertyFrom → toTimingReduced motion
PCM1Status changes (send, wait, stop)send iconsopacity, blur0, blur-swap → 1, 0 (the leaving icon does the reverse)duration-md (260)ease-outSwaps at once
PCM2Status changes (send, wait, stop)send iconsscalescale-swap → 1 (the leaving icon does the reverse)duration-xl (380)ease-outSwaps at once
PCM3While sendingwait spinnerrotate0deg → 360deg, repeatsduration-6xl (900)ease-linearNo loop
PCM4Send pressedsend buttonscale1 → scale-pressduration-sm (200)ease-outStays still
PCM5Prompt filled or emptiedsend buttonopacity0.35 → 1duration-sm (200)ease-outShows at once
PCM6Card gets focuscard borderborder colorborder-secondary → border-primaryduration-xs (160)ease-outSwaps at once
PCM7Files dragged over the card, or awaydrop layeropacity0 → 1 (back to 0 on leave or drop)duration-sm (200)ease-outShows at once
PCM8Files dragged over the card, or awaycard borderborder color, styleborder-secondary, solid → border-brand, dashedduration-xs (160)ease-outSwaps at once
PCM9Disabled with a reason, or enabled againdisabled reason barheight (grid rows)0fr → 1fr (back to 0fr when enabled)duration-xl (380)ease-outResizes at once
PCM10Disabled with a reasondisabled reason baropacity0 → 1duration-md (260)ease-outShows at once
PCM11Enabled againdisabled reason baropacity1 → 0duration-xs (160)ease-outHides at once
PCM12First file added, or last file removedattachments rowheight (grid rows)0fr → 1fr (back to 0fr when empty)duration-xl (380)ease-outResizes at once
PCM13First file addedattachments rowopacity0 → 1duration-md (260)ease-outShows at once
PCM14Last file removedattachments rowopacity1 → 0duration-xs (160)ease-outHides at once
PCM15Text passes 80% of maxLength, or drops below itcharacter countercolumn width (grid columns)0fr → 1frduration-xl (380)ease-outResizes at once
PCM16Text passes 80% of maxLength, or drops below itcharacter counteropacity0 → 1duration-md (260)ease-outShows at once
PCM17Text added or removed near the limitcounter numbernumber roll412 → 413duration-lg (320)ease-outSwaps at once
PCM18Text goes over maxLength, or back under itcharacter countertext colortext-tertiary → text-error-primaryduration-lg (320)ease-outSwaps at once
PCM19Hover the add files buttonadd files buttonbackground color, icon colortransparent, fg-tertiary → bg-tertiary, fg-primaryduration-xs (160)ease-outSwaps at once
PCM20Text added or removedpromptheightold height → grows with the text, up to 200px, then scrolls0ease-linearResizes 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).