Chat Message

AI / agent

User bubbles right-aligned, assistant plain with avatar; ChatList staggers the initial mount.

Can you summarize last month's spend?
AI

Install

npx shadcn@latest add https://labs.duku.design/r/chat-message.json

Ask your agent to use this

Connect DUKU Labs to MCP, then hand your coding agent this prompt:

Use the DUKU Labs MCP server to install the Chat Message component (get_component "chat-message", then its install command). Adapt it to my existing design tokens, preserve all accessibility and reduced-motion behavior, and keep every interaction state working.

Interaction

TryWhat happens
Mount a listMessages cascade rise-in at 0.05s
streaming on assistantString children stream via StreamText

Props

PropTypeDefaultDescription
role"user" | "assistant"Alignment and styling.
streamingbooleanfalseWraps string children in StreamText.
Source — registry/default/ai/chat-message.tsx
"use client";

import * as React from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/lib/use-reduced-motion";
import { StreamText } from "@/registry/default/ai/stream-text";

export interface ChatMessageProps
  extends React.HTMLAttributes<HTMLDivElement> {
  role: "user" | "assistant";
  /** Assistant only: wraps string children in StreamText. */
  streaming?: boolean;
  avatar?: React.ReactNode;
  ref?: React.Ref<HTMLDivElement>;
}

export function ChatMessage({
  role,
  streaming = false,
  avatar,
  className,
  children,
  ref,
  ...rest
}: ChatMessageProps) {
  const rootRef = React.useRef<HTMLDivElement>(null);
  const reduced = useReducedMotion();
  React.useImperativeHandle(ref, () => rootRef.current as HTMLDivElement);

  useGSAP(
    () => {
      const el = rootRef.current;
      if (!el) return;
      if (reduced) {
        gsap.set(el, { y: 0, opacity: 1 });
        return;
      }
      gsap.fromTo(
        el,
        { y: 12, opacity: 0 },
        { y: 0, opacity: 1, duration: 0.35, ease: "power2.out" }
      );
    },
    { scope: rootRef }
  );

  const body =
    role === "assistant" && streaming && typeof children === "string" ? (
      <StreamText text={children} />
    ) : (
      children
    );

  if (role === "user") {
    return (
      <div
        ref={rootRef}
        data-slot="chat-message"
        data-role="user"
        className={cn("flex justify-end opacity-0", className)}
        {...rest}
      >
        <div className="max-w-[80%] rounded-2xl rounded-br-md bg-muted px-4 py-2.5 text-sm leading-6 text-foreground">
          {body}
        </div>
      </div>
    );
  }

  return (
    <div
      ref={rootRef}
      data-slot="chat-message"
      data-role="assistant"
      className={cn("flex items-start gap-3 opacity-0", className)}
      {...rest}
    >
      <span
        data-slot="avatar"
        className="flex size-7 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-[10px] font-semibold uppercase text-muted-foreground"
      >
        {avatar ?? "AI"}
      </span>
      <div className="min-w-0 flex-1 text-sm leading-6 text-foreground">
        {body}
      </div>
    </div>
  );
}

export interface ChatListProps extends React.HTMLAttributes<HTMLDivElement> {
  ref?: React.Ref<HTMLDivElement>;
}

/** Staggers the initial mount of its ChatMessage children. */
export function ChatList({ className, ref, ...rest }: ChatListProps) {
  const rootRef = React.useRef<HTMLDivElement>(null);
  const reduced = useReducedMotion();
  React.useImperativeHandle(ref, () => rootRef.current as HTMLDivElement);

  useGSAP(
    () => {
      const root = rootRef.current;
      if (!root) return;
      const messages = gsap.utils.toArray<HTMLElement>(
        root.querySelectorAll('[data-slot="chat-message"]')
      );
      if (reduced) {
        gsap.set(messages, { y: 0, opacity: 1 });
        return;
      }
      gsap.fromTo(
        messages,
        { y: 12, opacity: 0 },
        {
          y: 0,
          opacity: 1,
          duration: 0.35,
          ease: "power2.out",
          stagger: 0.05,
        }
      );
    },
    { scope: rootRef }
  );

  return (
    <div
      ref={rootRef}
      data-slot="chat-list"
      className={cn("flex flex-col gap-4", className)}
      {...rest}
    />
  );
}