{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"skeleton","type":"registry:ui","title":"Skeleton","description":"Placeholder bones — a block, one or more text lines, or a circular avatar — with a phase-synced shimmer sweep or opacity pulse, that swaps to real content once loading finishes while keeping aria-busy semantics correct throughout","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["feedback"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/internals-motion.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/skeleton/Skeleton.tsx","content":"\"use client\";\n\nimport { forwardRef, useEffect, useState } from \"react\";\nimport type { CSSProperties, HTMLAttributes, ReactNode } from \"react\";\n\nimport { cn } from \"../../utils\";\nimport { preset } from \"../../internals/motion/transitions\";\nimport { prefersReducedMotion } from \"../../internals/motion/anchored\";\nimport { DURATIONS, JS_EASINGS } from \"../../internals/motion/tokens\";\nimport { usePresence } from \"../../internals/motion/presence\";\nimport \"./skeleton.css\";\n\n/**\n * Props for Skeleton.\n */\nexport interface SkeletonProps\n\textends Omit<HTMLAttributes<HTMLDivElement>, \"children\" | \"className\"> {\n\t/** Bone shape: a block, one or more text lines, or a circular avatar */\n\tvariant?: \"rect\" | \"text\" | \"circle\";\n\t/** Number of text lines to render; only read when `variant=\"text\"`. The\n\t * last line renders at 60% width so a paragraph placeholder doesn't read\n\t * as a perfect rectangle. */\n\tlines?: number;\n\t/** Shimmer sweep, opacity pulse, or a static muted bone (still a valid\n\t * loading cue on its own). */\n\tanimation?: \"shimmer\" | \"pulse\" | \"none\";\n\t/** Whether the placeholder is currently showing. In wrapping mode\n\t * (`children` supplied) this drives the swap to real content; in\n\t * standalone mode it drives whether anything renders at all. */\n\tloading?: boolean;\n\t/** The one screen-reader announcement. Pass `\"\"` to silence it entirely. */\n\tlabel?: string;\n\t/** Real content to reveal once `loading` is false. Its mere presence (not\n\t * its value) is what switches Skeleton from standalone to wrapping mode —\n\t * see the README for the two ARIA shapes that follow from that. */\n\tchildren?: ReactNode;\n\t/** Additional CSS classes. Also the usual sizing hook: `rect`/`text` bones\n\t * have no intrinsic size, so a caller sizes them with `className=\"h-4 w-40\"`\n\t * or similar. */\n\tclassName?: string;\n}\n\n// Opacity only, `DURATIONS.exit` (200ms) on `JS_EASINGS.in` — a departure\n// curve for a departing layer (params below).\nconst bonesFade = preset(\"fade\");\n\n/**\n * The forwarded ref is `null` whenever nothing is rendered (standalone mode\n * with `loading=false`), matching the rest of the library's \"ref is null\n * while there's no root to bind\" convention (see Presence).\n */\nexport const Skeleton = forwardRef<HTMLDivElement, SkeletonProps>(function Skeleton(\n\t{\n\t\tvariant = \"rect\",\n\t\tlines = 1,\n\t\tanimation = \"shimmer\",\n\t\tloading = true,\n\t\tlabel = \"Loading\",\n\t\tchildren,\n\t\tclassName,\n\t\tstyle,\n\t\t...restProps\n\t},\n\tforwardedRef\n) {\n\t// Presence of `children` — not its current value — is what decides the\n\t// mode. A caller that always passes `children` (even while it renders\n\t// nothing meaningful yet) is asking for wrapping mode's ARIA shape, which\n\t// is the point: the two shapes differ in what happens once `loading` goes\n\t// false, and only wrapping mode has something to swap to.\n\tconst wrapping = children !== undefined;\n\n\t// `Math.floor` alone would let a non-finite `lines` (NaN, ±Infinity) leak\n\t// through `Math.max` unclamped (`Math.max(1, NaN)` is `NaN`, and\n\t// `Array.from({ length: NaN })` is `[]` — a caller-supplied bad value would\n\t// silently render zero bones). `Number.isFinite` catches that before the\n\t// clamp instead.\n\tlet lineCount = 1;\n\tif (variant === \"text\") {\n\t\tconst n = Math.floor(lines);\n\t\tlineCount = Number.isFinite(n) ? Math.max(1, n) : 1;\n\t}\n\tconst bones = Array.from({ length: lineCount }, (_, i) => i);\n\n\t// The reveal. Bones do not cut to content, they fade out ON TOP of it:\n\t// the content lands in its final, unwrapped position on the first frame\n\t// and never moves, while the outgoing bones become an out-of-flow overlay\n\t// the content sizes for them. That answers \"what sizes the slot while both\n\t// layers are mounted\" with \"the content does\", and it is why there is no\n\t// layer wrapper here — the root IS the eventual content container (see the\n\t// comment on the wrapping branch below), so introducing one, even only for\n\t// the length of the fade, would move the caller's children in the tree\n\t// and make React destroy and recreate them: focus, scroll position and\n\t// media playback lost, in exchange for a cross-fade nobody asked for.\n\t//\n\t// `bonesLingering` deliberately LAGS `loading` by one update. The instant\n\t// `loading` flips false the content branch renders while this is still\n\t// true, so the overlay mounts already sitting over the real content; the\n\t// effect then writes it false, and THAT is what starts the fade.\n\t//\n\t// Seeded `false`, never `true`: a Skeleton that mounts with\n\t// `loading={false}` has nothing to reveal and must not flash a set of\n\t// bones over content that was never hidden. Mounting the other way round\n\t// costs nothing — while `loading` is true the bones are in flow and the\n\t// overlay is not rendered at all, and the effect below has already armed\n\t// this by the time the first reveal can happen.\n\tconst [bonesLingering, setBonesLingering] = useState(false);\n\tuseEffect(() => {\n\t\tsetBonesLingering(loading);\n\t}, [loading]);\n\n\t// The overlay's mount clock. The Svelte source expresses the fade as an\n\t// exit-only transition on a closing block; here `usePresence` owns the\n\t// same window: the overlay opens the instant `loading` flips false (while\n\t// `bonesLingering` still lags true), and the lag effect's write is what\n\t// starts the exit. The ENTER leg runs at duration 0 — an exit-only\n\t// transition has no intro, so the overlay must appear already at rest,\n\t// full opacity, exactly as a freshly mounted block does. The exit reads\n\t// `prefersReducedMotion()` inside the params factory, which `usePresence`\n\t// calls at the instant the leg starts: the preference is read then, never\n\t// at construction and never during SSR. `duration: 0` finishes the leg\n\t// synchronously without ever touching `element.animate()`, so the overlay\n\t// is gone in the same flush it mounted — exactly the instant swap this\n\t// component had before the reveal existed.\n\tconst overlayOpen = !loading && bonesLingering;\n\tconst overlayPresence = usePresence(overlayOpen);\n\tconst overlayRef = overlayPresence.register(bonesFade, (entering) =>\n\t\tentering\n\t\t\t? { duration: 0 }\n\t\t\t: {\n\t\t\t\t\tduration: prefersReducedMotion() ? 0 : DURATIONS.exit,\n\t\t\t\t\teasing: JS_EASINGS.in,\n\t\t\t\t}\n\t);\n\n\t// Page-wide shimmer phase sync. Every Skeleton instance on the page reads\n\t// the SAME shared monotonic timeline, so instances that mount at different\n\t// wall-clock moments (e.g. a list revealing rows progressively) still\n\t// converge on one shimmer phase instead of each starting its own visibly\n\t// drifting 1.6s loop. `document.timeline` is a Level-2 Web Animations API\n\t// surface — not universal (Safari support lagged historically) and absent\n\t// in jsdom — so this degrades to \"every instance loops unsynced from 0%,\n\t// still animates correctly\" whenever it's unavailable. Never a hard\n\t// requirement, just a nicety where supported.\n\t//\n\t// Seeded `null` and written from an effect, never read during render:\n\t// nothing may differ between a server render and its hydration (C-7).\n\tconst [phaseValue, setPhaseValue] = useState<string | null>(null);\n\tuseEffect(() => {\n\t\tif (typeof document === \"undefined\" || typeof document.timeline?.currentTime !== \"number\") {\n\t\t\tsetPhaseValue(null);\n\t\t\treturn;\n\t\t}\n\t\t// Mirrors the CSS literal fallback for --ft-skeleton-duration (1.6s).\n\t\t// Nothing enforces the two stay equal; update both by hand if the CSS\n\t\t// default ever changes.\n\t\tconst durationMs = 1600;\n\t\tconst phase = -(Number(document.timeline.currentTime) % durationMs);\n\t\tsetPhaseValue(`${phase}ms`);\n\t}, []);\n\n\t// Merged INTO the caller's own `style` rather than rendered as a separate\n\t// attribute, so a caller-supplied `style` and the internal phase custom\n\t// property coexist — the same reason the Svelte source writes the phase\n\t// through a `style:` directive rather than a raw attribute.\n\tconst mergedStyle =\n\t\tphaseValue === null\n\t\t\t? style\n\t\t\t: ({ ...style, \"--ft-skeleton-phase\": phaseValue } as CSSProperties);\n\n\tfunction boneClass(index: number): string {\n\t\treturn cn(\n\t\t\t\"ft-skeleton-bone\",\n\t\t\tindex === lineCount - 1 && lineCount > 1 && \"ft-skeleton-bone--short\"\n\t\t);\n\t}\n\n\tconst bonesList = bones.map((i) => (\n\t\t<div key={i} className={boneClass(i)} aria-hidden=\"true\"></div>\n\t));\n\n\tif (wrapping) {\n\t\t// Wrapping mode: the root IS the eventual content container, so it never\n\t\t// carries role=\"status\" itself — once loading flips false the real content\n\t\t// takes over this exact node, and a live region that outlives its own\n\t\t// announcement would be wrong. aria-busy mirrors Button's own semantics\n\t\t// (a machine-readable \"still working\" flag, independent of any visual\n\t\t// dimming).\n\t\t//\n\t\t// The sr-only status span outlives the bones on purpose: a live region\n\t\t// that is INSERTED already populated is announced unreliably (assistive\n\t\t// tech registers the region first, then reports changes to it), and\n\t\t// `loading` false → true is a normal reuse flow here — a refetch on an\n\t\t// already-rendered wrapper. So the region is mounted for as long as the\n\t\t// component is, and only its TEXT changes; emptied rather than removed\n\t\t// once `children` takes over, so nothing lingers to re-announce.\n\t\treturn (\n\t\t\t<div\n\t\t\t\tref={forwardedRef}\n\t\t\t\tclassName={cn(\"ft-skeleton\", className)}\n\t\t\t\t{...restProps}\n\t\t\t\tstyle={mergedStyle}\n\t\t\t\taria-busy={loading ? \"true\" : undefined}\n\t\t\t\tdata-variant={variant}\n\t\t\t\tdata-animation={animation}\n\t\t\t\tdata-loading={loading ? \"true\" : undefined}\n\t\t\t>\n\t\t\t\t{loading ? (\n\t\t\t\t\tbonesList\n\t\t\t\t) : (\n\t\t\t\t\t<>\n\t\t\t\t\t\t{/*\n\t\t\t\t\t\t\tThe bones on their way out, `aria-hidden` and out of flow. They\n\t\t\t\t\t\t\tare NOT the live region: `role=\"status\"` lives and dies with\n\t\t\t\t\t\t\tthe in-flow bones above, so the announcement never outlives\n\t\t\t\t\t\t\titself by lingering into the fade. `pointer-events: none` (see\n\t\t\t\t\t\t\tthe stylesheet) is what makes the revealed content clickable\n\t\t\t\t\t\t\tfrom frame one despite something still being painted over it.\n\t\t\t\t\t\t*/}\n\t\t\t\t\t\t{overlayPresence.mounted && (\n\t\t\t\t\t\t\t<div ref={overlayRef} className=\"ft-skeleton-bones-out\" aria-hidden=\"true\">\n\t\t\t\t\t\t\t\t{bonesList}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t{children}\n\t\t\t\t\t</>\n\t\t\t\t)}\n\t\t\t\t{label !== \"\" && (\n\t\t\t\t\t<span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n\t\t\t\t\t\t{loading ? label : \"\"}\n\t\t\t\t\t</span>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t);\n\t}\n\n\tif (!loading) {\n\t\t// Standalone mode with nothing to swap to: renders nothing at all\n\t\t// rather than leaving inert, unannounced bones sitting in the DOM,\n\t\t// and the forwarded ref is null while there's no root to bind.\n\t\treturn null;\n\t}\n\n\t// Standalone mode: Skeleton IS the loading indicator (a list seeded with\n\t// placeholder rows before data arrives), so the root itself announces —\n\t// copying PixelLoader/TypingIndicator's own role=\"status\" placement\n\t// exactly. There is no `children` to swap to here, so `loading=false` with\n\t// nothing supplied renders nothing at all (above).\n\t//\n\t// That is also why standalone mode cannot keep its live region mounted the\n\t// way wrapping mode does: with nothing rendered at all there is no node\n\t// left to host one, and the \"renders nothing / ref is null\" contract\n\t// (shared with Presence) outranks it. A caller who toggles `loading` back\n\t// and forth on a persistent node wants wrapping mode.\n\treturn (\n\t\t<div\n\t\t\tref={forwardedRef}\n\t\t\tclassName={cn(\"ft-skeleton\", className)}\n\t\t\t{...restProps}\n\t\t\tstyle={mergedStyle}\n\t\t\trole=\"status\"\n\t\t\taria-live=\"polite\"\n\t\t\tdata-variant={variant}\n\t\t\tdata-animation={animation}\n\t\t\tdata-loading={loading ? \"true\" : undefined}\n\t\t>\n\t\t\t{bonesList}\n\t\t\t{label !== \"\" && <span className=\"sr-only\">{label}</span>}\n\t\t</div>\n\t);\n});\n","type":"registry:ui","target":"@ui/fancy/components/skeleton/Skeleton.tsx"},{"path":"react/src/components/skeleton/index.ts","content":"\"use client\";\n\nexport { Skeleton } from \"./Skeleton\";\nexport type { SkeletonProps } from \"./Skeleton\";\n","type":"registry:ui","target":"@ui/fancy/components/skeleton/index.ts"},{"path":"react/src/components/skeleton/skeleton.css","content":"/*\n * Ported verbatim from the Svelte source's <style> block. The compiler scoped\n * those rules; plain CSS is global, so the `[data-variant]`/`[data-animation]`\n * selectors are anchored on the component's root class (`.ft-skeleton`, which\n * is where those data attributes live) — the anchoring is port-added, the\n * declarations are not.\n */\n\n.ft-skeleton {\n\tdisplay: block;\n\t/* NEW, and a named layout-property exception: the containing block for\n\t   the outgoing bones overlay. `display: block` is unchanged; this\n\t   changes nothing for a consumer unless they were relying on the\n\t   skeleton root NOT being a containing block for an absolutely\n\t   positioned descendant of their own content. */\n\tposition: relative;\n}\n\n/* The outgoing bones, for the length of the fade only. Out of flow, so the\n   content underneath is what sizes the root and the container never\n   overshoots and settles; `inset: 0` then hands the bones exactly the\n   content's own box to fade out over. Not interactive for a single frame\n   of it — the revealed content is clickable from the moment it lands. */\n.ft-skeleton .ft-skeleton-bones-out {\n\tposition: absolute;\n\tinset: 0;\n\tpointer-events: none;\n}\n\n.ft-skeleton .ft-skeleton-bone {\n\tposition: relative;\n\tdisplay: block;\n\toverflow: hidden;\n\tbackground-color: var(\n\t\t--ft-skeleton-base,\n\t\tlight-dark(oklch(0.93 0 0), oklch(0.32 0 0))\n\t); /* tokens.* n/a: component-owned */\n\tborder-radius: var(--ft-skeleton-radius, 0.375rem); /* tokens.* n/a: component-owned */\n}\n\n/* `rect` is the DEFAULT variant and has no other height rule anywhere — a\n   `display: block` bone with no content is otherwise 0px tall. `100%`\n   resolves against the root the consumer already sizes (`class=\"h-4\"`);\n   `min-height` is the same 0.85em floor `text` gets, for the case the root\n   itself has no explicit height (an auto-height wrapper in wrapping mode)\n   so a bone is never literally invisible. Deliberately not applied to\n   `circle`, which sizes itself via `aspect-ratio` instead. */\n.ft-skeleton[data-variant=\"rect\"] .ft-skeleton-bone {\n\theight: 100%;\n\tmin-height: 0.85em;\n}\n\n.ft-skeleton[data-variant=\"text\"] .ft-skeleton-bone {\n\tborder-radius: 0.25rem;\n\theight: 0.85em;\n}\n/* Line spacing isn't part of the frozen contract; without it, stacked text\n   bones would visually touch. Kept minimal and relative to font size so it\n   scales with whatever text size the caller's `class` sets. */\n.ft-skeleton[data-variant=\"text\"] .ft-skeleton-bone + .ft-skeleton-bone {\n\tmargin-top: 0.5em;\n}\n.ft-skeleton .ft-skeleton-bone--short {\n\twidth: 60%;\n}\n\n.ft-skeleton[data-variant=\"circle\"] .ft-skeleton-bone {\n\tborder-radius: 9999px;\n\taspect-ratio: 1 / 1;\n}\n\n/*\n * Shimmer is a translated ::after overlay — transform only, per the\n * family's \"opacity/transform animate, nothing else\" rule. Animating\n * `background-position` (ThinkingIndicator's own text-shimmer technique)\n * would violate that rule for a plain background property, so the sweep\n * here is a separate absolutely-positioned layer that only ever moves via\n * `translateX`.\n */\n.ft-skeleton[data-animation=\"shimmer\"] .ft-skeleton-bone::after {\n\tcontent: \"\";\n\tposition: absolute;\n\tinset: 0;\n\ttransform: translateX(-100%);\n\tbackground-image: linear-gradient(\n\t\t90deg,\n\t\ttransparent,\n\t\tvar(--ft-skeleton-highlight, color-mix(in oklab, currentColor 12%, transparent)),\n\t\ttransparent\n\t);\n}\n\n/* :dir(rtl) reverses the sweep by playing the SAME keyframe backward rather\n   than authoring a mirrored one — half the CSS, same visual result (the\n   highlight still starts at the reading-direction's leading edge). Kept as\n   a plain custom-property override on the root (matching ScrollProgress's\n   own `:dir(rtl)` rule) rather than chained onto the `::after` selector\n   itself, matching the Svelte source. */\n.ft-skeleton:dir(rtl) {\n\t--ft-skeleton-shimmer-direction: reverse;\n}\n\n@media (prefers-reduced-motion: no-preference) {\n\t.ft-skeleton[data-animation=\"shimmer\"] .ft-skeleton-bone::after {\n\t\tanimation: ft-skeleton-shimmer var(--ft-skeleton-duration, 1.6s) linear infinite; /* tokens.* n/a: component-owned */\n\t\tanimation-delay: var(--ft-skeleton-phase, 0s);\n\t\tanimation-direction: var(--ft-skeleton-shimmer-direction, normal);\n\t}\n\t.ft-skeleton[data-animation=\"pulse\"] .ft-skeleton-bone {\n\t\tanimation: ft-skeleton-pulse var(--ft-skeleton-duration, 1.6s) ease-in-out infinite; /* tokens.* n/a: a symmetric breathe wants a symmetric curve, not the asymmetric --ft-ease-inout */\n\t}\n}\n\n@keyframes ft-skeleton-shimmer {\n\tfrom {\n\t\ttransform: translateX(-100%);\n\t}\n\tto {\n\t\ttransform: translateX(100%);\n\t}\n}\n\n@keyframes ft-skeleton-pulse {\n\t0%,\n\t100% {\n\t\topacity: 1;\n\t}\n\t50% {\n\t\topacity: 0.6;\n\t}\n}\n\n/* A forced-colors palette (Windows High Contrast) replaces `background-color`\n   wholesale — the bone's only visual — so without this every bone repaints\n   to `Canvas` and vanishes against the page (the shimmer/pulse are forced\n   too). The sr-only label still announces, but the visible placeholder is\n   the entire point of a skeleton, so an outline stands in for the bone. */\n@media (forced-colors: active) {\n\t.ft-skeleton .ft-skeleton-bone {\n\t\toutline: 1px solid CanvasText;\n\t\toutline-offset: -1px;\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/skeleton/skeleton.css"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/skeleton?framework=react"}}