Zum Inhalt springen
UI Kit
Accent

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

3 Variants

Variants

Kundenchat

Ein Gast fragt, das Café antwortet. Am Ende erscheinen Schnellantworten.

Assistent mit Quellen

Eine Frage zu einem Vertrag, eine Antwort mit einer Quellenkarte in der Nachricht.

Buchen im Chat

Zeiten erscheinen als Knöpfe in der Antwort, die Kundin wählt, die Bestätigung bringt einen Kalenderlink.

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

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.

  • messagesPflicht
    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

Eingebaute Texte

Diese Texte schreibt Chat-Szene selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 4 Texte anzeigen
placeholderEnglisch Write a messageDeutsch Nachricht schreiben
sendEnglisch SendDeutsch Senden
typingEnglisch is typingDeutsch schreibt
startEnglisch New conversationDeutsch 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
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>
  );
}

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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert