Chat-Szene
Ein Gespräch, das sich selbst schreibt. Tipp-Punkte, ein Eingabefeld, das tippt und sendet, Karten in Nachrichten. Darunter springt nie etwas.
Eine von 4 Components in Animations
Details
Was Chat-Szene mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Animations
- Bereich
- Visuals
- Variants
- 3
- Version
- 1.0.2 · seit 1.0.0
- Datei
- components/ui/chat-scene.tsx
- Bringt mit
- utilskitmotion-scene
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 4 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle ChatSceneProps aus components/ui/chat-scene.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von SceneMotionProps an.
messagesPflichtChatMessage[]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
Eingebaute Texte
Diese Texte schreibt Chat-Szene selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 4 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| placeholder | Englisch Write a message | Deutsch Nachricht schreiben |
| send | Englisch Send | Deutsch Senden |
| typing | Englisch is typing | Deutsch schreibt |
| start | Englisch New conversation | Deutsch Neues Gespräch |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-label, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rolle group.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Kommt ohne Client-State aus. Der Server schreibt sie vollständig, sie ist auch ohne JavaScript lesbar.
Bewegung
- Ohne Animationen, sie steht sofort ruhig da.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 221 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich Chat-Szene?
Mit aiw add @ai-website/chat-scene. Der Befehl legt die Datei components/ui/chat-scene.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind motion-scene. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Chat-Szene?
3 Varianten, Kundenchat, Assistent mit Quellen und Buchen im Chat.
Kann ich die Texte von Chat-Szene übersetzen?
Ja. Chat-Szene bringt 4 eigene Texte auf Deutsch und Englisch mit, etwa „Nachricht schreiben“, „Senden“ und „schreibt“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Chat-Szene einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Chat-Szene mit aiw_components und installieren sie mit aiw_add @ai-website/chat-scene.