{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "crossfade",
  "title": "Crossfade",
  "description": "Crossfades between versions of its content: the old layer blurs out while the new one blurs in (the blur hides that two different things overlap), and for text the box animates to the new height.",
  "registryDependencies": [
    "https://sproutboat.com/ikada/r/cn.json"
  ],
  "files": [
    {
      "path": "registry/ikada/lib/ikada/crossfade.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { cn } from \"@/components/ikada/lib/cn\";\n\ninterface Layer<T> {\n  key: string;\n  value: T;\n}\n\nexport interface CrossfadeProps<T> {\n  /** Changes when the content is a different thing (new text, new glyph). Same key re-renders in place. */\n  layerKey: string;\n  value: T;\n  /** `live` is true for the layer that stays; the outgoing one is rendered once more from its snapshot. */\n  render: (value: T, live: boolean) => ReactNode;\n  /** \"text\" animates the height between layers; \"slot\" stacks centered layers in a fixed box (icons). */\n  kind?: \"text\" | \"slot\";\n  className?: string;\n  /** Milliseconds until the outgoing layer is removed. Match the CSS duration. */\n  settleMs?: number;\n}\n\nconst layerMotion = [\n  \"transition-[opacity,filter,translate,scale] duration-(--sb-duration-toast-in) ease-out\",\n  // The incoming layer starts blurred and a few pixels low. --sb-move is 0 under reduced motion: only the fade stays.\n  \"starting:opacity-0 starting:blur-[3px] starting:translate-y-[calc(3px*var(--sb-move))]\",\n].join(\" \");\n\nconst leaving = \"pointer-events-none opacity-0 blur-[3px] -translate-y-[calc(3px*var(--sb-move))]\";\n\n/**\n * Crossfades between versions of its content: the old layer blurs out while the new one blurs in\n * (the blur hides that two different things overlap), and for text the box animates to the new height.\n * Opacity, translate, scale and blur only; the height transition is the one exception and is a\n * layout property on a box that clips, so nothing around it moves.\n */\nexport function Crossfade<T>({ layerKey, value, render, kind = \"text\", className, settleMs = 340 }: CrossfadeProps<T>) {\n  const [layers, setLayers] = useState<Layer<T>[]>([{ key: layerKey, value }]);\n  const boxRef = useRef<HTMLDivElement>(null);\n  const live = layers[layers.length - 1];\n  if (live && live.key !== layerKey) setLayers([live, { key: layerKey, value }]);\n\n  const morphing = layers.length > 1;\n\n  useEffect(() => {\n    if (!morphing) return undefined;\n    const timer = setTimeout(() => setLayers((l) => l.slice(-1)), settleMs);\n    return () => clearTimeout(timer);\n  }, [morphing, layers, settleMs]);\n\n  useLayoutEffect(() => {\n    const box = boxRef.current;\n    if (!box || kind !== \"text\") return undefined;\n    if (!morphing) {\n      box.style.height = \"\";\n      return undefined;\n    }\n    // The outgoing layer is still in flow, so the box measures its height; pin it, then move to the new one.\n    const from = box.offsetHeight;\n    box.style.height = `${from}px`;\n    const incoming = box.querySelector<HTMLElement>(\"[data-layer=live]\");\n    const to = incoming?.offsetHeight ?? from;\n    const frame = requestAnimationFrame(() => {\n      box.style.height = `${to}px`;\n    });\n    // Focus on a control in the outgoing layer would be lost; hand it to the toast itself.\n    const active = box.ownerDocument.activeElement;\n    if (active && box.querySelector(\"[data-layer=out]\")?.contains(active)) {\n      box.closest<HTMLElement>(\"[tabindex]\")?.focus({ preventScroll: true });\n    }\n    return () => cancelAnimationFrame(frame);\n  }, [layers, morphing, kind]);\n\n  return (\n    <div\n      ref={boxRef}\n      data-crossfade={kind}\n      data-morphing={morphing ? \"\" : undefined}\n      className={cn(\n        \"relative min-w-0\",\n        kind === \"text\" &&\n          \"transition-[height] duration-(--sb-duration-toast-in) ease-out data-morphing:overflow-hidden\",\n        kind === \"slot\" && \"grid size-6 place-items-center\",\n        className,\n      )}\n    >\n      {layers.map((layer, i) => {\n        const isLive = i === layers.length - 1;\n        return (\n          <div\n            key={layer.key}\n            data-layer={isLive ? \"live\" : \"out\"}\n            aria-hidden={isLive ? undefined : true}\n            inert={isLive ? undefined : true}\n            className={cn(\n              layerMotion,\n              kind === \"text\" && \"min-w-0\",\n              kind === \"slot\" && \"col-start-1 row-start-1 grid place-items-center\",\n              // While both layers exist the newcomer overlays the old one; afterwards it takes over the flow.\n              kind === \"text\" && isLive && morphing && \"absolute inset-x-0 top-0\",\n              kind === \"slot\" && !isLive && \"absolute inset-0\",\n              kind === \"slot\" && \"starting:scale-(--sb-scale-icon)\",\n              kind === \"slot\" && !isLive && \"scale-(--sb-scale-icon)\",\n              !isLive && leaving,\n            )}\n          >\n            {render(isLive ? value : layer.value, isLive)}\n          </div>\n        );\n      })}\n    </div>\n  );\n}\n",
      "type": "registry:lib",
      "target": "@components/ikada/lib/crossfade.tsx"
    }
  ],
  "categories": [
    "utilities"
  ],
  "type": "registry:lib"
}