{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"skeleton-loader","type":"registry:component","title":"Skeleton loader","description":"Holds the exact shape of what is coming, so nothing jumps.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":[],"registryDependencies":["utils"],"files":[{"path":"components/skeleton-loader.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\ntype Profile = {\n  initials: string;\n  name: string;\n  handle: string;\n  bio: string;\n  stat: string;\n  statLabel: string;\n  // Seven days of activity, 0 to 1.\n  week: number[];\n};\n\n// Enough gap between blocks to read as a sequence, short enough that the\n// last block lands only 80ms after the first.\nconst STAGGER = 40;\n\n// The highlight is barely lighter than the bone and crawls across it, then\n// rests for the last 30% of each loop so it reads as calm, not busy. It only\n// runs while the card is busy, so a loaded card costs nothing.\nconst CSS = `\n.skeleton-bone {\n  position: relative;\n  overflow: hidden;\n  background: color-mix(in oklab, var(--foreground) 7%, transparent);\n}\n[aria-busy=\"true\"] .skeleton-bone::after {\n  content: \"\";\n  position: absolute;\n  inset: 0;\n  background: linear-gradient(90deg, transparent, color-mix(in oklab, var(--foreground) 6%, transparent), transparent);\n  translate: -100% 0;\n  animation: skeleton-shimmer 1.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;\n}\n@keyframes skeleton-shimmer {\n  70%, 100% { translate: 100% 0; }\n}\n@media (prefers-reduced-motion: reduce) {\n  [aria-busy=\"true\"] .skeleton-bone::after { animation: none; display: none; }\n}\n`;\n\nexport function ProfileCard({\n  profile,\n  loading,\n  className,\n}: {\n  profile: Profile;\n  loading: boolean;\n  className?: string;\n}) {\n  const loaded = !loading;\n  return (\n    // 32px radius around 20px padding leaves 12px for the inner stat block.\n    <div\n      aria-busy={loading}\n      className={cn(\n        \"flex w-[400px] max-w-full flex-col gap-4 rounded-[32px] bg-muted p-5 shadow-raised\",\n        className,\n      )}\n    >\n      <style href=\"skeleton-loader\" precedence=\"default\">\n        {CSS}\n      </style>\n      {loading && <span className=\"sr-only\">Loading profile</span>}\n\n      {/* Every skeleton below is sized to the exact box its real content\n          fills: a 48px avatar, 24px and 20px line boxes with a shorter bar\n          centered in each (like the x-height of text), a 48px two-line\n          paragraph and a 72px stat block. Skeleton and content share one grid\n          cell, so if the two ever disagreed the card would jump when data\n          arrives. Matching them is the whole trick. */}\n      <Block\n        index={0}\n        loaded={loaded}\n        skeleton={\n          <div className=\"flex items-center gap-4\">\n            <div className=\"skeleton-bone size-12 shrink-0 rounded-full\" />\n            <div className=\"flex flex-col\">\n              <div className=\"flex h-6 items-center\">\n                <div className=\"skeleton-bone h-3.5 w-32 rounded-full\" />\n              </div>\n              <div className=\"flex h-5 items-center\">\n                <div className=\"skeleton-bone h-3 w-24 rounded-full\" />\n              </div>\n            </div>\n          </div>\n        }\n      >\n        <div className=\"flex items-center gap-4\">\n          <div className=\"flex size-12 shrink-0 items-center justify-center rounded-full bg-foreground/10 text-base font-medium text-foreground\">\n            {profile.initials}\n          </div>\n          <div className=\"flex min-w-0 flex-col\">\n            <p className=\"truncate text-base leading-6 font-medium text-foreground\">\n              {profile.name}\n            </p>\n            <p className=\"truncate text-sm leading-5 text-muted-foreground\">\n              {profile.handle}\n            </p>\n          </div>\n        </div>\n      </Block>\n\n      <Block\n        index={1}\n        loaded={loaded}\n        skeleton={\n          <div className=\"flex flex-col\">\n            <div className=\"flex h-6 items-center\">\n              <div className=\"skeleton-bone h-3.5 w-full rounded-full\" />\n            </div>\n            <div className=\"flex h-6 items-center\">\n              <div className=\"skeleton-bone h-3.5 w-2/3 rounded-full\" />\n            </div>\n          </div>\n        }\n      >\n        {/* Clamped to the two lines the skeleton promised. */}\n        <p className=\"line-clamp-2 h-12 text-sm leading-6 text-pretty text-foreground\">\n          {profile.bio}\n        </p>\n      </Block>\n\n      <Block\n        index={2}\n        loaded={loaded}\n        skeleton={<div className=\"skeleton-bone h-18 rounded-xl\" />}\n      >\n        <div className=\"flex h-18 items-center justify-between rounded-xl bg-background px-4\">\n          <div className=\"flex flex-col\">\n            <span className=\"text-xl leading-7 font-medium text-foreground tabular-nums\">\n              {profile.stat}\n            </span>\n            <span className=\"text-[13px] leading-5 text-muted-foreground\">\n              {profile.statLabel}\n            </span>\n          </div>\n          <div aria-hidden className=\"flex h-8 items-end gap-1.5\">\n            {profile.week.map((v, i) => (\n              <span\n                key={i}\n                className={cn(\n                  \"w-2 rounded-full\",\n                  i === profile.week.length - 1\n                    ? \"bg-foreground\"\n                    : \"bg-foreground/20\",\n                )}\n                style={{ height: `${Math.max(v, 0.15) * 100}%` }}\n              />\n            ))}\n          </div>\n        </div>\n      </Block>\n    </div>\n  );\n}\n\nfunction Block({\n  index,\n  loaded,\n  skeleton,\n  children,\n}: {\n  index: number;\n  loaded: boolean;\n  skeleton: React.ReactNode;\n  children: React.ReactNode;\n}) {\n  const delay = `${index * STAGGER}ms`;\n  return (\n    <div className=\"grid\">\n      {/* Fades out under the arriving content. Coming back on reload it\n          appears at once: the old content is already gone, and a slow fade\n          would leave a blank card for a beat. */}\n      <div\n        aria-hidden\n        className={cn(\n          \"col-start-1 row-start-1 transition-[opacity] ease-out\",\n          loaded ? \"opacity-0 duration-150\" : \"duration-0\",\n        )}\n        style={{ transitionDelay: loaded ? delay : \"0ms\" }}\n      >\n        {skeleton}\n      </div>\n      {/* Mounts with the data and transitions from its @starting-style, so\n          no effect or extra render is needed to trigger the entrance.\n          Reduced motion keeps only the opacity. */}\n      {loaded && (\n        <div\n          className=\"col-start-1 row-start-1 transition-[opacity,filter,translate] duration-[250ms] ease-[cubic-bezier(0.23,1,0.32,1)] starting:opacity-0 motion-safe:starting:translate-y-0.5 motion-safe:starting:blur-[4px]\"\n          style={{ transitionDelay: delay }}\n        >\n          {children}\n        </div>\n      )}\n    </div>\n  );\n}\n\nconst PROFILE: Profile = {\n  initials: \"AL\",\n  name: \"Ada Lindqvist\",\n  handle: \"@ada.builds\",\n  bio: \"Design engineer. Tuning springs and easing curves until the interface stops getting in the way.\",\n  stat: \"2,481\",\n  statLabel: \"Stars this week\",\n  week: [0.35, 0.5, 0.3, 0.65, 0.55, 0.8, 1],\n};\n\n// Long enough for the shimmer to make one full pass, short enough that the\n// demo never feels like it is actually waiting on something.\nconst LOAD_TIME = 1200;\n\nexport default function SkeletonLoaderDemo() {\n  const [loading, setLoading] = useState(true);\n  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);\n\n  useEffect(() => {\n    timer.current = setTimeout(() => setLoading(false), LOAD_TIME);\n    return () => clearTimeout(timer.current);\n  }, []);\n\n  const reload = () => {\n    setLoading(true);\n    clearTimeout(timer.current);\n    timer.current = setTimeout(() => setLoading(false), LOAD_TIME);\n  };\n\n  return (\n    <div className=\"flex flex-col items-center gap-4\">\n      <ProfileCard profile={PROFILE} loading={loading} />\n      <button\n        type=\"button\"\n        onClick={reload}\n        className=\"flex h-9 touch-manipulation items-center gap-1.5 rounded-full bg-muted pr-4 pl-3.5 text-sm font-medium text-foreground shadow-raised outline-hidden transition-[scale] duration-150 ease-out select-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:scale-[0.96] motion-reduce:transition-none\"\n      >\n        <svg\n          viewBox=\"0 0 16 16\"\n          className=\"size-4\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth={1.5}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          aria-hidden\n        >\n          <path d=\"M13.25 8a5.25 5.25 0 1 1-1.54-3.71\" />\n          <path d=\"M13.25 2.75v2.5h-2.5\" />\n        </svg>\n        Reload\n      </button>\n    </div>\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["feedback"],"docs":"From ui lab: https://lab.xevrion.dev/lab/skeleton-loader"}