Back to components

Ring HUD

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.

displaystroke-draw
  1. Enter
  2. Hold
  3. Exit
  4. Done
"use client";

import {
  useCallback,
  useId,
  useImperativeHandle,
  useRef,
  useState,
  type AnimationEvent,
  type ComponentProps,
  type CSSProperties,
  type ReactNode,
  type Ref,
} from "react";

import { cn } from "@/lib/utils";

import "./ring-hud.css";

export type RingHudPhase = "enter" | "hold" | "exit" | "done";

export type RingHudControls = {
  /** Play the whole sequence again from the entrance. */
  replay: () => void;
  /** Freeze the sequence where it is, including the hold countdown. */
  pause: () => void;
  resume: () => void;
  /** Leave now. During the entrance, leaves as soon as the ring settles. */
  dismiss: () => void;
};

export type RingHudProps = Omit<ComponentProps<"div">, "children"> & {
  /** Ring fill from 0 to 100. Changes glide along the ring in any phase. */
  value: number;
  /**
   * Caption under the ring. A function receives the clamped, rounded value.
   * Pass `null` to omit it. Defaults to the percentage.
   */
  label?: ReactNode | ((value: number) => ReactNode);
  /** Content centred in the ring, usually an icon. Sized to 42% of the ring. */
  icon?: ReactNode;
  /**
   * How long the settled ring shows, in milliseconds. `Infinity` holds until
   * `dismiss()`, or until this changes to a finite number, which starts the
   * countdown from that moment.
   */
  holdDuration?: number;
  /** Ring diameter. Numbers are pixels; CSS lengths are also accepted. Defaults to 12rem. */
  size?: number | string;
  /**
   * Imperative replay/pause/resume/dismiss. `ref` points at the root element,
   * matching every other component in the registry.
   */
  controlsRef?: Ref<RingHudControls>;
  onPhaseChange?: (phase: RingHudPhase) => void;
  /**
   * Called after each run's exit finishes. The element then stays mounted
   * with `data-phase="done"` and `visibility: hidden`, keeping its space so
   * surrounding layout never jumps. To give the space back, unmount it here
   * or add `className="data-[phase=done]:hidden"`.
   */
  onComplete?: () => void;
  /** Classes for the ring. Set `color` here with a `text-*` utility to tint it. */
  ringClassName?: string;
  iconClassName?: string;
  labelClassName?: string;
};

const DEFAULT_HOLD_DURATION = 1340;

const formatPercent = (value: number) => `${value}%`;

/**
 * A transient ring HUD: the ring fills to `value` around an icon, holds with a
 * caption, then sweeps away. Use it for any at-a-glance level or confirmation:
 * charging, upload progress, goals, connection status.
 *
 * Also themeable through `--ring-hud-color`, `--ring-hud-track-color`, and
 * `--ring-hud-size`.
 *
 * Phases advance on the root's CSS `animationend`, with no JS timers, so
 * pausing freezes every phase at once. The flow therefore needs animations
 * to run: inside a `display: none` ancestor it waits until shown, and a global
 * `animation: none !important` reset stalls it outright. Short-duration
 * resets such as `animation-duration: 0.01ms` are fine.
 */
export function RingHud({
  value,
  label = formatPercent,
  icon,
  holdDuration = DEFAULT_HOLD_DURATION,
  size,
  controlsRef,
  onPhaseChange,
  onComplete,
  onAnimationEnd,
  ref,
  className,
  ringClassName,
  iconClassName,
  labelClassName,
  style,
  ...props
}: RingHudProps) {
  const [phase, setPhase] = useState<RingHudPhase>("enter");
  // Controls and event handlers read the phase from here, not from render
  // state, so calls made back to back (replay() then dismiss()) see the
  // phase the previous call just moved to.
  const phaseRef = useRef<RingHudPhase>("enter");
  const [paused, setPaused] = useState(false);
  const [announcement, setAnnouncement] = useState("");
  const dismissRequestedRef = useRef(false);
  const labelRef = useRef<HTMLSpanElement>(null);
  const rootRef = useRef<HTMLDivElement | null>(null);
  const maskId = useId();

  const level = Number.isFinite(value)
    ? Math.round(Math.min(100, Math.max(0, value)))
    : 0;
  const caption = typeof label === "function" ? label(level) : label;
  const hasCaption =
    caption !== null &&
    caption !== undefined &&
    caption !== false &&
    caption !== "";
  const manualHold = holdDuration === Infinity;
  const hold = Number.isFinite(holdDuration)
    ? Math.max(1, holdDuration)
    : DEFAULT_HOLD_DURATION;

  const goTo = (next: RingHudPhase) => {
    phaseRef.current = next;
    setPhase(next);
    // The live region exists from mount and is filled once the caption is
    // settled, so screen readers announce it. Re-setting identical text on
    // exit is a no-op, so only a changed caption is announced twice.
    if (next === "hold" || next === "exit") {
      setAnnouncement(
        labelRef.current?.textContent?.trim() || formatPercent(level),
      );
    }
    onPhaseChange?.(next);
    if (next === "done") onComplete?.();
  };

  useImperativeHandle(controlsRef, () => ({
    replay() {
      dismissRequestedRef.current = false;
      setPaused(false);
      setAnnouncement("");
      // Leaving any other phase swaps animation names, which restarts them on
      // the same node. Replaying mid-entrance keeps the names, so rewind them.
      if (phaseRef.current === "enter") {
        for (const animation of rootRef.current?.getAnimations?.({
          subtree: true,
        }) ?? []) {
          animation.currentTime = 0;
        }
      }
      goTo("enter");
    },
    pause() {
      setPaused(true);
    },
    resume() {
      setPaused(false);
    },
    dismiss() {
      if (phaseRef.current === "enter") dismissRequestedRef.current = true;
      else if (phaseRef.current === "hold") goTo("exit");
    },
  }));

  // The root is needed internally (replay rewinds its animations) and by
  // consumers, so the consumer's ref is merged in rather than overwritten.
  const setRootRef = useCallback(
    (node: HTMLDivElement | null) => {
      rootRef.current = node;

      if (typeof ref === "function") {
        ref(node);
      } else if (ref) {
        ref.current = node;
      }
    },
    [ref],
  );

  const handleAnimationEnd = (event: AnimationEvent<HTMLDivElement>) => {
    onAnimationEnd?.(event);
    if (
      event.target !== event.currentTarget ||
      !event.animationName.startsWith("ring-hud-")
    )
      return;

    const current = phaseRef.current;
    if (current === "enter")
      goTo(dismissRequestedRef.current ? "exit" : "hold");
    else if (current === "hold" && !manualHold) goTo("exit");
    else if (current === "exit") goTo("done");
  };

  return (
    <div
      {...props}
      ref={setRootRef}
      data-slot="ring-hud"
      data-phase={phase}
      data-hold={manualHold ? "manual" : undefined}
      data-paused={paused ? "" : undefined}
      onAnimationEnd={handleAnimationEnd}
      className={cn("ring-hud", className)}
      style={
        {
          "--ring-hud-value": level,
          "--ring-hud-hold": `${hold}ms`,
          ...(size !== undefined && {
            "--ring-hud-size": typeof size === "number" ? `${size}px` : size,
          }),
          ...style,
        } as CSSProperties
      }
    >
      <div
        aria-hidden="true"
        data-slot="ring-hud-ring"
        className={cn("ring-hud-ring", ringClassName)}
      >
        <svg viewBox="0 0 200 200" fill="none">
          <defs>
            <mask
              id={maskId}
              maskUnits="userSpaceOnUse"
              x="0"
              y="0"
              width="200"
              height="200"
            >
              <circle
                className="ring-hud-trim"
                cx="100"
                cy="100"
                r="82"
                pathLength="100"
                stroke="white"
                strokeWidth="14"
                strokeLinecap="round"
                strokeDasharray="100 100"
                transform="rotate(-90 100 100)"
              />
            </mask>
          </defs>
          <g className="ring-hud-ink" strokeWidth="14" strokeLinecap="round">
            <circle
              className="ring-hud-track"
              mask={`url(#${maskId})`}
              cx="100"
              cy="100"
              r="82"
            />
            <circle
              className="ring-hud-path"
              visibility={level === 0 ? "hidden" : undefined}
              cx="100"
              cy="100"
              r="82"
              pathLength="100"
              stroke="currentColor"
              transform="rotate(-90 100 100)"
            />
          </g>
        </svg>
        {icon != null && (
          <span
            data-slot="ring-hud-icon"
            className={cn("ring-hud-icon", iconClassName)}
          >
            {icon}
          </span>
        )}
      </div>
      {hasCaption && (
        <span
          ref={labelRef}
          aria-hidden="true"
          data-slot="ring-hud-label"
          className={cn(
            "ring-hud-label text-sm text-foreground tabular-nums",
            labelClassName,
          )}
        >
          {caption}
        </span>
      )}
      <span role="status" aria-atomic="true" className="sr-only">
        {announcement}
      </span>
    </div>
  );
}

Installation