{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "skeleton-loader",
  "type": "registry:ui",
  "title": "Skeleton Loader",
  "description": "Content-aware skeletons that mirror your layout, shimmer in sync and crossfade into real content without layout shift.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/skeleton-loader.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type SkeletonPreset = \"auto\" | \"text\" | \"avatar\" | \"card\" | \"table\";\n\nexport interface SkeletonLoaderProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  /** While true, placeholders are shown and the children are hidden (but keep their space). */\n  loading: boolean;\n  /** The real content. In \"auto\" mode it is rendered invisibly while loading and its boxes are mirrored. */\n  children?: React.ReactNode;\n  /** Placeholder layout: mirror the children (\"auto\") or a fixed shape. */\n  preset?: SkeletonPreset;\n  /** Text lines for the \"text\" and \"card\" presets. */\n  lines?: number;\n  /** Rows for the \"table\" preset. */\n  rows?: number;\n  /** Highlight sweep, opacity pulse, or static placeholders. */\n  animation?: \"wave\" | \"pulse\" | \"none\";\n  /** Direction of the wave. */\n  direction?: \"ltr\" | \"rtl\";\n  /** Duration of one sweep / pulse in ms. */\n  speed?: number;\n  /** Corner radius of rectangular placeholders in px. */\n  radius?: number;\n  /** Crossfade from placeholders to content in ms (0 = instant). */\n  fadeDuration?: number;\n  /** Text announced to screen readers while loading. */\n  label?: string;\n}\n\ninterface Shape {\n  x: number;\n  y: number;\n  w: number;\n  h: number;\n  /** Corner radius in px, or \"circle\". */\n  r: number | \"circle\";\n}\n\n/** Elements mirrored as one solid block instead of being walked for text. */\nconst BLOCK_TAGS = new Set([\"img\", \"svg\", \"video\", \"canvas\", \"picture\", \"iframe\", \"input\", \"textarea\", \"select\", \"button\", \"progress\", \"meter\"]);\nconst MAX_SHAPES = 200;\n\n/**\n * Measures the visible boxes of `root`: one bar per line box of every text node (via\n * Range.getClientRects) and one block per replaced element, relative to `root`.\n * Mark an element with data-skeleton=\"block\" to mirror it whole, or \"ignore\" to skip it.\n */\nfunction measureShapes(root: HTMLElement): Shape[] {\n  const base = root.getBoundingClientRect();\n  const shapes: Shape[] = [];\n  const range = document.createRange();\n\n  const walk = (node: Node) => {\n    if (shapes.length >= MAX_SHAPES) return;\n    if (node.nodeType === Node.TEXT_NODE) {\n      if (!node.textContent?.trim()) return;\n      range.selectNodeContents(node);\n      for (const r of range.getClientRects()) {\n        if (r.width < 1 || r.height < 1) continue;\n        // A bar about the height of the x-height band reads as text, not as a box.\n        const h = Math.max(6, Math.round(r.height * 0.62));\n        shapes.push({ x: r.left - base.left, y: r.top - base.top + (r.height - h) / 2, w: r.width, h, r: Math.min(h / 2, 4) });\n      }\n      return;\n    }\n    if (!(node instanceof Element)) return;\n    const hint = node instanceof HTMLElement || node instanceof SVGElement ? node.dataset.skeleton : undefined;\n    if (hint === \"ignore\") return;\n    if (hint === \"block\" || BLOCK_TAGS.has(node.tagName.toLowerCase())) {\n      const r = node.getBoundingClientRect();\n      if (r.width < 1 || r.height < 1) return;\n      const raw = getComputedStyle(node).borderTopLeftRadius;\n      const radius = raw.endsWith(\"%\") ? (parseFloat(raw) / 100) * Math.min(r.width, r.height) : parseFloat(raw) || 0;\n      const circle = Math.abs(r.width - r.height) < 1 && radius >= r.width / 2 - 0.5;\n      shapes.push({ x: r.left - base.left, y: r.top - base.top, w: r.width, h: r.height, r: circle ? \"circle\" : radius });\n      return;\n    }\n    node.childNodes.forEach(walk);\n  };\n\n  root.childNodes.forEach(walk);\n  return shapes;\n}\n\n/**\n * Content-aware skeleton: wraps real content and, while `loading`, shows shimmering placeholders —\n * either fixed presets or (in \"auto\" mode) a mirror of the children's own text lines and images,\n * measured after mount. Placeholders and content share one grid cell, so the swap reserves space\n * and crossfades without layout shift. One synchronised wave runs per instance via the Web\n * Animations API and pauses offscreen, in hidden tabs and with reduced motion.\n */\nexport function SkeletonLoader({\n  loading,\n  children,\n  preset = \"auto\",\n  lines = 3,\n  rows = 4,\n  animation = \"wave\",\n  direction = \"ltr\",\n  speed = 1600,\n  radius = 6,\n  fadeDuration = 300,\n  label = \"Loading\",\n  className = \"\",\n  style,\n  ...props\n}: SkeletonLoaderProps) {\n  const contentRef = React.useRef<HTMLDivElement>(null);\n  const overlayRef = React.useRef<HTMLDivElement>(null);\n  const [shapes, setShapes] = React.useState<Shape[]>([]);\n  const auto = preset === \"auto\";\n\n  // Keep the placeholders mounted while they fade out after loading finishes.\n  const [fading, setFading] = React.useState(false);\n  const [wasLoading, setWasLoading] = React.useState(loading);\n  if (wasLoading !== loading) {\n    setWasLoading(loading);\n    setFading(!loading && fadeDuration > 0);\n  }\n  const showSkeleton = loading || fading;\n\n  React.useEffect(() => {\n    if (!fading) return;\n    const timer = window.setTimeout(() => setFading(false), fadeDuration + 40);\n    return () => window.clearTimeout(timer);\n  }, [fading, fadeDuration]);\n\n  // Auto mode: measure the hidden children whenever they resize while loading.\n  React.useEffect(() => {\n    const el = contentRef.current;\n    if (!auto || !loading || !el) return;\n    const ro = new ResizeObserver(() => setShapes(measureShapes(el)));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [auto, loading]);\n\n  // One wave across the whole skeleton: every shape's gradient is offset by its own position so\n  // the highlight lines up between shapes. Paused offscreen, in hidden tabs and for reduced motion.\n  React.useEffect(() => {\n    const overlay = overlayRef.current;\n    if (!overlay || !showSkeleton || animation === \"none\") return;\n    if (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) return;\n    const box = overlay.getBoundingClientRect();\n    const width = Math.max(box.width, 1);\n    const animations: Animation[] = [];\n\n    if (animation === \"pulse\") {\n      const inner = overlay.firstElementChild;\n      if (inner) {\n        animations.push(\n          inner.animate([{ opacity: 1 }, { opacity: 0.45 }, { opacity: 1 }], { duration: speed, iterations: Infinity, easing: \"ease-in-out\" }),\n        );\n      }\n    } else {\n      overlay.style.setProperty(\"--sk-w\", `${width}px`);\n      overlay.querySelectorAll<HTMLElement>(\"[data-sk-shape]\").forEach((shape) => {\n        const left = shape.getBoundingClientRect().left - box.left;\n        const from = `${-width - left}px 0`;\n        const to = `${width - left}px 0`;\n        animations.push(\n          shape.animate([{ backgroundPosition: direction === \"rtl\" ? to : from }, { backgroundPosition: direction === \"rtl\" ? from : to }], {\n            duration: speed,\n            iterations: Infinity,\n            easing: \"ease-in-out\",\n          }),\n        );\n      });\n    }\n\n    let inView = true;\n    const sync = () => {\n      const run = inView && !document.hidden;\n      for (const a of animations) {\n        if (run) a.play();\n        else a.pause();\n      }\n    };\n    const io = new IntersectionObserver(([entry]) => {\n      inView = entry.isIntersecting;\n      sync();\n    });\n    io.observe(overlay);\n    document.addEventListener(\"visibilitychange\", sync);\n    return () => {\n      io.disconnect();\n      document.removeEventListener(\"visibilitychange\", sync);\n      for (const a of animations) a.cancel();\n    };\n  }, [showSkeleton, animation, direction, speed, shapes, preset, lines, rows]);\n\n  const fade = `opacity ${fadeDuration}ms ease`;\n  const wave = animation === \"wave\";\n  const shapeClass = `bg-zinc-900/[0.07] bg-no-repeat dark:bg-white/[0.07] ${\n    wave ? \"bg-[linear-gradient(90deg,transparent,var(--sk-hi),transparent)]\" : \"\"\n  }`;\n  const shapeStyle = (extra?: React.CSSProperties): React.CSSProperties => ({\n    backgroundSize: \"var(--sk-w, 100%) 100%\",\n    backgroundPosition: \"-200vw 0\",\n    borderRadius: radius,\n    ...extra,\n  });\n  const bar = (width: string, height = 12, key?: React.Key) => (\n    <div key={key} data-sk-shape=\"\" className={shapeClass} style={shapeStyle({ width, height, borderRadius: Math.min(radius, height / 2) })} />\n  );\n  const circle = (size: number) => (\n    <div data-sk-shape=\"\" className={`shrink-0 ${shapeClass}`} style={shapeStyle({ width: size, height: size, borderRadius: 9999 })} />\n  );\n\n  const textLines = (count: number) =>\n    Array.from({ length: Math.max(1, count) }, (_, i) =>\n      bar(i === count - 1 && count > 1 ? \"62%\" : `${[100, 94, 97, 90][i % 4]}%`, 12, i),\n    );\n\n  let presetShapes: React.ReactNode = null;\n  if (!auto) {\n    if (preset === \"text\") presetShapes = <div className=\"flex flex-col gap-2.5\">{textLines(lines)}</div>;\n    else if (preset === \"avatar\")\n      presetShapes = (\n        <div className=\"flex items-center gap-3\">\n          {circle(44)}\n          <div className=\"flex flex-1 flex-col gap-2\">\n            {bar(\"42%\", 13)}\n            {bar(\"68%\", 10)}\n          </div>\n        </div>\n      );\n    else if (preset === \"card\")\n      presetShapes = (\n        <div className=\"flex flex-col gap-3\">\n          <div data-sk-shape=\"\" className={`aspect-[16/9] w-full ${shapeClass}`} style={shapeStyle()} />\n          {bar(\"58%\", 16)}\n          <div className=\"flex flex-col gap-2\">{textLines(lines)}</div>\n        </div>\n      );\n    else\n      presetShapes = (\n        <div className=\"flex flex-col\">\n          {Array.from({ length: Math.max(1, rows) }, (_, i) => (\n            <div key={i} className=\"flex items-center gap-3 border-b border-zinc-900/[0.06] py-3 last:border-b-0 dark:border-white/[0.06]\">\n              {circle(30)}\n              {bar(\"34%\")}\n              <div className=\"flex-1\" />\n              {bar(\"18%\")}\n              {bar(\"12%\")}\n            </div>\n          ))}\n        </div>\n      );\n  }\n\n  return (\n    <div\n      className={`relative grid [&>*]:[grid-area:1/1] ${className}`}\n      aria-busy={loading}\n      style={{ ...style }}\n      {...props}\n    >\n      <span role=\"status\" className=\"sr-only\">\n        {loading ? label : \"\"}\n      </span>\n\n      {/* Real content: always laid out so its size is reserved, invisible and inert while loading. */}\n      <div\n        ref={contentRef}\n        inert={loading || undefined}\n        className=\"min-w-0 motion-reduce:transition-none!\"\n        style={{ visibility: loading ? \"hidden\" : \"visible\", opacity: loading ? 0 : 1, transition: loading ? \"none\" : fade }}\n      >\n        {children}\n      </div>\n\n      {showSkeleton && (\n        <div\n          ref={overlayRef}\n          aria-hidden=\"true\"\n          className={`pointer-events-none min-w-0 [--sk-hi:rgb(255_255_255/0.75)] motion-reduce:transition-none! dark:[--sk-hi:rgb(255_255_255/0.07)] ${auto ? \"relative\" : \"\"}`}\n          style={{ opacity: loading ? 1 : 0, transition: fade }}\n        >\n          {auto ? (\n            <div className=\"absolute inset-0\">\n              {shapes.map((s, i) => (\n                <div\n                  key={i}\n                  data-sk-shape=\"\"\n                  className={`absolute ${shapeClass}`}\n                  style={shapeStyle({ left: s.x, top: s.y, width: s.w, height: s.h, borderRadius: s.r === \"circle\" ? 9999 : Math.min(s.r || radius, radius * 2) })}\n                />\n              ))}\n            </div>\n          ) : (\n            <div>{presetShapes}</div>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "data"
  ],
  "docs": "Documentation: https://wancraft.my/components/skeleton-loader"
}
