Chat scene
A conversation that writes itself. Typing dots, a composer that types and sends, cards inside messages. Nothing below ever jumps.
One of 4 components in Animations
Details
What Chat scene brings along, read from the registry and the source.
- Category
- Animations
- Area
- Visuals
- Variants
- 3
- Version
- 1.0.2 · since 1.0.0
- File
- components/ui/chat-scene.tsx
- Brings along
- utilskitmotion-scene
- npm packages
- None
- Languages
- English and German, 4 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The ChatSceneProps interface from components/ui/chat-scene.tsx, with the comments from the code. Also takes every prop of SceneMotionProps.
messagesrequiredChatMessage[]titleoptionalstringName in the header
subtitleoptionalstringSecond line in the header, e.g. "Usually replies in minutes"
avataroptionalReact.ReactNodeAvatar or mark of the other side
composeroptionalbooleanYour messages are typed into the composer before they are sent. Default true.
repliesoptionalstring[]Buttons that appear after the last message
typingoptionalnumberSeconds the other side types before an answer. Default 1.1.
labeloptionalstringAccessible description; the messages stay readable for screen readers
Built-in texts
Chat scene writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 4 texts
| Key | English | German |
|---|---|---|
| placeholder | English Write a message | German Nachricht schreiben |
| send | English Send | German Senden |
| typing | English is typing | German schreibt |
| start | English New conversation | German Neues Gespräch |
Accessibility
Read from the source of the component.
- Sets aria-label, so screen readers announce purpose and state.
- Uses the role group.
- Adds text for screen readers where icons and graphics stand alone.
- Needs no client state. The server writes it in full, so it reads without JavaScript too.
Motion
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 221 lines
'use client';
import { cn, round, useTexts } from '@/lib/kit';
import { Cue, PlayToggle, SCENE_CSS, SceneTimeline, Track, Typed, keyframes, useSceneId, useScenePlay, type SceneMotionProps } from '@/components/ui/motion-scene';
/*
* A conversation that writes itself: the other side shows typing dots and
* answers, your side types into the composer and sends. Every message keeps
* its place from the first paint, it only fades and rises in, so nothing
* below ever jumps. Server, no JavaScript and reduced motion show the whole
* conversation.
*/
export interface ChatMessage {
from: 'me' | 'them';
text?: string;
/** Anything richer than text: a card, a booking, sources, an image */
content?: React.ReactNode;
/** Small line under the bubble, e.g. a time or "Read" */
meta?: string;
}
const TEXTS = {
en: { placeholder: 'Write a message', send: 'Send', typing: 'is typing', start: 'New conversation' },
de: { placeholder: 'Nachricht schreiben', send: 'Senden', typing: 'schreibt', start: 'Neues Gespräch' },
};
export interface ChatSceneProps extends SceneMotionProps {
messages: ChatMessage[];
/** Name in the header */
title?: string;
/** Second line in the header, e.g. "Usually replies in minutes" */
subtitle?: string;
/** Avatar or mark of the other side */
avatar?: React.ReactNode;
/** Your messages are typed into the composer before they are sent. Default true. */
composer?: boolean;
/** Buttons that appear after the last message */
replies?: string[];
/** Seconds the other side types before an answer. Default 1.1. */
typing?: number;
/** Accessible description; the messages stay readable for screen readers */
label?: string;
labels?: Partial<typeof TEXTS.en>;
className?: string;
}
interface Beat {
show: number;
dots?: number;
type?: number;
}
/**
* <ChatScene title="Café Mira" messages={[{ from: 'me', text: 'Do you have a table for four tonight?' }, { from: 'them', text: 'Yes, at 19:30 by the window.' }]} />
*/
export function ChatScene({ messages, title, subtitle, avatar, composer = true, replies, typing = 1.1, speed = 1, autoPlay = 'in-view', loop = true, controls = true, label, labels, className }: ChatSceneProps) {
const sid = useSceneId('kvch');
const play = useScenePlay<HTMLDivElement>(autoPlay);
const t = useTexts(TEXTS, labels);
const sp = Math.max(0.1, speed);
// The script in design seconds
let clock = 0.5;
const beats: Beat[] = messages.map((m) => {
const len = (m.text ?? '').length;
if (m.from === 'them') {
const b = { dots: clock, show: clock + typing };
clock = b.show + 0.5 + Math.min(2.2, len / 45) + (m.content ? 0.9 : 0);
return b;
}
if (composer && m.text) {
const dur = Math.min(2.4, len / 20);
const b = { type: clock, show: clock + dur + 0.35 };
clock = b.show + 0.6;
return b;
}
const b = { show: clock };
clock += 0.9;
return b;
});
const repliesAt = clock;
const T = round((clock + (replies?.length ? 1 : 0) + 2.6) / sp, 3);
const css = `${SCENE_CSS}
${keyframes(`${sid}-dot`, [[0, 'transform:translateY(0);opacity:.45'], [30, 'transform:translateY(-3px);opacity:1'], [60, 'transform:translateY(0);opacity:.45'], [100, 'transform:translateY(0);opacity:.45']])}
.${sid}:not([data-kv="off"]) .${sid}-dot { animation: ${sid}-dot 1s ease-in-out infinite; }`;
const rise = 'opacity:0;transform:translateY(10px) scale(.98)';
const here = 'opacity:1;transform:translateY(0) scale(1)';
return (
<SceneTimeline sid={sid} T={T} speed={sp} loop={loop}>
<div className={cn('relative w-full', className)}>
<div
ref={play.ref}
data-kv={play.state}
role="group"
aria-label={label}
className={cn(sid, 'flex w-full flex-col overflow-hidden rounded-2xl border border-border bg-card text-left text-foreground shadow-[var(--highlight),var(--shadow-lg)]')}
>
<style dangerouslySetInnerHTML={{ __html: css }} />
{title && (
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<span className="relative grid size-9 shrink-0 place-items-center overflow-hidden rounded-full bg-muted text-[12px] font-semibold">
{avatar ?? title.slice(0, 1)}
</span>
<span className="min-w-0">
<span className="block truncate text-[14px] font-semibold">{title}</span>
{subtitle && (
<span className="flex items-center gap-1.5 text-[12px] text-muted-foreground">
<span className="size-1.5 rounded-full bg-success" />
{subtitle}
</span>
)}
</span>
</div>
)}
<div key={play.cycle} className="relative flex flex-1 flex-col gap-2.5 px-4 py-4">
{/* Before the first message the window is never an empty box: the other side waits, ready */}
{messages.length > 0 && (
<Cue
at={Math.max(0, beats[0].show - 0.35)}
take={0.35}
from="opacity:1;transform:translateY(0);visibility:visible"
to="opacity:0;transform:translateY(-8px);visibility:hidden"
className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-3 text-center"
>
<span aria-hidden className="relative grid size-14 place-items-center rounded-full bg-muted text-[16px] font-semibold text-foreground shadow-[0_0_0_6px_color-mix(in_oklab,var(--primary)_8%,transparent)]">
{avatar ?? title?.slice(0, 1)}
</span>
<span aria-hidden className="text-[13px] font-medium text-muted-foreground">{t.start}</span>
</Cue>
)}
{messages.map((m, i) => {
const b = beats[i];
const me = m.from === 'me';
const bubble = (
<div className={cn('flex max-w-[85%] flex-col gap-1', me ? 'items-end self-end' : 'items-start self-start')}>
{m.text && (
<p className={cn('rounded-2xl px-3.5 py-2 text-[14px] leading-snug text-pretty', me ? 'rounded-br-md bg-primary text-primary-foreground' : 'rounded-bl-md bg-muted text-foreground')}>{m.text}</p>
)}
{m.content}
{m.meta && <p className="px-1 text-[11px] text-subtle-foreground">{m.meta}</p>}
</div>
);
return (
<div key={i} className={cn('relative flex flex-col', me ? 'items-end' : 'items-start')}>
<Cue at={b.show} take={0.45} from={rise} to={here} className={cn('flex w-full flex-col', me ? 'items-end' : 'items-start')}>
{bubble}
</Cue>
{b.dots != null && (
<Track
frames={[[0, 'opacity:0;transform:scale(.8)'], [b.dots, 'opacity:0;transform:scale(.8)'], [b.dots + 0.25, 'opacity:1;transform:scale(1)'], [b.show - 0.05, 'opacity:1;transform:scale(1)'], [b.show + 0.1, 'opacity:0;transform:scale(.8)']]}
end="opacity:0"
className="absolute left-0 top-0 flex origin-bottom-left items-center gap-1 rounded-2xl rounded-bl-md bg-muted px-3.5 py-3"
>
<span className="sr-only">{`${title ?? ''} ${t.typing}`}</span>
{[0, 1, 2].map((k) => (
<span key={k} aria-hidden className={`${sid}-dot size-1.5 rounded-full bg-muted-foreground`} style={{ animationDelay: `${k * 0.15}s` }} />
))}
</Track>
)}
</div>
);
})}
{replies?.length ? (
<Cue at={repliesAt} from="opacity:0;transform:translateY(8px)" to="opacity:1;transform:translateY(0)" className="mt-1 flex flex-wrap gap-2">
{replies.map((r) => (
<span key={r} className="rounded-full border border-[color-mix(in_oklab,var(--primary)_40%,var(--border))] px-3 py-1.5 text-[12.5px] font-medium text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">{r}</span>
))}
</Cue>
) : null}
</div>
{composer && (
<div className="flex items-center gap-2 border-t border-border p-3" aria-hidden>
<div data-clip-ok className="relative flex h-10 min-w-0 flex-1 items-center overflow-hidden rounded-full bg-muted px-4 text-[13.5px]">
<span className="text-muted-foreground">{t.placeholder}</span>
{messages.map((m, i) => {
const b = beats[i];
if (b.type == null || !m.text) return null;
return (
<Track
key={i}
frames={[[0, 'opacity:0'], [b.type - 0.01, 'opacity:0'], [b.type, 'opacity:1'], [b.show - 0.05, 'opacity:1'], [b.show, 'opacity:0']]}
end="opacity:0"
className="absolute inset-y-0 left-0 right-0 flex items-center bg-muted px-4"
>
<span className="min-w-0 truncate">
<Typed at={b.type} text={m.text} cps={m.text.length / Math.min(2.4, m.text.length / 20)} until={b.show} />
</span>
</Track>
);
})}
</div>
<span className="relative grid size-10 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground" title={t.send}>
<svg viewBox="0 0 24 24" className="size-[18px]" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 19V5M6 11l6-6 6 6" />
</svg>
{messages.map((_m, i) =>
beats[i].type != null ? (
<Track
key={i}
frames={[[0, 'opacity:0;transform:scale(.6)'], [beats[i].show - 0.08, 'opacity:0;transform:scale(.6)'], [beats[i].show, 'opacity:.6;transform:scale(1)'], [beats[i].show + 0.5, 'opacity:0;transform:scale(1.6)']]}
end="opacity:0"
className="pointer-events-none absolute inset-0 rounded-full border-2 border-[var(--primary)]"
/>
) : null,
)}
</span>
</div>
)}
</div>
{controls && (
<div className="mt-2 flex justify-end">
<PlayToggle inline state={play.state} reduced={play.reduced} onToggle={play.toggle} />
</div>
)}
</div>
</SceneTimeline>
);
}Frequent questions
How do I install Chat scene?
With aiw add @ai-website/chat-scene. The command puts components/ui/chat-scene.tsx into your project, the code is yours from then on. It brings along motion-scene. It needs no further npm packages.
Which variants does Chat scene have?
3 variants, Customer chat, Assistant with sources and Booking in the chat.
Can I translate the texts of Chat scene?
Yes. Chat scene carries 4 texts of its own in English and German, such as “Write a message”, “Send” and “is typing”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Chat scene?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Chat scene with aiw_components and install it with aiw_add @ai-website/chat-scene.