‮Skip to content‬
uiify

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

‮Prop‬‮Type‬‮Default‬‮Description‬
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.‬
delaynumber0‮Milliseconds to wait before the spinner appears, applied in CSS so it needs no JavaScript.‬
labelstring"Loading"‮Accessible name announced by the status region.‬
showLabelbooleanfalse‮Renders 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‬

‮Key‬‮Action‬

‮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