Elevator
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>
</>
);
}Markup where the component goes, CSS in your stylesheet. No JavaScript.
HTML
<div class="loader-elevator" role="status" aria-label="Loading">
<span class="loader-elevator__plate" aria-hidden="true"></span>
<span class="loader-elevator__floor" aria-hidden="true" style="transform:rotate(-68deg) translateY(-86px) rotate(68deg)">L</span>
<span class="loader-elevator__floor" aria-hidden="true" style="transform:rotate(-51deg) translateY(-86px) rotate(51deg)">1</span>
<span class="loader-elevator__floor" aria-hidden="true" style="transform:rotate(-34deg) translateY(-86px) rotate(34deg)">2</span>
<span class="loader-elevator__floor" aria-hidden="true" style="transform:rotate(-17deg) translateY(-86px) rotate(17deg)">3</span>
<span class="loader-elevator__floor" aria-hidden="true" style="transform:rotate(0deg) translateY(-86px) rotate(0deg)">4</span>
<span class="loader-elevator__floor" aria-hidden="true" style="transform:rotate(17deg) translateY(-86px) rotate(-17deg)">5</span>
<span class="loader-elevator__floor" aria-hidden="true" style="transform:rotate(34deg) translateY(-86px) rotate(-34deg)">6</span>
<span class="loader-elevator__floor" aria-hidden="true" style="transform:rotate(51deg) translateY(-86px) rotate(-51deg)">7</span>
<span class="loader-elevator__floor" aria-hidden="true" style="transform:rotate(68deg) translateY(-86px) rotate(-68deg)">8</span>
<span class="loader-elevator__needle" aria-hidden="true"></span>
<span class="loader-elevator__boss" aria-hidden="true"></span>
<span class="loader-elevator__arrows" aria-hidden="true">
<i class="loader-elevator__arrow loader-elevator__arrow--down"></i>
<i class="loader-elevator__arrow loader-elevator__arrow--up"></i>
</span>
</div>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: 5.19s 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 5.19s 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: 5.19s 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 { 0% { transform: rotate(-68deg); } 2.70% { transform: rotate(-68deg); animation-timing-function: cubic-bezier(0.3, 0, 0.3, 1.45); } 7.32% { transform: rotate(-51deg); } 10.02% { transform: rotate(-51deg); animation-timing-function: cubic-bezier(0.3, 0, 0.3, 1.45); } 14.64% { transform: rotate(-34deg); } 17.34% { transform: rotate(-34deg); animation-timing-function: cubic-bezier(0.3, 0, 0.3, 1.45); } 21.97% { transform: rotate(-17deg); } 24.66% { transform: rotate(-17deg); animation-timing-function: cubic-bezier(0.3, 0, 0.3, 1.45); } 29.29% { transform: rotate(0deg); } 31.98% { transform: rotate(0deg); animation-timing-function: cubic-bezier(0.3, 0, 0.3, 1.45); } 36.61% { transform: rotate(17deg); } 39.31% { transform: rotate(17deg); animation-timing-function: cubic-bezier(0.3, 0, 0.3, 1.45); } 43.93% { transform: rotate(34deg); } 46.63% { transform: rotate(34deg); animation-timing-function: cubic-bezier(0.3, 0, 0.3, 1.45); } 51.25% { transform: rotate(51deg); } 53.95% { transform: rotate(51deg); animation-timing-function: cubic-bezier(0.3, 0, 0.3, 1.45); } 58.57% { transform: rotate(68deg); } 72.06% { transform: rotate(68deg); animation-timing-function: cubic-bezier(0.55, 0, 0.3, 1); } 91.33%, 100% { transform: rotate(-68deg); } }
@keyframes loader-elevator-up { 0% { border-bottom-color: var(--lit); filter: drop-shadow(0 0 4px var(--glow)); } 58.57% { border-bottom-color: var(--lit); } 61.46% { border-bottom-color: var(--dim); } 64.35% { border-bottom-color: var(--lit); } 67.24% { border-bottom-color: var(--dim); } 70.13% { border-bottom-color: var(--lit); } 72.06%, 100% { border-bottom-color: var(--dim); filter: none; } }
@keyframes loader-elevator-down { 0%, 71.87% { border-top-color: var(--dim); filter: none; } 72.06% { border-top-color: var(--lit); filter: drop-shadow(0 0 4px var(--glow)); } 91.33%, 100% { border-top-color: var(--dim); filter: none; } }
.loader-elevator__floor:nth-child(2) { animation-name: loader-elevator-floor-0; } @keyframes loader-elevator-floor-0 { 0%, 100% { color: var(--dim); text-shadow: none; } 0.00%, 2.70% { color: var(--lit); text-shadow: 0 0 6px var(--glow); } 3.85% { color: var(--dim); text-shadow: none; } 90.94% { color: var(--dim); text-shadow: none; } 92.10%, 99.9% { color: var(--lit); text-shadow: 0 0 6px var(--glow); } }
.loader-elevator__floor:nth-child(3) { animation-name: loader-elevator-floor-1; } @keyframes loader-elevator-floor-1 { 0%, 100% { color: var(--dim); text-shadow: none; } 6.55%, 10.02% { color: var(--lit); text-shadow: 0 0 6px var(--glow); } 11.18% { color: var(--dim); text-shadow: none; } }
.loader-elevator__floor:nth-child(4) { animation-name: loader-elevator-floor-2; } @keyframes loader-elevator-floor-2 { 0%, 100% { color: var(--dim); text-shadow: none; } 13.87%, 17.34% { color: var(--lit); text-shadow: 0 0 6px var(--glow); } 18.50% { color: var(--dim); text-shadow: none; } }
.loader-elevator__floor:nth-child(5) { animation-name: loader-elevator-floor-3; } @keyframes loader-elevator-floor-3 { 0%, 100% { color: var(--dim); text-shadow: none; } 21.19%, 24.66% { color: var(--lit); text-shadow: 0 0 6px var(--glow); } 25.82% { color: var(--dim); text-shadow: none; } }
.loader-elevator__floor:nth-child(6) { animation-name: loader-elevator-floor-4; } @keyframes loader-elevator-floor-4 { 0%, 100% { color: var(--dim); text-shadow: none; } 28.52%, 31.98% { color: var(--lit); text-shadow: 0 0 6px var(--glow); } 33.14% { color: var(--dim); text-shadow: none; } }
.loader-elevator__floor:nth-child(7) { animation-name: loader-elevator-floor-5; } @keyframes loader-elevator-floor-5 { 0%, 100% { color: var(--dim); text-shadow: none; } 35.84%, 39.31% { color: var(--lit); text-shadow: 0 0 6px var(--glow); } 40.46% { color: var(--dim); text-shadow: none; } }
.loader-elevator__floor:nth-child(8) { animation-name: loader-elevator-floor-6; } @keyframes loader-elevator-floor-6 { 0%, 100% { color: var(--dim); text-shadow: none; } 43.16%, 46.63% { color: var(--lit); text-shadow: 0 0 6px var(--glow); } 47.78% { color: var(--dim); text-shadow: none; } }
.loader-elevator__floor:nth-child(9) { animation-name: loader-elevator-floor-7; } @keyframes loader-elevator-floor-7 { 0%, 100% { color: var(--dim); text-shadow: none; } 50.48%, 53.95% { color: var(--lit); text-shadow: 0 0 6px var(--glow); } 55.11% { color: var(--dim); text-shadow: none; } }
.loader-elevator__floor:nth-child(10) { animation-name: loader-elevator-floor-8; } @keyframes loader-elevator-floor-8 { 0%, 100% { color: var(--dim); text-shadow: none; } 57.80%, 72.06% { color: var(--lit); text-shadow: 0 0 6px var(--glow); } 73.22% { color: var(--dim); text-shadow: none; } }
@media (prefers-reduced-motion: reduce) {
.loader-elevator * { animation: none !important; }
.loader-elevator__needle { transform: rotate(0deg); }
.loader-elevator__floor:nth-child(6) { color: var(--lit); }
}Goal: Create a "Elevator" loader.
Visual behavior: An Art Deco floor dial: the needle climbs floor by floor, settling at each, lights the arrow at the top and sweeps back down.
Technique:
- Pure CSS: every motion is a keyframe animation, so the plain HTML version needs no script.
- The floors are spread along an arc and the needle keyframes are generated from them: a move and a small overshoot at each floor on the way up, then one sweep down.
- Each numeral has its own keyframes that light it while the needle is at its floor, so the lit floor always matches the needle.
- A half-moon plate with a sunburst, a double rule and up and down arrows carries the Deco look.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Elevator" loader.
Visual behavior: An Art Deco floor dial: the needle climbs floor by floor, settling at each, lights the arrow at the top and sweeps back down.
Technique:
- Pure CSS: every motion is a keyframe animation, so the plain HTML version needs no script.
- The floors are spread along an arc and the needle keyframes are generated from them: a move and a small overshoot at each floor on the way up, then one sweep down.
- Each numeral has its own keyframes that light it while the needle is at its floor, so the lit floor always matches the needle.
- A half-moon plate with a sunburst, a double rule and up and down arrows carries the Deco look.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
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.