# Telegram Chat Flow

> Timeline-driven telegram chat flow.

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

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

## Install

```bash
npx shadcn@latest add https://framecn.vercel.app/r/telegram-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`

## Files

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

```tsx
"use client";

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

import {
  FramecnIcon,
  mixOklch,
  revealedText,
  useFramecnTheme,
} from "@/lib/framecn-ui";
import type { FramecnTheme } from "@/lib/framecn-ui";
import { Caret } from "@/components/framecn/caret";

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

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

export interface TelegramChatFlowProps {
  messages?: TelegramMessage[];
  contact?: TelegramContact;
  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 = 16;
const TYPING_MIN = 34;
const TYPING_MAX = 70;
const TAIL = 28;
const PRESS_WINDOW = 7;

const TELEGRAM_BLUE = "#3390ec";
const OUTGOING_FG = "#ffffff";
const OUTGOING_META = "rgba(255,255,255,0.82)";

const DEFAULT_MESSAGES: TelegramMessage[] = [
  { from: "me", text: "Hey — ready for the demo?", time: "9:40" },
  {
    from: "them",
    reaction: "🔥",
    text: "Yep, pushing it live now",
    time: "9:41",
  },
  {
    from: "me",
    reaction: "👍",
    text: "Perfect, sending the link over",
    time: "9:41",
  },
];
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;
  time?: string;
  presenceStart: number;
  typeStart?: number;
  sendAt?: number;
  typingStart?: number;
  revealAt: number;
  reactAt?: number;
}

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

export const telegramChatFlowSchedule = (
  messages: TelegramMessage[]
): TelegramChatFlowSchedule => {
  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,
        time: message.time,
        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: revealAt,
        reactAt,
        reaction: hasReaction ? message.reaction : undefined,
        revealAt,
        text: message.text,
        time: message.time,
        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 telegramChatFlowDuration = (
  messages: TelegramMessage[] = DEFAULT_MESSAGES,
  speed = 1
): number => {
  const raw = telegramChatFlowSchedule(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,
  size,
}: {
  contact: TelegramContact;
  size: number;
}) => {
  const initial = contact.name.trim().charAt(0).toUpperCase();
  return (
    <div
      style={{
        alignItems: "center",
        background: `linear-gradient(180deg, #72d5fd 0%, ${TELEGRAM_BLUE} 100%)`,
        borderRadius: "50%",
        color: "#ffffff",
        display: "flex",
        flexShrink: 0,
        fontFamily:
          "var(--font-geist-sans), -apple-system, BlinkMacSystemFont, sans-serif",
        fontSize: size * 0.42,
        fontWeight: 600,
        height: size,
        justifyContent: "center",
        minWidth: size,
        overflow: "hidden",
        width: size,
      }}
    >
      {contact.avatar === undefined ? (
        initial
      ) : (
        <img
          src={contact.avatar}
          alt={contact.name}
          style={{ height: "100%", objectFit: "cover", width: "100%" }}
        />
      )}
    </div>
  );
};

const BubbleTail = ({
  side,
  color,
}: {
  side: "left" | "right";
  color: string;
}) => {
  const path =
    side === "right"
      ? "M0 0 H4 C4 7 7 12 13 13 C6 13 0 9 0 0 Z"
      : "M13 0 H9 C9 7 6 12 0 13 C7 13 13 9 13 0 Z";
  return (
    <svg
      width={13}
      height={13}
      viewBox="0 0 13 13"
      style={{
        bottom: 0,
        left: side === "left" ? -6 : undefined,
        position: "absolute",
        right: side === "right" ? -6 : undefined,
      }}
    >
      <path d={path} fill={color} />
    </svg>
  );
};

// eslint-disable-next-line complexity
const TelegramRow = ({
  item,
  eff,
  contact,
  accent,
  theme,
}: {
  item: ScheduledMessage;
  eff: number;
  contact?: TelegramContact;
  accent: string;
  theme: FramecnTheme;
}) => {
  const outgoing = item.from === "me";

  const enterProgress = clamp(
    (eff - item.revealAt) / (16 * (1 / Math.max(0.01, 1))),
    0,
    1
  );
  const ease = 1 - (1 - enterProgress) ** 3;
  const opacity = ease;
  const translateY = 10 * (1 - ease);
  const scale = 0.92 + 0.08 * ease;

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

  const bg = outgoing ? accent : theme.card;
  const fg = outgoing ? OUTGOING_FG : theme.cardForeground;
  const metaColor = outgoing ? OUTGOING_META : theme.mutedForeground;
  const tailColor = bg;

  const bubble = (
    <div
      style={{
        background: bg,
        borderBottomLeftRadius: outgoing ? 16 : 6,
        borderBottomRightRadius: outgoing ? 6 : 16,
        borderRadius: 16,
        boxShadow: outgoing ? "none" : "0 1px 1px rgba(0,0,0,0.1)",
        color: fg,
        fontSize: 15,
        lineHeight: 1.35,
        maxWidth: "76%",
        padding: "6px 11px 7px",
        position: "relative",
      }}
    >
      <span style={{ overflowWrap: "break-word", wordBreak: "break-word" }}>
        {item.text}
      </span>
      <div
        style={{
          alignItems: "center",
          display: "flex",
          gap: 8,
          justifyContent:
            item.reaction === undefined ? "flex-end" : "space-between",
          marginTop: 2,
        }}
      >
        {item.reaction !== undefined && (
          <span
            style={{
              alignItems: "center",
              background: mixOklch(
                bg,
                outgoing ? OUTGOING_FG : accent,
                outgoing ? 0.22 : 0.12
              ),
              borderRadius: 11,
              color: outgoing ? OUTGOING_FG : accent,
              display: "inline-flex",
              fontSize: 13,
              fontWeight: 600,
              gap: 4,
              opacity: reactionOpacity,
              padding: "1px 7px",
              transform: `scale(${reactionScale})`,
              transformOrigin: "left center",
            }}
          >
            {item.reaction} 1
          </span>
        )}
        <span
          style={{
            alignItems: "center",
            color: metaColor,
            display: "inline-flex",
            fontSize: 12,
            gap: 3,
            whiteSpace: "nowrap",
          }}
        >
          {item.time ?? "9:41"}
          {outgoing && (
            <FramecnIcon name="CheckCheck" size={16} color={metaColor} />
          )}
        </span>
      </div>
      <BubbleTail side={outgoing ? "right" : "left"} color={tailColor} />
    </div>
  );

  return (
    <div
      style={{
        alignItems: "flex-end",
        display: "flex",
        gap: 7,
        justifyContent: outgoing ? "flex-end" : "flex-start",
        opacity,
        transform: `translateY(${translateY}px) scale(${scale})`,
        transformOrigin: outgoing ? "bottom right" : "bottom left",
        width: "100%",
      }}
    >
      {!outgoing && contact !== undefined && (
        <Avatar contact={contact} size={30} />
      )}
      {bubble}
    </div>
  );
};

export const TelegramChatFlow = ({
  messages = DEFAULT_MESSAGES,
  contact,
  accentColor,
  speed = 1,
  fps = 30,
  durationInFrames,
  theme,
  className,
}: TelegramChatFlowProps) => {
  const [eff, setEff] = useState(0);
  const startRef = useRef<number | null>(null);
  const accent = accentColor ?? TELEGRAM_BLUE;
  const resolved = useFramecnTheme(theme);

  const { items, duration: rawDuration } = telegramChatFlowSchedule(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 themTyping = items.some(
    (item) =>
      item.from === "them" &&
      item.typingStart !== undefined &&
      eff >= item.typingStart &&
      eff < item.revealAt
  );

  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-tg-msg-enter {
            from { opacity: 0; transform: translateY(10px) scale(0.92); }
            to { opacity: 1; transform: translateY(0) scale(1); }
          }
          @keyframes framecn-tg-send-pulse {
            0%, 100% { transform: scale(1); }
            50% { transform: scale(0.84); }
          }
          @keyframes framecn-tg-reaction-pop {
            0% { opacity: 0; transform: scale(0); }
            60% { transform: scale(1.15); }
            100% { opacity: 1; transform: 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.background,
              boxSizing: "border-box",
              color: resolved.foreground,
              display: "flex",
              flexDirection: "column",
              height: "100%",
              maxWidth: MOBILE_WIDTH,
              width: "100%",
            }}
          >
            <div
              style={{
                alignItems: "center",
                background: resolved.card,
                borderBottom: `1px solid ${resolved.border}`,
                display: "flex",
                gap: 12,
                padding: "12px 14px",
              }}
            >
              {contact !== undefined && <Avatar contact={contact} size={38} />}
              <div
                style={{
                  display: "flex",
                  flex: 1,
                  flexDirection: "column",
                  gap: 0,
                }}
              >
                <span
                  style={{
                    color: resolved.cardForeground,
                    fontSize: 15,
                    fontWeight: 600,
                    letterSpacing: "-0.01em",
                    lineHeight: 1.2,
                  }}
                >
                  {contact?.name ?? "Chat"}
                </span>
                <span
                  style={{
                    color: accent,
                    fontSize: 13,
                    fontStyle: themTyping ? "italic" : "normal",
                    lineHeight: 1.15,
                  }}
                >
                  {themTyping ? "typing…" : "online"}
                </span>
              </div>
              <div style={{ alignItems: "center", display: "flex", gap: 18 }}>
                <FramecnIcon
                  name="Phone"
                  size={22}
                  color={resolved.mutedForeground}
                />
                <FramecnIcon
                  name="EllipsisVertical"
                  size={22}
                  color={resolved.mutedForeground}
                />
              </div>
            </div>

            <div
              style={{
                flex: 1,
                minHeight: 0,
                overflow: "hidden",
                position: "relative",
              }}
            >
              <div
                style={{
                  boxSizing: "border-box",
                  display: "flex",
                  flexDirection: "column",
                  gap: 4,
                  justifyContent: "flex-end",
                  minHeight: "100%",
                  padding: "16px 12px 14px",
                }}
              >
                <div
                  style={{
                    display: "flex",
                    justifyContent: "center",
                    marginBottom: 8,
                  }}
                >
                  <span
                    style={{
                      background: resolved.muted,
                      borderRadius: resolved.radius,
                      color: resolved.mutedForeground,
                      fontSize: 12,
                      fontWeight: 500,
                      padding: "3px 11px",
                    }}
                  >
                    Today
                  </span>
                </div>
                {present.map((item) => (
                  <TelegramRow
                    key={item.index}
                    item={item}
                    eff={eff}
                    contact={contact}
                    accent={accent}
                    theme={resolved}
                  />
                ))}
              </div>
            </div>

            <div
              style={{
                alignItems: "flex-end",
                background: resolved.card,
                borderTop: `1px solid ${resolved.border}`,
                display: "flex",
                gap: 8,
                padding: "8px 10px 12px",
              }}
            >
              <div
                style={{
                  alignItems: "center",
                  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",
                  flex: 1,
                  gap: 8,
                  minHeight: 44,
                  padding: "0 12px",
                }}
              >
                <FramecnIcon
                  name="Smile"
                  size={22}
                  color={resolved.mutedForeground}
                />
                <div
                  style={{
                    alignItems: "center",
                    color: sendActive
                      ? resolved.foreground
                      : resolved.mutedForeground,
                    display: "flex",
                    flex: 1,
                    fontSize: 15,
                  }}
                >
                  <span
                    style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
                  >
                    {sendActive ? composerText : "Message"}
                  </span>
                  {typing && (
                    <Caret
                      color={accent}
                      height={18}
                      radius={1}
                      blink
                      marginLeft={composerText.length > 0 ? 2 : 0}
                    />
                  )}
                </div>
                <FramecnIcon
                  name="Paperclip"
                  size={22}
                  color={resolved.mutedForeground}
                />
              </div>
              <div
                style={{
                  alignItems: "center",
                  background: accent,
                  borderRadius: "50%",
                  display: "flex",
                  flexShrink: 0,
                  height: 46,
                  justifyContent: "center",
                  transform: `scale(${sendScale})`,
                  width: 46,
                }}
              >
                {sendActive ? (
                  <FramecnIcon name="Send" size={20} color={OUTGOING_FG} />
                ) : (
                  <FramecnIcon name="Mic" size={22} color={OUTGOING_FG} />
                )}
              </div>
            </div>
          </div>
        </div>
      </>
    </Timegroup>
  );
};
```
