{
  "name": "quote-card",
  "type": "registry:block",
  "description": "Pull quote read aloud: the mark draws, the quote rises line by line, a marker sweeps the phrase, the attribution lands last",
  "dependencies": [
    "remotion",
    "@remotion/google-fonts"
  ],
  "registryDependencies": [
    "code-syntax",
    "layout",
    "motion-tokens",
    "text-emphasis"
  ],
  "files": [
    {
      "path": "registry/bases/default/scenes/quote-card/index.tsx",
      "type": "registry:block",
      "content": "import { loadFont } from \"@remotion/google-fonts/Inter\";\nimport { interpolate, useCurrentFrame, useVideoConfig } from \"remotion\";\nimport { CODE_THEMES } from \"@/remotion/lib/code-syntax\";\nimport { getSafeAreaPadding } 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: [\"400\", \"500\", \"600\", \"700\"],\n  subsets: [\"latin\"],\n});\n\nexport type QuoteCardProps = {\n  quote: string;\n  /**\n   * Phrase inside the quote swept with a marker, word by word. Matched\n   * case-insensitively over the whole quote, so it may span a line break.\n   */\n  emphasis?: string;\n  author?: string;\n  /** Second attribution line — role, company, handle. */\n  role?: string;\n  /** Initials in the attribution disc. Falls back to the author's initials. */\n  initials?: string;\n  /** Characters per line the quote is balanced to. */\n  charsPerLine?: number;\n  backgroundColor?: string;\n  accentColor?: string;\n  theme?: \"dark\" | \"light\";\n  /** Animation speed multiplier. */\n  speed?: number;\n};\n\n/** Beat plan in seconds. */\nconst T = {\n  mark: 0,\n  quote: 0.3,\n  lineStagger: 0.11,\n  /** Marker starts once the quote is standing. */\n  sweep: 0.34,\n  sweepStagger: 0.11,\n  attribution: 0.5,\n} as const;\n\nconst clamp = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\nconst WORD_GAP = \"0.28em\";\n\n/** Greedy wrap that keeps the last line from being a runt. */\nfunction balanceLines(text: string, target: number): string[] {\n  const words = text.split(/\\s+/).filter(Boolean);\n  const lines: string[] = [];\n  let line = \"\";\n\n  for (const word of words) {\n    const candidate = line ? `${line} ${word}` : word;\n    if (candidate.length > target && line) {\n      lines.push(line);\n      line = word;\n    } else {\n      line = candidate;\n    }\n  }\n  if (line) {\n    lines.push(line);\n  }\n  return lines;\n}\n\n/**\n * A pull quote read aloud rather than pasted in: the quote mark draws, the\n * quote rises line by line out of its own masks, a marker sweeps the phrase the\n * speaker leant on, and the attribution arrives last.\n */\nexport const QuoteCard: React.FC<QuoteCardProps> = ({\n  quote,\n  emphasis,\n  author,\n  role,\n  initials,\n  charsPerLine = 30,\n  backgroundColor,\n  accentColor = \"#F472B6\",\n  theme = \"dark\",\n  speed = 1,\n}) => {\n  const frame = useCurrentFrame();\n  const { fps, width, height } = useVideoConfig();\n  const palette = CODE_THEMES[theme];\n  const safe = getSafeAreaPadding({ width, height });\n\n  const at = (seconds: number) => (seconds * fps) / speed;\n  const ease = (from: number, to: number, easing = EASING.enter) =>\n    interpolate(frame, [at(from), at(to)], [0, 1], { easing, ...clamp });\n\n  const stage = {\n    w: width - safe.paddingLeft - safe.paddingRight,\n    h: height - safe.paddingTop - safe.paddingBottom,\n  };\n  const portrait = height > width;\n  const u = portrait\n    ? Math.min(stage.w / 620, stage.h / 1120)\n    : Math.min(stage.w / 1120, stage.h / 620);\n\n  // Emphasis is matched over the whole quote and then sliced per line — a\n  // per-line match silently drops any phrase that spans the break.\n  const words = markEmphasis(quote, emphasis);\n  const lines = balanceLines(quote, charsPerLine);\n  let cursor = 0;\n  const lineWords = lines.map((line) => {\n    const length = line.split(/\\s+/).filter(Boolean).length;\n    const slice = words.slice(cursor, cursor + length);\n    cursor += length;\n    return slice;\n  });\n\n  const markDraw = ease(T.mark, T.mark + 0.6, EASING.editorial);\n  const attributionIn = ease(T.attribution, T.attribution + 0.5);\n  const quoteSize = 52 * u;\n  const badge =\n    initials ??\n    (author\n      ? author\n          .split(/\\s+/)\n          .slice(0, 2)\n          .map((part) => part[0])\n          .join(\"\")\n          .toUpperCase()\n      : \"\");\n\n  return (\n    <div\n      style={{\n        width,\n        height,\n        background: backgroundColor ?? palette.page,\n        fontFamily,\n        position: \"relative\",\n        overflow: \"hidden\",\n        display: \"grid\",\n        placeItems: \"center\",\n      }}\n    >\n      <div\n        style={{\n          position: \"absolute\",\n          inset: 0,\n          background: `radial-gradient(ellipse 60% 50% at 50% 42%, ${accentColor}1A, transparent 72%)`,\n        }}\n      />\n\n      <div\n        style={{\n          position: \"relative\",\n          width: Math.min(stage.w, 900 * u),\n          display: \"flex\",\n          flexDirection: \"column\",\n          gap: 22 * u,\n        }}\n      >\n        {/* Quote mark, drawn rather than typed */}\n        <svg\n          width={64 * u}\n          height={52 * u}\n          viewBox=\"0 0 64 52\"\n          fill=\"none\"\n          style={{ opacity: markDraw }}\n        >\n          <path\n            d=\"M24 4C12 10 4 21 4 34c0 8 5 14 12 14s12-5 12-12-5-12-11-12c-1 0-2 0-3 .3C16 18 20 11 27 7l-3-3ZM58 4C46 10 38 21 38 34c0 8 5 14 12 14s12-5 12-12-5-12-11-12c-1 0-2 0-3 .3C50 18 54 11 61 7l-3-3Z\"\n            fill={accentColor}\n            fillOpacity={0.9}\n            style={{\n              transform: `translateY(${(1 - markDraw) * 14 * u}px)`,\n            }}\n          />\n        </svg>\n\n        <div\n          style={{\n            color: palette.fg,\n            fontSize: quoteSize,\n            fontWeight: 600,\n            lineHeight: 1.28,\n            letterSpacing: \"-0.02em\",\n          }}\n        >\n          {lineWords.map((line, lineIndex) => {\n            const lineIn = ease(\n              T.quote + lineIndex * T.lineStagger,\n              T.quote + lineIndex * T.lineStagger + 0.55,\n            );\n            return (\n              <span\n                key={`line-${lineIndex}`}\n                style={{\n                  display: \"block\",\n                  overflow: \"hidden\",\n                  paddingBottom: \"0.06em\",\n                }}\n              >\n                <span\n                  style={{\n                    display: \"flex\",\n                    flexWrap: \"wrap\",\n                    gap: `0 ${WORD_GAP}`,\n                    transform: `translateY(${(1 - lineIn) * 100}%)`,\n                  }}\n                >\n                  {line.map((word, wordIndex) => {\n                    if (!word.marked) {\n                      return (\n                        <span key={`w-${lineIndex}-${wordIndex}`}>\n                          {word.text}\n                        </span>\n                      );\n                    }\n                    const sweep = ease(\n                      T.sweep + word.order * T.sweepStagger,\n                      T.sweep + word.order * T.sweepStagger + 0.34,\n                      EASING.editorial,\n                    );\n                    const edges = markerEdges(\n                      line,\n                      wordIndex,\n                      WORD_GAP,\n                      6 * u,\n                      6 * u,\n                    );\n                    return (\n                      <span\n                        key={`w-${lineIndex}-${wordIndex}`}\n                        style={{ position: \"relative\", whiteSpace: \"nowrap\" }}\n                      >\n                        <span\n                          style={{\n                            position: \"absolute\",\n                            top: \"0.1em\",\n                            bottom: \"0.06em\",\n                            left: edges.left,\n                            right: edges.right,\n                            background: `${accentColor}44`,\n                            borderTopLeftRadius: edges.borderTopLeftRadius,\n                            borderBottomLeftRadius: edges.borderBottomLeftRadius,\n                            borderTopRightRadius: edges.borderTopRightRadius,\n                            borderBottomRightRadius:\n                              edges.borderBottomRightRadius,\n                            transformOrigin: \"left center\",\n                            transform: `scaleX(${sweep})`,\n                          }}\n                        />\n                        <span style={{ position: \"relative\" }}>\n                          {word.text}\n                        </span>\n                      </span>\n                    );\n                  })}\n                </span>\n              </span>\n            );\n          })}\n        </div>\n\n        {author ? (\n          <div\n            style={{\n              display: \"flex\",\n              alignItems: \"center\",\n              gap: 14 * u,\n              opacity: attributionIn,\n              transform: `translateX(${(1 - attributionIn) * -16 * u}px)`,\n            }}\n          >\n            {badge ? (\n              <div\n                style={{\n                  width: 52 * u,\n                  height: 52 * u,\n                  borderRadius: \"50%\",\n                  display: \"grid\",\n                  placeItems: \"center\",\n                  background: `${accentColor}22`,\n                  border: `1px solid ${accentColor}55`,\n                  color: accentColor,\n                  fontSize: 20 * u,\n                  fontWeight: 700,\n                }}\n              >\n                {badge}\n              </div>\n            ) : null}\n            <div>\n              <div\n                style={{\n                  color: palette.fg,\n                  fontSize: 25 * u,\n                  fontWeight: 600,\n                }}\n              >\n                {author}\n              </div>\n              {role ? (\n                <div\n                  style={{\n                    marginTop: 2 * u,\n                    color: palette.faint,\n                    fontSize: 20 * u,\n                  }}\n                >\n                  {role}\n                </div>\n              ) : null}\n            </div>\n          </div>\n        ) : null}\n      </div>\n    </div>\n  );\n};\n"
    }
  ],
  "atlas": {
    "lane": "blocks",
    "drive": "time",
    "tier": "core"
  }
}