{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"skeleton-swap","type":"registry:ui","title":"Skeleton Swap","description":"Skeleton to content with zero layout shift.","dependencies":["motion"],"categories":["async"],"docs":"https://www.interior.dev/docs/skeleton-swap","files":[{"path":"registry/interior/skeleton-swap.tsx","type":"registry:ui","target":"components/interior/skeleton-swap.tsx","content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nconst CROSSFADE = {\n  type: \"spring\",\n  stiffness: 260,\n  damping: 34,\n  mass: 0.8,\n} as const;\n\nconst WIDTHS = [100, 93, 97, 88, 95, 91] as const;\n\nfunction widthFor(index: number, total: number) {\n  if (total > 1 && index === total - 1) return 62;\n  return WIDTHS[(index * 7 + 3) % WIDTHS.length];\n}\n\nexport type UseSkeletonSwapOptions = {\n  ready: boolean;\n  delay?: number;\n  minVisible?: number;\n};\n\nexport function useSkeletonSwap({\n  ready,\n  delay = 120,\n  minVisible = 380,\n}: UseSkeletonSwapOptions) {\n  const [visible, setVisible] = useState(false);\n  const shownAt = useRef(0);\n\n  useEffect(() => {\n    if (!ready) {\n      if (visible) return;\n      const t = setTimeout(() => {\n        shownAt.current = performance.now();\n        setVisible(true);\n      }, delay);\n      return () => clearTimeout(t);\n    }\n\n    if (!visible) return;\n    const rest = Math.max(0, minVisible - (performance.now() - shownAt.current));\n    const t = setTimeout(() => setVisible(false), rest);\n    return () => clearTimeout(t);\n  }, [ready, visible, delay, minVisible]);\n\n  return { showSkeleton: visible, busy: !ready };\n}\n\nexport type SkeletonSwapProps = {\n  ready: boolean;\n  children: React.ReactNode;\n  lines?: number;\n  lineHeight?: number;\n  barHeight?: number;\n  reserve?: number;\n  delay?: number;\n  minVisible?: number;\n  label?: string;\n  skeleton?: React.ReactNode;\n  className?: string;\n};\n\nexport function SkeletonSwap({\n  ready,\n  children,\n  lines = 3,\n  lineHeight = 21,\n  barHeight = 9,\n  reserve,\n  delay = 120,\n  minVisible = 380,\n  label,\n  skeleton,\n  className = \"\",\n}: SkeletonSwapProps) {\n  const { showSkeleton } = useSkeletonSwap({ ready, delay, minVisible });\n  const reduced = useReducedMotion();\n\n  const shell = useRef<HTMLDivElement>(null);\n  const body = useRef<HTMLDivElement>(null);\n  const [scrollable, setScrollable] = useState(false);\n\n  const box = reserve ?? lines * lineHeight;\n\n  useEffect(() => {\n    const el = shell.current;\n    const inner = body.current;\n    if (!el || typeof ResizeObserver === \"undefined\") return;\n\n    const check = () => setScrollable(el.scrollHeight - el.clientHeight > 1);\n    check();\n\n    const ro = new ResizeObserver(check);\n    ro.observe(el);\n    if (inner) ro.observe(inner);\n    return () => ro.disconnect();\n  }, []);\n\n  return (\n    <div\n      ref={shell}\n      aria-busy={!ready}\n      aria-label={label}\n      // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex\n      tabIndex={scrollable ? 0 : undefined}\n      style={{ height: box }}\n      className={`relative grid overflow-y-auto overscroll-contain text-stone-700 dark:text-stone-200 ${className}`}\n    >\n      <motion.div\n        ref={body}\n        className=\"col-start-1 row-start-1 min-w-0\"\n        initial={false}\n        animate={\n          reduced\n            ? { opacity: showSkeleton ? 0 : 1 }\n            : {\n                opacity: showSkeleton ? 0 : 1,\n                scale: showSkeleton ? 0.99 : 1,\n                filter: showSkeleton ? \"blur(4px)\" : \"blur(0px)\",\n              }\n        }\n        transition={reduced ? { duration: 0 } : CROSSFADE}\n        style={{\n          transformOrigin: \"top left\",\n          pointerEvents: showSkeleton ? \"none\" : undefined,\n        }}\n      >\n        {children}\n      </motion.div>\n\n      <AnimatePresence initial={false}>\n        {showSkeleton ? (\n          <motion.div\n            key=\"skeleton\"\n            aria-hidden\n            className=\"pointer-events-none col-start-1 row-start-1 w-full self-start\"\n            initial={reduced ? { opacity: 1 } : { opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={reduced ? { opacity: 0 } : { opacity: 0, filter: \"blur(3px)\" }}\n            transition={reduced ? { duration: 0 } : CROSSFADE}\n          >\n            {skeleton ?? (\n              <div className=\"w-full\">\n                {Array.from({ length: lines }, (_, i) => (\n                  <div\n                    key={i}\n                    className=\"flex items-center\"\n                    style={{ height: lineHeight }}\n                  >\n                    <div\n                      className=\"rounded-[5px] bg-stone-200 dark:bg-white/15\"\n                      style={{\n                        height: barHeight,\n                        width: `${widthFor(i, lines)}%`,\n                      }}\n                    />\n                  </div>\n                ))}\n              </div>\n            )}\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n\n      {label ? (\n        <span role=\"status\" className=\"sr-only\">\n          {ready ? `${label} loaded` : \"\"}\n        </span>\n      ) : null}\n    </div>\n  );\n}\n"}]}