{
  "name": "hook-card",
  "type": "registry:block",
  "description": "Short-form opener where the hook rises line by line out of its mask and an underline draws under the promise",
  "dependencies": [
    "remotion",
    "@remotion/google-fonts"
  ],
  "registryDependencies": [
    "code-syntax",
    "layout",
    "motion-tokens",
    "text-emphasis"
  ],
  "files": [
    {
      "path": "registry/bases/default/scenes/hook-card/index.tsx",
      "type": "registry:block",
      "content": "import { loadFont } from \"@remotion/google-fonts/Inter\";\nimport { interpolate, spring, useCurrentFrame, useVideoConfig } from \"remotion\";\nimport { CODE_THEMES } from \"@/remotion/lib/code-syntax\";\nimport { getSafeAreaPadding, scaleFont } from \"@/remotion/lib/layout\";\nimport { EASING } from \"@/remotion/lib/motion-tokens\";\nimport { markEmphasis, markerEdges } from \"@/remotion/lib/text-emphasis\";\n\nconst { fontFamily } = loadFont(\"normal\", {\n  weights: [\"500\", \"600\", \"700\", \"800\"],\n  subsets: [\"latin\"],\n});\n\n/** Characters a hook line is broken at when the caller gives no line breaks. */\nconst LINE_TARGET_LANDSCAPE = 22;\nconst LINE_TARGET_PORTRAIT = 15;\n\n/** Width of the space between words, bridged so an underline stays unbroken. */\nconst WORD_SPACE = \"0.26em\";\n\nexport type HookCardProps = {\n  /**\n   * The hook line. Newlines are honoured as written; without them the hook is\n   * balanced across lines so no line is left with a single orphan word.\n   */\n  headline: string;\n  /** Small live label that counts in above the hook. */\n  kicker?: string;\n  /** Supporting line that settles once the hook has landed. */\n  subtitle?: string;\n  /**\n   * Substring of `headline` carrying the promise — it takes the accent colour\n   * and an underline that draws under it. Matched case-insensitively.\n   */\n  emphasis?: string;\n  align?: \"left\" | \"center\";\n  accentColor?: string;\n  /** Overrides the page background. */\n  backgroundColor?: string;\n  theme?: \"dark\" | \"light\";\n  /** Animation speed multiplier. */\n  speed?: number;\n};\n\n/**\n * Breaks a hook into balanced lines at roughly `target` characters, never\n * leaving a line holding a single word. Deterministic, so the reveal masks\n * line up without measuring the DOM.\n */\nfunction balanceLines(text: string, target: number): string[] {\n  const words = text.split(/\\s+/).filter(Boolean);\n  const lines: string[] = [];\n  let current = \"\";\n\n  for (const word of words) {\n    const candidate = current ? `${current} ${word}` : word;\n    if (current && candidate.length > target) {\n      lines.push(current);\n      current = word;\n    } else {\n      current = candidate;\n    }\n  }\n  if (current) lines.push(current);\n\n  // Pull a word down rather than stranding the last line on its own.\n  if (lines.length > 1) {\n    const last = lines[lines.length - 1];\n    if (!last.includes(\" \")) {\n      const previous = lines[lines.length - 2].split(\" \");\n      if (previous.length > 1) {\n        lines[lines.length - 1] = `${previous.pop()} ${last}`;\n        lines[lines.length - 2] = previous.join(\" \");\n      }\n    }\n  }\n\n  return lines;\n}\n\n/**\n * The opening card of a short, staged as the delivery it stands in for: a live\n * label counts in, the hook rises line by line out of its own mask at the pace\n * it is spoken, an underline draws under the promise, and the supporting line\n * settles beneath — over a field that keeps drifting so the frame never sits\n * still on the feed.\n */\nexport const HookCard: React.FC<HookCardProps> = ({\n  headline,\n  kicker,\n  subtitle,\n  emphasis,\n  align = \"left\",\n  accentColor = \"#E8B86D\",\n  backgroundColor,\n  theme = \"dark\",\n  speed = 1,\n}) => {\n  const rawFrame = useCurrentFrame();\n  const { fps, width, height } = useVideoConfig();\n  const palette = CODE_THEMES[theme];\n  const page = backgroundColor ?? palette.page;\n  const safeArea = getSafeAreaPadding({ width, height });\n  const isPortrait = height > width;\n  const frame = rawFrame * speed;\n\n  const seconds = (value: number) => value * fps;\n  const clamp = {\n    extrapolateLeft: \"clamp\" as const,\n    extrapolateRight: \"clamp\" as const,\n  };\n\n  const lines = headline.includes(\"\\n\")\n    ? headline.split(\"\\n\").map((line) => line.trim()).filter(Boolean)\n    : balanceLines(\n        headline,\n        isPortrait ? LINE_TARGET_PORTRAIT : LINE_TARGET_LANDSCAPE,\n      );\n\n  // Marked across the whole hook, then sliced per line — a phrase that spans\n  // the line break has to stay one emphasis, not two failed lookups.\n  const marked = markEmphasis(lines.join(\" \"), emphasis);\n  const lineWords = lines.map((line) => line.split(/\\s+/).filter(Boolean).length);\n  const lineOffsets = lineWords.map((_, index) =>\n    lineWords.slice(0, index).reduce((total, count) => total + count, 0),\n  );\n\n  const headlineSize = scaleFont(isPortrait ? 78 : 96, width);\n  const lineHeight = Math.round(headlineSize * 1.06);\n\n  // --- Delivery plan ------------------------------------------------------\n  const kickerAt = seconds(0.1);\n  const lineStart = seconds(kicker ? 0.4 : 0.14);\n  const lineStep = seconds(0.16);\n  const lineDown = lineStart + lines.length * lineStep + seconds(0.34);\n  const underlineAt = lineDown - seconds(0.1);\n  const subtitleAt = underlineAt + seconds(0.22);\n\n  const kickerIn = kicker\n    ? spring({\n        fps,\n        frame: frame - kickerAt,\n        config: { damping: 16, stiffness: 150, mass: 0.7 },\n      })\n    : 0;\n  const dot = 0.55 + 0.45 * Math.sin((frame / fps) * Math.PI * 1.6);\n  const subtitleIn = subtitle\n    ? interpolate(frame, [subtitleAt, subtitleAt + seconds(0.42)], [0, 1], {\n        easing: EASING.enter,\n        ...clamp,\n      })\n    : 0;\n\n  // The field keeps moving under the type — a still frame reads as a slide.\n  const drift = frame / fps;\n  const bloomX = 24 + Math.sin(drift * 0.5) * 5;\n  const bloomY = 32 + Math.cos(drift * 0.42) * 6;\n  const push = interpolate(frame, [0, seconds(6)], [1, 1.045], clamp);\n  const glow = interpolate(frame, [lineStart, lineDown], [0.4, 1], {\n    easing: EASING.enter,\n    ...clamp,\n  });\n\n  return (\n    <div\n      style={{\n        width,\n        height,\n        boxSizing: \"border-box\",\n        position: \"relative\",\n        overflow: \"hidden\",\n        background: page,\n        color: palette.fg,\n        fontFamily,\n      }}\n    >\n      <div\n        style={{\n          position: \"absolute\",\n          inset: `-${scaleFont(60, width)}px`,\n          background: `radial-gradient(ellipse 62% 52% at ${bloomX}% ${bloomY}%, ${accentColor}2E, transparent 66%)`,\n          opacity: glow,\n          transform: `scale(${push})`,\n        }}\n      />\n      <div\n        style={{\n          position: \"absolute\",\n          inset: 0,\n          background:\n            theme === \"dark\"\n              ? \"radial-gradient(ellipse 130% 95% at 50% 45%, transparent 38%, rgba(0,0,0,0.66) 100%)\"\n              : \"radial-gradient(ellipse 130% 95% at 50% 45%, transparent 52%, rgba(15,18,25,0.09) 100%)\",\n        }}\n      />\n\n      <div\n        style={{\n          position: \"absolute\",\n          inset: 0,\n          boxSizing: \"border-box\",\n          paddingLeft: safeArea.paddingLeft,\n          paddingRight: safeArea.paddingRight,\n          paddingTop: safeArea.paddingTop,\n          paddingBottom: safeArea.paddingBottom,\n          display: \"flex\",\n          flexDirection: \"column\",\n          justifyContent: \"center\",\n          alignItems: align === \"center\" ? \"center\" : \"flex-start\",\n          textAlign: align,\n          gap: scaleFont(26, width),\n        }}\n      >\n        {kicker ? (\n          <div\n            style={{\n              display: \"inline-flex\",\n              alignItems: \"center\",\n              gap: scaleFont(12, width),\n              padding: `${scaleFont(10, width)}px ${scaleFont(20, width)}px`,\n              borderRadius: 999,\n              border: `1px solid ${accentColor}4D`,\n              background: `${accentColor}14`,\n              color: accentColor,\n              fontSize: scaleFont(24, width),\n              fontWeight: 600,\n              letterSpacing: \"0.12em\",\n              textTransform: \"uppercase\",\n              opacity: Math.min(1, kickerIn * 1.3),\n              transform: `translateY(${interpolate(kickerIn, [0, 1], [scaleFont(18, width), 0])}px)`,\n            }}\n          >\n            <span\n              style={{\n                width: scaleFont(10, width),\n                height: scaleFont(10, width),\n                borderRadius: \"50%\",\n                background: accentColor,\n                opacity: dot,\n              }}\n            />\n            {kicker}\n          </div>\n        ) : null}\n\n        <h1\n          style={{\n            margin: 0,\n            fontSize: headlineSize,\n            lineHeight: `${lineHeight}px`,\n            letterSpacing: \"-0.034em\",\n            fontWeight: 800,\n            width: \"100%\",\n          }}\n        >\n          {lines.map((line, lineIndex) => {\n            // Each line rises out of its own mask, so the hook lands in the\n            // cadence it is spoken rather than appearing already finished.\n            const rise = spring({\n              fps,\n              frame: frame - (lineStart + lineIndex * lineStep),\n              config: { damping: 18, stiffness: 130, mass: 0.75 },\n            });\n            const words = marked.slice(\n              lineOffsets[lineIndex],\n              lineOffsets[lineIndex] + lineWords[lineIndex],\n            );\n\n            return (\n              <div\n                key={`${line}-${lineIndex}`}\n                style={{\n                  height: lineHeight,\n                  overflow: \"hidden\",\n                  display: \"flex\",\n                  justifyContent:\n                    align === \"center\" ? \"center\" : \"flex-start\",\n                }}\n              >\n                <div\n                  style={{\n                    position: \"relative\",\n                    display: \"inline-block\",\n                    whiteSpace: \"pre\",\n                    paddingBottom: scaleFont(4, width),\n                    transform: `translateY(${interpolate(rise, [0, 1], [lineHeight, 0])}px)`,\n                  }}\n                >\n                  {words.map((word, wordIndex) => (\n                    <span key={`${word.text}-${wordIndex}`}>\n                      {wordIndex > 0 ? \" \" : \"\"}\n                      <span\n                        style={{\n                          position: \"relative\",\n                          display: \"inline-block\",\n                          color: word.marked ? accentColor : palette.fg,\n                        }}\n                      >\n                        {word.text}\n                        {word.marked ? (\n                          <span\n                            style={{\n                              position: \"absolute\",\n                              ...markerEdges(\n                                words,\n                                wordIndex,\n                                WORD_SPACE,\n                                scaleFont(2, width),\n                                999,\n                              ),\n                              top: \"100%\",\n                              marginTop: scaleFont(-6, width),\n                              height: scaleFont(7, width),\n                              background: accentColor,\n                              opacity: 0.92,\n                              // Draws under the promise once the line is down.\n                              transform: `scaleX(${interpolate(\n                                frame,\n                                [\n                                  underlineAt + word.order * seconds(0.07),\n                                  underlineAt +\n                                    word.order * seconds(0.07) +\n                                    seconds(0.34),\n                                ],\n                                [0, 1],\n                                { easing: EASING.enter, ...clamp },\n                              )})`,\n                              transformOrigin: \"left center\",\n                            }}\n                          />\n                        ) : null}\n                      </span>\n                    </span>\n                  ))}\n                </div>\n              </div>\n            );\n          })}\n        </h1>\n\n        {subtitle ? (\n          <p\n            style={{\n              margin: 0,\n              color: palette.dim,\n              fontSize: scaleFont(34, width),\n              lineHeight: 1.3,\n              fontWeight: 500,\n              maxWidth: isPortrait ? \"100%\" : \"72%\",\n              opacity: subtitleIn,\n              transform: `translateY(${(1 - subtitleIn) * scaleFont(18, width)}px)`,\n            }}\n          >\n            {subtitle}\n          </p>\n        ) : null}\n      </div>\n    </div>\n  );\n};\n"
    }
  ],
  "atlas": {
    "lane": "blocks",
    "drive": "time",
    "tier": "advanced",
    "tags": [
      "creator"
    ]
  }
}