{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "channel-thread",
  "title": "Channel Thread",
  "description": "Slack-style chat animation: team messages arrive with typing dots. A work chat filling itself in, the way the channel actually reads: an avatar, a name in bold, a quiet timestamp and the messages as plain lines under them. Messages land one at a time, a second message from the same person joins the group without repeating the name, and when somebody new starts typing three dots hold their line until the words arrive. The transcript never creeps — it sits perfectly still between arrivals and then scrolls, once, by exactly the room the new line needs, and only when the newest line would fall past the anchor. Every vertical number came off a recording and closes on it: carry the layout's arithmetic across the whole scene and it predicts the last frame's baselines to within a pixel.",
  "dependencies": [
    "remotion",
    "@remotion/google-fonts"
  ],
  "registryDependencies": [
    "https://snapcn.dev/r/snap-cn-ui.json"
  ],
  "files": [
    {
      "path": "registry/snap-cn/channel-thread/index.tsx",
      "content": "// Channel Thread · snapcn — https://snapcn.dev/docs/text/channel-thread\n\"use client\";\n\nimport { loadFont as loadSans } from \"@remotion/google-fonts/Barlow\";\nimport type { CSSProperties } from \"react\";\nimport { useCallback, useState } from \"react\";\nimport {\n  AbsoluteFill,\n  continueRender,\n  delayRender,\n  getRemotionEnvironment,\n  staticFile,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\nimport {\n  Item,\n  mixOklch,\n  resolveFont,\n  type SnapCnTheme,\n  useSnapCnTheme,\n  withAlpha,\n} from \"@/lib/snap-cn-ui\";\n\n/**\n * A work chat filling itself in, the way the channel actually reads.\n *\n * Not bubbles: a transcript. An avatar, a name in bold, a quiet timestamp, and\n * the messages under them as plain lines — the shape every team tool has\n * settled on. Messages land one at a time; a second message from the same\n * person joins the group without repeating the name; and when somebody new\n * starts typing, three dots hold their line until the words arrive. The\n * transcript never creeps: it sits perfectly still between arrivals and then\n * scrolls, once, by exactly the room the new line needs.\n *\n * ## The layout came off a recording and it closes to the pixel\n *\n * Four numbers describe every vertical position in the scene, measured on a\n * 638 × 354 capture by thresholding the ink of eighty frames:\n *\n *     name → its first message      37.59\n *     message → message             46.01\n *     last message → next name      69.00\n *     first message, before any scroll, at   158.90\n *\n * That is not a model fitted to the frames, it is *checked* against them. Take\n * the first message's start, add the two arrivals' scroll (46.01 + 2 × 46.01),\n * and the oldest line lands at 20.87; the recording's last frame has it at 21.\n * Carry the same arithmetic down to the newest name and it predicts 138.9 where\n * the recording measures 139.00. Nine tenths of a pixel over a whole scene.\n *\n * ## The scroll is an event, not a drift\n *\n * Between arrivals the transcript is *byte-identical* frame to frame — the\n * measured movement is 0.00px for twenty-nine frames at the head of the clip and\n * for another eight in the middle. Everything that looks like life comes from\n * the arrivals themselves. `SCROLL` is one of those moves, measured: it\n * accelerates for four frames, peaks at 5.1px, and then spends three quarters of\n * a second arriving.\n *\n * A caveat, because it is measured and it is not reproduced: the recording's\n * two-row scroll runs about 1.6× faster in normalised time than its one-row\n * scroll — a longer move that takes *less* time, which is a browser's native\n * smooth-scroll heuristic and not something a scene should imitate. One curve\n * ships, taken from the one-row move, and a two-row move simply travels twice\n * as far along it.\n */\n\nconst { fontFamily: SANS } = loadSans(\"normal\", {\n  weights: [\"400\", \"500\", \"600\", \"700\"],\n  subsets: [\"latin\"],\n});\n\n/* ─────────────────────────────────────────────────────────────────────────\n   The transcript, in the recording's own 638 × 354 pixels\n   ───────────────────────────────────────────────────────────────────────── */\n\nexport const REF_W = 638;\nexport const REF_H = 354;\n\n/** Baseline of a group's first message, below the name. */\nexport const NAME_GAP = 37.59;\n/** Baseline to baseline, within a group. */\nexport const MSG_GAP = 46.01;\n/** Last message of a group to the next group's name. */\nexport const GROUP_GAP = 69;\n/** Where the oldest message sits before anything has arrived. */\nexport const START_Y = 158.9;\n/**\n * The line the newest message rests on, and the whole reason the transcript is\n * still between arrivals.\n *\n * It does not scroll on every message — the recording's second message lands\n * with the stack measurably motionless, 0.00px for twenty-nine frames. It\n * scrolls only by however far the newest baseline would fall *past* this line,\n * which is zero until the thread is tall enough to reach it. One constant\n * replaces a per-message \"does this one scroll\" flag, and it is what makes the\n * arithmetic close: 158.90 + 46.01 is above it and moves nothing; the next two\n * rows put the newest baseline at 311.50 and the scroll settles at exactly\n * 92.02, which is what the recording measures.\n */\nexport const ANCHOR = 219.48;\n\n/** The avatar is square, and its top sits this far above the name's baseline. */\nexport const AVATAR = 70;\nexport const AVATAR_X = 119.5;\nexport const AVATAR_RISE = 28;\n/** Left of the text column's box. Ink starts at 201.1 (name) and 202.4 (body). */\nexport const TEXT_X = 202;\n\n/** Body type. Cap height measured at 22.40px. */\nexport const SIZE = 31.2;\n/** The timestamp, measured on its digits: cap 18.09. */\nexport const TIME_SIZE = 24.9;\n/** Box top → baseline at `lineHeight: 1`. A face metric, measured, not guessed. */\nexport const ASC = 28.95;\n\n/** Typing dots: on the line the message will take, a little above its baseline. */\nexport const DOT = 5;\nexport const DOT_GAP = 8.4;\nexport const DOT_RISE = 5.7;\n/** A dot every this many seconds, then the ellipsis starts over. */\nexport const DOT_BEAT = 0.1;\n/**\n * Frames between a group's row opening and the group appearing in it.\n *\n * The room is made first and the person arrives into it: the recording starts\n * scrolling on frame 37 and does not paint the avatar, the name or the first\n * dot until frame 40. Three frames is nothing to watch and everything to match,\n * and it is also what the dots are timed from — anchor their cycle to the scroll\n * instead and the ellipsis is a step out of phase for its whole life.\n */\nexport const LEAD = 3;\n\n/**\n * One arrival's scroll, as (seconds since it started, share of the travel).\n *\n * Recovered by correlating each frame's vertical ink profile against the last —\n * which reads the move off every visible row at once rather than tracking one\n * word — and checked against the absolute baseline of the bold name row, which\n * agrees to a third of a pixel.\n */\nexport const SCROLL: readonly (readonly [number, number])[] = [\n  [0, 0],\n  [0.0167, 0.004],\n  [0.05, 0.016],\n  [0.0833, 0.044],\n  [0.1167, 0.093],\n  [0.15, 0.178],\n  [0.1833, 0.289],\n  [0.2167, 0.398],\n  [0.25, 0.486],\n  [0.2833, 0.557],\n  [0.3167, 0.616],\n  [0.35, 0.663],\n  [0.3833, 0.702],\n  [0.4167, 0.737],\n  [0.45, 0.767],\n  [0.4833, 0.791],\n  [0.5167, 0.814],\n  [0.55, 0.834],\n  [0.5833, 0.852],\n  [0.6167, 0.867],\n  [0.65, 0.881],\n  [0.6917, 0.893],\n  [0.7167, 0.903],\n  [0.75, 0.912],\n  [0.7833, 0.919],\n  [1.1, 1],\n];\n\n/**\n * How far a line is faded, by its height in the frame.\n *\n * Read as an envelope over the whole clip: the brightest any pixel of a line\n * ever got at each height. Taking the maximum across every frame is what makes\n * it a property of the *position* — one sample would have read a line with no\n * ascenders as a dip in the gradient.\n *\n * It stops at a tenth rather than at nothing: the oldest line is cut off by the\n * frame, not erased by the fade.\n */\nexport const FADE: readonly (readonly [number, number])[] = [\n  [10, 0.098],\n  [25, 0.125],\n  [40, 0.149],\n  [55, 0.216],\n  [70, 0.357],\n  [85, 0.518],\n  [100, 0.651],\n  [115, 0.843],\n  [140, 0.98],\n  [175, 1],\n];\n\ntype Table = readonly (readonly [number, number])[];\n\n/** Linear read of a rising table, clamped at both ends. */\nexport function read(table: Table, x: number): number {\n  const first = table[0];\n  const last = table[table.length - 1];\n  if (!first || !last) return 0;\n  if (x <= first[0]) return first[1];\n  if (x >= last[0]) return last[1];\n  for (let i = 1; i < table.length; i++) {\n    const hi = table[i];\n    const lo = table[i - 1];\n    if (hi && lo && x <= hi[0]) {\n      return lo[1] + ((hi[1] - lo[1]) * (x - lo[0])) / (hi[0] - lo[0]);\n    }\n  }\n  return last[1];\n}\n\ninterface Line {\n  /** Baseline in transcript space, before any scroll. */\n  y: number;\n  /** Baseline of this group's name row, when this line heads a group. */\n  nameY: number;\n  group: number;\n  /** First line of a group, i.e. the one the name and avatar belong to. */\n  head: boolean;\n  text: string;\n  /** Frame the words land on. The row itself opens at `opens`. */\n  at: number;\n  opens: number;\n}\n\n/**\n * Lay the transcript out once, in transcript space.\n *\n * Positions are absolute and never re-flow: the scroll is a single translate\n * over the top of them. That is what lets every arrival be a number rather than\n * a layout pass, and it is why the stack can be perfectly still between them.\n */\nexport function layout(\n  groups: { person: string; time: string; lines: string[] }[],\n): Line[] {\n  const out: Line[] = [];\n  let last = START_Y - NAME_GAP;\n  let first = true;\n  for (let g = 0; g < groups.length; g++) {\n    const group = groups[g];\n    if (!group) continue;\n    for (let m = 0; m < group.lines.length; m++) {\n      const head = m === 0;\n      const nameY = head ? (first ? last : last + GROUP_GAP) : 0;\n      const y = head ? nameY + NAME_GAP : last + MSG_GAP;\n      out.push({\n        y,\n        nameY,\n        group: g,\n        head,\n        text: group.lines[m] ?? \"\",\n        at: 0,\n        opens: 0,\n      });\n      last = y;\n      first = false;\n    }\n  }\n  return out;\n}\n\n/**\n * One message. A message with the same author, time and avatar as the one\n * above it joins that group, under one name, the way the channel reads.\n */\nexport interface ThreadMessage {\n  text: string;\n  author?: string;\n  time?: string;\n  avatar?: string;\n  /** Frame the words land on. Default: the measured 0, 12, 60, 84, then every 24. */\n  at?: number;\n  /**\n   * Frame the row opens on, before the words land — typing dots, if it starts\n   * a group. Default: the measured 0, 12, 37, 72, then every 24.\n   */\n  opens?: number;\n}\n\nexport const DEFAULT_MESSAGES: readonly ThreadMessage[] = [\n  [\"rhea\", \"9:41 AM\", \"07\", \"Launch video by Thursday?\"],\n  [\"rhea\", \"9:41 AM\", \"07\", \"We have nothing shot.\"],\n  [\"sam\", \"9:42 AM\", \"13\", \"Already done.\"],\n  [\"sam\", \"9:42 AM\", \"13\", \"Built it out of snapcn.\"],\n].map(([author, time, face, text]) => ({\n  author,\n  time,\n  avatar: `/avatars/${face}.jpg`,\n  text: text ?? \"\",\n}));\n\n/** The recording's four arrivals; after them, a message every 24 frames. */\nconst BEATS = [0, 12, 60, 84];\nconst OPENS = [0, 12, 37, 72];\n\n/**\n * The string form of `messages`, for the customizer and for quick edits:\n * messages split by `|`, each `avatar > name time > text`. The name runs to\n * the first space. A message that is only text continues the one above it.\n *\n *   \"/avatars/07.jpg > rhea 9:41 AM > Launch video? | Nothing shot.\"\n */\nexport function toMessages(\n  input: readonly ThreadMessage[] | string,\n): readonly ThreadMessage[] {\n  if (typeof input !== \"string\") return input;\n  let head = { author: \"\", time: \"\", avatar: \"\" };\n  return input\n    .split(\"|\")\n    .filter((m) => m.trim())\n    .map((m) => {\n      const parts = m.split(\">\").map((v) => v.trim());\n      const text = parts.pop() ?? \"\";\n      const who = parts.pop();\n      if (who !== undefined) {\n        const cut = who.indexOf(\" \");\n        head = {\n          author: cut < 0 ? who : who.slice(0, cut),\n          time: cut < 0 ? \"\" : who.slice(cut + 1),\n          avatar: parts.pop() ?? \"\",\n        };\n      }\n      return { ...head, text };\n    });\n}\n\nexport interface ChannelThreadProps {\n  /** The conversation, in order. Also takes the string form: see `toMessages`. */\n  messages?: readonly ThreadMessage[] | string;\n  theme?: Partial<SnapCnTheme>;\n  mode?: \"light\" | \"dark\";\n  fontFamily?: string;\n}\n\nexport function ChannelThread({\n  messages = DEFAULT_MESSAGES,\n  theme,\n  mode = \"dark\",\n  fontFamily = \"Default\",\n}: ChannelThreadProps) {\n  const frame = useCurrentFrame();\n  const { fps, width, height } = useVideoConfig();\n  const t = useSnapCnTheme(theme, mode);\n  const face = resolveFont(fontFamily) ?? SANS;\n\n  const u = height / REF_H;\n  const ox = (width - REF_W * u) / 2;\n  const now = frame / fps;\n\n  const said = toMessages(messages).filter((m) => m.text.trim());\n  const groups: {\n    person: string;\n    time: string;\n    avatar: string;\n    lines: string[];\n  }[] = [];\n  for (const m of said) {\n    const [person = \"\", time = \"\", avatar = \"\"] = [m.author, m.time, m.avatar];\n    const g = groups[groups.length - 1];\n    if (g && g.person === person && g.time === time && g.avatar === avatar) {\n      g.lines.push(m.text.trim());\n    } else {\n      groups.push({ person, time, avatar, lines: [m.text.trim()] });\n    }\n  }\n\n  const lines = layout(groups);\n  lines.forEach((l, i) => {\n    const m = said[i];\n    l.at = m?.at ?? BEATS[i] ?? 84 + 24 * (i - 3);\n    l.opens =\n      i === 0\n        ? Number.NEGATIVE_INFINITY\n        : (m?.opens ?? OPENS[i] ?? 72 + 24 * (i - 3));\n  });\n\n  // Each opened row moves the target to however far its baseline falls past the\n  // anchor. A row that does not reach the anchor moves nothing, which is the\n  // stillness in the reference rather than an exception carved out for it.\n  let scroll = 0;\n  let target = 0;\n  for (const l of lines) {\n    const want = Math.max(0, l.y - ANCHOR);\n    const step = want - target;\n    target = want;\n    if (step <= 0) continue;\n    // A longer move takes *less* time, not more — measured, twice: the two-row\n    // scroll runs 1.60× faster in normalised time than the one-row scroll, which\n    // is what a browser's native smooth scroll does with distance and what the\n    // recording therefore does. One curve, read faster the further it has to go.\n    const rate = (step / MSG_GAP) ** 0.68;\n    scroll += step * read(SCROLL, (now - l.opens / fps) * rate);\n  }\n\n  const ink = t.foreground;\n  const dim = t.mutedForeground;\n\n  return (\n    <AbsoluteFill style={{ backgroundColor: t.background }}>\n      <div\n        style={{\n          position: \"absolute\",\n          left: ox,\n          top: 0,\n          width: REF_W * u,\n          height: REF_H * u,\n          fontFamily: face,\n          // Hinting re-snaps every stem as a line slides past, and the\n          // letterforms boil. This renders the outline as it actually is.\n          textRendering: \"geometricPrecision\",\n          whiteSpace: \"nowrap\",\n          overflow: \"hidden\",\n        }}\n      >\n        {lines.map((l, i) => {\n          const y = l.y - scroll;\n          if (y < -MSG_GAP || y > REF_H + MSG_GAP) return null;\n          const shown = frame >= l.at;\n          if (frame < l.opens) return null;\n          const arrived = frame >= l.opens + LEAD;\n          const g = groups[l.group];\n          const nameA = read(FADE, y - NAME_GAP);\n          const bodyA = read(FADE, y);\n          return (\n            <div key={`${l.group}-${i}`}>\n              {l.head && g && arrived && (\n                <>\n                  <Avatar\n                    src={g.avatar}\n                    name={g.person}\n                    y={(y - NAME_GAP - AVATAR_RISE) * u}\n                    u={u}\n                    t={t}\n                    alpha={read(FADE, y - NAME_GAP - AVATAR_RISE + AVATAR / 2)}\n                  />\n                  <div\n                    style={{\n                      position: \"absolute\",\n                      left: TEXT_X * u,\n                      top: (y - NAME_GAP - ASC) * u,\n                      fontSize: SIZE * u,\n                      lineHeight: 1,\n                      fontWeight: 700,\n                      color: ink,\n                      // Per row, not per group. The name sits 37.59px above its\n                      // first message and the gradient moves measurably over\n                      // that distance — sharing one opacity between them reads\n                      // as a name that refuses to leave.\n                      opacity: nameA,\n                    }}\n                  >\n                    {g.person}\n                    <span\n                      style={{\n                        fontSize: TIME_SIZE * u,\n                        fontWeight: 500,\n                        color: dim,\n                        // The timestamp rides the name's baseline, so it is one\n                        // inline run and not a second absolutely-placed box to\n                        // keep in step when the name's width changes.\n                        marginLeft: 13.4 * u,\n                      }}\n                    >\n                      {g.time}\n                    </span>\n                  </div>\n                </>\n              )}\n              {shown ? (\n                // The message is the object Studio selects: its body line, by\n                // its index across the whole conversation.\n                <Item index={i}>\n                  <div\n                    style={{\n                      position: \"absolute\",\n                      left: TEXT_X * u,\n                      top: (y - ASC) * u,\n                      fontSize: SIZE * u,\n                      lineHeight: 1,\n                      color: ink,\n                      opacity: bodyA,\n                    }}\n                  >\n                    {l.text}\n                  </div>\n                </Item>\n              ) : (\n                l.head &&\n                arrived && (\n                  <Dots\n                    y={(y - DOT_RISE) * u}\n                    u={u}\n                    colour={mixOklch(dim, ink, 0.3)}\n                    since={now - (l.opens + LEAD) / fps}\n                    alpha={bodyA}\n                  />\n                )\n              )}\n            </div>\n          );\n        })}\n      </div>\n    </AbsoluteFill>\n  );\n}\n\n/** Three dots that arrive one at a time, then start over. */\nfunction Dots({\n  y,\n  u,\n  colour,\n  since,\n  alpha,\n}: {\n  y: number;\n  u: number;\n  colour: string;\n  since: number;\n  alpha: number;\n}) {\n  const step = Math.floor((since % (DOT_BEAT * 4)) / DOT_BEAT);\n  return (\n    <>\n      {[0, 1, 2].map((i) => (\n        <div\n          key={i}\n          style={{\n            position: \"absolute\",\n            left: (TEXT_X + 3.3 + i * DOT_GAP) * u,\n            top: y - (DOT / 2) * u,\n            width: DOT * u,\n            height: DOT * u,\n            borderRadius: \"50%\",\n            background: colour,\n            opacity: i < step ? alpha : 0,\n          }}\n        />\n      ))}\n    </>\n  );\n}\n\n/**\n * A root-relative asset is a `public/` file. Only the site's own Player serves\n * it at that path; Remotion Studio and a render know its URL through\n * `staticFile()` alone — so every environment but the Player rewrites it.\n */\nfunction resolveSrc(src: string): string {\n  const isLocal = src.startsWith(\"/\") && !src.startsWith(\"//\");\n  if (!isLocal || getRemotionEnvironment().isPlayer) return src;\n  // A value that already came out of staticFile() carries the static base\n  // (`/static-<hash>/…`); running it through again would prefix it twice.\n  const base = staticFile(\"_\").slice(0, -2);\n  if (base && src.startsWith(`${base}/`)) return src;\n  try {\n    return staticFile(src.replace(/^\\/+/, \"\"));\n  } catch {\n    return src;\n  }\n}\n\n/**\n * An avatar that holds the render until it has actually loaded.\n *\n * Without this a frame can be captured before the picture arrives and the\n * square comes out empty — invisible in the Player, where an image simply\n * appears, and silent in an mp4 until somebody watches it. Remotion's own `Img`\n * is avoided for the reason `hero-launch` documents: it awaits `decode()`, which\n * the headless compositor rejects for some progressive JPEGs and hangs the\n * export. `onError` releases too, so a bad source degrades to a missing picture\n * rather than a stuck render.\n */\nfunction Face({ src, style }: { src: string; style: CSSProperties }) {\n  const [handle] = useState(() => delayRender(`channel avatar: ${src}`));\n  const release = useCallback(() => continueRender(handle), [handle]);\n  return (\n    // biome-ignore lint/performance/noImgElement: Remotion frame, not a Next route.\n    <img // eslint-disable-line @remotion/warn-native-media-tag -- onLoad releases the frame; <Img> hangs on some JPEGs\n      src={resolveSrc(src)}\n      alt=\"\"\n      // Tailwind's preflight sets `img { max-width: 100% }`, which collapses an\n      // image inside a shrink-to-fit box to zero pixels wide on the site while\n      // rendering perfectly in an mp4. Size it here and say so.\n      style={{ ...style, maxWidth: \"none\" }}\n      onLoad={release}\n      onError={release}\n    />\n  );\n}\n\n/** The person's picture, or their initial on a tinted square when there is none. */\nfunction Avatar({\n  src,\n  name,\n  y,\n  u,\n  t,\n  alpha,\n}: {\n  src: string;\n  name: string;\n  y: number;\n  u: number;\n  t: SnapCnTheme;\n  alpha: number;\n}) {\n  const box = {\n    position: \"absolute\" as const,\n    left: AVATAR_X * u,\n    top: y,\n    width: AVATAR * u,\n    height: AVATAR * u,\n    borderRadius: 8 * u,\n    overflow: \"hidden\" as const,\n    opacity: alpha,\n  };\n  if (src) return <Face src={src} style={{ ...box, objectFit: \"cover\" }} />;\n  return (\n    <div\n      style={{\n        ...box,\n        // A tinted tile with the accent's own letter on it: legible on either\n        // theme, and it moves with a user's palette instead of inventing a grey.\n        // An alpha tint rather than a mix — the page is a near-neutral, its hue\n        // in oklch is undefined, and interpolating toward the accent through it\n        // comes out somewhere else entirely. Measured: a blue accent over the\n        // warm off-white page mixed to a pale *green*.\n        background: withAlpha(t.primary, 0.16),\n        color: t.primary,\n        fontSize: AVATAR * 0.45 * u,\n        fontWeight: 600,\n        display: \"flex\",\n        alignItems: \"center\",\n        justifyContent: \"center\",\n      }}\n    >\n      {name.slice(0, 1).toUpperCase()}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/snap-cn/channel-thread.tsx"
    }
  ],
  "docs": "Docs and props: https://snapcn.dev/docs/text/channel-thread?ref=cli — script is the conversation, groups split by `;` and a group's messages by `|`; people is one `name time` per group; beats is the frame each message lands on and opens is the frame its row appears, so a pause before someone answers is a number rather than a gap you have to guess at. avatars takes one image per group, split by `|` (not by comma — a data: URL has one of its own). Pass mode=\"dark\" if you are rendering onto a dark frame — these default to shadcn's light palette. — 82 Pro components too — charts, launch scenes, device shots: https://snapcn.dev/docs/pricing?ref=cli-docs",
  "type": "registry:component"
}
