Skip to content

Spinner

Animated indicator for a loading state.

Browsers
Chrome 123+Firefox 121+Safari 17.5+
Tier
0
Bundle compressed
447 B
Forced layout budget
0 ms
Axe budget
0
SSR compatible
Yes
Published contract
Test harness

Playground

Spinner

Sizes

Label

Loading content
Loading content

Value

import { Spinner } from "@gunkin/uiify";

export default function Example() {
  return <Spinner className="" label="Loading results" />;
}
@property --uiify-spinner-tail {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}

@property --uiify-spinner-length {
  syntax: "<angle>";
  inherits: false;
  initial-value: 36deg;
}

@layer components {
  [data-uiify-spinner] {
    position: relative;
    display: inline-flex;
    align-items: center;
    gap: 0.5rem;
    color: var(--primary);
  }

  [data-uiify-spinner] > [data-part="label"] {
    color: var(--foreground);
  }

  [data-uiify-spinner]::before {
    flex: none;
    display: block;
    inline-size: 2rem;
    block-size: 2rem;
    content: "";
    border-radius: 50%;
    background: conic-gradient(
      from calc(-90deg + var(--uiify-spinner-tail)),
      currentcolor 0 var(--uiify-spinner-length),
      transparent 0
    );
    mask: radial-gradient(farthest-side, transparent calc(100% - 4px), #000 calc(100% - 3px));
    animation:
      uiify-spinner-rotate 2s linear infinite,
      uiify-spinner-orbit 1.5s ease-in-out infinite;
  }

  [data-uiify-spinner][data-value]::before {
    --uiify-spinner-tail: 0deg;
    --uiify-spinner-length: calc(attr(data-value type(<number>), 0) * 3.6deg);

    background: conic-gradient(
      from 0deg,
      currentcolor 0 var(--uiify-spinner-length),
      color-mix(in oklch, currentcolor 20%, transparent) 0
    );
    animation: none;
  }

  [data-uiify-spinner][data-size="sm"]::before {
    inline-size: 1.25rem;
    block-size: 1.25rem;
    mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 1px));
  }

  [data-uiify-spinner][data-size="lg"]::before {
    inline-size: 6.25rem;
    block-size: 6.25rem;
    mask: radial-gradient(farthest-side, transparent calc(100% - 4px), #000 calc(100% - 3px));
  }

  [data-uiify-spinner][data-align="center"] {
    margin-inline: auto;
  }

  [data-uiify-spinner][data-align="fixed"],
  [data-uiify-spinner][data-align="absolute"] {
    inset-block-start: 50%;
    inset-inline-start: 50%;
    translate: -50% -50%;
  }

  [data-uiify-spinner][data-align="fixed"] {
    position: fixed;
  }

  [data-uiify-spinner][data-align="absolute"] {
    position: absolute;
  }

  @keyframes uiify-spinner-rotate {
    to {
      transform: rotate(360deg);
    }
  }

  @keyframes uiify-spinner-orbit {
    0% {
      --uiify-spinner-tail: 0deg;
      --uiify-spinner-length: 36deg;
    }

    50% {
      --uiify-spinner-tail: 90deg;
      --uiify-spinner-length: 324deg;
    }

    100% {
      --uiify-spinner-tail: 360deg;
      --uiify-spinner-length: 36deg;
    }
  }

  @media (prefers-reduced-motion: reduce) {
    [data-uiify-spinner]:not([data-value])::before {
      animation: uiify-spinner-orbit 2.5s ease-in-out infinite;
    }
  }
}
import { Spinner } from "@gunkin/uiify";

export default function Example() {
  return (
    <Spinner
      className={
        "relative inline-flex items-center [gap:0.5rem] text-primary [&>[data-part=label]]:text-foreground before:fl" +
        "ex-none before:block before:[inline-size:2rem] before:[block-size:2rem] before:content-[''] before:rounded-" +
        "[50%] before:[background:conic-gradient(_from_calc(-90deg_+_var(--uiify-spinner-tail)),_currentcolor_0_var(" +
        "--uiify-spinner-length),_transparent_0_)] before:[mask:radial-gradient(farthest-side,_transparent_calc(100%" +
        "_-_4px),_#000_calc(100%_-_3px))] before:[animation:uiify-spinner-rotate_2s_linear_infinite,_uiify-spinner-o" +
        "rbit_1.5s_ease-in-out_infinite] data-[value]:before:[--uiify-spinner-tail:0deg] data-[value]:before:[--uiif" +
        "y-spinner-length:calc(attr(data-value_type(<number>),_0)_*_3.6deg)] data-[value]:before:[background:conic-g" +
        "radient(_from_0deg,_currentcolor_0_var(--uiify-spinner-length),_color-mix(in_oklch,_currentcolor_20%,_trans" +
        "parent)_0_)] data-[value]:before:[animation:none] data-[size=sm]:before:[inline-size:1.25rem] data-[size=sm" +
        "]:before:[block-size:1.25rem] data-[size=sm]:before:[mask:radial-gradient(farthest-side,_transparent_calc(1" +
        "00%_-_3px),_#000_calc(100%_-_1px))] data-[size=lg]:before:[inline-size:6.25rem] data-[size=lg]:before:[bloc" +
        "k-size:6.25rem] data-[size=lg]:before:[mask:radial-gradient(farthest-side,_transparent_calc(100%_-_4px),_#0" +
        "00_calc(100%_-_3px))] data-[align=center]:[margin-inline:auto] data-[align=fixed]:[inset-block-start:50%] d" +
        "ata-[align=fixed]:[inset-inline-start:50%] data-[align=fixed]:[translate:-50%_-50%] data-[align=absolute]:[" +
        "inset-block-start:50%] data-[align=absolute]:[inset-inline-start:50%] data-[align=absolute]:[translate:-50%" +
        "_-50%] data-[align=fixed]:fixed data-[align=absolute]:absolute motion-reduce:[&:not([data-value])::before]:" +
        "[animation:uiify-spinner-orbit_2.5s_ease-in-out_infinite]"
      }
      label="Loading results"
    />
  );
}
/* app.css */

@layer reset, ui, app, tokens, base, demo, components, utilities, theme;

@import "@gunkin/uiify/styles/reset";

@import "@gunkin/uiify/components/behavior.css";

@import "@gunkin/uiify/styles/tokens";

@import "@gunkin/uiify/styles/tailwind";

@import "tailwindcss/utilities.css";

@theme { --spacing: 0.25rem; --font-weight-normal: 400; --font-weight-medium: 500; --font-weight-semibold: 600; --font-weight-bold: 700; }

@source inline('relative inline-flex items-center [gap:0.5rem] text-primary [&>[data-part=label]]:text-foreground before:flex-none before:block before:[inline-size:2rem] before:[block-size:2rem] before:rounded-[50%] before:[background:conic-gradient(_from_calc(-90deg_+_var(--uiify-spinner-tail)),_currentcolor_0_var(--uiify-spinner-length),_transparent_0_)] before:[mask:radial-gradient(farthest-side,_transparent_calc(100%_-_4px),_#000_calc(100%_-_3px))] before:[animation:uiify-spinner-rotate_2s_linear_infinite,_uiify-spinner-orbit_1.5s_ease-in-out_infinite] data-[value]:before:[--uiify-spinner-tail:0deg] data-[value]:before:[--uiify-spinner-length:calc(attr(data-value_type(<number>),_0)_*_3.6deg)] data-[value]:before:[background:conic-gradient(_from_0deg,_currentcolor_0_var(--uiify-spinner-length),_color-mix(in_oklch,_currentcolor_20%,_transparent)_0_)] data-[value]:before:[animation:none] data-[size=sm]:before:[inline-size:1.25rem] data-[size=sm]:before:[block-size:1.25rem] data-[size=sm]:before:[mask:radial-gradient(farthest-side,_transparent_calc(100%_-_3px),_#000_calc(100%_-_1px))] data-[size=lg]:before:[inline-size:6.25rem] data-[size=lg]:before:[block-size:6.25rem] data-[size=lg]:before:[mask:radial-gradient(farthest-side,_transparent_calc(100%_-_4px),_#000_calc(100%_-_3px))] data-[align=center]:[margin-inline:auto] data-[align=fixed]:[inset-block-start:50%] data-[align=fixed]:[inset-inline-start:50%] data-[align=fixed]:[translate:-50%_-50%] data-[align=absolute]:[inset-block-start:50%] data-[align=absolute]:[inset-inline-start:50%] data-[align=absolute]:[translate:-50%_-50%] data-[align=fixed]:fixed data-[align=absolute]:absolute motion-reduce:[&:not([data-value])::before]:[animation:uiify-spinner-orbit_2.5s_ease-in-out_infinite]');

@source inline("before:content-['']");

@layer base { :root { --uiify-control-text: var(--text-sm); } @media (pointer: coarse) { :root { --uiify-control-text: var(--text-base); } } }

/* Authored animation and registered properties used by the Tailwind skin.
 * Visual rules belong in utility recipes; only at-rule definitions live here. */
@layer components {
  @keyframes uiify-presence-in {
    from {
      opacity: 0;
      scale: var(--presence-scale);
    }
  }
}

@layer components {
  @keyframes uiify-presence-out {
    to {
      opacity: 0;
      scale: var(--presence-scale);
    }
  }
}

@property --uiify-spinner-tail {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}

@property --uiify-spinner-length {
  syntax: "<angle>";
  inherits: false;
  initial-value: 36deg;
}

@layer components {
  @keyframes uiify-spinner-rotate {
    to {
      transform: rotate(360deg);
    }
  }
}

@layer components {
  @keyframes uiify-spinner-orbit {
    0% {
      --uiify-spinner-tail: 0deg;
      --uiify-spinner-length: 36deg;
    }

    50% {
      --uiify-spinner-tail: 90deg;
      --uiify-spinner-length: 324deg;
    }

    100% {
      --uiify-spinner-tail: 360deg;
      --uiify-spinner-length: 36deg;
    }
  }
}

@layer components {
  @keyframes uiify-skeleton-pulse {
    50% {
      opacity: 0.45;
    }
  }
}

@layer components {
  @keyframes uiify-skeleton-shimmer {
    to {
      background-position: -200% 0;
    }
  }
}

@layer components {
  @keyframes uiify-skeleton-reveal {
    from {
      opacity: 0;
    }
    to {
      opacity: 1;
    }
  }
}

@property --candle-body-height {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 0%;
}

@property --candle-body-top {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 50%;
}

@property --candle-slot {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 100%;
}

@property --candle-wick-height {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 0%;
}

@property --candle-wick-top {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 50%;
}

@property --candle-x {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 0%;
}

@property --candle-scale {
  syntax: "<number>";
  inherits: true;
  initial-value: 1;
}

@property --line-y {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 50%;
}

@property --line-prev-y {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 50%;
}

@property --line-count {
  syntax: "<integer>";
  inherits: false;
  initial-value: 1;
}

@property --line-scale {
  syntax: "<number>";
  inherits: true;
  initial-value: 1;
}

@layer components {
  @keyframes uiify-otp-input-caret-blink {
    50% {
      opacity: 0;
    }
  }
}

API

Spinner

PropTypeDefaultDescription
asElementType"div"Changes the rendered root tag, for example as="span".
classNamestring—Class names are forwarded to the root element for client-side styling.
align"center" | "fixed" | "absolute"—Positions the spinner in normal flow, fixed to the viewport, or absolutely.
childrenReactNode—Visible content rendered alongside the status region's accessible name.
valuenumber—Fixed 0–100 percentage. Replaces the animation with a static arc and switches the role to progressbar.
delaynumber0Milliseconds to wait before the spinner appears, applied in CSS so it needs no JavaScript.
labelstring"Loading"Accessible name announced by the status region.
showLabelbooleanfalseRenders the visible label as a [data-part="label"] part.
size"small" | "default" | "large""default"Controls the CSS-only spinner diameter.
renderReactElement | ((props, state) => ReactElement)—Replace the rendered element while keeping behavior and accessibility wiring.

Keyboard

KeyAction

Accessibility

Uses a role="status" live region with an aria-label. The visual indicator is CSS-only and does not render SVG.

Styling hooks

data-uiify-spinnerdata-statedata-delaydata-valuedata-sizedata-aligndata-part="label"--uiify-spinner-tail--uiify-spinner-length