# Chat Flow

> Timeline-driven chat flow.

A shadcn/ui block from the [@framecn](https://framecn.vercel.app) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/framecn/chat-flow).

- Page, with a live preview: https://shadcn.exchange/r/framecn/chat-flow
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/framecn/chat-flow-light-28583cdb12e58caf.png
- Registry item: https://framecn.vercel.app/r/chat-flow.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://framecn.vercel.app/r/chat-flow.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `@framecn/framecn-ui`, `@framecn/caret`, `@framecn/message-bubble`, `@framecn/typing-indicator`

## Files

### `registry/bases/editframe/ui/chat-flow/index.tsx`

```tsx
"use client";

import { Timegroup } from "@editframe/react";
import { useEffect, useRef, useState } from "react";

import { FramecnIcon, revealedText, useFramecnTheme } from "@/lib/framecn-ui";
import type { FramecnTheme } from "@/lib/framecn-ui";
import { Caret } from "@/components/framecn/caret";
import { MessageBubble } from "@/components/framecn/message-bubble";
import type {
  MessageBubbleReactionStyle,
  MessageBubbleStyle,
} from "@/components/framecn/message-bubble";
import { TypingIndicator } from "@/components/framecn/typing-indicator";

export interface ChatMessage {
  from: "me" | "them";
  text: string;
  reaction?: string;
}

export interface ChatContact {
  name: string;
  avatar?: string;
}

export interface ChatFlowProps {
  messages?: ChatMessage[];
  contact?: ChatContact;
  accentColor?: string;
  speed?: number;
  fps?: number;
  durationInFrames?: number;
  theme?: Partial<FramecnTheme>;
  className?: string;
}

const MOBILE_WIDTH = 460;
const LEAD_IN = 12;
const FRAMES_PER_CHAR = 2.2;
const MIN_TYPE = 18;
const MAX_TYPE = 86;
const SEND_GAP = 10;
const REVEAL = 14;
const REACT_DELAY = 8;
const REACT_DUR = 14;
const MSG_GAP = 18;
const TYPING_MIN = 34;
const TYPING_MAX = 70;
const TAIL = 28;
const PRESS_WINDOW = 7;

const DEFAULT_MESSAGES: ChatMessage[] = [
  { from: "me", text: "Hey — ready for the demo?" },
  { from: "them", reaction: "🔥", text: "Yep, pushing it live now" },
  { from: "me", reaction: "👍", text: "Perfect, sending the link over" },
];
const clamp = (value: number, lo: number, hi: number): number =>
  Math.max(lo, Math.min(hi, value));

export interface ScheduledMessage {
  index: number;
  from: "me" | "them";
  text: string;
  reaction?: string;
  presenceStart: number;
  typeStart?: number;
  sendAt?: number;
  typingStart?: number;
  revealAt: number;
  reactAt?: number;
}

export interface ChatFlowSchedule {
  items: ScheduledMessage[];
  duration: number;
}

export const chatFlowSchedule = (messages: ChatMessage[]): ChatFlowSchedule => {
  const items: ScheduledMessage[] = [];
  let cursor = LEAD_IN;

  for (const [index, message] of messages.entries()) {
    const hasReaction =
      message.reaction !== undefined && message.reaction !== "";
    if (message.from === "me") {
      const typeStart = cursor;
      const typeDur = clamp(
        Math.round(message.text.length * FRAMES_PER_CHAR),
        MIN_TYPE,
        MAX_TYPE
      );
      const sendAt = typeStart + typeDur + SEND_GAP;
      const revealAt = sendAt;
      const reactAt = hasReaction ? revealAt + REVEAL + REACT_DELAY : undefined;
      items.push({
        from: "me",
        index,
        presenceStart: sendAt - 2,
        reactAt,
        reaction: hasReaction ? message.reaction : undefined,
        revealAt,
        sendAt,
        text: message.text,
        typeStart,
      });
      cursor =
        revealAt +
        REVEAL +
        (hasReaction ? REACT_DELAY + REACT_DUR : 0) +
        MSG_GAP;
    } else {
      const typingStart = cursor;
      const typingDur = clamp(
        Math.round(message.text.length * FRAMES_PER_CHAR),
        TYPING_MIN,
        TYPING_MAX
      );
      const revealAt = typingStart + typingDur;
      const reactAt = hasReaction ? revealAt + REVEAL + REACT_DELAY : undefined;
      items.push({
        from: "them",
        index,
        presenceStart: typingStart,
        reactAt,
        reaction: hasReaction ? message.reaction : undefined,
        revealAt,
        text: message.text,
        typingStart,
      });
      cursor =
        revealAt +
        REVEAL +
        (hasReaction ? REACT_DELAY + REACT_DUR : 0) +
        MSG_GAP;
    }
  }

  const duration = Math.max(cursor - MSG_GAP + TAIL, LEAD_IN + TAIL);
  return { duration, items };
};

export const chatFlowDuration = (
  messages: ChatMessage[] = DEFAULT_MESSAGES,
  speed = 1
): number => {
  const raw = chatFlowSchedule(messages).duration;
  return Math.ceil(raw / (speed <= 0 ? 1 : speed));
};

export const sendPulse = (items: ScheduledMessage[], eff: number): number => {
  let best = 0;
  for (const item of items) {
    if (item.sendAt === undefined) {
      continue;
    }
    const distance = Math.abs(eff - item.sendAt);
    if (distance <= PRESS_WINDOW) {
      best = Math.max(best, 1 - distance / PRESS_WINDOW);
    }
  }
  return best;
};

const Avatar = ({
  contact,
  theme,
}: {
  contact: ChatContact;
  theme: FramecnTheme;
}) => {
  const initial = contact.name.trim().charAt(0).toUpperCase();
  return (
    <div
      style={{
        alignItems: "center",
        background: theme.muted,
        borderRadius: "50%",
        color: theme.mutedForeground,
        display: "flex",
        flexShrink: 0,
        fontFamily:
          "var(--font-geist-sans), -apple-system, BlinkMacSystemFont, sans-serif",
        fontSize: 14,
        fontWeight: 600,
        height: 32,
        justifyContent: "center",
        minWidth: 32,
        overflow: "hidden",
        width: 32,
      }}
    >
      {contact.avatar === undefined ? (
        initial
      ) : (
        // eslint-disable-next-line next/no-img-element
        <img
          src={contact.avatar}
          alt={contact.name}
          style={{ height: "100%", objectFit: "cover", width: "100%" }}
        />
      )}
    </div>
  );
};

const ChatRow = ({
  item,
  eff,
  contact,
  themeProp,
  theme,
}: {
  item: ScheduledMessage;
  eff: number;
  contact?: ChatContact;
  themeProp: Partial<FramecnTheme>;
  theme: FramecnTheme;
}) => {
  const variant = item.from === "me" ? "outgoing" : "incoming";
  const showTyping =
    item.from === "them" &&
    item.typingStart !== undefined &&
    eff < item.revealAt;

  const bubbleStyle: MessageBubbleStyle = (() => {
    if (showTyping && item.typingStart !== undefined) {
      const fadeInProgress = clamp((eff - item.typingStart) / 8, 0, 1);
      const fadeOutProgress = clamp((eff - (item.revealAt - 6)) / 6, 0, 1);
      return {
        opacity: fadeInProgress * (1 - fadeOutProgress),
        scale: 1,
        translateY: 10 * (1 - fadeInProgress),
      };
    }
    const revealProgress = clamp((eff - item.revealAt) / REVEAL, 0, 1);
    return {
      opacity: revealProgress,
      scale: 0.92 + 0.08 * revealProgress,
      translateY: 10 * (1 - revealProgress),
    };
  })();

  let reactionStyle: MessageBubbleReactionStyle | undefined;
  if (item.reactAt !== undefined && eff >= item.reactAt) {
    const reactProgress = clamp((eff - item.reactAt) / 12, 0, 1);
    const pop =
      reactProgress < 0.6
        ? (reactProgress / 0.6) * 1.15
        : 1.15 - ((reactProgress - 0.6) / 0.4) * 0.15;
    const opacity = clamp((eff - item.reactAt) / 5, 0, 1);
    reactionStyle = { opacity, scale: pop };
  }

  const bubbleNode = showTyping ? (
    <MessageBubble variant="incoming" style={bubbleStyle} theme={themeProp}>
      <TypingIndicator color={theme.mutedForeground} />
    </MessageBubble>
  ) : (
    <MessageBubble
      variant={variant}
      style={bubbleStyle}
      reaction={item.reaction}
      reactionStyle={reactionStyle}
      theme={themeProp}
    >
      {item.text}
    </MessageBubble>
  );

  if (item.from === "them" && contact !== undefined) {
    return (
      <div style={{ alignItems: "flex-end", display: "flex", gap: 8 }}>
        <Avatar contact={contact} theme={theme} />
        <div style={{ flex: 1, minWidth: 0 }}>{bubbleNode}</div>
      </div>
    );
  }

  return bubbleNode;
};

export const ChatFlow = ({
  messages = DEFAULT_MESSAGES,
  contact,
  accentColor,
  speed = 1,
  fps = 30,
  durationInFrames,
  theme: themeOverride,
  className,
}: ChatFlowProps) => {
  const [eff, setEff] = useState(0);
  const startRef = useRef<number | null>(null);

  const themeProp = {
    ...themeOverride,
    ...(accentColor ? { primary: accentColor } : {}),
  };
  const resolved = useFramecnTheme(themeProp);

  const { items, duration: rawDuration } = chatFlowSchedule(messages);
  const totalDuration =
    durationInFrames ?? Math.ceil(rawDuration / (speed <= 0 ? 1 : speed));
  const durationMs = (totalDuration / fps) * 1000;

  useEffect(() => {
    let raf: number;
    const tick = (now: number) => {
      if (startRef.current === null) {
        startRef.current = now;
      }
      const elapsed = now - startRef.current;
      const frame = (elapsed / 1000) * fps;
      setEff(Math.min(frame * speed, totalDuration));
      if (frame * speed < totalDuration) {
        raf = requestAnimationFrame(tick);
      }
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [fps, speed, totalDuration]);

  const activeMe = items.find(
    (item) =>
      item.from === "me" &&
      item.typeStart !== undefined &&
      item.sendAt !== undefined &&
      eff >= item.typeStart &&
      eff < item.sendAt
  );
  let composerText = "";
  let typing = false;
  if (
    activeMe &&
    activeMe.typeStart !== undefined &&
    activeMe.sendAt !== undefined
  ) {
    const typeDur = Math.max(
      activeMe.sendAt - SEND_GAP - activeMe.typeStart,
      1
    );
    const progress = clamp((eff - activeMe.typeStart) / typeDur, 0, 1);
    composerText = revealedText(
      activeMe.text,
      Math.floor(progress * activeMe.text.length)
    );
    typing = true;
  }

  const sendActive = composerText.length > 0;
  const sendScale = 1 - 0.16 * sendPulse(items, eff);
  const present = items.filter((item) => eff >= item.presenceStart);

  return (
    <Timegroup
      className={className}
      duration={`${durationMs}ms`}
      mode="fixed"
      style={{
        inset: 0,
        position: "absolute",
      }}
    >
      <>
        <style>{`
          @keyframes framecn-chat-typing-in {
            from { opacity: 0; transform: translateY(10px); }
            to { opacity: 1; transform: translateY(0); }
          }
          @keyframes framecn-chat-typing-out {
            from { opacity: 1; }
            to { opacity: 0; }
          }
          @keyframes framecn-chat-send-pulse {
            0%, 100% { transform: scale(1); }
            50% { transform: scale(0.84); }
          }
          @keyframes framecn-chat-reaction-pop {
            0% { opacity: 0; transform: scale(0); }
            60% { transform: scale(1.15); }
            100% { opacity: 1; transform: scale(1); }
          }
          @keyframes framecn-chat-msg-enter {
            from { opacity: 0; transform: translateY(10px) scale(0.92); }
            to { opacity: 1; transform: translateY(0) scale(1); }
          }
        `}</style>
        <div
          style={{
            background: "transparent",
            display: "flex",
            fontFamily:
              "var(--font-geist-sans), -apple-system, BlinkMacSystemFont, sans-serif",
            inset: 0,
            justifyContent: "center",
            position: "absolute",
          }}
        >
          <div
            style={{
              background: resolved.card,
              border: `1px solid ${resolved.border}`,
              borderRadius: resolved.radius + 4,
              boxShadow:
                "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
              boxSizing: "border-box",
              color: resolved.cardForeground,
              display: "flex",
              flexDirection: "column",
              height: "100%",
              maxWidth: MOBILE_WIDTH,
              padding: "24px 16px 18px",
              width: "100%",
            }}
          >
            {contact !== undefined && (
              <div
                style={{
                  alignItems: "center",
                  borderBottom: `1px solid ${resolved.border}`,
                  display: "flex",
                  gap: 10,
                  marginBottom: 8,
                  paddingBottom: 16,
                }}
              >
                <Avatar contact={contact} theme={resolved} />
                <div
                  style={{ display: "flex", flexDirection: "column", gap: 2 }}
                >
                  <span
                    style={{
                      color: resolved.cardForeground,
                      fontSize: 15,
                      fontWeight: 600,
                      letterSpacing: "-0.01em",
                      lineHeight: 1.25,
                    }}
                  >
                    {contact.name}
                  </span>
                  <span
                    style={{
                      alignItems: "center",
                      color: resolved.mutedForeground,
                      display: "flex",
                      fontSize: 12,
                      gap: 5,
                      lineHeight: 1.2,
                    }}
                  >
                    <span
                      style={{
                        background: resolved.primary,
                        borderRadius: "50%",
                        height: 6,
                        width: 6,
                      }}
                    />
                    online
                  </span>
                </div>
              </div>
            )}

            <div
              style={{
                WebkitMaskImage:
                  "linear-gradient(to bottom, transparent 0, #000 48px, #000 100%)",
                flex: 1,
                maskImage:
                  "linear-gradient(to bottom, transparent 0, #000 48px, #000 100%)",
                minHeight: 0,
                overflow: "hidden",
                position: "relative",
              }}
            >
              <div
                style={{
                  boxSizing: "border-box",
                  display: "flex",
                  flexDirection: "column",
                  gap: 20,
                  justifyContent: "flex-end",
                  minHeight: "100%",
                  paddingBottom: 22,
                  paddingTop: 24,
                }}
              >
                {present.map((item) => (
                  <ChatRow
                    key={item.index}
                    item={item}
                    eff={eff}
                    contact={contact}
                    themeProp={themeProp}
                    theme={resolved}
                  />
                ))}
              </div>
            </div>

            <div
              style={{
                background: resolved.background,
                border: `1px solid ${resolved.input}`,
                borderRadius: Math.max(0, resolved.radius - 2),
                boxShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
                display: "flex",
                flexDirection: "column",
                gap: 10,
                marginTop: 14,
                padding: 14,
              }}
            >
              <div
                style={{
                  alignItems: "flex-start",
                  color: sendActive
                    ? resolved.foreground
                    : resolved.mutedForeground,
                  display: "flex",
                  fontSize: 15,
                  letterSpacing: "-0.01em",
                  lineHeight: 1.45,
                  minHeight: 24,
                }}
              >
                <span
                  style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
                >
                  {sendActive ? composerText : "Message"}
                </span>
                {typing && (
                  <Caret
                    color={resolved.foreground}
                    height={18}
                    radius={1}
                    blink
                    marginLeft={composerText.length > 0 ? 2 : 0}
                  />
                )}
              </div>
              <div
                style={{
                  alignItems: "center",
                  display: "flex",
                  justifyContent: "space-between",
                }}
              >
                <div
                  style={{
                    alignItems: "center",
                    background: "transparent",
                    border: `1px solid ${resolved.border}`,
                    borderRadius: Math.max(0, resolved.radius - 2),
                    display: "flex",
                    height: 36,
                    justifyContent: "center",
                    width: 36,
                  }}
                >
                  <FramecnIcon
                    name="Plus"
                    size={16}
                    color={resolved.foreground}
                  />
                </div>
                <div
                  style={{
                    alignItems: "center",
                    background: sendActive ? resolved.primary : resolved.muted,
                    borderRadius: Math.max(0, resolved.radius - 2),
                    display: "flex",
                    height: 36,
                    justifyContent: "center",
                    transform: `scale(${sendScale})`,
                    width: 36,
                  }}
                >
                  <FramecnIcon
                    name="ArrowUp"
                    size={16}
                    color={
                      sendActive
                        ? resolved.primaryForeground
                        : resolved.mutedForeground
                    }
                  />
                </div>
              </div>
            </div>
          </div>
        </div>
      </>
    </Timegroup>
  );
};
```
