Quarters

No dependencies
Code format
React + Tailwind
import type { CSSProperties } from 'react';

const STEPS = 12; // three pieces, four slots: twelve moves bring every piece home
const STEP = 0.32;
const T = STEPS * STEP;
const MOTION = 0.62;
const pct = (t: number) => `${(t * 100).toFixed(2)}%`;

// Slots go clockwise from the top left. The piece just behind the gap swings
// into it, which leaves the gap where that piece was.
const slotOf = [1, 2, 3];
const angle = slotOf.map((s) => s * 90);
const frames: string[][] = angle.map((a) => [`0% { rotate: ${a}deg; scale: 1; }`]);
for (let k = 0; k < STEPS; k++) {
  const gap = [0, 1, 2, 3].find((s) => !slotOf.includes(s))!;
  const mover = slotOf.indexOf((gap + 3) % 4);
  frames[mover].push(`${pct(k / STEPS)} { rotate: ${angle[mover]}deg; scale: 1; animation-timing-function: cubic-bezier(0.7, 0, 0.25, 1); }`);
  frames[mover].push(`${pct((k + MOTION / 2) / STEPS)} { scale: 1.07; }`);
  angle[mover] += 90;
  frames[mover].push(`${pct((k + MOTION) / STEPS)} { rotate: ${angle[mover]}deg; scale: 1; }`);
  slotOf[mover] = gap;
}
frames.forEach((f, i) => f.push(`100% { rotate: ${angle[i]}deg; scale: 1; }`));

const css = `
  .loader-quarters { --ink: light-dark(#373a40, #f1f2f3); position: relative; display: inline-block; font-size: 88px; width: 1em; height: 1em; }
  .loader-quarters::before { content: ''; position: absolute; inset: 0; border-radius: 50%; background: color-mix(in oklab, var(--ink) 8%, transparent); }
  /* Every piece is drawn as the top-left quarter and turned into its slot round the disc's centre. */
  .loader-quarters__piece { position: absolute; left: 0; top: 0; width: 0.47em; height: 0.47em; border-top-left-radius: 100%; background: var(--ink); transform-origin: 0.5em 0.5em; rotate: var(--a); animation: ${T.toFixed(2)}s linear infinite; }
  ${frames.map((f, i) => `.loader-quarters__piece:nth-child(${i + 1}) { animation-name: loader-quarters-piece-${i}; }\n  @keyframes loader-quarters-piece-${i} {\n    ${f.join('\n    ')}\n  }`).join('\n  ')}
  @media (prefers-reduced-motion: reduce) {
    .loader-quarters__piece { animation: none !important; }
  }
`;

export function Preview() {
  return (
    <>
      <style href="kit-loaders-quarters" precedence="kit">
        {css}
      </style>
      <div className="loader-quarters" role="status" aria-label="Loading">
        {[1, 2, 3].map((s) => (
          <span key={s} className="loader-quarters__piece" style={{ '--a': `${s * 90}deg` } as CSSProperties} aria-hidden="true" />
        ))}
      </div>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/loader-quarters
Source on GitHub

About this component

A disc with one quarter missing: the pieces swing round into the gap one at a time, so the gap walks the other way.

Dependencies
None
Difficulty
Intermediate
Triggers
continuous, loop
Techniques
keyframes, border radius, transform only
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

The root has role="status" and aria-label="Loading"; everything inside it is aria-hidden. Under prefers-reduced-motion the disc rests with its gap at the top left.

More loaders

See all
Record
Relay
Shuffle
Sine