{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"skeleton","type":"registry:component","title":"Skeleton","description":"The shape of what's coming, with one soft band of light sweeping across, and a swap that hands over to the real content without a pop.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/ui-interaction-motion.json?as=dependency"],"files":[{"path":"registry/skeleton.tsx","type":"registry:component","content":"// Hairline UI · skeleton · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { EASE_OUT, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * SKELETON: the shape of what's coming\n *\n *   wait     muted shapes with one soft band of light sweeping\n *            across them all together, left to right\n *   arrive   with <Skeleton.Swap loading>, the shapes fade out as\n *            the real content rises out of a 4px blur in their\n *            place, so nothing pops; the box eases to the\n *            content's height\n * ───────────────────────────────────────────────────────── */\n\nexport interface SkeletonProps extends React.HTMLAttributes<HTMLSpanElement> {\n  /** \"line\" is text height, \"circle\" a round avatar, \"block\" a box. */\n  shape?: \"line\" | \"circle\" | \"block\";\n  width?: number | string;\n  height?: number | string;\n}\n\nexport function Skeleton({ shape = \"line\", width, height, className = \"\", style, ...props }: SkeletonProps) {\n  const reduced = useInteractionReducedMotion();\n  const dims = shape === \"circle\" ? { width: width ?? 36, height: height ?? width ?? 36 } : { width: width ?? \"100%\", height: height ?? (shape === \"line\" ? 12 : 80) };\n  return (\n    <span\n      aria-hidden=\"true\"\n      className={`relative block overflow-hidden bg-muted ${shape === \"circle\" ? \"rounded-full\" : shape === \"line\" ? \"rounded-full\" : \"rounded-[12px]\"} ${className}`}\n      style={{ ...dims, ...style }}\n      {...props}\n    >\n      {!reduced && (\n        <span className=\"absolute inset-0 [animation:ui-skeleton_1.6s_cubic-bezier(0.4,0,0.2,1)_infinite] bg-[linear-gradient(90deg,transparent,color-mix(in_oklab,var(--background)_55%,transparent),transparent)]\" />\n      )}\n    </span>\n  );\n}\n\nexport interface SkeletonSwapProps {\n  loading: boolean;\n  /** The skeleton shown while loading. */\n  fallback: React.ReactNode;\n  children: React.ReactNode;\n  /** Accessible text while loading. */\n  label?: string;\n  className?: string;\n}\n\n/** Holds a skeleton while `loading`, then hands over to the content in one motion. */\nexport function SkeletonSwap({ loading, fallback, children, label = \"Loading\", className = \"\" }: SkeletonSwapProps) {\n  const reduced = useInteractionReducedMotion();\n  const inner = React.useRef<HTMLDivElement>(null);\n  const [height, setHeight] = React.useState<number | null>(null);\n  // True for the first frames after loading ends, so the content starts from its hidden pose.\n  const [arriving, setArriving] = React.useState(false);\n  const [wasLoading, setWasLoading] = React.useState(loading);\n  if (wasLoading !== loading) {\n    setWasLoading(loading);\n    setArriving(!loading);\n  }\n\n  React.useEffect(() => {\n    if (!arriving) return;\n    let second = 0;\n    const first = requestAnimationFrame(() => {\n      second = requestAnimationFrame(() => setArriving(false));\n    });\n    return () => {\n      cancelAnimationFrame(first);\n      cancelAnimationFrame(second);\n    };\n  }, [arriving]);\n\n  // The box eases between the skeleton's height and the content's.\n  React.useLayoutEffect(() => {\n    const el = inner.current;\n    if (!el) return;\n    const measure = () => setHeight((h) => (h === el.offsetHeight ? h : el.offsetHeight));\n    measure();\n    const ro = new ResizeObserver(measure);\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  const t = (ms: number, delay = 0) => (reduced ? \"none\" : `${ms}ms ${EASE_OUT} ${delay}ms`);\n\n  return (\n    <div aria-busy={loading || undefined} className={`relative overflow-hidden ${className}`} style={{ height: height ?? undefined, transition: reduced ? \"none\" : `height 360ms ${EASE_OUT}` }}>\n      {loading && (\n        <span className=\"sr-only\" role=\"status\">\n          {label}\n        </span>\n      )}\n      <div ref={inner}>\n        {loading ? (\n          fallback\n        ) : (\n          <div\n            style={{\n              opacity: arriving ? 0 : 1,\n              filter: arriving && !reduced ? \"blur(4px)\" : \"blur(0px)\",\n              translate: arriving && !reduced ? \"0 4px\" : \"0 0\",\n              transition: arriving ? \"none\" : `opacity ${t(300, 40)}, filter ${t(300, 40)}, translate ${t(420, 40)}`,\n            }}\n          >\n            {children}\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n\nexport default Skeleton;\n"}],"css":{"@keyframes ui-skeleton":{"from":{"transform":"translateX(-100%)"},"to":{"transform":"translateX(100%)"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/skeleton. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}