{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "skeleton",
  "type": "registry:ui",
  "title": "Skeleton",
  "description": "Reserve space while content is still loading.",
  "categories": [
    "feedback"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/skeleton/skeleton.tsx",
      "type": "registry:component",
      "target": "@components/xzero/skeleton/skeleton.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, type CSSProperties, type ReactNode } from \"react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, type AnimationPlaybackControls } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./skeleton.module.css\";\n\nexport interface SkeletonProps {\n  label?: string;\n  lines?: number;\n  avatar?: boolean;\n  className?: string;\n  /** Content to reveal once loading finishes. With children, the placeholder crossfades into them. */\n  children?: ReactNode;\n  /** Keeps the placeholder visible while true. Only used together with children. */\n  loading?: boolean;\n}\n\n/** Follows its content height. After `morphKey` changes, the height springs from the old size to the new one and then returns to auto, so passive reflows (a resize, a font swap) follow instantly. It clips only while moving, so focus rings stay visible at rest. */\nfunction HeightFrame({ className, busy, reduce, children }: { className?: string; busy: boolean; reduce: boolean | null; children: ReactNode }) {\n  const frame = useRef<HTMLDivElement>(null);\n  const content = useRef<HTMLDivElement>(null);\n  const height = useMotionValue<number | \"auto\">(\"auto\");\n  const changedAt = useRef(0);\n  useLayoutEffect(() => { changedAt.current = performance.now(); }, [busy]);\n  useEffect(() => {\n    const node = content.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    let last: number | undefined;\n    let controls: AnimationPlaybackControls | undefined;\n    const settle = () => { height.jump(\"auto\"); if (frame.current) Object.assign(frame.current.style, { overflow: \"\", height: \"auto\" }); };\n    const observer = new ResizeObserver(([entry]) => {\n      if (!entry) return;\n      const next = entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight;\n      const current = height.get();\n      const from = typeof current === \"number\" ? current : last;\n      last = next;\n      controls?.stop();\n      if (reduce || from === undefined || from === next || performance.now() - changedAt.current > 120) return settle();\n      // Pin the old height before this frame paints, then spring to the new one.\n      if (frame.current) Object.assign(frame.current.style, { overflow: \"hidden\", height: `${from}px` });\n      controls = animate(height, [from, next], { ...motionTokens.spring.smooth, onComplete: settle });\n    });\n    observer.observe(node);\n    return () => { observer.disconnect(); controls?.stop(); };\n  }, [height, reduce]);\n  return <motion.div ref={frame} className={className} aria-busy={busy} style={{ height }}>\n    <div ref={content} className={styles.frame}>{children}</div>\n  </motion.div>;\n}\n\nexport function Skeleton({ label = \"Loading content\", lines = 3, avatar = false, className, children, loading = true }: SkeletonProps) {\n  const reduce = useReducedMotion();\n  const count = Math.min(Math.max(Math.floor(lines), 1), 6);\n  // Each block pulses a beat after the one above it, so the wait reads as one calm wave.\n  const placeholder = (extra?: string) => <div className={[styles.root, extra].filter(Boolean).join(\" \")} role=\"status\" aria-label={label} aria-busy=\"true\">\n    {avatar && <span className={styles.avatar} aria-hidden=\"true\" />}\n    <span className={styles.lines} aria-hidden=\"true\">{Array.from({ length: count }, (_, index) => <span className={styles.line} style={{ \"--index\": index + (avatar ? 1 : 0) } as CSSProperties} key={index} />)}</span>\n  </div>;\n  if (children === undefined) return placeholder(className);\n  return <HeightFrame className={className} busy={loading} reduce={reduce}>\n    <AnimatePresence mode=\"popLayout\" initial={false}>\n      {loading\n        ? <motion.div key=\"placeholder\" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: reduce ? motionTokens.duration.instant : motionTokens.duration.fast, ease: [...motionTokens.ease.standard] }} exit={{ opacity: 0, transition: { duration: reduce ? motionTokens.duration.instant : motionTokens.duration.fast } }}>{placeholder()}</motion.div>\n        : <motion.div key=\"content\" initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, transition: { duration: reduce ? motionTokens.duration.instant : motionTokens.duration.fast } }} transition={{ duration: reduce ? motionTokens.duration.instant : motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}>{children}</motion.div>}\n    </AnimatePresence>\n  </HeightFrame>;\n}\n\nexport default Skeleton;\n"
    },
    {
      "path": "registry/components/skeleton/skeleton.module.css",
      "type": "registry:component",
      "target": "@components/xzero/skeleton/skeleton.module.css",
      "content": ".root { display: flex; width: 100%; min-width: 0; align-items: flex-start; gap: var(--space-4); }\n/* A calm, finite pulse: each block dims a beat after the one above it, then rests after about 20 seconds. */\n.avatar, .line { display: block; flex: 0 0 auto; border-radius: var(--radius-control); background: color-mix(in oklab, var(--border-strong) 38%, var(--surface)); animation: skeleton-pulse 1.8s var(--ease-in-out) calc(var(--index, 0) * 90ms) 11 both; }\n.avatar { width: var(--control-height-md); height: var(--control-height-md); border-radius: var(--radius-pill); }\n.lines { display: grid; flex: 1; gap: var(--space-3); padding-top: var(--space-1); }\n.line { width: 100%; height: var(--space-3); }\n.line:first-child { width: 48%; height: var(--space-4); }\n.line:last-child { width: 72%; }\n.frame { position: relative; display: flow-root; }\n@keyframes skeleton-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .52; } }\n@media (prefers-reduced-motion: reduce) { .avatar, .line { animation: none; } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/skeleton",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "loading",
      "status"
    ],
    "docs": "https://ui.xzero.co.uk/components/skeleton",
    "markdown": "https://ui.xzero.co.uk/components/skeleton/markdown"
  }
}