{"$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://uiarc.dev/r/arc-motion-tokens.json"],"files":[{"path":"registry/components/skeleton/skeleton.tsx","type":"registry:component","target":"@components/arc/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/arc/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   motion: a loading pulse is a loop, so it keeps its own duration. */\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://uiarc.dev/components/skeleton","meta":{"tier":"free","kind":"component","tags":["loading","status"],"docs":"https://uiarc.dev/components/skeleton","markdown":"https://uiarc.dev/components/skeleton/markdown"}}