Composer
The input at the bottom of a chat, taken apart: a root that owns the draft and eight parts that read it — a growing textarea, a send button that becomes a stop button, a toolbar, a model picker, an attachment row with its chips, and a completion menu you can mount twice on the same draft for / commands and @ mentions
Preview
<script lang="ts">
import { onMount } from "svelte";
import { Composer } from "fancy-ui-svelte";
/** What the rig types when nobody has taken the keyboard yet. */
const QUESTION = "Why did the deploy stall?";
/** Between keystrokes while the rig types. */
const KEY_MS = 55;
/** The beat between the last keystroke and the send. */
const SEND_MS = 520;
/** Before the first word of a reply, and between the words after it. */
const REPLY_MS = 380;
const WORD_MS = 32;
/** How long a finished answer sits there before the rig starts over. */
const READ_MS = 3200;
let draft = $state("");
let reply = $state("");
let streaming = $state(false);
let timer: ReturnType<typeof setTimeout> | undefined;
let typed = 0;
/** True while the rig owns the composer. A person touching it takes it back. */
let driving = false;
let reduced = false;
/**
* The fake model. It answers the prompt it was handed and nothing is behind it
* — which is the honest thing for a docs page to demonstrate.
*/
function sentenceFor(prompt: string): string {
return `You asked: “${prompt}” — and nothing is wired up behind this composer. The reply is on a timer, arriving one word at a time so the send button has something to turn into.`;
}
function answer(prompt: string) {
clearTimeout(timer);
const words = sentenceFor(prompt).split(" ");
// Reduced motion gets the finished answer: a reply that types itself is the
// motion this setting is asking us not to run.
if (reduced) {
reply = words.join(" ");
streaming = false;
return;
}
reply = "";
streaming = true;
let i = 0;
function chunk() {
if (i < words.length) {
reply = i === 0 ? words[0] : `${reply} ${words[i]}`;
i += 1;
timer = setTimeout(chunk, WORD_MS);
return;
}
streaming = false;
if (driving) timer = setTimeout(restart, READ_MS);
}
timer = setTimeout(chunk, REPLY_MS);
}
/** The composer clears its own text on a successful send; the reply is ours. */
function send(payload: { text: string }) {
answer(payload.text);
}
/** What the stop button does here: drop the rest of the sentence and stand down. */
function halt() {
clearTimeout(timer);
streaming = false;
if (driving) timer = setTimeout(restart, READ_MS);
}
function typeNext() {
if (typed < QUESTION.length) {
typed += 1;
draft = QUESTION.slice(0, typed);
timer = setTimeout(typeNext, KEY_MS);
return;
}
timer = setTimeout(() => {
const text = draft.trim();
draft = "";
answer(text);
}, SEND_MS);
}
function restart() {
reply = "";
draft = "";
typed = 0;
timer = setTimeout(typeNext, KEY_MS);
}
/** The demo is yours the moment you reach for it, and the rig does not fight you. */
function handOver() {
if (!driving) return;
driving = false;
clearTimeout(timer);
}
onMount(() => {
reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Nothing loops under reduced motion: one answered question, already there,
// and an empty composer waiting for a real one.
if (reduced) {
reply = sentenceFor(QUESTION);
return;
}
driving = true;
timer = setTimeout(typeNext, READ_MS / 4);
return () => {
driving = false;
clearTimeout(timer);
};
});
</script>
<!-- Capture, so the hand-over happens before the composer's own handlers run. -->
<div
class="flex w-full max-w-xl flex-col gap-3 p-6"
onpointerdowncapture={handOver}
onkeydowncapture={handOver}
>
{#if reply}
<div class="bg-muted text-foreground mr-auto max-w-[90%] rounded-2xl rounded-bl-sm px-4 py-3">
<p class="text-sm leading-relaxed">{reply}</p>
</div>
{/if}
<Composer
bind:value={draft}
{streaming}
placeholder="Ask anything"
onSubmit={send}
onStop={halt}
/>
<p class="text-muted-foreground text-xs">
Enter sends, Shift+Enter adds a line. While the reply streams the send button becomes a stop
button, and the composer refuses to send again until it lands.
</p>
</div>Installation
Usage
<script lang="ts">
import { Composer } from 'fancy-ui-svelte';
</script>
<Composer />Examples
Basic Usage
<script lang="ts">
import { onMount } from "svelte";
import { Composer } from "$lib/fancy-ui/composer";
/** What the rig types when nobody has taken the keyboard yet. */
const QUESTION = "Why did the deploy stall?";
/** Between keystrokes while the rig types. */
const KEY_MS = 55;
/** The beat between the last keystroke and the send. */
const SEND_MS = 520;
/** Before the first word of a reply, and between the words after it. */
const REPLY_MS = 380;
const WORD_MS = 32;
/** How long a finished answer sits there before the rig starts over. */
const READ_MS = 3200;
let draft = $state("");
let reply = $state("");
let streaming = $state(false);
let timer: ReturnType<typeof setTimeout> | undefined;
let typed = 0;
/** True while the rig owns the composer. A person touching it takes it back. */
let driving = false;
let reduced = false;
/**
* The fake model. It answers the prompt it was handed and nothing is behind it
* — which is the honest thing for a docs page to demonstrate.
*/
function sentenceFor(prompt: string): string {
return `You asked: “${prompt}” — and nothing is wired up behind this composer. The reply is on a timer, arriving one word at a time so the send button has something to turn into.`;
}
function answer(prompt: string) {
clearTimeout(timer);
const words = sentenceFor(prompt).split(" ");
// Reduced motion gets the finished answer: a reply that types itself is the
// motion this setting is asking us not to run.
if (reduced) {
reply = words.join(" ");
streaming = false;
return;
}
reply = "";
streaming = true;
let i = 0;
function chunk() {
if (i < words.length) {
reply = i === 0 ? words[0] : `${reply} ${words[i]}`;
i += 1;
timer = setTimeout(chunk, WORD_MS);
return;
}
streaming = false;
if (driving) timer = setTimeout(restart, READ_MS);
}
timer = setTimeout(chunk, REPLY_MS);
}
/** The composer clears its own text on a successful send; the reply is ours. */
function send(payload: { text: string }) {
answer(payload.text);
}
/** What the stop button does here: drop the rest of the sentence and stand down. */
function halt() {
clearTimeout(timer);
streaming = false;
if (driving) timer = setTimeout(restart, READ_MS);
}
function typeNext() {
if (typed < QUESTION.length) {
typed += 1;
draft = QUESTION.slice(0, typed);
timer = setTimeout(typeNext, KEY_MS);
return;
}
timer = setTimeout(() => {
const text = draft.trim();
draft = "";
answer(text);
}, SEND_MS);
}
function restart() {
reply = "";
draft = "";
typed = 0;
timer = setTimeout(typeNext, KEY_MS);
}
/** The demo is yours the moment you reach for it, and the rig does not fight you. */
function handOver() {
if (!driving) return;
driving = false;
clearTimeout(timer);
}
onMount(() => {
reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Nothing loops under reduced motion: one answered question, already there,
// and an empty composer waiting for a real one.
if (reduced) {
reply = sentenceFor(QUESTION);
return;
}
driving = true;
timer = setTimeout(typeNext, READ_MS / 4);
return () => {
driving = false;
clearTimeout(timer);
};
});
</script>
<!-- Capture, so the hand-over happens before the composer's own handlers run. -->
<div
class="flex w-full max-w-xl flex-col gap-3 p-6"
onpointerdowncapture={handOver}
onkeydowncapture={handOver}
>
{#if reply}
<div class="bg-muted text-foreground mr-auto max-w-[90%] rounded-2xl rounded-bl-sm px-4 py-3">
<p class="text-sm leading-relaxed">{reply}</p>
</div>
{/if}
<Composer
bind:value={draft}
{streaming}
placeholder="Ask anything"
onSubmit={send}
onStop={halt}
/>
<p class="text-muted-foreground text-xs">
Enter sends, Shift+Enter adds a line. While the reply streams the send button becomes a stop
button, and the composer refuses to send again until it lands.
</p>
</div>Full Composer
<script lang="ts">
import { onMount } from "svelte";
import {
Composer,
ComposerInput,
ComposerSubmit,
ComposerToolbar,
ComposerModelPicker,
ComposerAttachments,
ComposerCommandMenu,
} from "$lib/fancy-ui/composer";
import { ContextRing } from "$lib/fancy-ui/context-ring";
import { VoiceInput } from "$lib/fancy-ui/voice-input";
import type {
AttachmentData,
CommandItemData,
ModelOptionData,
TokenUsageData,
} from "$lib/fancy-ui/_internals/ai-types.js";
/** Generic tiers, so the fixture says nothing about anyone's product line-up. */
const MODELS: ModelOptionData[] = [
{ id: "mini", label: "Mini", badge: "Fast", description: "Short answers, small context." },
{ id: "pro", label: "Pro", description: "Deeper reasoning, slower." },
{ id: "max", label: "Max", badge: "New", description: "Everything, when it matters." },
];
const COMMANDS: CommandItemData[] = [
{ id: "deploy", label: "/deploy", description: "Ship the current branch", hint: "⌘⏎" },
{ id: "describe", label: "/describe", description: "Summarise the diff" },
{ id: "tests", label: "/tests", description: "Run the affected suites" },
{ id: "reset", label: "/reset", description: "Clear the conversation" },
];
const PEOPLE: CommandItemData[] = [
{ id: "jordan", label: "@jordan", description: "Reviewer" },
{ id: "sam", label: "@sam", description: "On call" },
{ id: "robin", label: "@robin", description: "Release manager" },
];
/** What the fake recogniser hears, one word at a time. */
const HEARD = "add a retry with backoff to the release job";
/** Tokens the system prompt is holding before anything is typed. */
const SYSTEM_TOKENS = 1_850;
/** What one attached file costs, roughly, once it has been read. */
const FILE_TOKENS = 1_450;
/** Four characters to a token is close enough for a meter on a docs page. */
const CHARS_PER_TOKEN = 4;
const UPLOAD_MS = 240;
const REPLY_MS = 420;
const WORD_MS = 34;
const HEAR_MS = 180;
interface Turn {
id: string;
role: "user" | "assistant";
text: string;
}
let draft = $state("");
let attachments = $state<AttachmentData[]>([]);
let model = $state("pro");
let streaming = $state(false);
let recording = $state(false);
let heard = $state("");
let turns = $state<Turn[]>([]);
let serial = 0;
let timers: ReturnType<typeof setTimeout>[] = [];
let reduced = false;
/** Just the reply in flight, so stopping it leaves the rest of the demo alone. */
let replyTimers: ReturnType<typeof setTimeout>[] = [];
/** Every timer in this file goes through here, so unmount can take them all back. */
function later(fn: () => void, ms: number) {
timers.push(setTimeout(fn, ms));
}
/** A reply's own timer: tracked twice, so `halt` can cancel it and unmount still can. */
function laterReply(fn: () => void, ms: number) {
const timer = setTimeout(fn, ms);
timers.push(timer);
replyTimers.push(timer);
}
// -------------------------------------------------------------------------
// The meter
// -------------------------------------------------------------------------
function tokens(text: string): number {
return Math.ceil(text.length / CHARS_PER_TOKEN);
}
const historyTokens = $derived(turns.reduce((total, turn) => total + tokens(turn.text), 0));
const draftTokens = $derived(tokens(draft));
const fileTokens = $derived(attachments.length * FILE_TOKENS);
// A small window on purpose: the ring should visibly move when you attach a
// file, which it would not on a budget nothing in a demo can dent.
const usage: TokenUsageData = $derived({
used: SYSTEM_TOKENS + historyTokens + draftTokens + fileTokens,
max: 16_000,
breakdown: [
{ label: "System prompt", tokens: SYSTEM_TOKENS },
{ label: "Transcript", tokens: historyTokens },
{ label: "Attachments", tokens: fileTokens },
{ label: "Draft", tokens: draftTokens },
],
});
// -------------------------------------------------------------------------
// The fake upload
// -------------------------------------------------------------------------
function patch(id: string, next: Partial<AttachmentData>) {
attachments = attachments.map((entry) => (entry.id === id ? { ...entry, ...next } : entry));
}
function march(id: string) {
later(() => {
const entry = attachments.find((item) => item.id === id);
// The chip may have been removed mid-upload, and a fake upload with no
// chip left to fill has nothing to do.
if (!entry || entry.status !== "uploading") return;
const progress = Math.min(1, (entry.progress ?? 0) + 0.2);
patch(id, progress >= 1 ? { progress: 1, status: "done" } : { progress });
if (progress < 1) march(id);
}, UPLOAD_MS);
}
/**
* The composer never uploads anything: it hands the picked files over and the
* consumer pushes onto `attachments` once the upload has an id for them.
*/
function attach(files: File[]) {
for (const file of files) {
serial += 1;
const id = `${file.name}#${serial}`;
const entry: AttachmentData = {
id,
name: file.name,
size: file.size,
type: file.type,
progress: reduced ? 1 : 0,
status: reduced ? "done" : "uploading",
};
attachments = [...attachments, entry];
// A progress bar creeping across a chip is motion; reduced motion gets
// the finished upload instead, which is where it was heading anyway.
if (!reduced) march(id);
}
}
// -------------------------------------------------------------------------
// The fake model
// -------------------------------------------------------------------------
function replyFor(prompt: string): string {
return `Nothing is wired up behind this composer — “${prompt.slice(0, 60)}” went nowhere. The reply is on a timer, and the ring above counts this sentence against the window as it arrives.`;
}
function stream(prompt: string) {
const words = replyFor(prompt).split(" ");
serial += 1;
const id = `assistant-${serial}`;
turns = [...turns, { id, role: "assistant", text: reduced ? words.join(" ") : "" }];
if (reduced) return;
streaming = true;
let i = 0;
function chunk() {
if (i >= words.length) {
streaming = false;
return;
}
const word = words[i];
turns = turns.map((turn) =>
turn.id === id ? { ...turn, text: turn.text === "" ? word : `${turn.text} ${word}` } : turn
);
i += 1;
laterReply(chunk, WORD_MS);
}
laterReply(chunk, REPLY_MS);
}
function send(payload: { text: string; attachments: AttachmentData[] }) {
const files = payload.attachments;
const label =
payload.text || `${files.length} file${files.length === 1 ? "" : "s"}, and nothing else`;
serial += 1;
turns = [...turns, { id: `user-${serial}`, role: "user", text: label }];
// The turn has really left, so its files go with it — the composer leaves
// that call to us, since only we know whether an upload is still in flight.
attachments = [];
stream(payload.text || label);
}
function halt() {
// Only the reply: an upload or a transcription running alongside it has
// nothing to do with the answer being stopped, and cancelling their timers
// would leave their chips frozen mid-progress.
for (const timer of replyTimers) clearTimeout(timer);
replyTimers = [];
streaming = false;
}
// -------------------------------------------------------------------------
// The fake microphone
// -------------------------------------------------------------------------
function startVoice() {
recording = true;
heard = reduced ? HEARD : "";
if (reduced) return;
const words = HEARD.split(" ");
let i = 0;
function next() {
// A cancelled recording stops being transcribed.
if (!recording || i >= words.length) return;
heard = i === 0 ? words[0] : `${heard} ${words[i]}`;
i += 1;
later(next, HEAR_MS);
}
later(next, HEAR_MS);
}
/** Confirmed: what was heard joins the draft, where it can still be edited. */
function keepVoice() {
draft = draft === "" ? heard : `${draft} ${heard}`;
heard = "";
}
function dropVoice() {
heard = "";
}
onMount(() => {
reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Nothing here autoplays — every timer above is started by something you did
// — so reduced motion changes only how the fake work lands: all at once
// rather than in steps.
return () => {
for (const timer of timers) clearTimeout(timer);
timers = [];
};
});
</script>
<div class="flex w-full max-w-2xl flex-col gap-3 p-6">
{#each turns as turn (turn.id)}
{#if turn.role === "user"}
<div
class="bg-primary text-primary-foreground ml-auto max-w-[85%] rounded-2xl rounded-br-sm px-4 py-2.5"
>
<p class="text-sm leading-relaxed">{turn.text}</p>
</div>
{:else}
<div class="bg-muted text-foreground mr-auto max-w-[85%] rounded-2xl rounded-bl-sm px-4 py-3">
<p class="text-sm leading-relaxed">{turn.text}</p>
</div>
{/if}
{/each}
<Composer
bind:value={draft}
bind:attachments
{streaming}
onSubmit={send}
onStop={halt}
onAttach={attach}
accessory={recording ? voicePanel : undefined}
>
{#snippet children()}
<ComposerAttachments accept="image/*,.pdf,.md" />
<ComposerInput placeholder="Ask anything — / for commands, @ to mention" maxRows={6} />
<ComposerToolbar class="mt-2">
<ComposerModelPicker models={MODELS} bind:value={model} />
<button
type="button"
class="text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:ring-ring inline-flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md transition-colors focus-visible:ring-1 focus-visible:outline-none"
aria-label="Start voice input"
title="Start voice input"
onclick={startVoice}
>
<svg
class="size-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<path d="M12 19v3" />
</svg>
</button>
<!-- The spacer is the split: everything after it sits on the right. -->
<div class="flex-1"></div>
<ContextRing {usage} size={22} strokeWidth={3} showLabel={false} expandable />
<ComposerSubmit />
</ComposerToolbar>
<!-- Two menus, one draft: each answers only to its own trigger character,
so only one of them can ever be on screen. -->
<ComposerCommandMenu trigger="/" items={COMMANDS} />
<ComposerCommandMenu trigger="@" items={PEOPLE} />
{/snippet}
</Composer>
<p class="text-muted-foreground text-xs">
Type <code>/</code> for commands or <code>@</code> to mention someone — arrows move, Enter or Tab
completes, Escape dismisses that token. The paperclip runs a fake upload, the ring counts the draft
against a small window, and the microphone covers the composer with the accessory overlay.
</p>
</div>
{#snippet voicePanel()}
<!-- The accessory is a plain layer over the composition, not a focus trap:
what it holds and how it is dismissed are the consumer's to decide. -->
<div class="bg-background flex h-full items-center rounded-xl border p-2">
<VoiceInput
bind:active={recording}
transcript={heard}
demo
height={40}
class="w-full"
onStop={keepVoice}
onCancel={dropVoice}
/>
</div>
{/snippet}Props
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | "" | The draft text, bindable. Cleared by a successful submit; the attachments are not, since only the consumer knows whether an upload is still in flight |
attachments | AttachmentData[] | [] | Files riding along with the draft, bindable. The consumer owns uploading them and pushing the results here |
disabled | boolean | false | Blocks typing, sending, and attaching. Every part reads it off the context, so one flag takes the whole composition inert |
streaming | boolean | false | A response is arriving: the send button becomes a stop button, the textarea goes readonly rather than disabled, and a submit is refused whatever triggered it |
placeholder | string | - | Placeholder for the default input. Ignored once children replaces the composition — the ComposerInput inside it carries its own |
onSubmit | (payload: { text: string; attachments: AttachmentData[] }) => void | - | Called with the trimmed draft and a snapshot of the attachments. Never fires on an empty draft that carries no files |
onStop | () => void | - | Called when the stop button is pressed while streaming |
onAttach | (files: File[]) => void | - | Called with the files handed to the picker. Upload them, then push the results onto attachments — the composer never uploads anything itself |
Slots
| Slot | Description |
|---|---|
children | Replaces the default input-and-send-row composition entirely: mount the parts in whatever order the layout needs |
accessory | An overlay covering the composer — a voice panel, a drop target, a confirmation. It sits above the composition rather than replacing it, so the draft is still there when it lifts |
Links
Related components
ChatError
Quiet inline failure banner for a chat turn: the error, an optional detail line, and a retry button that disables itself while the retry is in flight
ChatMessage
One conversation turn, aligned and dressed by its role, streaming its body while the answer arrives, with an action rail that fades in on hover and a version navigator underneath
ChatPanel
The shell a conversation lives in: a sticky header, a transcript that pins itself to the bottom while an answer arrives and offers the way back once you scroll up, and a sticky composer row