{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"spinner","title":"Spinner","description":"Indeterminate loading status with seven visual variants and reduced-motion behavior.","dependencies":["lucide-react@1.18.0"],"registryDependencies":["@siteplane/base"],"files":[{"path":"packages/ui/src/components/spinner/spinner.tsx","content":"import { Loader2Icon } from \"lucide-react\";\nimport type React from \"react\";\n\nimport { cn } from \"../../system/utils/cn\";\nimport { slotClass } from \"../../system/utils/slot-class\";\n\nimport \"./spinner.css\";\n\nexport type SpinnerVariant =\n  \"default\" | \"ring\" | \"dots\" | \"bars\" | \"spokes\" | \"pulse\" | \"comet\";\n\n/** The variants that draw an svg; the others are a div around their shapes. */\ntype SpinnerSvgVariant = \"default\" | \"comet\";\n\n/**\n * The attributes and the ref of the element the variant renders: an svg for\n * `default` and `comet`, a div for the others.\n */\nexport type SpinnerProps<V extends SpinnerVariant = SpinnerVariant> =\n  (V extends SpinnerSvgVariant\n    ? React.ComponentProps<\"svg\">\n    : React.ComponentProps<\"div\">) & {\n    variant?: V;\n  };\n\nexport function Spinner<V extends SpinnerVariant = \"default\">(\n  allProps: SpinnerProps<V>,\n): React.ReactElement {\n  // The variants share one body; each renders its own element type, which the\n  // props above already say, so the rest props are read loosely here.\n  const {\n    className,\n    variant = \"default\",\n    ...props\n  } = allProps as React.ComponentProps<\"div\"> & { variant?: SpinnerVariant };\n  if (variant === \"default\") {\n    return (\n      <Loader2Icon\n        aria-label=\"Loading\"\n        // 16 px like the other variants. Width and height, not a size-*\n        // class, so parents that size unsized icons (Button) still apply.\n        className={cn(\n          slotClass(\"spinner\", props),\n          \"sp-spinner-default sp-spinner-motion h-4 w-4\",\n          className,\n        )}\n        data-slot=\"spinner\"\n        data-variant=\"default\"\n        role=\"status\"\n        {...(props as React.ComponentProps<typeof Loader2Icon>)}\n      />\n    );\n  }\n\n  if (variant === \"comet\") {\n    return (\n      <svg\n        aria-label=\"Loading\"\n        className={cn(\n          slotClass(\"spinner\", props),\n          \"sp-spinner-motion size-4\",\n          className,\n        )}\n        data-slot=\"spinner\"\n        data-variant=\"comet\"\n        fill=\"none\"\n        role=\"status\"\n        viewBox=\"0 0 50 50\"\n        {...(props as React.SVGProps<SVGSVGElement>)}\n      >\n        <circle\n          cx=\"25\"\n          cy=\"25\"\n          fill=\"none\"\n          r=\"20\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeWidth=\"4\"\n        />\n      </svg>\n    );\n  }\n\n  const shared = {\n    \"aria-label\": \"Loading\",\n    \"data-slot\": \"spinner\",\n    \"data-variant\": variant,\n    role: \"status\" as const,\n    ...props,\n  };\n\n  if (variant === \"ring\") {\n    return (\n      <div\n        className={cn(\n          slotClass(\"spinner\", props),\n          \"sp-spinner-motion inline-block size-4 rounded-full border-2 border-current/25 border-t-current\",\n          className,\n        )}\n        {...shared}\n      />\n    );\n  }\n\n  if (variant === \"pulse\") {\n    return (\n      <div\n        className={cn(\n          slotClass(\"spinner\", props),\n          \"sp-spinner-motion relative inline-grid size-4 place-items-center\",\n          className,\n        )}\n        {...shared}\n      >\n        <span className=\"absolute inline-flex size-full rounded-full bg-current opacity-60\" />\n        <span className=\"relative inline-flex size-1/2 rounded-full bg-current\" />\n      </div>\n    );\n  }\n\n  if (variant === \"dots\") {\n    return (\n      <div\n        className={cn(\n          slotClass(\"spinner\", props),\n          \"sp-spinner-motion inline-flex items-center gap-1\",\n          className,\n        )}\n        {...shared}\n      >\n        {/* Their delays are the stylesheet's (spinner.css). */}\n        {[0, 1, 2].map((index) => (\n          <span className=\"size-2 rounded-full bg-current\" key={index} />\n        ))}\n      </div>\n    );\n  }\n\n  if (variant === \"bars\") {\n    return (\n      <div\n        className={cn(\n          slotClass(\"spinner\", props),\n          \"sp-spinner-motion inline-flex h-4 items-center gap-[3px]\",\n          className,\n        )}\n        {...shared}\n      >\n        {[0, 1, 2, 3].map((index) => (\n          <span\n            className=\"h-full w-[3px] origin-center rounded-full bg-current\"\n            key={index}\n          />\n        ))}\n      </div>\n    );\n  }\n\n  return (\n    <div\n      className={cn(\n        slotClass(\"spinner\", props),\n        \"sp-spinner-motion relative inline-block size-4\",\n        className,\n      )}\n      {...shared}\n    >\n      {/* Their angles and delays are the stylesheet's (spinner.css). */}\n      {Array.from({ length: 8 }, (_, index) => (\n        <span\n          className=\"absolute top-0 left-1/2 h-[42%] w-[2px] -translate-x-1/2 rounded-full bg-current [transform-origin:50%_119%]\"\n          key={index}\n        />\n      ))}\n    </div>\n  );\n}\n","type":"registry:ui","target":"~/src/siteplane/components/spinner/spinner.tsx"},{"path":"packages/ui/src/components/spinner/spinner.css","content":"@layer theme, base, components, siteplane, utilities;\n\n@layer siteplane {\n  @keyframes sp-spinner-bars {\n    0%,\n    100% {\n      transform: scaleY(0.4);\n    }\n\n    50% {\n      transform: scaleY(1);\n    }\n  }\n\n  @keyframes sp-spinner-dot {\n    0%,\n    80%,\n    100% {\n      opacity: 0.35;\n      transform: scale(0.6);\n    }\n\n    40% {\n      opacity: 1;\n      transform: scale(1);\n    }\n  }\n\n  @keyframes sp-spinner-spoke {\n    0% {\n      opacity: 1;\n    }\n\n    100% {\n      opacity: 0.2;\n    }\n  }\n\n  @keyframes sp-spinner-rotate {\n    to {\n      transform: rotate(360deg);\n    }\n  }\n\n  @keyframes sp-spinner-dash {\n    0% {\n      stroke-dasharray: 1, 200;\n      stroke-dashoffset: 0;\n    }\n\n    50% {\n      stroke-dasharray: 89, 200;\n      stroke-dashoffset: -35;\n    }\n\n    100% {\n      stroke-dasharray: 89, 200;\n      stroke-dashoffset: -124;\n    }\n  }\n\n  @keyframes sp-spinner-ping {\n    75%,\n    100% {\n      opacity: 0;\n      transform: scale(2);\n    }\n  }\n\n  /* Each variant's motion, at zero specificity so the reduced-motion rules\n   * below and a consumer's animate-* utility both win. */\n  :where(.sp-spinner:is([data-variant=\"default\"], [data-variant=\"ring\"])) {\n    animation: sp-spinner-rotate var(--motion-duration-loop)\n      var(--motion-ease-linear) infinite;\n  }\n\n  :where(.sp-spinner[data-variant=\"comet\"]) {\n    animation: sp-spinner-rotate calc(var(--motion-duration-loop) * 2)\n      var(--motion-ease-linear) infinite;\n  }\n\n  :where(.sp-spinner[data-variant=\"comet\"] > circle) {\n    animation: sp-spinner-dash calc(var(--motion-duration-loop) * 1.5)\n      var(--motion-ease-loop) infinite;\n  }\n\n  :where(.sp-spinner[data-variant=\"pulse\"] > :first-child) {\n    animation: sp-spinner-ping var(--motion-duration-loop)\n      var(--motion-ease-ping) infinite;\n  }\n\n  :where(.sp-spinner[data-variant=\"dots\"] > span) {\n    animation: sp-spinner-dot calc(var(--motion-duration-loop) * 1.2)\n      var(--motion-ease-loop) infinite;\n  }\n\n  :where(.sp-spinner[data-variant=\"bars\"] > span) {\n    animation: sp-spinner-bars var(--motion-duration-loop)\n      var(--motion-ease-loop) infinite;\n  }\n\n  :where(.sp-spinner[data-variant=\"spokes\"] > span) {\n    animation: sp-spinner-spoke calc(var(--motion-duration-loop) * 0.8)\n      var(--motion-ease-linear) infinite;\n  }\n\n  /* Each dot and bar starts a step after the one before it. The eight spokes\n   * stand 45 degrees apart, each a tenth of the loop later, and start mid-loop\n   * so they never pulse together. These values live here rather than in style\n   * attributes, which a Content Security Policy may block. */\n  :where(.sp-spinner[data-variant=\"dots\"] > span:nth-child(2)) {\n    animation-delay: calc(var(--motion-duration-loop) * 0.16);\n  }\n\n  :where(.sp-spinner[data-variant=\"dots\"] > span:nth-child(3)) {\n    animation-delay: calc(var(--motion-duration-loop) * 0.32);\n  }\n\n  :where(.sp-spinner[data-variant=\"bars\"] > span:nth-child(2)) {\n    animation-delay: calc(var(--motion-duration-loop) * 0.12);\n  }\n\n  :where(.sp-spinner[data-variant=\"bars\"] > span:nth-child(3)) {\n    animation-delay: calc(var(--motion-duration-loop) * 0.24);\n  }\n\n  :where(.sp-spinner[data-variant=\"bars\"] > span:nth-child(4)) {\n    animation-delay: calc(var(--motion-duration-loop) * 0.36);\n  }\n\n  :where(.sp-spinner[data-variant=\"spokes\"] > span:nth-child(1)) {\n    animation-delay: calc(var(--motion-duration-loop) * -0.8);\n  }\n\n  :where(.sp-spinner[data-variant=\"spokes\"] > span:nth-child(2)) {\n    rotate: 45deg;\n    animation-delay: calc(var(--motion-duration-loop) * -0.7);\n  }\n\n  :where(.sp-spinner[data-variant=\"spokes\"] > span:nth-child(3)) {\n    rotate: 90deg;\n    animation-delay: calc(var(--motion-duration-loop) * -0.6);\n  }\n\n  :where(.sp-spinner[data-variant=\"spokes\"] > span:nth-child(4)) {\n    rotate: 135deg;\n    animation-delay: calc(var(--motion-duration-loop) * -0.5);\n  }\n\n  :where(.sp-spinner[data-variant=\"spokes\"] > span:nth-child(5)) {\n    rotate: 180deg;\n    animation-delay: calc(var(--motion-duration-loop) * -0.4);\n  }\n\n  :where(.sp-spinner[data-variant=\"spokes\"] > span:nth-child(6)) {\n    rotate: 225deg;\n    animation-delay: calc(var(--motion-duration-loop) * -0.3);\n  }\n\n  :where(.sp-spinner[data-variant=\"spokes\"] > span:nth-child(7)) {\n    rotate: 270deg;\n    animation-delay: calc(var(--motion-duration-loop) * -0.2);\n  }\n\n  :where(.sp-spinner[data-variant=\"spokes\"] > span:nth-child(8)) {\n    rotate: 315deg;\n    animation-delay: calc(var(--motion-duration-loop) * -0.1);\n  }\n\n  html[data-motion-reduce=\"true\"] .sp-spinner-motion,\n  html[data-motion-reduce=\"true\"] .sp-spinner-motion *,\n  [data-sp-motion=\"reduced\"] .sp-spinner-motion,\n  [data-sp-motion=\"reduced\"] .sp-spinner-motion * {\n    animation: none;\n  }\n\n  @media (prefers-reduced-motion: reduce) {\n    .sp-spinner-motion,\n    .sp-spinner-motion * {\n      animation: none;\n    }\n  }\n}\n","type":"registry:ui","target":"~/src/siteplane/components/spinner/spinner.css"},{"path":"packages/ui/src/components/spinner/index.ts","content":"export * from \"./spinner\";\n","type":"registry:ui","target":"~/src/siteplane/components/spinner/index.ts"}],"type":"registry:ui","docs":"https://ui.siteplane.io/docs-markdown/components/spinner.md","categories":["Feedback"],"meta":{"avoidWhen":["Avoid when completion is measurable; use Progress.","Avoid for content that loads into a known layout; use Skeleton."],"description":"Indeterminate loading status with seven visual variants and reduced-motion behavior.","knownLimitations":["A spinner does not start, cancel or finish a request; the application owns the pending and error state."],"providerNotes":[],"releaseStatus":"stable","searchTerms":["spinner"],"useWhen":["Use to show that an operation is in progress when its length is unknown, on its own, inside a button or in an input."]}}