{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "format-morph",
  "title": "Format Morph",
  "description": "One asset reframes itself through every aspect ratio while the headline stretches and compresses around it.",
  "dependencies": [
    "@editframe/react"
  ],
  "files": [
    {
      "path": "registry/bases/editframe/components/format-morph/index.tsx",
      "content": "\"use client\";\n\nimport { Timegroup } from \"@editframe/react\";\nimport type { CSSProperties } from \"react\";\n\nconst FONT_FAMILY =\n  \"var(--font-geist-sans), -apple-system, BlinkMacSystemFont, sans-serif\";\n\n/**\n * Easing blueprint for an on-screen morph. The media never enters or exits —\n * it is already on screen and changing shape — so every curve here is from the\n * ease-in-out family, sorted weak to strong. Ease-out would make the shape leave\n * instantly and coast, which reads as a UI transition rather than as mass.\n *\n * `quint` is the default: it sits still longer at both ends and crosses the\n * middle hard, which is the punctuation a launch film wants. `quart` is the\n * calmer, more neutral choice.\n */\nexport const MORPH_EASINGS = {\n  circ: \"cubic-bezier(0.785, 0.135, 0.15, 0.86)\",\n  cubic: \"cubic-bezier(0.645, 0.045, 0.355, 1)\",\n  expo: \"cubic-bezier(1, 0, 0, 1)\",\n  linear: \"linear\",\n  quad: \"cubic-bezier(0.455, 0.03, 0.515, 0.955)\",\n  quart: \"cubic-bezier(0.77, 0, 0.175, 1)\",\n  quint: \"cubic-bezier(0.86, 0, 0.07, 1)\",\n} as const;\n\nexport type MorphEasingName = keyof typeof MORPH_EASINGS;\n\n/** Accepts a preset name, or any raw CSS easing (`cubic-bezier(...)`, `steps(...)`, `ease-out`). */\nconst resolveEasing = (easing: string): string =>\n  easing in MORPH_EASINGS\n    ? MORPH_EASINGS[easing as MorphEasingName]\n    : easing || MORPH_EASINGS.quint;\n\nexport interface FormatMorphStage {\n  /** Aspect ratio as `w:h`, e.g. `\"16:9\"`. */\n  ratio: string;\n  /** Tilt in degrees. Small values keep the morph alive without reading as skew. */\n  rotate?: number;\n  /** Overrides the font size derived from this stage's height. */\n  fontSize?: number;\n}\n\nexport interface FormatMorphProps {\n  src?: string;\n  gradient?: string;\n  lead?: string;\n  trail?: string;\n  stages?: FormatMorphStage[];\n  maxWidth?: number;\n  maxHeight?: number;\n  fontSize?: number;\n  fontWeight?: number | string;\n  color?: string;\n  background?: string;\n  radiusRatio?: number;\n  holdRatio?: number;\n  /** A `MORPH_EASINGS` preset name, or any raw CSS easing value. */\n  easing?: string;\n  lagMs?: number;\n  gap?: number;\n  shadow?: string;\n  speed?: number;\n  fps?: number;\n  durationInFrames?: number;\n  width?: number;\n  height?: number;\n  className?: string;\n}\n\nconst DEFAULT_STAGES: FormatMorphStage[] = [\n  { ratio: \"9:16\", rotate: 4 },\n  { ratio: \"16:9\", rotate: -3 },\n  { ratio: \"1:1\", rotate: 0 },\n];\n\nconst DEFAULT_SRC =\n  \"https://raw.githubusercontent.com/shadcn-labs/framecn/main/.github/assets/gh.png\";\n\nconst DEFAULT_GRADIENT =\n  \"linear-gradient(135deg, #ff6b6b 0%, #845ec2 48%, #4d8dff 100%)\";\n\ninterface ResolvedStage {\n  fontSize: number;\n  height: number;\n  radius: number;\n  rotate: number;\n  width: number;\n}\n\nconst parseRatio = (ratio: string): number => {\n  const [w, h] = ratio.split(\":\").map(Number);\n\n  if (!(Number.isFinite(w) && Number.isFinite(h)) || w <= 0 || h <= 0) {\n    return 1;\n  }\n\n  return w / h;\n};\n\n/** Largest box with `ratio` that fits inside the bounds. */\nconst fitBox = (ratio: number, maxWidth: number, maxHeight: number) => {\n  let width = maxWidth;\n  let height = width / ratio;\n\n  if (height > maxHeight) {\n    height = maxHeight;\n    width = height * ratio;\n  }\n\n  return { height: Math.round(height), width: Math.round(width) };\n};\n\nconst resolveStage = (\n  stage: FormatMorphStage,\n  maxWidth: number,\n  maxHeight: number,\n  baseFontSize: number,\n  radiusRatio: number\n): ResolvedStage => {\n  const { height, width } = fitBox(\n    parseRatio(stage.ratio),\n    maxWidth,\n    maxHeight\n  );\n  /** Type tracks the media's height so the lockup stays proportionate. */\n  const scale = 0.6 + 0.4 * (height / maxHeight);\n\n  return {\n    fontSize: Math.round(stage.fontSize ?? baseFontSize * scale),\n    height,\n    radius: Math.round(Math.min(width, height) * radiusRatio),\n    rotate: stage.rotate ?? 0,\n    width,\n  };\n};\n\nconst toPercent = (value: number): string =>\n  Number(value.toFixed(4)).toString();\n\n/** Stable per-instance suffix so two morphs on one page never share keyframes. */\nconst stageKey = (\n  stages: ResolvedStage[],\n  holdRatio: number,\n  easing: string\n): string =>\n  `${stages.map((s) => `${s.width}x${s.height}r${s.radius}`).join(\"-\")}-h${Math.round(holdRatio * 100)}-e${easing.replaceAll(/\\W/g, \"\")}`;\n\n/**\n * Builds hold-then-morph keyframes. Each stage owns an equal slot: it sits\n * still for `holdRatio` of that slot, then eases into the next stage. The last\n * stage returns to the first so the cycle loops seamlessly.\n */\nconst buildKeyframes = (\n  name: string,\n  stages: ResolvedStage[],\n  holdRatio: number,\n  easing: string,\n  declare: (stage: ResolvedStage) => string\n): string => {\n  const slot = 100 / stages.length;\n  const steps: string[] = [];\n\n  for (const [index, stage] of stages.entries()) {\n    const start = index * slot;\n    const holdEnd = start + slot * holdRatio;\n\n    steps.push(\n      `${toPercent(start)}% { ${declare(stage)} animation-timing-function: linear; }`,\n      `${toPercent(holdEnd)}% { ${declare(stage)} animation-timing-function: ${easing}; }`\n    );\n  }\n\n  const [initial] = stages;\n\n  steps.push(`100% { ${declare(initial)} }`);\n\n  return `@keyframes ${name} {\\n  ${steps.join(\"\\n  \")}\\n}`;\n};\n\nconst mediaDeclaration = (stage: ResolvedStage): string =>\n  `width: ${stage.width}px; height: ${stage.height}px; border-radius: ${stage.radius}px; transform: rotate(${stage.rotate}deg);`;\n\nconst typeDeclaration = (stage: ResolvedStage): string =>\n  `font-size: ${stage.fontSize}px;`;\n\n/** Resolves every layout-affecting prop into concrete per-stage geometry. */\nconst resolvePlan = ({\n  stages = DEFAULT_STAGES,\n  maxWidth = 460,\n  maxHeight = 440,\n  fontSize = 128,\n  radiusRatio = 0.08,\n  holdRatio = 0.45,\n}: FormatMorphProps) => ({\n  holdRatio: Math.min(0.95, Math.max(0, holdRatio)),\n  resolved: (stages.length > 0 ? stages : DEFAULT_STAGES).map((stage) =>\n    resolveStage(stage, maxWidth, maxHeight, fontSize, radiusRatio)\n  ),\n});\n\nexport const FormatMorph = (props: FormatMorphProps) => {\n  const {\n    src = DEFAULT_SRC,\n    gradient = DEFAULT_GRADIENT,\n    lead = \"FRA\",\n    trail = \"MECN\",\n    fontWeight = 500,\n    color = \"#ffffff\",\n    background = \"#4e4e50\",\n    easing = \"quint\",\n    lagMs = 40,\n    gap = 20,\n    shadow = \"0 40px 100px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.06)\",\n    speed = 1,\n    fps = 30,\n    durationInFrames = 180,\n    width = 1280,\n    height = 720,\n    className,\n  } = props;\n\n  const safeSpeed = Math.max(0.01, speed);\n  const durationMs = (durationInFrames / fps) * 1000;\n  const cycleMs = durationMs / safeSpeed;\n\n  const { holdRatio: safeHold, resolved } = resolvePlan(props);\n  const morphEasing = resolveEasing(easing);\n  const [first] = resolved;\n\n  const uid = stageKey(resolved, safeHold, morphEasing);\n  const mediaName = `framecn-format-morph-media-${uid}`;\n  const typeName = `framecn-format-morph-type-${uid}`;\n\n  const containerStyle: CSSProperties = {\n    background,\n    fontFamily: FONT_FAMILY,\n    height,\n    overflow: \"hidden\",\n    position: \"relative\",\n    width,\n  };\n\n  const textStyle: CSSProperties = {\n    animation: `${typeName} ${cycleMs}ms linear ${lagMs}ms infinite both`,\n    color,\n    fontSize: first.fontSize,\n    fontWeight,\n    letterSpacing: \"-0.04em\",\n    lineHeight: 1,\n    minWidth: 0,\n    whiteSpace: \"nowrap\",\n  };\n\n  const mediaStyle: CSSProperties = {\n    animation: `${mediaName} ${cycleMs}ms linear infinite both`,\n    background: gradient,\n    borderRadius: first.radius,\n    boxShadow: shadow,\n    flexShrink: 0,\n    height: first.height,\n    overflow: \"hidden\",\n    transform: `rotate(${first.rotate}deg)`,\n    width: first.width,\n  };\n\n  return (\n    <Timegroup\n      className={className}\n      duration={`${durationMs}ms`}\n      mode=\"fixed\"\n      style={containerStyle}\n    >\n      <>\n        <style>{`\n${buildKeyframes(mediaName, resolved, safeHold, morphEasing, mediaDeclaration)}\n${buildKeyframes(typeName, resolved, safeHold, morphEasing, typeDeclaration)}\n        `}</style>\n\n        {/* The Timegroup host does not lay its children out as a flex\n            container, so an inset-0 stage owns the centering. */}\n        <div\n          style={{\n            alignItems: \"center\",\n            display: \"flex\",\n            inset: 0,\n            justifyContent: \"center\",\n            position: \"absolute\",\n          }}\n        >\n          {/* 1fr auto 1fr — the media column sizes itself, so resizing it\n              physically displaces the type on either side. */}\n          <div\n            style={{\n              alignItems: \"center\",\n              columnGap: gap,\n              display: \"grid\",\n              gridTemplateColumns: \"1fr auto 1fr\",\n              padding: \"0 32px\",\n              width: \"100%\",\n            }}\n          >\n            <span style={{ ...textStyle, textAlign: \"right\" }}>{lead}</span>\n            <div style={mediaStyle}>\n              {src ? (\n                // `crossOrigin` is required: a CSS background cannot carry a\n                // CORS request, so remote art has to be a real <img> here.\n                // eslint-disable-next-line next/no-img-element\n                <img\n                  alt=\"\"\n                  crossOrigin=\"anonymous\"\n                  src={src}\n                  style={{\n                    display: \"block\",\n                    height: \"100%\",\n                    objectFit: \"cover\",\n                    width: \"100%\",\n                  }}\n                />\n              ) : null}\n            </div>\n            <span style={{ ...textStyle, textAlign: \"left\" }}>{trail}</span>\n          </div>\n        </div>\n      </>\n    </Timegroup>\n  );\n};\n",
      "type": "registry:component",
      "target": "components/framecn/format-morph.tsx"
    }
  ],
  "type": "registry:component"
}