{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ring-hud",
  "title": "Ring HUD",
  "description": "A transient heads-up ring: it fills to a level around an icon, holds with a caption, then sweeps away. Hold for a set time or until dismissed, and update the value live for charging, uploads, goals, storage, or connection status.",
  "files": [
    {
      "path": "registry/base/ui/ring-hud.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useId,\n  useImperativeHandle,\n  useRef,\n  useState,\n  type AnimationEvent,\n  type ComponentProps,\n  type CSSProperties,\n  type ReactNode,\n  type Ref,\n} from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport \"./ring-hud.css\";\n\nexport type RingHudPhase = \"enter\" | \"hold\" | \"exit\" | \"done\";\n\nexport type RingHudControls = {\n  /** Play the whole sequence again from the entrance. */\n  replay: () => void;\n  /** Freeze the sequence where it is, including the hold countdown. */\n  pause: () => void;\n  resume: () => void;\n  /** Leave now. During the entrance, leaves as soon as the ring settles. */\n  dismiss: () => void;\n};\n\nexport type RingHudProps = Omit<ComponentProps<\"div\">, \"children\"> & {\n  /** Ring fill from 0 to 100. Changes glide along the ring in any phase. */\n  value: number;\n  /**\n   * Caption under the ring. A function receives the clamped, rounded value.\n   * Pass `null` to omit it. Defaults to the percentage.\n   */\n  label?: ReactNode | ((value: number) => ReactNode);\n  /** Content centred in the ring, usually an icon. Sized to 42% of the ring. */\n  icon?: ReactNode;\n  /**\n   * How long the settled ring shows, in milliseconds. `Infinity` holds until\n   * `dismiss()`, or until this changes to a finite number, which starts the\n   * countdown from that moment.\n   */\n  holdDuration?: number;\n  /** Ring diameter. Numbers are pixels; CSS lengths are also accepted. Defaults to 12rem. */\n  size?: number | string;\n  /**\n   * Imperative replay/pause/resume/dismiss. `ref` points at the root element,\n   * matching every other component in the registry.\n   */\n  controlsRef?: Ref<RingHudControls>;\n  onPhaseChange?: (phase: RingHudPhase) => void;\n  /**\n   * Called after each run's exit finishes. The element then stays mounted\n   * with `data-phase=\"done\"` and `visibility: hidden`, keeping its space so\n   * surrounding layout never jumps. To give the space back, unmount it here\n   * or add `className=\"data-[phase=done]:hidden\"`.\n   */\n  onComplete?: () => void;\n  /** Classes for the ring. Set `color` here with a `text-*` utility to tint it. */\n  ringClassName?: string;\n  iconClassName?: string;\n  labelClassName?: string;\n};\n\nconst DEFAULT_HOLD_DURATION = 1340;\n\nconst formatPercent = (value: number) => `${value}%`;\n\n/**\n * A transient ring HUD: the ring fills to `value` around an icon, holds with a\n * caption, then sweeps away. Use it for any at-a-glance level or confirmation:\n * charging, upload progress, goals, connection status.\n *\n * Also themeable through `--ring-hud-color`, `--ring-hud-track-color`, and\n * `--ring-hud-size`.\n *\n * Phases advance on the root's CSS `animationend`, with no JS timers, so\n * pausing freezes every phase at once. The flow therefore needs animations\n * to run: inside a `display: none` ancestor it waits until shown, and a global\n * `animation: none !important` reset stalls it outright. Short-duration\n * resets such as `animation-duration: 0.01ms` are fine.\n */\nexport function RingHud({\n  value,\n  label = formatPercent,\n  icon,\n  holdDuration = DEFAULT_HOLD_DURATION,\n  size,\n  controlsRef,\n  onPhaseChange,\n  onComplete,\n  onAnimationEnd,\n  ref,\n  className,\n  ringClassName,\n  iconClassName,\n  labelClassName,\n  style,\n  ...props\n}: RingHudProps) {\n  const [phase, setPhase] = useState<RingHudPhase>(\"enter\");\n  // Controls and event handlers read the phase from here, not from render\n  // state, so calls made back to back (replay() then dismiss()) see the\n  // phase the previous call just moved to.\n  const phaseRef = useRef<RingHudPhase>(\"enter\");\n  const [paused, setPaused] = useState(false);\n  const [announcement, setAnnouncement] = useState(\"\");\n  const dismissRequestedRef = useRef(false);\n  const labelRef = useRef<HTMLSpanElement>(null);\n  const rootRef = useRef<HTMLDivElement | null>(null);\n  const maskId = useId();\n\n  const level = Number.isFinite(value)\n    ? Math.round(Math.min(100, Math.max(0, value)))\n    : 0;\n  const caption = typeof label === \"function\" ? label(level) : label;\n  const hasCaption =\n    caption !== null &&\n    caption !== undefined &&\n    caption !== false &&\n    caption !== \"\";\n  const manualHold = holdDuration === Infinity;\n  const hold = Number.isFinite(holdDuration)\n    ? Math.max(1, holdDuration)\n    : DEFAULT_HOLD_DURATION;\n\n  const goTo = (next: RingHudPhase) => {\n    phaseRef.current = next;\n    setPhase(next);\n    // The live region exists from mount and is filled once the caption is\n    // settled, so screen readers announce it. Re-setting identical text on\n    // exit is a no-op, so only a changed caption is announced twice.\n    if (next === \"hold\" || next === \"exit\") {\n      setAnnouncement(\n        labelRef.current?.textContent?.trim() || formatPercent(level),\n      );\n    }\n    onPhaseChange?.(next);\n    if (next === \"done\") onComplete?.();\n  };\n\n  useImperativeHandle(controlsRef, () => ({\n    replay() {\n      dismissRequestedRef.current = false;\n      setPaused(false);\n      setAnnouncement(\"\");\n      // Leaving any other phase swaps animation names, which restarts them on\n      // the same node. Replaying mid-entrance keeps the names, so rewind them.\n      if (phaseRef.current === \"enter\") {\n        for (const animation of rootRef.current?.getAnimations?.({\n          subtree: true,\n        }) ?? []) {\n          animation.currentTime = 0;\n        }\n      }\n      goTo(\"enter\");\n    },\n    pause() {\n      setPaused(true);\n    },\n    resume() {\n      setPaused(false);\n    },\n    dismiss() {\n      if (phaseRef.current === \"enter\") dismissRequestedRef.current = true;\n      else if (phaseRef.current === \"hold\") goTo(\"exit\");\n    },\n  }));\n\n  // The root is needed internally (replay rewinds its animations) and by\n  // consumers, so the consumer's ref is merged in rather than overwritten.\n  const setRootRef = useCallback(\n    (node: HTMLDivElement | null) => {\n      rootRef.current = node;\n\n      if (typeof ref === \"function\") {\n        ref(node);\n      } else if (ref) {\n        ref.current = node;\n      }\n    },\n    [ref],\n  );\n\n  const handleAnimationEnd = (event: AnimationEvent<HTMLDivElement>) => {\n    onAnimationEnd?.(event);\n    if (\n      event.target !== event.currentTarget ||\n      !event.animationName.startsWith(\"ring-hud-\")\n    )\n      return;\n\n    const current = phaseRef.current;\n    if (current === \"enter\")\n      goTo(dismissRequestedRef.current ? \"exit\" : \"hold\");\n    else if (current === \"hold\" && !manualHold) goTo(\"exit\");\n    else if (current === \"exit\") goTo(\"done\");\n  };\n\n  return (\n    <div\n      {...props}\n      ref={setRootRef}\n      data-slot=\"ring-hud\"\n      data-phase={phase}\n      data-hold={manualHold ? \"manual\" : undefined}\n      data-paused={paused ? \"\" : undefined}\n      onAnimationEnd={handleAnimationEnd}\n      className={cn(\"ring-hud\", className)}\n      style={\n        {\n          \"--ring-hud-value\": level,\n          \"--ring-hud-hold\": `${hold}ms`,\n          ...(size !== undefined && {\n            \"--ring-hud-size\": typeof size === \"number\" ? `${size}px` : size,\n          }),\n          ...style,\n        } as CSSProperties\n      }\n    >\n      <div\n        aria-hidden=\"true\"\n        data-slot=\"ring-hud-ring\"\n        className={cn(\"ring-hud-ring\", ringClassName)}\n      >\n        <svg viewBox=\"0 0 200 200\" fill=\"none\">\n          <defs>\n            <mask\n              id={maskId}\n              maskUnits=\"userSpaceOnUse\"\n              x=\"0\"\n              y=\"0\"\n              width=\"200\"\n              height=\"200\"\n            >\n              <circle\n                className=\"ring-hud-trim\"\n                cx=\"100\"\n                cy=\"100\"\n                r=\"82\"\n                pathLength=\"100\"\n                stroke=\"white\"\n                strokeWidth=\"14\"\n                strokeLinecap=\"round\"\n                strokeDasharray=\"100 100\"\n                transform=\"rotate(-90 100 100)\"\n              />\n            </mask>\n          </defs>\n          <g className=\"ring-hud-ink\" strokeWidth=\"14\" strokeLinecap=\"round\">\n            <circle\n              className=\"ring-hud-track\"\n              mask={`url(#${maskId})`}\n              cx=\"100\"\n              cy=\"100\"\n              r=\"82\"\n            />\n            <circle\n              className=\"ring-hud-path\"\n              visibility={level === 0 ? \"hidden\" : undefined}\n              cx=\"100\"\n              cy=\"100\"\n              r=\"82\"\n              pathLength=\"100\"\n              stroke=\"currentColor\"\n              transform=\"rotate(-90 100 100)\"\n            />\n          </g>\n        </svg>\n        {icon != null && (\n          <span\n            data-slot=\"ring-hud-icon\"\n            className={cn(\"ring-hud-icon\", iconClassName)}\n          >\n            {icon}\n          </span>\n        )}\n      </div>\n      {hasCaption && (\n        <span\n          ref={labelRef}\n          aria-hidden=\"true\"\n          data-slot=\"ring-hud-label\"\n          className={cn(\n            \"ring-hud-label text-sm text-foreground tabular-nums\",\n            labelClassName,\n          )}\n        >\n          {caption}\n        </span>\n      )}\n      <span role=\"status\" aria-atomic=\"true\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@ui/ring-hud.tsx"
    },
    {
      "path": "registry/base/ui/ring-hud.css",
      "content": "/* Registered so value changes interpolate: the ring glides to a new level in\n   any phase instead of jumping, including mid-hold progress updates. */\n@property --ring-hud-value {\n  syntax: \"<number>\";\n  inherits: true;\n  initial-value: 0;\n}\n\n@layer components {\n  /* Base styles are the settled frame. Each phase layers its own animations\n     on top, so the hold is simply \"no child animation\" and can last any time.\n\n     Enter (530ms): fade in 0-120ms, arc fills 0-530ms, icon pops by 340ms.\n     Exit  (450ms): sweep and thinning 0-384ms, strokes fade from 186ms,\n     then the root fades so no empty frame shows. */\n  .ring-hud {\n    --ring-hud-enter: 530ms;\n    --ring-hud-exit: 450ms;\n    display: inline-flex;\n    flex-direction: column;\n    align-items: center;\n    gap: 1.25rem;\n    transition: --ring-hud-value 480ms cubic-bezier(0.33, 1, 0.68, 1);\n  }\n\n  .ring-hud-ring {\n    position: relative;\n    display: grid;\n    flex-shrink: 0;\n    place-items: center;\n    width: var(--ring-hud-size, 12rem);\n    height: var(--ring-hud-size, 12rem);\n    color: var(--ring-hud-color, var(--primary));\n  }\n\n  .ring-hud-ring > svg {\n    position: absolute;\n    inset: 0;\n    width: 100%;\n    height: 100%;\n  }\n\n  .ring-hud-track {\n    stroke: var(\n      --ring-hud-track-color,\n      color-mix(in oklab, var(--foreground) 30%, transparent)\n    );\n  }\n\n  .ring-hud-path {\n    stroke-dasharray: var(--ring-hud-value) 100;\n  }\n\n  .ring-hud-icon {\n    position: relative;\n    display: grid;\n    place-items: center;\n    width: 42%;\n    height: 42%;\n  }\n\n  .ring-hud-icon > svg {\n    width: 100%;\n    height: 100%;\n  }\n\n  .ring-hud[data-phase=\"done\"] {\n    visibility: hidden;\n    opacity: 0;\n  }\n\n  /* Root animations drive the phase machine: each one's animationend advances\n     to the next phase. A manual hold has none and waits for dismiss(). */\n  .ring-hud[data-phase=\"enter\"] {\n    animation: ring-hud-enter var(--ring-hud-enter) linear both;\n  }\n  .ring-hud[data-phase=\"hold\"] {\n    animation: ring-hud-hold var(--ring-hud-hold) linear both;\n  }\n  .ring-hud[data-phase=\"hold\"][data-hold=\"manual\"] {\n    animation: none;\n  }\n  .ring-hud[data-phase=\"exit\"] {\n    animation: ring-hud-exit var(--ring-hud-exit) linear both;\n  }\n\n  .ring-hud[data-phase=\"enter\"] .ring-hud-path {\n    animation: ring-hud-draw-in var(--ring-hud-enter) linear both;\n  }\n  .ring-hud[data-phase=\"enter\"] .ring-hud-icon {\n    animation: ring-hud-icon-in var(--ring-hud-enter) linear both;\n  }\n  .ring-hud[data-phase=\"enter\"] .ring-hud-label {\n    animation: ring-hud-label-in var(--ring-hud-enter) linear both;\n  }\n\n  .ring-hud[data-phase=\"exit\"] .ring-hud-path {\n    animation: ring-hud-draw-out var(--ring-hud-exit) linear both;\n  }\n  .ring-hud[data-phase=\"exit\"] .ring-hud-trim {\n    animation:\n      ring-hud-trim-out var(--ring-hud-exit) linear both,\n      ring-hud-weight-out var(--ring-hud-exit) linear both;\n  }\n  .ring-hud[data-phase=\"exit\"] .ring-hud-ink {\n    animation:\n      ring-hud-weight-out var(--ring-hud-exit) linear both,\n      ring-hud-ink-out var(--ring-hud-exit) linear both;\n  }\n  .ring-hud[data-phase=\"exit\"] .ring-hud-icon {\n    animation: ring-hud-icon-out var(--ring-hud-exit) linear both;\n  }\n  .ring-hud[data-phase=\"exit\"] .ring-hud-label {\n    animation: ring-hud-label-out var(--ring-hud-exit) linear both;\n  }\n\n  .ring-hud[data-paused][data-phase],\n  .ring-hud[data-paused][data-phase] * {\n    animation-play-state: paused;\n  }\n\n  @keyframes ring-hud-enter {\n    0% {\n      opacity: 0;\n      animation-timing-function: cubic-bezier(0.23, 1, 0.32, 1);\n    }\n    22.641509%,\n    100% {\n      opacity: 1;\n    }\n  }\n\n  /* Carries no visual change; its duration is the hold countdown. */\n  @keyframes ring-hud-hold {\n    from,\n    to {\n      opacity: 1;\n    }\n  }\n\n  @keyframes ring-hud-exit {\n    0%,\n    85.333333% {\n      opacity: 1;\n    }\n    100% {\n      opacity: 0;\n    }\n  }\n\n  /* A soft cubic ease-out so the arc visibly travels rather than snapping.\n     Opacity hides the zero-length round cap at the very start. */\n  @keyframes ring-hud-draw-in {\n    0% {\n      stroke-dasharray: 0 100;\n      opacity: 0;\n      animation-timing-function: cubic-bezier(0.33, 1, 0.68, 1);\n    }\n    4.90566% {\n      opacity: 1;\n    }\n    100% {\n      stroke-dasharray: var(--ring-hud-value) 100;\n      opacity: 1;\n    }\n  }\n\n  @keyframes ring-hud-icon-in {\n    0% {\n      transform: scale(0.6);\n      opacity: 0;\n      animation-timing-function: cubic-bezier(0.33, 1, 0.68, 1);\n    }\n    39.811321% {\n      transform: scale(1.04);\n      opacity: 1;\n      animation-timing-function: cubic-bezier(0.37, 0, 0.63, 1);\n    }\n    64.716981%,\n    100% {\n      transform: scale(1);\n      opacity: 1;\n    }\n  }\n\n  @keyframes ring-hud-label-in {\n    0%,\n    29.811321% {\n      opacity: 0;\n      transform: translateY(6px);\n      animation-timing-function: cubic-bezier(0.23, 1, 0.32, 1);\n    }\n    69.811321%,\n    100% {\n      opacity: 1;\n      transform: translateY(0);\n    }\n  }\n\n  /* The head advances to 100 while the tail erases clockwise behind it.\n     Ease-in-out keeps the whole sweep legible instead of front-loading it. */\n  @keyframes ring-hud-draw-out {\n    0% {\n      stroke-dasharray: var(--ring-hud-value) 100;\n      stroke-dashoffset: 0;\n      animation-timing-function: cubic-bezier(0.65, 0, 0.35, 1);\n    }\n    85.333333% {\n      stroke-dasharray: 0 100;\n      stroke-dashoffset: -100;\n      opacity: 1;\n    }\n    100% {\n      stroke-dasharray: 0 100;\n      stroke-dashoffset: -100;\n      opacity: 0;\n    }\n  }\n\n  /* The track's mask erases with the same tail. */\n  @keyframes ring-hud-trim-out {\n    0% {\n      stroke-dashoffset: 0;\n      animation-timing-function: cubic-bezier(0.65, 0, 0.35, 1);\n    }\n    85.333333%,\n    100% {\n      stroke-dashoffset: -100;\n    }\n  }\n\n  @keyframes ring-hud-weight-out {\n    0% {\n      stroke-width: 14;\n      animation-timing-function: cubic-bezier(0.65, 0, 0.35, 1);\n    }\n    85.333333%,\n    100% {\n      stroke-width: 1.5;\n    }\n  }\n\n  /* Fade before the round caps collapse into overlapping dots. */\n  @keyframes ring-hud-ink-out {\n    0%,\n    41.333333% {\n      opacity: 1;\n      animation-timing-function: cubic-bezier(0.55, 0, 1, 0.45);\n    }\n    85.333333%,\n    100% {\n      opacity: 0;\n    }\n  }\n\n  @keyframes ring-hud-icon-out {\n    0%,\n    6.222222% {\n      transform: scale(1);\n      opacity: 1;\n      animation-timing-function: cubic-bezier(0.55, 0, 1, 0.45);\n    }\n    70.666667%,\n    100% {\n      transform: scale(0.6);\n      opacity: 0;\n    }\n  }\n\n  @keyframes ring-hud-label-out {\n    0%,\n    12% {\n      opacity: 1;\n      animation-timing-function: cubic-bezier(0.55, 0, 1, 0.45);\n    }\n    64.888889%,\n    100% {\n      opacity: 0;\n    }\n  }\n\n  @keyframes ring-hud-fade-out {\n    from {\n      opacity: 1;\n    }\n    to {\n      opacity: 0;\n    }\n  }\n\n  /* Children hold their settled frame; the root keeps opacity-only fades so\n     the phase machine still advances on the same animationend events. */\n  @media (prefers-reduced-motion: reduce) {\n    .ring-hud {\n      transition: none;\n    }\n\n    .ring-hud[data-phase] .ring-hud-path,\n    .ring-hud[data-phase] .ring-hud-trim,\n    .ring-hud[data-phase] .ring-hud-ink,\n    .ring-hud[data-phase] .ring-hud-icon,\n    .ring-hud[data-phase] .ring-hud-label {\n      animation: none;\n    }\n\n    .ring-hud[data-phase=\"exit\"] {\n      animation-name: ring-hud-fade-out;\n    }\n  }\n}\n",
      "type": "registry:file",
      "target": "@ui/ring-hud.css"
    }
  ],
  "meta": {
    "tags": [
      "hud",
      "progress",
      "ring",
      "status",
      "charging",
      "upload",
      "notification"
    ],
    "effects": [
      "stroke-draw",
      "stroke-erase",
      "reduced-motion"
    ]
  },
  "categories": [
    "display"
  ],
  "type": "registry:ui"
}