Elevator

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

const FLOORS = ['L', '1', '2', '3', '4', '5', '6', '7', '8'];
const SPREAD = 136; // degrees from L to the top floor
const ang = (f: number) => -SPREAD / 2 + (f * SPREAD) / (FLOORS.length - 1);
const MOVE = 0.24; // seconds between floors
const STOP = 0.14; // seconds settled at each
const UP = (FLOORS.length - 1) * (MOVE + STOP);
const TOP = 0.7; // waiting at the top, arrow lit
const DOWN = 1.0;
const T = +(UP + TOP + DOWN + 0.45).toFixed(2);
const pct = (s: number) => `${((s / T) * 100).toFixed(2)}%`;
const arrive = (f: number) => f * (MOVE + STOP);
// The needle: floor by floor on the way up, each stop with a little overshoot, one sweep down.
const needle = [
  `0% { transform: rotate(${ang(0)}deg); }`,
  ...FLOORS.slice(1).flatMap((_, k) => {
    const f = k + 1;
    const leave = arrive(f) - MOVE;
    return [`${pct(leave)} { transform: rotate(${ang(f - 1)}deg); animation-timing-function: cubic-bezier(0.3, 0, 0.3, 1.45); }`, `${pct(arrive(f))} { transform: rotate(${ang(f)}deg); }`];
  }),
  `${pct(UP + TOP)} { transform: rotate(${ang(FLOORS.length - 1)}deg); animation-timing-function: cubic-bezier(0.55, 0, 0.3, 1); }`,
  `${pct(UP + TOP + DOWN)}, 100% { transform: rotate(${ang(0)}deg); }`,
].join(' ');
// Each numeral lights while the car is at its floor (the lobby also at the end of the ride down).
const lit = (f: number) => {
  const on = f === 0 ? 0 : arrive(f) - 0.04;
  const off = f === FLOORS.length - 1 ? UP + TOP : arrive(f) + STOP;
  const frames = [`0%, 100% { color: var(--dim); text-shadow: none; }`, `${pct(on)}, ${pct(off)} { color: var(--lit); text-shadow: 0 0 6px var(--glow); }`, `${pct(Math.min(off + 0.06, T))} { color: var(--dim); text-shadow: none; }`];
  if (f === 0) frames.push(`${pct(UP + TOP + DOWN - 0.02)} { color: var(--dim); text-shadow: none; }`, `${pct(UP + TOP + DOWN + 0.04)}, 99.9% { color: var(--lit); text-shadow: 0 0 6px var(--glow); }`);
  return frames.join(' ');
};

const css = `
  .loader-elevator { --plate: light-dark(#ebedf0, #232529); --brass: light-dark(#5f626a, #a7abb2); --dim: color-mix(in oklab, light-dark(#6d7179, #7d8087) 80%, transparent); --lit: light-dark(#373a40, #f1f2f3); --glow: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 45%, transparent); position: relative; width: 224px; height: 150px; }
  /* A half-moon plate with a sunburst behind the numerals, edged in a double rule. */
  .loader-elevator__plate { position: absolute; left: 0; top: 10px; width: 224px; height: 112px; box-sizing: border-box; border-radius: 112px 112px 6px 6px; border: 2px solid var(--brass); background: repeating-conic-gradient(from -90deg at 50% 100%, color-mix(in oklab, var(--brass) 18%, transparent) 0 1.5deg, transparent 1.5deg 9deg), radial-gradient(circle at 50% 100%, color-mix(in oklab, var(--plate) 80%, light-dark(#373a40, #f1f2f3)) 0 30px, var(--plate) 70px); box-shadow: inset 0 0 0 4px var(--plate), inset 0 0 0 5px var(--brass), 0 10px 18px -8px light-dark(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.5)); }
  .loader-elevator__floor { position: absolute; left: 112px; top: 120px; width: 24px; margin-left: -12px; color: var(--dim); font: 400 16px/1 Didot, 'Bodoni 72', 'Bodoni MT', 'Playfair Display', Georgia, serif; text-align: center; transform-origin: 50% 0; animation: ${T}s linear infinite; }
  /* The needle and its counterweight turn about a brass boss at the foot of the dial. */
  .loader-elevator__needle { position: absolute; left: 110px; top: 50px; width: 4px; height: 82px; transform-origin: 2px 70px; animation: loader-elevator-needle ${T}s infinite; }
  .loader-elevator__needle::before { content: ''; position: absolute; left: -2px; top: 0; width: 8px; height: 82px; clip-path: polygon(50% 0, 75% 76%, 100% 86%, 100% 100%, 0 100%, 0 86%, 25% 76%); background: light-dark(#373a40, #f1f2f3); }
  .loader-elevator__boss { position: absolute; left: 103px; top: 111px; width: 18px; height: 18px; border-radius: 50%; background: radial-gradient(circle at 38% 34%, color-mix(in oklab, var(--brass) 50%, light-dark(#373a40, #f1f2f3)), var(--brass) 70%); box-shadow: 0 2px 3px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4)); }
  .loader-elevator__arrows { position: absolute; left: 0; right: 0; top: 130px; display: flex; justify-content: center; gap: 26px; }
  .loader-elevator__arrow { width: 0; height: 0; border: 7px solid transparent; animation: ${T}s steps(1) infinite; }
  .loader-elevator__arrow--up { border-bottom: 10px solid var(--dim); border-top: 0; animation-name: loader-elevator-up; }
  .loader-elevator__arrow--down { border-top: 10px solid var(--dim); border-bottom: 0; animation-name: loader-elevator-down; }
  @keyframes loader-elevator-needle { ${needle} }
  @keyframes loader-elevator-up { 0% { border-bottom-color: var(--lit); filter: drop-shadow(0 0 4px var(--glow)); } ${pct(UP)} { border-bottom-color: var(--lit); } ${pct(UP + 0.15)} { border-bottom-color: var(--dim); } ${pct(UP + 0.3)} { border-bottom-color: var(--lit); } ${pct(UP + 0.45)} { border-bottom-color: var(--dim); } ${pct(UP + 0.6)} { border-bottom-color: var(--lit); } ${pct(UP + TOP)}, 100% { border-bottom-color: var(--dim); filter: none; } }
  @keyframes loader-elevator-down { 0%, ${pct(UP + TOP - 0.01)} { border-top-color: var(--dim); filter: none; } ${pct(UP + TOP)} { border-top-color: var(--lit); filter: drop-shadow(0 0 4px var(--glow)); } ${pct(UP + TOP + DOWN)}, 100% { border-top-color: var(--dim); filter: none; } }
  ${FLOORS.map((_, f) => `.loader-elevator__floor:nth-child(${f + 2}) { animation-name: loader-elevator-floor-${f}; } @keyframes loader-elevator-floor-${f} { ${lit(f)} }`).join('\n  ')}
  @media (prefers-reduced-motion: reduce) {
    .loader-elevator * { animation: none !important; }
    .loader-elevator__needle { transform: rotate(${ang(4)}deg); }
    .loader-elevator__floor:nth-child(6) { color: var(--lit); }
  }
`;

export function Preview() {
  return (
    <>
      <style href="kit-loaders-elevator" precedence="kit">
        {css}
      </style>
      <div className="loader-elevator" role="status" aria-label="Loading">
        <span className="loader-elevator__plate" aria-hidden="true" />
        {FLOORS.map((f, i) => (
          <span key={f} className="loader-elevator__floor" aria-hidden="true" style={{ transform: `rotate(${ang(i)}deg) translateY(-86px) rotate(${-ang(i)}deg)` } as CSSProperties}>
            {f}
          </span>
        ))}
        <span className="loader-elevator__needle" aria-hidden="true" />
        <span className="loader-elevator__boss" aria-hidden="true" />
        <span className="loader-elevator__arrows" aria-hidden="true">
          <i className="loader-elevator__arrow loader-elevator__arrow--down" />
          <i className="loader-elevator__arrow loader-elevator__arrow--up" />
        </span>
      </div>
    </>
  );
}

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

Install with shadcn

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

About this component

An Art Deco floor dial: the needle climbs floor by floor, settling at each, lights the arrow at the top and sweeps back down.

Dependencies
None
Difficulty
Advanced
Triggers
continuous, loop, spring
Techniques
keyframes, css variables, 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 needle rests on floor 4 with that numeral lit.

More loaders

See all
Fan
Goo
Marble Run
Pencil