{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"loader-set","type":"registry:component","title":"Loader set","description":"Four loaders that each know how to finish: leapfrog dots, an inchworm bar, a ring that closes into a check.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":[],"registryDependencies":["utils"],"files":[{"path":"components/loader-set.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n// Every loop is plain CSS on transform, opacity or clip-path, so it keeps\n// moving even while the main thread is busy loading whatever the user is\n// waiting for. Each loader also knows how to finish: pass `done` and it\n// resolves in its own way instead of just vanishing.\nconst CSS = `\n.loader-spin {\n  /* Linear because a spinner has no start or end; any easing would read as\n     a stutter once per turn. 700ms a turn is deliberately fast: a quicker\n     spinner makes the same wait feel shorter. */\n  animation: loader-spin 700ms linear infinite;\n}\n@keyframes loader-spin { to { rotate: 360deg; } }\n\n/* Leapfrog: the back dot hops over the other two while they shuffle along\n   underneath it. One 1.5s cycle is three moves; each dot runs the same loop a\n   third of a cycle apart, so there is always exactly one dot in the air. The\n   x and y halves live on separate elements, so the hop is a real arc rather\n   than a diagonal. */\n.loader-hop-x { animation: loader-hop-x 1.5s infinite; }\n.loader-hop-y { animation: loader-hop-y 1.5s infinite; }\n@keyframes loader-hop-x {\n  0% { translate: 0 0; animation-timing-function: cubic-bezier(0.77, 0, 0.175, 1); }\n  20%, 33.3% { translate: 14px 0; animation-timing-function: cubic-bezier(0.77, 0, 0.175, 1); }\n  53.3%, 66.7% { translate: 28px 0; animation-timing-function: cubic-bezier(0.77, 0, 0.175, 1); }\n  86.7%, 100% { translate: 0 0; }\n}\n@keyframes loader-hop-y {\n  0%, 66.7% { translate: 0 0; animation-timing-function: cubic-bezier(0.23, 1, 0.32, 1); }\n  76.7% { translate: 0 -9px; animation-timing-function: cubic-bezier(0.55, 0, 1, 0.45); }\n  86.7%, 100% { translate: 0 0; }\n}\n\n/* Inchworm: the head reaches ahead while the tail holds, then the tail\n   catches up and the pair slips off the far end. Clipping a full-width bar\n   lets the segment stretch without scaling its round ends. */\n.loader-worm { animation: loader-worm 1.4s infinite; }\n@keyframes loader-worm {\n  0% { clip-path: inset(0 100% 0 0 round 999px); animation-timing-function: cubic-bezier(0.23, 1, 0.32, 1); }\n  45% { clip-path: inset(0 30% 0 0 round 999px); animation-timing-function: cubic-bezier(0.77, 0, 0.175, 1); }\n  100% { clip-path: inset(0 0 0 100% round 999px); }\n}\n\n.loader-bone { position: relative; overflow: hidden; }\n.loader-bone::after {\n  content: \"\";\n  position: absolute;\n  inset: 0;\n  background: linear-gradient(90deg, transparent, color-mix(in oklab, var(--foreground) 8%, transparent), transparent);\n  translate: -100% 0;\n  /* Slow and eased, since a skeleton stands in for content and should sit\n     quietly behind it. The sweep ends at 70% and rests for the remaining\n     30%, so it reads as a gentle pass rather than a constant blur. */\n  animation: loader-shimmer 1.6s cubic-bezier(0.45, 0, 0.55, 1) infinite;\n}\n@keyframes loader-shimmer { 70%, 100% { translate: 100% 0; } }\n\n/* Reduced motion: nothing travels or turns, but everything still pulses in\n   opacity so the tile never looks finished or frozen. */\n@keyframes loader-pulse {\n  0%, 100% { opacity: 1; }\n  50% { opacity: 0.4; }\n}\n@media (prefers-reduced-motion: reduce) {\n  .loader-spin { animation: loader-pulse 1.6s ease-in-out infinite; }\n  .loader-hop-y { animation: none; }\n  .loader-hop-x { animation: loader-pulse 1.6s ease-in-out infinite; }\n  .loader-worm { animation: loader-pulse 1.6s ease-in-out infinite; clip-path: none; }\n  .loader-bone::after { display: none; }\n  .loader-bone { animation: loader-pulse 1.6s ease-in-out infinite; }\n}\n`;\n\nconst EASE_OUT = \"cubic-bezier(0.23, 1, 0.32, 1)\";\n// A finish is the payoff of the wait, so it gets a little longer than a\n// press: long enough to see the shape change, still under 300ms.\nconst FINISH_MS = 260;\n\nfunction Styles() {\n  return (\n    <style href=\"loader-set\" precedence=\"default\">\n      {CSS}\n    </style>\n  );\n}\n\ntype LoaderProps = {\n  label?: string;\n  /** Replaces `label` once `done` turns true. */\n  doneLabel?: string;\n  /** Plays the loader's own finish instead of looping. */\n  done?: boolean;\n  className?: string;\n};\n\nfunction Status({\n  label,\n  className,\n  children,\n}: {\n  label: string;\n  className?: string;\n  children: React.ReactNode;\n}) {\n  return (\n    <div role=\"status\" aria-label={label} className={className}>\n      <Styles />\n      <span className=\"sr-only\">{label}</span>\n      {children}\n    </div>\n  );\n}\n\n// Freezes each element at the frame its CSS loop has reached, then carries it\n// from there to `to`. Cancelling the loop outright would snap it back to its\n// resting position first.\nfunction settleFrom(\n  elements: (HTMLElement | null)[],\n  property: \"translate\" | \"clipPath\",\n  to: string,\n) {\n  const reduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n  const running: Animation[] = [];\n  for (const el of elements) {\n    if (!el) continue;\n    const style = getComputedStyle(el);\n    const from = property === \"translate\" ? style.translate : style.clipPath;\n    el.style.animation = \"none\";\n    running.push(\n      el.animate([{ [property]: from }, { [property]: to }], {\n        duration: reduce ? 0 : FINISH_MS,\n        easing: EASE_OUT,\n        fill: \"forwards\",\n      }),\n    );\n  }\n  return () => {\n    for (const a of running) a.cancel();\n    for (const el of elements) if (el) el.style.animation = \"\";\n  };\n}\n\n/** The arc closes into a full ring, then a check draws itself inside it. */\nexport function Spinner({\n  label = \"Loading\",\n  doneLabel = \"Loaded\",\n  done = false,\n  className,\n}: LoaderProps) {\n  return (\n    <Status\n      label={done ? doneLabel : label}\n      className={cn(\"grid text-foreground\", className)}\n    >\n      <svg\n        viewBox=\"0 0 24 24\"\n        className={cn(\n          \"loader-spin col-start-1 row-start-1 size-7\",\n          // A full ring looks the same at any angle, so the turn can stop\n          // the moment it starts closing.\n          done && \"[animation-play-state:paused]\",\n        )}\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={2.5}\n        aria-hidden\n      >\n        <circle cx=\"12\" cy=\"12\" r=\"9\" className=\"opacity-15\" />\n        {/* A quarter of the 56.5px circumference, rounded at both ends,\n            growing to the whole of it. */}\n        <circle\n          cx=\"12\"\n          cy=\"12\"\n          r=\"9\"\n          strokeLinecap=\"round\"\n          strokeDasharray={done ? \"56.5 56.5\" : \"14 56.5\"}\n          className=\"transition-[stroke-dasharray] duration-[260ms] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none\"\n        />\n      </svg>\n      <svg\n        viewBox=\"0 0 24 24\"\n        className=\"col-start-1 row-start-1 size-7 text-foreground\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={2.25}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        aria-hidden\n      >\n        {/* Starts drawing as the ring finishes closing, so the two read as\n            one gesture rather than two events. */}\n        <path\n          d=\"m8.25 12.25 2.5 2.5 5-5.5\"\n          pathLength={1}\n          strokeDasharray=\"1 1\"\n          strokeDashoffset={done ? 0 : 1}\n          className={cn(\n            \"transition-[stroke-dashoffset] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none\",\n            done ? \"delay-150 duration-[240ms]\" : \"duration-100\",\n          )}\n        />\n      </svg>\n    </Status>\n  );\n}\n\n/** Three dots playing leapfrog; finishing pulls them into one full stop. */\nexport function PulseDots({\n  label = \"Loading\",\n  doneLabel = \"Loaded\",\n  done = false,\n  className,\n}: LoaderProps) {\n  const xs = useRef<(HTMLSpanElement | null)[]>([]);\n  const ys = useRef<(HTMLSpanElement | null)[]>([]);\n\n  useLayoutEffect(() => {\n    if (!done) return;\n    // The middle slot, so the merged dot sits where the group was centered.\n    const undoX = settleFrom(xs.current, \"translate\", \"14px 0\");\n    const undoY = settleFrom(ys.current, \"translate\", \"0 0\");\n    return () => {\n      undoX();\n      undoY();\n    };\n  }, [done]);\n\n  return (\n    <Status\n      label={done ? doneLabel : label}\n      // 38px: three 10px dots on a 14px pitch.\n      className={cn(\"relative h-2.5 w-[38px]\", className)}\n    >\n      {[0, 1, 2].map((i) => (\n        <span\n          key={i}\n          aria-hidden\n          ref={(el) => {\n            xs.current[i] = el;\n          }}\n          // Each dot is a third of a cycle further along the same loop. The\n          // inline translate is its slot, which only shows when the loop is\n          // off (reduced motion).\n          style={{ animationDelay: `${-0.5 * i}s`, translate: `${14 * i}px 0` }}\n          className=\"loader-hop-x absolute top-0 left-0\"\n        >\n          <span\n            ref={(el) => {\n              ys.current[i] = el;\n            }}\n            style={{ animationDelay: `${-0.5 * i}s` }}\n            className=\"loader-hop-y block size-2.5 rounded-full bg-foreground\"\n          />\n        </span>\n      ))}\n    </Status>\n  );\n}\n\n/** An inchworm segment; finishing stretches it across the whole track. */\nexport function IndeterminateBar({\n  label = \"Loading\",\n  doneLabel = \"Loaded\",\n  done = false,\n  className,\n}: LoaderProps) {\n  const bar = useRef<HTMLSpanElement>(null);\n\n  useLayoutEffect(() => {\n    if (!done) return;\n    return settleFrom([bar.current], \"clipPath\", \"inset(0 0 0 0 round 999px)\");\n  }, [done]);\n\n  return (\n    <Status\n      label={done ? doneLabel : label}\n      className={cn(\"h-1 w-24 overflow-hidden rounded-full bg-foreground/10\", className)}\n    >\n      <span ref={bar} aria-hidden className=\"loader-worm block h-full w-full bg-foreground\" />\n    </Status>\n  );\n}\n\n/** Bones that shimmer, then cross-dissolve into the content they held. */\nexport function SkeletonLines({\n  label = \"Loading\",\n  doneLabel = \"Loaded\",\n  done = false,\n  title = \"Report.pdf\",\n  detail = \"2.4 MB\",\n  className,\n}: LoaderProps & { title?: string; detail?: string }) {\n  // Each bone sits on the line box its text will occupy, so the dissolve\n  // swaps shapes in place instead of shifting anything.\n  const bone = cn(\n    \"loader-bone col-start-1 row-start-1 self-center rounded-full bg-foreground/10 transition-[opacity,filter] duration-150 ease-out motion-reduce:transition-[opacity]\",\n    done && \"opacity-0 blur-[4px]\",\n  );\n  const text = cn(\n    \"col-start-1 row-start-1 truncate transition-[opacity,filter,translate] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-[opacity]\",\n    done\n      ? \"translate-y-0 opacity-100 blur-[0px] duration-[260ms]\"\n      : \"translate-y-0.5 opacity-0 blur-[4px] duration-150 motion-reduce:translate-y-0 motion-reduce:blur-[0px]\",\n  );\n\n  return (\n    <Status label={done ? doneLabel : label} className={cn(\"flex w-24 flex-col gap-1\", className)}>\n      <span className=\"grid h-5\">\n        <span aria-hidden className={cn(bone, \"h-2.5 w-full\")} />\n        <span aria-hidden={!done} className={cn(text, \"text-sm font-medium text-foreground\")}>\n          {title}\n        </span>\n      </span>\n      <span className=\"grid h-4\">\n        <span aria-hidden className={cn(bone, \"h-2.5 w-2/3\")} />\n        {/* Trails the title by 50ms: the lines resolve top to bottom, the\n            order they are read in. */}\n        <span\n          aria-hidden={!done}\n          className={cn(text, \"text-xs text-muted-foreground\", done && \"delay-50\")}\n        >\n          {detail}\n        </span>\n      </span>\n    </Status>\n  );\n}\n\nconst TILES = [\n  { name: \"Spinner\", Loader: Spinner },\n  { name: \"Dots\", Loader: PulseDots },\n  { name: \"Progress\", Loader: IndeterminateBar },\n  { name: \"Skeleton\", Loader: SkeletonLines },\n];\n\n// Long enough to take the finish in, then the loader starts over.\nconst REPLAY_AFTER = 1600;\n\nfunction Tile({ name, Loader }: (typeof TILES)[number]) {\n  const [done, setDone] = useState(false);\n  // A fresh key remounts the loader, so every replay starts from frame one.\n  const [run, setRun] = useState(0);\n\n  useEffect(() => {\n    if (!done) return;\n    const timer = setTimeout(() => {\n      setDone(false);\n      setRun((r) => r + 1);\n    }, REPLAY_AFTER);\n    return () => clearTimeout(timer);\n  }, [done]);\n\n  return (\n    <li className=\"relative flex h-36 flex-col items-center rounded-2xl bg-muted transition-[background-color] duration-150 ease-out has-[button:hover]:bg-border/60\">\n      {/* Blurs in on each replay rather than popping back into its loop. */}\n      <div\n        key={run}\n        className=\"flex flex-1 items-center justify-center transition-[opacity,filter] duration-200 ease-out starting:opacity-0 starting:blur-[4px]\"\n      >\n        <Loader\n          label={`Loading, ${name.toLowerCase()}`}\n          doneLabel={`${name} finished`}\n          done={done}\n        />\n      </div>\n      <span className=\"pb-4 text-sm text-muted-foreground\">{name}</span>\n      {/* Laid over the whole tile but outside the status, so the live region\n          is never swallowed by a button's presentational children. */}\n      <button\n        type=\"button\"\n        aria-label={`Finish ${name.toLowerCase()}`}\n        aria-disabled={done}\n        onClick={() => {\n          if (!done) setDone(true);\n        }}\n        className=\"absolute inset-0 rounded-2xl outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground aria-disabled:cursor-default\"\n      />\n    </li>\n  );\n}\n\nexport function LoaderSet({ className }: { className?: string }) {\n  return (\n    // Two by two on a phone, one row once there's room for four tiles.\n    <div\n      className={cn(\n        \"@container flex w-[min(560px,100%)] flex-col items-center gap-3\",\n        className,\n      )}\n    >\n      <ul className=\"grid w-full grid-cols-2 gap-3 @md:grid-cols-4\">\n        {TILES.map((tile) => (\n          <Tile key={tile.name} {...tile} />\n        ))}\n      </ul>\n      <p className=\"text-[13px] text-muted-foreground\">Click a tile to see how it finishes</p>\n    </div>\n  );\n}\n\nexport default function LoaderSetDemo() {\n  return <LoaderSet />;\n}\n"}],"categories":["feedback"],"docs":"From ui lab: https://lab.xevrion.dev/lab/loader-set"}