Triad

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

const T = 1.95;
const DOT = 0.15;
// An equilateral triangle in a 1em x 0.9em box: top, bottom right, bottom left.
const V = [
  [0.5, 0.1365],
  [0.86, 0.76],
  [0.14, 0.76],
];
const HEADING = [60, 180, 300]; // direction of each edge, so the dot stretches along it
const MOTION = 0.68;
const pct = (t: number) => `${(t * 100).toFixed(2)}%`;
const pos = ([x, y]: number[]) => `translate(${(x - DOT / 2).toFixed(4)}em, ${(y - DOT / 2).toFixed(4)}em)`;

const move: string[] = [];
const squash: string[] = [];
for (let k = 0; k < 3; k++) {
  const t0 = k / 3;
  const t1 = (k + MOTION) / 3;
  move.push(`${pct(t0)} { transform: ${pos(V[k])}; animation-timing-function: cubic-bezier(0.65, 0, 0.35, 1); }`, `${pct(t1)} { transform: ${pos(V[(k + 1) % 3])}; }`);
  squash.push(
    `${pct(t0)} { transform: rotate(${HEADING[k]}deg) scale(1, 1); animation-timing-function: cubic-bezier(0.5, 0, 0.5, 1); }`,
    `${pct((k + MOTION / 2) / 3)} { transform: rotate(${HEADING[k]}deg) scale(1.45, 0.76); animation-timing-function: cubic-bezier(0.5, 0, 0.5, 1); }`,
    `${pct(t1)} { transform: rotate(${HEADING[k]}deg) scale(1, 1); }`,
  );
}
move.push(`100% { transform: ${pos(V[0])}; }`);
squash.push(`100% { transform: rotate(${HEADING[0] + 360}deg) scale(1, 1); }`);

const css = `
  .loader-triad { --ink: light-dark(#373a40, #f1f2f3); --edge: light-dark(#dbdde1, #2e3035); position: relative; display: inline-block; font-size: 112px; width: 1em; height: 0.9em; }
  .loader-triad svg { position: absolute; inset: 0; width: 100%; height: 100%; fill: none; stroke: var(--edge); stroke-width: 2.5; stroke-linejoin: round; }
  .loader-triad__dot { position: absolute; left: 0; top: 0; width: ${DOT}em; height: ${DOT}em; animation: loader-triad-move ${T}s infinite; animation-delay: var(--d); }
  .loader-triad__dot::before { content: ''; position: absolute; inset: 0; border-radius: 50%; background: var(--ink); animation: loader-triad-squash ${T}s infinite; animation-delay: var(--d); }
  @keyframes loader-triad-move {
    ${move.join('\n    ')}
  }
  @keyframes loader-triad-squash {
    ${squash.join('\n    ')}
  }
  @media (prefers-reduced-motion: reduce) {
    .loader-triad__dot, .loader-triad__dot::before { animation: none; }
    ${V.map((v, i) => `.loader-triad__dot:nth-of-type(${i + 1}) { transform: ${pos(v)}; }`).join(' ')}
  }
`;

export function Preview() {
  return (
    <>
      <style href="kit-loaders-triad" precedence="kit">
        {css}
      </style>
      <div className="loader-triad" role="status" aria-label="Loading">
        <svg viewBox="0 0 100 90" aria-hidden="true">
          <polygon points={V.map(([x, y]) => `${(x * 100).toFixed(2)},${(y * 100).toFixed(2)}`).join(' ')} />
        </svg>
        {[0, 1, 2].map((i) => (
          <span key={i} className="loader-triad__dot" style={{ '--d': `${(-(i * T) / 3).toFixed(3)}s` } 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-triad
Source on GitHub

About this component

Three dots travel the edges of a triangle corner to corner, stretching as they move and rounding up as they arrive.

Dependencies
None
Difficulty
Intermediate
Triggers
continuous, loop, morph
Techniques
keyframes, pseudo elements, 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 a dot rests at each corner.

More loaders

See all
Twist
Typewriter
Worm
Abacus