{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "skeleton-swap",
  "type": "registry:ui",
  "title": "Skeleton Swap",
  "description": "Skeleton to content with a 4px settle and clearing blur, never a flash.",
  "categories": [
    "async"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/skeleton-swap.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease } from \"@/lib/motion\";\n\nexport type SwapPhase = \"waiting\" | \"skeleton\" | \"holding\" | \"content\";\n\ntype Options = {\n  /** Milliseconds before the skeleton appears. Data that lands sooner never shows one. */\n  delay?: number;\n  /** Once the skeleton is visible, keep it at least this long so it never flickers. */\n  minDuration?: number;\n  /** When loading starts again after content was shown: keep it (dimmed) or go back to the skeleton. */\n  refetch?: \"keep\" | \"skeleton\";\n};\n\n/**\n * The timing on its own. waiting (skeleton reserved but invisible) → skeleton →\n * holding (data is in, the minimum hasn't passed) → content. Loading again with\n * refetch=\"keep\" stays on content and reports refreshing instead.\n */\nexport function useSkeletonSwap(loading: boolean, { delay = 150, minDuration = 400, refetch = \"keep\" }: Options = {}) {\n  const [phase, setPhase] = useState<SwapPhase>(loading ? \"waiting\" : \"content\");\n  const [seen, setSeen] = useState(!loading);\n  const [prev, setPrev] = useState(loading);\n  const [swapping, setSwapping] = useState(false);\n  const shownAt = useRef(0);\n\n  // React to the loading prop on the same render it changes, so a fast answer\n  // goes straight to content without an intermediate frame.\n  if (loading !== prev) {\n    setPrev(loading);\n    if (loading) {\n      if (phase === \"holding\") setPhase(\"skeleton\");\n      else if (!(seen && refetch === \"keep\")) setPhase(\"waiting\");\n    } else if (phase === \"waiting\") {\n      setPhase(\"content\");\n      setSeen(true);\n    } else if (phase === \"skeleton\") {\n      setPhase(\"holding\");\n    }\n  }\n\n  useEffect(() => {\n    if (phase === \"waiting\") {\n      const id = window.setTimeout(() => {\n        shownAt.current = performance.now();\n        setPhase(\"skeleton\");\n      }, delay);\n      return () => window.clearTimeout(id);\n    }\n    if (phase === \"holding\") {\n      const left = Math.max(0, minDuration - (performance.now() - shownAt.current));\n      const id = window.setTimeout(() => {\n        setSwapping(true);\n        setPhase(\"content\");\n        setSeen(true);\n      }, left);\n      return () => window.clearTimeout(id);\n    }\n  }, [phase, delay, minDuration]);\n\n  // Height is only animated around a swap; the rest of the time it is plain auto.\n  useEffect(() => {\n    if (!swapping) return;\n    const id = window.setTimeout(() => setSwapping(false), 700);\n    return () => window.clearTimeout(id);\n  }, [swapping]);\n\n  const refreshing = loading && phase === \"content\";\n  // While waiting after content was already shown, keep showing it: never blank the region.\n  const showContent = phase === \"content\" || (phase === \"waiting\" && seen);\n  return { phase, showContent, refreshing, swapping };\n}\n\nexport type SkeletonSwapProps = Omit<React.ComponentProps<\"div\">, \"children\"> &\n  Options & {\n    loading: boolean;\n    /** What to show while loading, shaped like the content. */\n    skeleton: React.ReactNode;\n    children: React.ReactNode;\n  };\n\nexport function SkeletonSwap({ loading, skeleton, delay, minDuration, refetch, className, children, ...rest }: SkeletonSwapProps) {\n  const { phase, showContent, refreshing, swapping } = useSkeletonSwap(loading, { delay, minDuration, refetch });\n  const reduce = useReducedMotion();\n  const inner = useRef<HTMLDivElement>(null);\n  const [height, setHeight] = useState<number | null>(null);\n\n  useEffect(() => {\n    const el = inner.current;\n    if (!el) return;\n    const ro = new ResizeObserver(([entry]) => setHeight(entry.borderBoxSize?.[0]?.blockSize ?? el.offsetHeight));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  const animateHeight = swapping && !reduce && height !== null;\n\n  return (\n    <motion.div\n      aria-busy={loading || undefined}\n      data-phase={phase}\n      data-refreshing={refreshing || undefined}\n      className={cn(\"relative\", animateHeight && \"overflow-hidden\", className)}\n      initial={false}\n      animate={{ height: animateHeight ? height : \"auto\" }}\n      transition={{ duration: 0.28, ease: ease.inOut }}\n      {...(rest as React.ComponentProps<typeof motion.div>)}\n    >\n      {/* Both layers share one grid cell, so during the crossfade the box is as tall as the taller one. */}\n      <div ref={inner} className=\"grid\">\n        <AnimatePresence initial={false}>\n          {showContent ? (\n            <motion.div\n              key=\"content\"\n              className=\"col-start-1 row-start-1 min-w-0\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4, filter: \"blur(4px)\" }}\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={{ opacity: 0, transition: { duration: 0.15, ease: ease.in } }}\n              transition={{ duration: reduce ? 0.15 : 0.32, ease: ease.out }}\n            >\n              {/* A refetch slower than 150ms dims the old data; a quick one changes nothing. */}\n              <div className={cn(\"transition-opacity duration-200\", refreshing && \"opacity-60 delay-150\")}>{children}</div>\n            </motion.div>\n          ) : (\n            <motion.div\n              key=\"skeleton\"\n              aria-hidden={phase === \"waiting\" || undefined}\n              className=\"col-start-1 row-start-1 min-w-0\"\n              initial={{ opacity: 0 }}\n              // Reserved but invisible while waiting: the space is held, nothing flashes.\n              animate={{ opacity: phase === \"waiting\" ? 0 : 1 }}\n              exit={{ opacity: 0, transition: { duration: reduce ? 0.12 : 0.18, ease: ease.in } }}\n              transition={{ duration: 0.2, ease: ease.out }}\n            >\n              {skeleton}\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n    </motion.div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/skeleton-swap"
}
