{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"skeleton-morph","type":"registry:ui","title":"Skeleton morph","description":"Loading placeholders that turn into the real content one block at a time instead of being swapped out. The placeholders are the content's own boxes, so nothing moves when the data arrives.","dependencies":["clsx","tailwind-merge"],"registryDependencies":["https://caliperui.site/r/styles.json"],"files":[{"path":"ui/lib/cn.ts","type":"registry:ui","target":"components/ui/lib/cn.ts","content":"import { clsx, type ClassValue } from \"clsx\";\nimport { extendTailwindMerge } from \"tailwind-merge\";\n\nconst twMerge = extendTailwindMerge({\n  extend: { theme: { radius: [\"control\", \"surface\", \"overlay\"], shadow: [\"overlay\"] } },\n});\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"},{"path":"ui/skeleton-morph.tsx","type":"registry:ui","target":"components/ui/skeleton-morph.tsx","content":"\"use client\";\nimport { useLayoutEffect, useRef, useState, type ComponentProps, type CSSProperties } from \"react\";\nimport { cn } from \"./lib/cn\";\nimport \"./skeleton-morph.css\";\n\nexport type SkeletonMorphProps = ComponentProps<\"div\"> & {\n  /** While true, every element marked `data-bone` inside shows as a placeholder of its own size. */\n  loading: boolean;\n  /** Milliseconds between one block arriving and the next. */\n  stagger?: number;\n};\n\n/**\n * Loading placeholders that turn into the real content block by block instead of swapping. Render the real layout\n * with stand-in text while loading and mark each block with `data-bone`; each bone has the block's own size, so\n * nothing moves when the data arrives, and the blocks fill in from first to last. `data-bone=\"round\"` keeps a circle\n * (an avatar) round while it is a placeholder. The region is busy while loading.\n * Under reduced motion every block arrives at once.\n */\nexport function SkeletonMorph({ loading, stagger = 70, className, style, children, ...props }: SkeletonMorphProps) {\n  const root = useRef<HTMLDivElement>(null);\n  // Content that was never a placeholder shows as it is; only content that replaces bones plays the arrival.\n  const [seen, setSeen] = useState({ loading, arrived: false });\n  if (seen.loading !== loading) setSeen({ loading, arrived: seen.arrived || !loading });\n\n  // Order follows the document, so the blocks arrive top to bottom however the children are composed.\n  useLayoutEffect(() => {\n    root.current?.querySelectorAll<HTMLElement>(\"[data-bone]\").forEach((bone, index) => bone.style.setProperty(\"--sm-i\", String(index)));\n  });\n\n  return (\n    <div\n      ref={root}\n      data-slot=\"skeleton-morph\"\n      data-loading={loading ? \"\" : undefined}\n      data-arrived={seen.arrived ? \"\" : undefined}\n      aria-busy={loading || undefined}\n      className={cn(className)}\n      style={{ \"--sm-stagger\": `${stagger}ms`, ...style } as CSSProperties}\n      {...props}\n    >\n      {loading && <span className=\"sr-only\">Loading</span>}\n      {/* The stand-in text is not read out or reachable while it is a placeholder. */}\n      <div data-slot=\"sm-content\" aria-hidden={loading || undefined} inert={loading} className=\"contents\">\n        {children}\n      </div>\n    </div>\n  );\n}\n"},{"path":"ui/skeleton-morph.css","type":"registry:file","target":"components/ui/skeleton-morph.css","content":"/* skeleton-morph.tsx's styles; it imports this file. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  /* Skeleton morph: bones are the content's own boxes; arriving content fills each one in turn. */\n  [data-slot=\"skeleton-morph\"][data-loading] [data-bone] { color: transparent; background-color: var(--muted); background-image: linear-gradient(90deg, transparent 30%, color-mix(in oklab, var(--background) 55%, transparent) 50%, transparent 70%); background-size: 240% 100%; border-radius: 4px; box-decoration-break: clone; -webkit-box-decoration-break: clone; animation: sm-shimmer 1.6s linear infinite; }\n  [data-slot=\"skeleton-morph\"][data-loading] [data-bone=\"round\"] { border-radius: 50%; }\n  [data-slot=\"skeleton-morph\"][data-loading] [data-bone] > * { opacity: 0; }\n  [data-slot=\"skeleton-morph\"][data-arrived]:not([data-loading]) [data-bone] { animation: sm-fill 0.5s var(--ease-out) calc(var(--sm-i, 0) * var(--sm-stagger)) backwards; }\n  [data-slot=\"skeleton-morph\"][data-arrived]:not([data-loading]) [data-bone] > * { animation: sm-rise 0.5s var(--ease-out) calc(var(--sm-i, 0) * var(--sm-stagger) + 60ms) backwards; }\n  @media (prefers-reduced-motion: reduce) {\n    [data-slot=\"skeleton-morph\"] [data-bone], [data-slot=\"skeleton-morph\"] [data-bone] > * { animation: none; }\n  }\n  @keyframes sm-shimmer { from { background-position: 120% 0; } to { background-position: -120% 0; } }\n  @keyframes sm-fill { from { color: transparent; background-color: var(--muted); } }\n  @keyframes sm-rise { from { opacity: 0; scale: 0.98; } }\n}\n"}],"categories":["motion"],"meta":{"tier":"free","license":"MIT","family":"surfaces","docs":"/docs/components/skeleton-morph"}}