Skip to content
UI Kit
Accent color

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

3 variants

Variants

Customer chat

A guest asks, the café answers. Quick replies appear at the end.

Assistant with sources

A question about a contract, an answer with a card of sources inside the message.

Booking in the chat

Times appear as buttons in the answer, the client picks one, the confirmation brings a calendar link.

Details

What Chat scene brings along, read from the registry and the source.

Category
Animations
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

Props

The ChatSceneProps interface from components/ui/chat-scene.tsx, with the comments from the code. Also takes every prop of SceneMotionProps.

  • messagesrequired
    ChatMessage[]
  • titleoptional
    string

    Name in the header

  • subtitleoptional
    string

    Second line in the header, e.g. "Usually replies in minutes"

  • avataroptional
    React.ReactNode

    Avatar or mark of the other side

  • composeroptional
    boolean

    Your messages are typed into the composer before they are sent. Default true.

  • repliesoptional
    string[]

    Buttons that appear after the last message

  • typingoptional
    number

    Seconds the other side types before an answer. Default 1.1.

  • labeloptional
    string

    Accessible 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
placeholderEnglish Write a messageGerman Nachricht schreiben
sendEnglish SendGerman Senden
typingEnglish is typingGerman schreibt
startEnglish New conversationGerman 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
components/ui/chat-scene.tsx
'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.

Often searched

Jump to

Copied to the clipboard