{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "x-followers-overview",
  "title": "X Followers Overview",
  "description": "X follow notifications cycle through as 3D flipping text — '<name> followed you · 7h' — then the total follower count pops in with a confetti burst. Hardcoded sample list (X API ready). Light/dark, horizontal or vertical.",
  "dependencies": ["remotion", "@remotion/google-fonts"],
  "registryDependencies": ["@remocn/confetti"],
  "files": [
    {
      "path": "registry/remocn/x-followers-overview/index.tsx",
      "content": "\"use client\";\n\nimport { loadFont as loadSans } from \"@remotion/google-fonts/Manrope\";\nimport { useState } from \"react\";\nimport {\n  AbsoluteFill,\n  Img,\n  interpolate,\n  spring,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\nimport { Confetti } from \"@/components/remocn/confetti\";\n\nexport interface FollowerNotification {\n  name: string;\n  verified: boolean;\n  /** Relative time label, e.g. \"7h\", \"1d\". */\n  time: string;\n}\n\nexport interface XFollowersOverviewProps {\n  notifications?: FollowerNotification[];\n  totalFollowers?: number;\n  handle?: string;\n  avatarUrl?: string;\n  accentColor?: string;\n  orientation?: \"horizontal\" | \"vertical\";\n  speed?: number;\n}\n\nconst { fontFamily: SANS_FAMILY } = loadSans();\nconst FONT_FAMILY = SANS_FAMILY;\n\ninterface Theme {\n  bg: string;\n  fg: string;\n  fgMuted: string;\n  border: string;\n}\n\nconst THEMES: Record<\"light\" | \"dark\", Theme> = {\n  light: {\n    bg: \"#ffffff\",\n    fg: \"#0f1419\",\n    fgMuted: \"#536471\",\n    border: \"#eff3f4\",\n  },\n  dark: {\n    bg: \"#0a0a0a\",\n    fg: \"#e7e9ea\",\n    fgMuted: \"#71767b\",\n    border: \"#2f3336\",\n  },\n};\n\n/**\n * Hardcoded sample notifications so the composition renders immediately from\n * defaults. A future revision swaps this for live X API data.\n */\nexport const SAMPLE_FOLLOWERS: FollowerNotification[] = [\n  { name: \"Andre Vitorio\", verified: true, time: \"7h\" },\n  { name: \"Sarah Chen\", verified: true, time: \"7h\" },\n  { name: \"marcus\", verified: false, time: \"8h\" },\n  { name: \"Lena Powell\", verified: true, time: \"9h\" },\n  { name: \"dev_jay\", verified: false, time: \"10h\" },\n  { name: \"Priya Nair\", verified: true, time: \"11h\" },\n  { name: \"Tomás Rivera\", verified: true, time: \"13h\" },\n  { name: \"hana.eth\", verified: false, time: \"15h\" },\n  { name: \"Will Carter\", verified: true, time: \"18h\" },\n  { name: \"Yuki Tanaka\", verified: true, time: \"21h\" },\n  { name: \"benoit\", verified: false, time: \"23h\" },\n  { name: \"Amara Okafor\", verified: true, time: \"1d\" },\n  { name: \"Leo Martins\", verified: true, time: \"1d\" },\n  { name: \"sol\", verified: false, time: \"2d\" },\n];\n\n// --- Pure helpers (unit-tested) -------------------------------------------\n\n/** Map an effective frame to the active notification index + in-slot fraction. */\nexport function slotProgress(\n  fc: number,\n  slotFrames: number,\n  count: number,\n): { idx: number; frac: number } {\n  if (slotFrames <= 0 || count <= 0) return { idx: 0, frac: 0 };\n  const pos = fc / slotFrames;\n  const idx = Math.max(0, Math.min(Math.floor(pos), count - 1));\n  const frac = Math.max(0, Math.min(pos - idx, 1));\n  return { idx, frac };\n}\n\n/** Smoothstep flip ramp: rest for `hold` of the slot, then flip 0→1. */\nexport function flipEase(frac: number, hold: number): number {\n  if (hold >= 1) return 0;\n  const raw = (frac - hold) / (1 - hold);\n  const c = Math.max(0, Math.min(raw, 1));\n  return c * c * (3 - 2 * c);\n}\n\nexport function blurIn(\n  frame: number,\n  start: number,\n  end: number,\n): { blur: number; opacity: number; translateY: number } {\n  const range: [number, number] = [start, end];\n  const opts = {\n    extrapolateLeft: \"clamp\" as const,\n    extrapolateRight: \"clamp\" as const,\n  };\n  return {\n    blur: interpolate(frame, range, [10, 0], opts),\n    opacity: interpolate(frame, range, [0, 1], opts),\n    translateY: interpolate(frame, range, [12, 0], opts),\n  };\n}\n\n// --- Sub-components --------------------------------------------------------\n\nfunction VerifiedBadge({ accent, size }: { accent: string; size: number }) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox=\"0 0 22 22\"\n      width={size}\n      height={size}\n      fill={accent}\n      style={{ flexShrink: 0 }}\n    >\n      <title>Verified</title>\n      <path d=\"M20.396 11c-.018-.646-.215-1.275-.57-1.816-.354-.54-.852-.972-1.438-1.246.223-.607.27-1.264.14-1.897-.131-.634-.437-1.218-.882-1.687-.47-.445-1.053-.75-1.687-.882-.633-.13-1.29-.083-1.897.14-.273-.587-.704-1.086-1.245-1.44S11.647 1.62 11 1.604c-.646.017-1.273.213-1.813.568s-.969.854-1.24 1.44c-.608-.223-1.267-.272-1.902-.14-.635.13-1.22.436-1.69.882-.445.47-.749 1.055-.878 1.688-.13.633-.08 1.29.144 1.896-.587.274-1.087.705-1.443 1.245-.356.54-.555 1.17-.574 1.817.02.647.218 1.276.574 1.817.356.54.856.972 1.443 1.245-.224.606-.274 1.263-.144 1.896.13.634.433 1.218.877 1.688.47.443 1.054.747 1.687.878.633.132 1.29.084 1.897-.136.274.586.705 1.084 1.246 1.439.54.354 1.17.551 1.816.569.647-.016 1.276-.213 1.817-.567s.972-.854 1.245-1.44c.604.239 1.266.296 1.903.164.636-.132 1.22-.447 1.68-.907.46-.46.776-1.044.908-1.681s.075-1.299-.165-1.903c.586-.274 1.084-.705 1.439-1.246.354-.54.551-1.17.569-1.816zM9.662 14.85l-3.429-3.428 1.293-1.302 2.072 2.072 4.4-4.794 1.347 1.246z\" />\n    </svg>\n  );\n}\n\nfunction Avatar({\n  avatarUrl,\n  handle,\n  size,\n  theme,\n}: {\n  avatarUrl: string;\n  handle: string;\n  size: number;\n  theme: Theme;\n}) {\n  const [errored, setErrored] = useState(false);\n  const ringStyle = {\n    width: size,\n    height: size,\n    borderRadius: 9999,\n    border: `1px solid ${theme.border}`,\n    flexShrink: 0,\n  } as const;\n\n  if (errored || !avatarUrl) {\n    return (\n      <div\n        style={{\n          ...ringStyle,\n          background: theme.border,\n          display: \"flex\",\n          alignItems: \"center\",\n          justifyContent: \"center\",\n          color: theme.fgMuted,\n          fontSize: size * 0.42,\n          fontWeight: 700,\n          fontFamily: FONT_FAMILY,\n        }}\n      >\n        {handle.charAt(0).toUpperCase()}\n      </div>\n    );\n  }\n\n  return (\n    <Img\n      src={avatarUrl}\n      crossOrigin=\"anonymous\"\n      onError={() => setErrored(true)}\n      style={{ ...ringStyle, objectFit: \"cover\" }}\n    />\n  );\n}\n\n/** Username + verified badge — the only part that flips. Absolutely overlaid\n *  and centered inside a slot sized to the active name, so the line as a whole\n *  stays centered while names swap. */\nfunction NameBlock({\n  item,\n  fontSize,\n  height,\n  theme,\n  accent,\n  deg,\n  opacity,\n}: {\n  item: FollowerNotification;\n  fontSize: number;\n  height: number;\n  theme: Theme;\n  accent: string;\n  deg: number;\n  opacity: number;\n}) {\n  return (\n    <div\n      style={{\n        position: \"absolute\",\n        left: 0,\n        right: 0,\n        top: 0,\n        height,\n        display: \"flex\",\n        alignItems: \"center\",\n        justifyContent: \"center\",\n        gap: fontSize * 0.2,\n        whiteSpace: \"nowrap\",\n        transformOrigin: \"center\",\n        backfaceVisibility: \"hidden\",\n        transform: `rotateX(${deg}deg)`,\n        opacity,\n      }}\n    >\n      <span\n        style={{\n          fontFamily: FONT_FAMILY,\n          fontSize,\n          fontWeight: 800,\n          color: theme.fg,\n          lineHeight: 1,\n        }}\n      >\n        {item.name}\n      </span>\n      {item.verified && (\n        <VerifiedBadge accent={accent} size={fontSize * 0.66} />\n      )}\n    </div>\n  );\n}\n\n// --- Main composition ------------------------------------------------------\n\nexport function XFollowersOverview({\n  notifications = SAMPLE_FOLLOWERS,\n  totalFollowers = 1709,\n  handle = \"remocn\",\n  avatarUrl = \"/logo.svg\",\n  accentColor = \"#1d9bf0\",\n  orientation = \"horizontal\",\n  speed = 1,\n}: XFollowersOverviewProps) {\n  const frame = useCurrentFrame();\n  const { durationInFrames, width, height, fps } = useVideoConfig();\n  const t = THEMES.light;\n  const isVertical = orientation === \"vertical\";\n\n  const refW = isVertical ? 720 : 1280;\n  const refH = isVertical ? 1280 : 720;\n  const stageScale = Math.min(width / refW, height / refH);\n\n  const notifSize = isVertical ? 38 : 46;\n  const countNum = isVertical ? 92 : 110;\n  const countLabel = isVertical ? 40 : 46;\n  const perspective = isVertical ? 1000 : 1200;\n  const stageH = isVertical ? 160 : 180;\n  const lineH = notifSize * 1.4;\n  // Fast cadence: ~0.43s per username at speed 1; flip occupies the back half.\n  const SLOT = 13;\n  const HOLD = 0.45;\n\n  const fc = frame * speed;\n  const items = notifications.length > 0 ? notifications : SAMPLE_FOLLOWERS;\n  const count = items.length;\n\n  // Notifications cycle first, then the total blurs in. The reveal starts once\n  // the list is exhausted, capped so it always fits before the timeline ends.\n  const revealStart = Math.min(\n    count * SLOT,\n    Math.round(durationInFrames * 0.82),\n  );\n\n  const { idx, frac } = slotProgress(fc, SLOT, count);\n  const flipP = flipEase(frac, HOLD);\n  const hasNext = idx + 1 < count;\n\n  const current = items[idx];\n  const next = hasNext ? items[idx + 1] : undefined;\n  const activeItem = flipP >= 0.5 && next ? next : current;\n\n  const currentDeg = hasNext ? -90 * flipP : 0;\n  const currentOpacity = hasNext\n    ? interpolate(flipP, [0, 0.55], [1, 0], {\n        extrapolateLeft: \"clamp\",\n        extrapolateRight: \"clamp\",\n      })\n    : 1;\n  const nextDeg = 90 * (1 - flipP);\n  const nextOpacity = interpolate(flipP, [0.45, 1], [0, 1], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n  });\n\n  const notifOpacity = interpolate(\n    fc,\n    [revealStart - 4, revealStart + 8],\n    [1, 0],\n    { extrapolateLeft: \"clamp\", extrapolateRight: \"clamp\" },\n  );\n  const countIn = blurIn(fc, revealStart + 4, revealStart + 28);\n  // Dynamic pop: spring overshoot on the total as it blurs in.\n  const revealSpring = spring({\n    fps,\n    frame: fc - (revealStart + 2),\n    config: { damping: 10, stiffness: 170, mass: 0.8 },\n  });\n  const revealScale = interpolate(revealSpring, [0, 1], [0.5, 1]);\n\n  // Avatar + handle reveal in sync with the total: same window as countIn,\n  // avatar sliding in from the left, handle from the right.\n  const avatarSize = isVertical ? 72 : 64;\n  const handleSize = isVertical ? 30 : 34;\n  const avatarIn = blurIn(fc, revealStart + 4, revealStart + 28);\n  const handleIn = avatarIn;\n  const slideOpts = {\n    extrapolateLeft: \"clamp\" as const,\n    extrapolateRight: \"clamp\" as const,\n  };\n  const avatarX = interpolate(\n    fc,\n    [revealStart + 4, revealStart + 28],\n    [-36, 0],\n    slideOpts,\n  );\n  const handleX = interpolate(\n    fc,\n    [revealStart + 4, revealStart + 28],\n    [36, 0],\n    slideOpts,\n  );\n\n  // Confetti fires on the real frame the reveal begins (sync across speeds),\n  // centered on the canvas.\n  const confettiStart = Math.round((revealStart + 2) / speed);\n  const confettiColors = [\n    accentColor,\n    \"#ff5da2\",\n    \"#ffd23f\",\n    \"#22c55e\",\n    \"#a855f7\",\n  ];\n\n  return (\n    <AbsoluteFill style={{ background: \"transparent\" }}>\n      <div\n        style={{\n          position: \"absolute\",\n          left: \"50%\",\n          top: \"50%\",\n          width: refW,\n          height: refH,\n          translate: \"-50% -50%\",\n          scale: `${stageScale}`,\n        }}\n      >\n        <div\n          style={{\n            position: \"absolute\",\n            inset: 0,\n            display: \"flex\",\n            alignItems: \"center\",\n            justifyContent: \"center\",\n          }}\n        >\n          <div style={{ position: \"relative\", width: refW, height: stageH }}>\n            {/* Cycling notifications — only the username flips in 3D, the\n                \"followed you · <time>\" suffix stays put. */}\n            <div\n              style={{\n                position: \"absolute\",\n                inset: 0,\n                display: \"flex\",\n                alignItems: \"center\",\n                justifyContent: \"center\",\n                gap: notifSize * 0.28,\n                opacity: notifOpacity,\n              }}\n            >\n              <div\n                style={{\n                  position: \"relative\",\n                  height: lineH,\n                  perspective,\n                  display: \"inline-flex\",\n                }}\n              >\n                {/* Invisible spacer sizes the slot to the active name so the\n                    whole line stays centered; flips overlay it absolutely. */}\n                <div\n                  aria-hidden=\"true\"\n                  style={{\n                    visibility: \"hidden\",\n                    display: \"flex\",\n                    alignItems: \"center\",\n                    gap: notifSize * 0.2,\n                    whiteSpace: \"nowrap\",\n                    fontFamily: FONT_FAMILY,\n                    fontSize: notifSize,\n                    fontWeight: 800,\n                  }}\n                >\n                  {activeItem.name}\n                  {activeItem.verified && (\n                    <span style={{ width: notifSize * 0.66 }} />\n                  )}\n                </div>\n                <NameBlock\n                  item={current}\n                  fontSize={notifSize}\n                  height={lineH}\n                  theme={t}\n                  accent={accentColor}\n                  deg={currentDeg}\n                  opacity={currentOpacity}\n                />\n                {next && (\n                  <NameBlock\n                    item={next}\n                    fontSize={notifSize}\n                    height={lineH}\n                    theme={t}\n                    accent={accentColor}\n                    deg={nextDeg}\n                    opacity={nextOpacity}\n                  />\n                )}\n              </div>\n              <span\n                style={{\n                  fontFamily: FONT_FAMILY,\n                  fontSize: notifSize,\n                  fontWeight: 500,\n                  color: t.fgMuted,\n                  whiteSpace: \"nowrap\",\n                  lineHeight: 1,\n                }}\n              >\n                followed you · {activeItem.time}\n              </span>\n            </div>\n\n            {/* Total reveal: avatar + handle above the count */}\n            <div\n              style={{\n                position: \"absolute\",\n                inset: 0,\n                display: \"flex\",\n                flexDirection: \"column\",\n                alignItems: \"center\",\n                justifyContent: \"center\",\n                gap: isVertical ? 26 : 22,\n              }}\n            >\n              <div\n                style={{\n                  display: \"flex\",\n                  alignItems: \"center\",\n                  gap: 14,\n                }}\n              >\n                <div\n                  style={{\n                    opacity: avatarIn.opacity,\n                    filter:\n                      avatarIn.blur > 0 ? `blur(${avatarIn.blur}px)` : \"none\",\n                    translate: `${avatarX}px`,\n                  }}\n                >\n                  <Avatar\n                    avatarUrl={avatarUrl}\n                    handle={handle}\n                    size={avatarSize}\n                    theme={t}\n                  />\n                </div>\n                <span\n                  style={{\n                    fontFamily: FONT_FAMILY,\n                    fontSize: handleSize,\n                    fontWeight: 700,\n                    color: t.fg,\n                    whiteSpace: \"nowrap\",\n                    opacity: handleIn.opacity,\n                    filter:\n                      handleIn.blur > 0 ? `blur(${handleIn.blur}px)` : \"none\",\n                    translate: `${handleX}px`,\n                  }}\n                >\n                  @{handle}\n                </span>\n              </div>\n\n              <div\n                style={{\n                  display: \"flex\",\n                  alignItems: \"baseline\",\n                  justifyContent: \"center\",\n                  gap: 18,\n                  opacity: countIn.opacity,\n                  filter: countIn.blur > 0 ? `blur(${countIn.blur}px)` : \"none\",\n                  translate: `0 ${countIn.translateY}px`,\n                  scale: `${revealScale}`,\n                }}\n              >\n                <span\n                  style={{\n                    fontFamily: FONT_FAMILY,\n                    fontSize: countNum,\n                    fontWeight: 800,\n                    color: t.fg,\n                    letterSpacing: \"-0.03em\",\n                    fontVariantNumeric: \"tabular-nums\",\n                    lineHeight: 1,\n                  }}\n                >\n                  {totalFollowers.toLocaleString(\"en-US\")}\n                </span>\n                <span\n                  style={{\n                    fontFamily: FONT_FAMILY,\n                    fontSize: countLabel,\n                    fontWeight: 500,\n                    color: t.fgMuted,\n                    lineHeight: 1,\n                  }}\n                >\n                  Followers\n                </span>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <Confetti\n        startFrame={confettiStart}\n        originX={0.5}\n        originY={0.5}\n        colors={confettiColors}\n        particleCount={160}\n      />\n    </AbsoluteFill>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/x-followers-overview.tsx"
    }
  ],
  "type": "registry:component"
}
