reelcn
GitHubBack to landing

Chat thread

Message thread that shows three bouncing typing dots before a "them" reply, pops bubbles in, and scrolls up as it fills.

ChatThread, reply
ChatThread, reply thumbnail, 16:9

Installation

npx shadcn add https://www.reelcn.dev/r/chat-thread.json

Files land in src/reelcn/ with the items it builds on.

Usage

<PhoneFrame>
  <ChatThread
    messages={[
      { from: "me", text: "Can it render 9:16?", at: 0 },
      { from: "them", text: "Yes. Same component, three formats.", at: 30 },
    ]}
  />
</PhoneFrame>

Props

PropTypeDescription
delaynumberFrames to wait before entering.
durationnumberEnter duration in frames. Defaults to 0.6s. Some items reuse duration for an unrelated local timing. See that item's own prop docs.
exitnumber | booleanExit at the end of the parent `<Sequence>`. false keeps it on screen, a number sets the exit length in frames.
motionMotionPersonalityOverride the theme's motion personality.
posterbooleanRenders fully entered, with no exit: for a still frame or thumbnail. Defaults to false.
holdFramesnumberDelays the start of the exit window by this many frames, for a minimum on-screen hold. Defaults to 0.
messages*ChatMessage[]
typingDurationnumberFrames the typing dots show before a "them" message lands. Defaults to 0.6s. 0 skips the dots.
widthnumberVisible thread width in design units. Default 460.
heightnumberVisible thread height in design units. Older bubbles scroll out the top once it's full. Default 480.
fontSizenumberLargest font size in design units. Default 24.
backgroundstring
meColorstring"me" bubble fill. Defaults to the theme accent.
meTextColorstring
themColorstring"them" bubble fill. Defaults to the theme surface.
themTextColorstring
radiusnumber
styleCSSProperties
classNamestring

Built from

Use

  • A DM, support chat or group thread inside a `phone-frame` or app mockup
  • Showing a bot or assistant "typing" before it answers

Avoid

  • A single command-and-output log. Use terminal

Guides

Components take their colors from a theme, their timing from motion props and their layout from the canvas.

Source

registry/items/chat-thread.tsx
/**
 * @title Chat Thread
 * @category product
 * @description Message thread that shows three bouncing typing dots before a "them" reply, pops bubbles in, and scrolls up as it fills.
 * @duration data-driven
 * @use A DM, support chat or group thread inside a `phone-frame` or app mockup
 * @use Showing a bot or assistant "typing" before it answers
 * @avoid A single command-and-output log. Use `terminal`
 * @tags chat, messages, dm, bubbles, typing, conversation
 * @example
 * <PhoneFrame>
 *   <ChatThread
 *     messages={[
 *       { from: "me", text: "Can it render 9:16?", at: 0 },
 *       { from: "them", text: "Yes. Same component, three formats.", at: 30 },
 *     ]}
 *   />
 * </PhoneFrame>
 */
import type React from "react";
import { type MotionProps, tween, useMotion, useTheme, useViewport } from "./core";

export type ChatMessage = {
  from: "me" | "them";
  text: string;
  /** Frame this message lands, counted from `delay`. A "them" message shows typing dots for `typingDuration` frames before it. */
  at: number;
};

export type ChatThreadProps = MotionProps & {
  messages: ChatMessage[];
  /** Frames the typing dots show before a "them" message lands. Defaults to 0.6s. 0 skips the dots. */
  typingDuration?: number;
  /** Visible thread width in design units. */
  width?: number;
  /** Visible thread height in design units. Older bubbles scroll out the top once it's full. */
  height?: number;
  /** Largest font size in design units. */
  fontSize?: number;
  background?: string;
  /** "me" bubble fill. Defaults to the theme accent. */
  meColor?: string;
  meTextColor?: string;
  /** "them" bubble fill. Defaults to the theme surface. */
  themColor?: string;
  themTextColor?: string;
  radius?: number;
  style?: React.CSSProperties;
  className?: string;
};

/** Rough wrapped line count at `perLine` characters per line. Good enough to reserve bubble height without measuring the DOM. */
function wrappedLines(text: string, perLine: number): number {
  const words = text.split(" ");
  let lines = 1;
  let column = 0;
  for (const word of words) {
    const size = word.length + (column > 0 ? 1 : 0);
    if (column > 0 && column + size > perLine) {
      lines++;
      column = word.length;
    } else {
      column += size;
    }
  }
  return lines;
}

function TypingDots({ frame, color, size }: { frame: number; color: string; size: number }) {
  const dot = size * 0.22;
  return (
    <div style={{ display: "flex", gap: dot * 0.7 }}>
      {[0, 1, 2].map((index) => (
        <div
          key={index}
          style={{
            width: dot,
            height: dot,
            borderRadius: "50%",
            background: color,
            translate: `0 ${Math.sin(frame * 0.35 + index * 1.1) * dot * 0.5}px`,
          }}
        />
      ))}
    </div>
  );
}

export function ChatThread({
  messages,
  typingDuration,
  width = 460,
  height = 480,
  fontSize = 24,
  background,
  meColor,
  meTextColor,
  themColor,
  themTextColor,
  radius,
  style,
  className,
  ...motion
}: ChatThreadProps) {
  const theme = useTheme();
  const { u } = useViewport();
  const m = useMotion(motion);
  const dotsFrames = typingDuration ?? Math.round(m.fps * 0.6);
  const boxW = u(width);
  const boxH = u(height);
  const fontPx = u(fontSize);
  const padX = u(16);
  const padY = u(10);
  const gap = u(10);
  const lineH = fontPx * 1.35;
  const bubbleMaxW = boxW * 0.74;
  const perLine = Math.max(4, Math.floor((bubbleMaxW - padX * 2) / (fontPx * 0.56)));
  const appearFrames = Math.round(m.fps * 0.3);
  const popFrames = Math.round(m.fps * 0.35);

  const rows = messages.map((message) => {
    const lines = wrappedLines(message.text, perLine);
    const rowH = lines * lineH + padY * 2;
    const typingFrom = message.from === "them" ? message.at - dotsFrames : message.at;
    return { message, rowH, typingFrom };
  });

  let top = 0;
  const positioned = rows.map((row) => {
    const y = top;
    top += row.rowH + gap;
    return { ...row, y };
  });

  const appearOf = (from: number) =>
    Math.min(Math.max(tween(m.frame, m.fps, { from: m.delay + from, duration: appearFrames, motion: "smooth" }), 0), 1);

  const filled = positioned.reduce((sum, row) => sum + appearOf(row.typingFrom) * (row.rowH + gap), 0);
  const scroll = Math.max(0, filled - boxH + padY * 2);

  return (
    <div
      className={className}
      style={{
        position: "relative",
        width: boxW,
        height: boxH,
        overflow: "hidden",
        borderRadius: u(radius ?? theme.radius),
        background: background ?? theme.colors.background,
        fontFamily: theme.fonts.body,
        opacity: 1 - m.exit,
        ...style,
      }}
    >
      <div
        style={{
          position: "absolute",
          left: 0,
          right: 0,
          top: padY,
          padding: `0 ${padX}px`,
          translate: `0 ${-scroll}px`,
        }}
      >
        {positioned.map((row, index) => {
          const { message, rowH, typingFrom, y } = row;
          const isMe = message.from === "me";
          const shown = appearOf(typingFrom);
          const typing = !isMe && dotsFrames > 0 && m.frame < m.delay + message.at;
          const pop = Math.max(
            0,
            tween(m.frame, m.fps, { from: m.delay + message.at, duration: popFrames, motion: "bouncy" }),
          );
          return (
            <div
              key={index}
              style={{
                position: "absolute",
                top: y,
                left: 0,
                right: 0,
                display: "flex",
                justifyContent: isMe ? "flex-end" : "flex-start",
                opacity: shown,
                translate: `0 ${(1 - shown) * u(14)}px`,
              }}
            >
              <div
                style={{
                  maxWidth: bubbleMaxW,
                  minHeight: rowH,
                  display: "flex",
                  alignItems: "center",
                  padding: `${padY}px ${padX}px`,
                  borderRadius: u(radius ?? theme.radius * 0.9),
                  background: isMe ? (meColor ?? theme.colors.accent) : (themColor ?? theme.colors.surface),
                  color: isMe
                    ? (meTextColor ?? theme.colors.accentForeground)
                    : (themTextColor ?? theme.colors.foreground),
                  fontSize: fontPx,
                  lineHeight: `${lineH}px`,
                  scale: typing ? "1" : String(0.85 + 0.15 * pop),
                }}
              >
                {typing ? (
                  <TypingDots frame={m.frame} color={themTextColor ?? theme.colors.muted} size={fontPx} />
                ) : (
                  message.text
                )}
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
}

On this page