{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "typing-indicator",
  "title": "Typing indicator",
  "description": "The native typing bubble with its three staggered dots and trailing circles.",
  "registryDependencies": [
    "https://imessage.swerdlow.dev/r/bubble-shape.json",
    "https://imessage.swerdlow.dev/r/platform.json",
    "https://imessage.swerdlow.dev/r/tokens.json"
  ],
  "files": [
    {
      "path": "registry/imessage/typing-indicator.tsx",
      "content": "\"use client\";\n\nimport type { ComponentProps, CSSProperties } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { usePlatform, type Platform } from \"@/components/message-ui/platform\";\nimport { bodyClipPath, tailBox, tailPath, tailSeamOverlap } from \"@/components/message-ui/bubble-shape\";\nimport { bubbleMetrics, palettes } from \"@/components/message-ui/tokens\";\n\n/**\n * The \"someone is typing\" balloon.\n *\n * No capture in `references/` shows a typing indicator - every iOS and macOS still, both 60 fps\n * recordings and every motion contact sheet were checked on 2026-09-08 and none contains one - but\n * ChatKit describes the whole balloon, and the dots that used to be invented here now come from it:\n *\n * | value | ChatKit | what was here |\n * |---|---|---|\n * | dot diameter | `transcriptTypingIndicatorThinkingDotDiameter` **8.5** | 10 |\n * | dot pitch | `transcriptTypingIndicatorThinkingDotSpace` **12.5** centre to centre | 15 (10 + 5) |\n * | balloon | `transcriptTypingIndicatorLargeBubbleSize` **{57.5, 35}** | 3 dots plus the bubble's padding |\n *\n * Read at both idioms: the Mac keeps the same 8.5 dots at the same 12.5 pitch and shrinks only the\n * balloon, to **{44, 27}**.\n * | whole indicator | `transcriptTypingIndicatorDefaultSize` **{78.5, 35}** | - |\n *\n * 12.5 is the pitch and not the gap: three 8.5 dots at that pitch span 33.5, which leaves 12 of\n * padding on each side of the 57.5 balloon. Read as a gap they would span 50.5 and leave 3.5, which\n * no balloon looks like. The balloon's own 35 height replaces the one-line bubble height this used to\n * borrow, and it is 3 pt shorter than the 38 that measured 2 x 10 + 20 gives.\n *\n * ChatKit also has the two smaller bubbles that trail it - `…MediumBubbleSize` {11.5, 11.5} at\n * `…MediumBubbleOffset` (7, -7.5), and the large one at `…LargeBubbleOffset` (14, -28.5) - which is\n * how the 78.5 x 35 whole is bigger than the 57.5 balloon. Those are not drawn here yet.\n *\n * What is still anchored to a capture: the balloon is an incoming bubble, so its corner radius, its\n * traced bottom-left tail and its incoming screen-space fill are read from `bubbleMetrics` and\n * `bubble-shape` and can never drift from a real bubble.\n *\n * Still invented: the 1.2 s pulse and its stagger, and the dot colour, which reuses the platform's\n * measured secondary-label gray because nothing shows the real one.\n * The pulse is a CSS animation with negative delays, so every dot is inside its active phase at every\n * time: pausing `document.getAnimations()` and setting `currentTime` pins the balloon to a frame, and\n * that frozen frame is the one the animation shows while running.\n */\nexport type TypingIndicatorMetrics = {\n  /** Body box, both derived from the measured one-line incoming bubble. */\n  width: number;\n  height: number;\n  /** `transcriptTypingIndicatorThinkingDotDiameter`, and the gap that its 12.5 pitch implies. */\n  dot: number;\n  dotGap: number;\n  /** Measured bubble radius and tail scale, carried through so the balloon matches a bubble exactly. */\n  radius: number;\n  tailScale: number;\n};\n\n/**\n * `transcriptTypingIndicatorThinkingDotDiameter` and `…ThinkingDotSpace`, with the balloon box from\n * `transcriptTypingIndicatorLargeBubbleSize`. macOS scales them by the ratio its bubble already\n * carries, because `CKUIBehaviorMac` does not override any of the three and its balloon is smaller.\n */\nconst chatKitDots: Record<Platform, { dot: number; pitch: number; balloon: { width: number; height: number } }> = {\n  ios: { dot: 8.5, pitch: 12.5, balloon: { width: 57.5, height: 35 } },\n  // The Mac class keeps the dots and shrinks the balloon: `transcriptTypingIndicatorLargeBubbleSize`\n  // is {44, 27} at idiom 5, read rather than scaled from the phone's.\n  macos: { dot: 8.5, pitch: 12.5, balloon: { width: 44, height: 27 } },\n};\n\nfunction metricsFor(platform: Platform): TypingIndicatorMetrics {\n  const b = bubbleMetrics[platform];\n  const ck = chatKitDots[platform];\n  const dot = ck.dot;\n  const dotGap = ck.pitch - ck.dot;\n  return {\n    width: ck.balloon.width,\n    height: ck.balloon.height,\n    dot,\n    dotGap,\n    radius: b.radius,\n    tailScale: b.tailScale,\n  };\n}\n\nexport const typingIndicatorMetrics: Record<Platform, TypingIndicatorMetrics> = {\n  ios: metricsFor(\"ios\"),\n  macos: metricsFor(\"macos\"),\n};\n\n/** Dot loop and the stagger between two dots. Provisional; no capture times the pulse. */\nexport const typingLoopMs = 1200;\nexport const typingStaggerMs = 200;\n\n/**\n * The delays are negative so a dot is never in its \"before\" phase: at time 0 dot 1 is already\n * `typingStaggerMs` into the loop instead of sitting on its unanimated base style, which is what made\n * a frozen frame at t = 0 disagree with the running animation.\n */\nconst dotDelayMs = (index: number) => index * typingStaggerMs - typingLoopMs;\n\nconst keyframes = `\n@keyframes im-typing-dot { 0%, 60%, 100% { opacity: .35; transform: translateY(0) scale(1); } 30% { opacity: 1; transform: translateY(-1px) scale(1.06); } }\n[data-slot=\"typing-indicator\"] > span[data-dot] { animation: im-typing-dot ${typingLoopMs}ms ease-in-out infinite both; }\n@media (prefers-reduced-motion: reduce) { [data-slot=\"typing-indicator\"] > span[data-dot] { animation: none; opacity: .7; } }\n`;\n\nexport type TypingIndicatorProps = ComponentProps<\"div\"> & {\n  label?: string;\n  /** The balloon is always the last thing in the transcript, so it carries a tail like any last bubble. */\n  tail?: boolean;\n  /**\n   * Screen-space y of the body's bottom edge, in px, for the position-dependent incoming fill. The\n   * message list tracks this element too, so it is only needed outside a list: pass it to keep a macOS\n   * dark balloon exactly on the incoming ramp; the iOS grays are flat and never need it.\n   */\n  screenBottom?: number;\n  platform?: Platform;\n};\n\nexport function TypingIndicator({\n  label = \"Someone is typing\", tail = true, screenBottom, platform: platformProp, className, style, ...props\n}: TypingIndicatorProps) {\n  const contextPlatform = usePlatform();\n  const platform = platformProp ?? contextPlatform;\n  const t = typingIndicatorMetrics[platform];\n  const tailW = tailBox.width * t.tailScale;\n  const tailH = tailBox.height * t.tailScale;\n  const hang = tailBox.hang * t.tailScale;\n\n  // Same screen-space fill an incoming bubble paints: one gradient in screen coordinates, anchored to\n  // the body's bottom so the body and the tail (which hangs `hang` lower) share the same image. The\n  // fallbacks only matter when the balloon is used outside a palette; inside one they never apply.\n  const screen = `var(--im-screen-h, ${palettes[platform].light.incoming.screenHeight}px)`;\n  const bottomVar = screenBottom === undefined ? `var(--bubble-bottom, calc(${screen} * 0.55))` : `${screenBottom}px`;\n  const fill: CSSProperties = {\n    backgroundImage: \"linear-gradient(var(--im-fill-top), var(--im-fill-bottom))\",\n    backgroundSize: `100% ${screen}`,\n    backgroundRepeat: \"no-repeat\",\n    backgroundColor: \"var(--im-fill-bottom)\",\n  };\n  const bodyFill: CSSProperties = { ...fill, backgroundPosition: `0 calc(100% + (${screen} - ${bottomVar}))` };\n  const tailFill: CSSProperties = { ...fill, backgroundPosition: `0 calc(100% + (${screen} - ${bottomVar} - ${hang}px))` };\n  const vars = {\n    \"--im-fill-top\": \"var(--im-gray-top, #e9e9eb)\",\n    \"--im-fill-bottom\": \"var(--im-gray-bottom, #e9e9eb)\",\n    ...(screenBottom === undefined ? {} : { \"--bubble-bottom\": `${screenBottom}px` }),\n  } as CSSProperties;\n\n  return (\n    <div role=\"status\" aria-label={label} data-slot=\"typing-indicator\" data-platform={platform}\n      className={cn(\"relative flex shrink-0 items-center justify-center\", className)}\n      style={{ width: t.width, height: t.height, gap: t.dotGap, ...vars, ...style }} {...props}>\n      <style>{keyframes}</style>\n      {/* The fill sits behind the dots so clipping the tail corner never clips a dot. */}\n      <div aria-hidden=\"true\" data-slot=\"fill\" className=\"pointer-events-none absolute inset-0\"\n        style={{ borderRadius: t.radius, clipPath: tail ? bodyClipPath(\"left\", t.tailScale, tailSeamOverlap[platform]) : undefined, ...bodyFill }} />\n      {tail && <div aria-hidden=\"true\" data-slot=\"tail\" className=\"pointer-events-none absolute\" style={{\n        left: 0, bottom: -hang, width: tailW, height: tailH + hang, clipPath: `path(\"${tailPath(\"left\", t.tailScale)}\")`, ...tailFill,\n      }} />}\n      {[0, 1, 2].map(index => (\n        <span key={index} aria-hidden=\"true\" data-dot={index} className=\"relative block rounded-full\"\n          style={{ width: t.dot, height: t.dot, background: \"var(--im-secondary, #8a8a8e)\", animationDelay: `${dotDelayMs(index)}ms` }} />\n      ))}\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/message-ui/typing-indicator.tsx"
    }
  ],
  "type": "registry:ui"
}
