20 CSS Rotate Animations05 / 20
Continuous Spin Loading Indicator
A timing bench rather than a loading indicator: the same rotating mark shown at four durations — 0.6s, 1.2s, 2.4s and 4.8s — with a linear and an eased pair underneath. Seeing them side by side is the only reliable way to pick a speed, because duration changes what a rotation means: urgent, working, ambient or idle.
Published
The code
<section class="rot-05" aria-labelledby="rot-05-h">
<fieldset class="rot-05__theme">
<legend class="rot-05__themelegend">Theme</legend>
<input class="rot-05__themeinput" type="radio" name="rot-05-theme" id="rot-05-theme-auto" checked>
<label class="rot-05__themeopt" for="rot-05-theme-auto">Auto</label>
<input class="rot-05__themeinput" type="radio" name="rot-05-theme" id="rot-05-theme-light">
<label class="rot-05__themeopt" for="rot-05-theme-light">Light</label>
<input class="rot-05__themeinput" type="radio" name="rot-05-theme" id="rot-05-theme-dark">
<label class="rot-05__themeopt" for="rot-05-theme-dark">Dark</label>
</fieldset>
<label class="rot-05__pause">
<input class="rot-05__pausebox" type="checkbox" id="rot-05-pause">
<span class="rot-05__pausedot" aria-hidden="true">
<svg viewBox="0 0 16 16" width="12" height="12" focusable="false"><rect x="4" y="3" width="3" height="10" rx="1" fill="currentColor"></rect><rect x="9" y="3" width="3" height="10" rx="1" fill="currentColor"></rect></svg>
</span>
<span class="rot-05__pausetext">Pause</span>
</label>
<div class="rot-05__stage">
<header class="rot-05__head">
<p class="rot-05__eyebrow">Timing bench</p>
<h2 class="rot-05__h" id="rot-05-h">Same rotation. Four speeds. Four meanings.</h2>
<p class="rot-05__sub">One keyframe drives every mark below. Only <span class="rot-05__code">animation-duration</span> changes — and with it, what the motion says.</p>
</header>
<div class="rot-05__row">
<article class="rot-05__col" style="--d: 0.6s">
<div class="rot-05__well"><span class="rot-05__mark rot-05__spin" aria-hidden="true"></span></div>
<p class="rot-05__dur">0.6s</p>
<p class="rot-05__read">Urgent</p>
</article>
<article class="rot-05__col" style="--d: 1.2s">
<div class="rot-05__well"><span class="rot-05__mark rot-05__spin" aria-hidden="true"></span></div>
<p class="rot-05__dur">1.2s</p>
<p class="rot-05__read">Working</p>
</article>
<article class="rot-05__col" style="--d: 2.4s">
<div class="rot-05__well"><span class="rot-05__mark rot-05__spin" aria-hidden="true"></span></div>
<p class="rot-05__dur">2.4s</p>
<p class="rot-05__read">Ambient</p>
</article>
<article class="rot-05__col" style="--d: 4.8s">
<div class="rot-05__well"><span class="rot-05__mark rot-05__spin" aria-hidden="true"></span></div>
<p class="rot-05__dur">4.8s</p>
<p class="rot-05__read">Idle</p>
</article>
</div>
<div class="rot-05__curves">
<p class="rot-05__curvesh">Now hold the duration at 1.6s and change only the curve</p>
<div class="rot-05__curverow">
<article class="rot-05__curve">
<div class="rot-05__well rot-05__well--sm"><span class="rot-05__mark rot-05__spin rot-05__spin--linear" aria-hidden="true"></span></div>
<p class="rot-05__curvelabel">linear</p>
<p class="rot-05__curvenote">Constant velocity. What a rotating object actually does.</p>
</article>
<article class="rot-05__curve">
<div class="rot-05__well rot-05__well--sm"><span class="rot-05__mark rot-05__spin rot-05__spin--eased" aria-hidden="true"></span></div>
<p class="rot-05__curvelabel">ease-in-out</p>
<p class="rot-05__curvenote">Stalls at every cycle boundary. A pulse, not a spin.</p>
</article>
<article class="rot-05__curve">
<div class="rot-05__well rot-05__well--sm"><span class="rot-05__mark rot-05__spin rot-05__spin--stepped" aria-hidden="true"></span></div>
<p class="rot-05__curvelabel">steps(8)</p>
<p class="rot-05__curvenote">Eight discrete positions. A mechanical detent.</p>
</article>
</div>
</div>
<p class="rot-05__fine">Not a loading indicator. A real one needs a status role, a delay before it appears and a determinate replacement once progress is known — this is a bench for choosing how fast a thing should turn.</p>
</div>
</section><section class="rot-05" aria-labelledby="rot-05-h">
<fieldset class="rot-05__theme">
<legend class="rot-05__themelegend">Theme</legend>
<input class="rot-05__themeinput" type="radio" name="rot-05-theme" id="rot-05-theme-auto" checked>
<label class="rot-05__themeopt" for="rot-05-theme-auto">Auto</label>
<input class="rot-05__themeinput" type="radio" name="rot-05-theme" id="rot-05-theme-light">
<label class="rot-05__themeopt" for="rot-05-theme-light">Light</label>
<input class="rot-05__themeinput" type="radio" name="rot-05-theme" id="rot-05-theme-dark">
<label class="rot-05__themeopt" for="rot-05-theme-dark">Dark</label>
</fieldset>
<label class="rot-05__pause">
<input class="rot-05__pausebox" type="checkbox" id="rot-05-pause">
<span class="rot-05__pausedot" aria-hidden="true">
<svg viewBox="0 0 16 16" width="12" height="12" focusable="false"><rect x="4" y="3" width="3" height="10" rx="1" fill="currentColor"></rect><rect x="9" y="3" width="3" height="10" rx="1" fill="currentColor"></rect></svg>
</span>
<span class="rot-05__pausetext">Pause</span>
</label>
<div class="rot-05__stage">
<header class="rot-05__head">
<p class="rot-05__eyebrow">Timing bench</p>
<h2 class="rot-05__h" id="rot-05-h">Same rotation. Four speeds. Four meanings.</h2>
<p class="rot-05__sub">One keyframe drives every mark below. Only <span class="rot-05__code">animation-duration</span> changes — and with it, what the motion says.</p>
</header>
<div class="rot-05__row">
<article class="rot-05__col" style="--d: 0.6s">
<div class="rot-05__well"><span class="rot-05__mark rot-05__spin" aria-hidden="true"></span></div>
<p class="rot-05__dur">0.6s</p>
<p class="rot-05__read">Urgent</p>
</article>
<article class="rot-05__col" style="--d: 1.2s">
<div class="rot-05__well"><span class="rot-05__mark rot-05__spin" aria-hidden="true"></span></div>
<p class="rot-05__dur">1.2s</p>
<p class="rot-05__read">Working</p>
</article>
<article class="rot-05__col" style="--d: 2.4s">
<div class="rot-05__well"><span class="rot-05__mark rot-05__spin" aria-hidden="true"></span></div>
<p class="rot-05__dur">2.4s</p>
<p class="rot-05__read">Ambient</p>
</article>
<article class="rot-05__col" style="--d: 4.8s">
<div class="rot-05__well"><span class="rot-05__mark rot-05__spin" aria-hidden="true"></span></div>
<p class="rot-05__dur">4.8s</p>
<p class="rot-05__read">Idle</p>
</article>
</div>
<div class="rot-05__curves">
<p class="rot-05__curvesh">Now hold the duration at 1.6s and change only the curve</p>
<div class="rot-05__curverow">
<article class="rot-05__curve">
<div class="rot-05__well rot-05__well--sm"><span class="rot-05__mark rot-05__spin rot-05__spin--linear" aria-hidden="true"></span></div>
<p class="rot-05__curvelabel">linear</p>
<p class="rot-05__curvenote">Constant velocity. What a rotating object actually does.</p>
</article>
<article class="rot-05__curve">
<div class="rot-05__well rot-05__well--sm"><span class="rot-05__mark rot-05__spin rot-05__spin--eased" aria-hidden="true"></span></div>
<p class="rot-05__curvelabel">ease-in-out</p>
<p class="rot-05__curvenote">Stalls at every cycle boundary. A pulse, not a spin.</p>
</article>
<article class="rot-05__curve">
<div class="rot-05__well rot-05__well--sm"><span class="rot-05__mark rot-05__spin rot-05__spin--stepped" aria-hidden="true"></span></div>
<p class="rot-05__curvelabel">steps(8)</p>
<p class="rot-05__curvenote">Eight discrete positions. A mechanical detent.</p>
</article>
</div>
</div>
<p class="rot-05__fine">Not a loading indicator. A real one needs a status role, a delay before it appears and a determinate replacement once progress is known — this is a bench for choosing how fast a thing should turn.</p>
</div>
</section>.rot-05 {
--page: #000000;
--surface: #0a0c0a;
--ink: #f2f7f2;
--muted: #8a968c;
--rule: #1a1f1b;
--accent: #9dff4f;
--glow: 0 0 18px;
--mark-w: 0.375rem;
--mark-h: 44%;
--font-display: "Avenir Next", "Segoe UI", ui-rounded, "Nunito", system-ui, sans-serif;
position: relative;
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
background: var(--page);
color: var(--ink);
font-family: var(--font-display);
-webkit-font-smoothing: antialiased;
}
@supports (color: oklch(0 0 0)) {
.rot-05 {
--accent: oklch(0.88 0.22 130);
}
}
.rot-05 *,
.rot-05 *::before,
.rot-05 *::after {
box-sizing: border-box;
}
.rot-05__stage {
display: grid;
place-items: center;
gap: clamp(1.25rem, 3.5vw, 2rem);
padding: clamp(1rem, 3vw, 2.25rem);
padding-block-start: 5.25rem;
}
.rot-05__head {
max-inline-size: 38rem;
min-inline-size: 0;
text-align: center;
}
.rot-05__eyebrow {
margin: 0 0 0.5rem;
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--accent);
}
.rot-05__h {
margin: 0 0 0.5rem;
font-size: clamp(1.375rem, 4.6vw, 2.125rem);
font-weight: 600;
letter-spacing: -0.015em;
line-height: 1.15;
}
.rot-05__sub {
margin: 0;
font-size: 0.875rem;
line-height: 1.6;
color: var(--muted);
text-wrap: pretty;
}
.rot-05__code {
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 0.85em;
color: var(--accent);
}
.rot-05__row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(6.5rem, 1fr));
gap: 0.5rem;
inline-size: min(100%, 44rem);
padding: 0.75rem;
border: 1px solid var(--rule);
border-radius: 0.875rem;
background: var(--surface);
}
.rot-05__col {
min-inline-size: 0;
display: grid;
justify-items: center;
gap: 0.35rem;
padding: 0.75rem 0.25rem;
border-radius: 0.625rem;
}
.rot-05__well {
position: relative;
display: grid;
place-items: center;
inline-size: clamp(3.5rem, 12vw, 4.75rem);
aspect-ratio: 1;
}
.rot-05__well--sm {
inline-size: clamp(3rem, 10vw, 3.75rem);
}
.rot-05__well::before {
content: "";
position: absolute;
inset: 12%;
border: 1px solid var(--rule);
border-radius: 50%;
}
.rot-05__mark {
position: relative;
display: block;
inline-size: 100%;
block-size: 100%;
border-radius: 50%;
}
.rot-05__mark::before {
content: "";
position: absolute;
inset-block-start: 6%;
inset-inline-start: 50%;
inline-size: var(--mark-w);
block-size: var(--mark-h);
translate: -50% 0;
border-radius: 999px;
background: var(--accent);
box-shadow: var(--glow) color-mix(in srgb, var(--accent) 55%, transparent);
}
.rot-05__mark::after {
content: "";
position: absolute;
inset: 44%;
border-radius: 50%;
background: color-mix(in srgb, var(--accent) 40%, transparent);
}
.rot-05__spin {
animation: rot-05-turn var(--d, 1.6s) linear infinite;
}
.rot-05__spin--linear {
animation-duration: 1.6s;
animation-timing-function: linear;
}
.rot-05__spin--eased {
animation-duration: 1.6s;
animation-timing-function: ease-in-out;
}
.rot-05__spin--stepped {
animation-duration: 1.6s;
animation-timing-function: steps(8);
}
@keyframes rot-05-turn {
to {
transform: rotate(360deg);
}
}
.rot-05__dur {
margin: 0.2rem 0 0;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 0.9375rem;
font-weight: 600;
color: var(--ink);
}
.rot-05__read {
margin: 0;
font-size: 0.6875rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
}
.rot-05__curves {
inline-size: min(100%, 44rem);
display: grid;
gap: 0.75rem;
padding: 1rem 0.75rem;
border: 1px solid var(--rule);
border-radius: 0.875rem;
background: var(--surface);
}
.rot-05__curvesh {
margin: 0;
text-align: center;
font-size: 0.8125rem;
letter-spacing: 0.02em;
color: var(--muted);
}
.rot-05__curverow {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
gap: 0.75rem;
}
.rot-05__curve {
min-inline-size: 0;
display: grid;
justify-items: center;
gap: 0.3rem;
}
.rot-05__curvelabel {
margin: 0.15rem 0 0;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 0.8125rem;
color: var(--accent);
}
.rot-05__curvenote {
margin: 0;
max-inline-size: 13rem;
font-size: 0.75rem;
line-height: 1.5;
text-align: center;
color: var(--muted);
min-inline-size: 0;
}
.rot-05__fine {
inline-size: min(100%, 40rem);
margin: 0;
font-size: 0.75rem;
line-height: 1.65;
text-align: center;
color: var(--muted);
text-wrap: pretty;
}
@media (prefers-reduced-motion: reduce) {
.rot-05__spin {
animation: none;
}
.rot-05__col:nth-child(1) .rot-05__mark {
transform: rotate(20deg);
}
.rot-05__col:nth-child(2) .rot-05__mark {
transform: rotate(110deg);
}
.rot-05__col:nth-child(3) .rot-05__mark {
transform: rotate(200deg);
}
.rot-05__col:nth-child(4) .rot-05__mark {
transform: rotate(290deg);
}
.rot-05__curve:nth-child(1) .rot-05__mark {
transform: rotate(45deg);
}
.rot-05__curve:nth-child(2) .rot-05__mark {
transform: rotate(135deg);
}
.rot-05__curve:nth-child(3) .rot-05__mark {
transform: rotate(225deg);
}
}
.rot-05:has(.rot-05__pausebox:checked) .rot-05__spin {
animation-play-state: paused;
}
.rot-05__theme {
position: absolute;
inset-block-start: 0.875rem;
inset-inline-end: 0.875rem;
z-index: 9;
display: flex;
align-items: center;
gap: 0.125rem;
margin: 0;
padding: 0.1875rem;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 999px;
background: color-mix(in srgb, currentColor 8%, transparent);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
font-family: system-ui, sans-serif;
}
.rot-05__themelegend {
position: absolute;
inline-size: 1px;
block-size: 1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
}
.rot-05__themeinput {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.rot-05__themeopt {
display: inline-flex;
align-items: center;
justify-content: center;
min-block-size: 2.25rem;
min-inline-size: 2.75rem;
padding: 0 0.6875rem;
border-radius: 999px;
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: inherit;
opacity: 0.55;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.rot-05__themeopt:hover {
opacity: 0.85;
}
.rot-05__themeinput:checked + .rot-05__themeopt {
opacity: 1;
background: color-mix(in srgb, currentColor 15%, transparent);
}
.rot-05__themeinput:focus-visible + .rot-05__themeopt {
outline: 2px solid currentColor;
outline-offset: 2px;
}
.rot-05__pause {
position: absolute;
inset-block-start: 0.875rem;
inset-inline-start: 0.875rem;
z-index: 9;
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin: 0;
padding: 0.1875rem 0.75rem 0.1875rem 0.1875rem;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 999px;
background: color-mix(in srgb, currentColor 8%, transparent);
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
user-select: none;
}
.rot-05__pausebox {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.rot-05__pausedot {
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: 2.25rem;
block-size: 2.25rem;
border-radius: 999px;
background: color-mix(in srgb, currentColor 12%, transparent);
}
.rot-05__pause:has(.rot-05__pausebox:focus-visible) {
outline: 2px solid currentColor;
outline-offset: 2px;
}
.rot-05:has(.rot-05__pausebox:checked) .rot-05__pausedot {
background: var(--accent);
color: #06120a;
}
@media (forced-colors: active) {
.rot-05__theme,
.rot-05__pause {
border-color: CanvasText;
background: Canvas;
}
.rot-05__themeinput:checked + .rot-05__themeopt {
background: Highlight;
color: HighlightText;
}
}
@media (prefers-color-scheme: light) {
.rot-05:not(:has(#rot-05-theme-dark:checked)) {
--page: #f2f4f1;
--surface: #ffffff;
--ink: #12160f;
--muted: #5b6357;
--rule: #dfe4da;
--accent: #3f7d16;
--glow: 0 0 0;
}
}
@media (prefers-color-scheme: dark) {
.rot-05:has(#rot-05-theme-light:checked) {
--page: #f2f4f1;
--surface: #ffffff;
--ink: #12160f;
--muted: #5b6357;
--rule: #dfe4da;
--accent: #3f7d16;
--glow: 0 0 0;
}
}
[data-theme="light"] .rot-05:not(:has(#rot-05-theme-dark:checked)) {
--page: #f2f4f1;
--surface: #ffffff;
--ink: #12160f;
--muted: #5b6357;
--rule: #dfe4da;
--accent: #3f7d16;
--glow: 0 0 0;
}
.rot-05:has(#rot-05-theme-light:checked) {
--page: #f2f4f1;
--surface: #ffffff;
--ink: #12160f;
--muted: #5b6357;
--rule: #dfe4da;
--accent: #3f7d16;
--glow: 0 0 0;
}
.rot-05:has(#rot-05-theme-light:checked) .rot-05__pausedot {
color: #ffffff;
}
.rot-05:has(#rot-05-theme-dark:checked) {
--page: #000000;
--surface: #0a0c0a;
--ink: #f2f7f2;
--muted: #8a968c;
--rule: #1a1f1b;
--accent: #9dff4f;
--glow: 0 0 18px;
}
/* Site-theme bridge: the per-demo page stamps [data-theme] on the iframe
root. Without these rules a dark-by-default demo followed the OS instead
of the site when the visitor was on OS-light + site-dark. Guarded by the
light toggle so the in-demo switch still wins. */
[data-theme="dark"] .rot-05:not(:has(#rot-05-theme-light:checked)) {
--page: #000000;
--surface: #0a0c0a;
--ink: #f2f7f2;
--muted: #8a968c;
--rule: #1a1f1b;
--accent: #9dff4f;
--glow: 0 0 18px;
}.rot-05 {
--page: #000000;
--surface: #0a0c0a;
--ink: #f2f7f2;
--muted: #8a968c;
--rule: #1a1f1b;
--accent: #9dff4f;
--glow: 0 0 18px;
--mark-w: 0.375rem;
--mark-h: 44%;
--font-display: "Avenir Next", "Segoe UI", ui-rounded, "Nunito", system-ui, sans-serif;
position: relative;
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
background: var(--page);
color: var(--ink);
font-family: var(--font-display);
-webkit-font-smoothing: antialiased;
}
@supports (color: oklch(0 0 0)) {
.rot-05 {
--accent: oklch(0.88 0.22 130);
}
}
.rot-05 *,
.rot-05 *::before,
.rot-05 *::after {
box-sizing: border-box;
}
.rot-05__stage {
display: grid;
place-items: center;
gap: clamp(1.25rem, 3.5vw, 2rem);
padding: clamp(1rem, 3vw, 2.25rem);
padding-block-start: 5.25rem;
}
.rot-05__head {
max-inline-size: 38rem;
min-inline-size: 0;
text-align: center;
}
.rot-05__eyebrow {
margin: 0 0 0.5rem;
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--accent);
}
.rot-05__h {
margin: 0 0 0.5rem;
font-size: clamp(1.375rem, 4.6vw, 2.125rem);
font-weight: 600;
letter-spacing: -0.015em;
line-height: 1.15;
}
.rot-05__sub {
margin: 0;
font-size: 0.875rem;
line-height: 1.6;
color: var(--muted);
text-wrap: pretty;
}
.rot-05__code {
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 0.85em;
color: var(--accent);
}
.rot-05__row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(6.5rem, 1fr));
gap: 0.5rem;
inline-size: min(100%, 44rem);
padding: 0.75rem;
border: 1px solid var(--rule);
border-radius: 0.875rem;
background: var(--surface);
}
.rot-05__col {
min-inline-size: 0;
display: grid;
justify-items: center;
gap: 0.35rem;
padding: 0.75rem 0.25rem;
border-radius: 0.625rem;
}
.rot-05__well {
position: relative;
display: grid;
place-items: center;
inline-size: clamp(3.5rem, 12vw, 4.75rem);
aspect-ratio: 1;
}
.rot-05__well--sm {
inline-size: clamp(3rem, 10vw, 3.75rem);
}
.rot-05__well::before {
content: "";
position: absolute;
inset: 12%;
border: 1px solid var(--rule);
border-radius: 50%;
}
.rot-05__mark {
position: relative;
display: block;
inline-size: 100%;
block-size: 100%;
border-radius: 50%;
}
.rot-05__mark::before {
content: "";
position: absolute;
inset-block-start: 6%;
inset-inline-start: 50%;
inline-size: var(--mark-w);
block-size: var(--mark-h);
translate: -50% 0;
border-radius: 999px;
background: var(--accent);
box-shadow: var(--glow) color-mix(in srgb, var(--accent) 55%, transparent);
}
.rot-05__mark::after {
content: "";
position: absolute;
inset: 44%;
border-radius: 50%;
background: color-mix(in srgb, var(--accent) 40%, transparent);
}
.rot-05__spin {
animation: rot-05-turn var(--d, 1.6s) linear infinite;
}
.rot-05__spin--linear {
animation-duration: 1.6s;
animation-timing-function: linear;
}
.rot-05__spin--eased {
animation-duration: 1.6s;
animation-timing-function: ease-in-out;
}
.rot-05__spin--stepped {
animation-duration: 1.6s;
animation-timing-function: steps(8);
}
@keyframes rot-05-turn {
to {
transform: rotate(360deg);
}
}
.rot-05__dur {
margin: 0.2rem 0 0;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 0.9375rem;
font-weight: 600;
color: var(--ink);
}
.rot-05__read {
margin: 0;
font-size: 0.6875rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
}
.rot-05__curves {
inline-size: min(100%, 44rem);
display: grid;
gap: 0.75rem;
padding: 1rem 0.75rem;
border: 1px solid var(--rule);
border-radius: 0.875rem;
background: var(--surface);
}
.rot-05__curvesh {
margin: 0;
text-align: center;
font-size: 0.8125rem;
letter-spacing: 0.02em;
color: var(--muted);
}
.rot-05__curverow {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
gap: 0.75rem;
}
.rot-05__curve {
min-inline-size: 0;
display: grid;
justify-items: center;
gap: 0.3rem;
}
.rot-05__curvelabel {
margin: 0.15rem 0 0;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 0.8125rem;
color: var(--accent);
}
.rot-05__curvenote {
margin: 0;
max-inline-size: 13rem;
font-size: 0.75rem;
line-height: 1.5;
text-align: center;
color: var(--muted);
min-inline-size: 0;
}
.rot-05__fine {
inline-size: min(100%, 40rem);
margin: 0;
font-size: 0.75rem;
line-height: 1.65;
text-align: center;
color: var(--muted);
text-wrap: pretty;
}
@media (prefers-reduced-motion: reduce) {
.rot-05__spin {
animation: none;
}
.rot-05__col:nth-child(1) .rot-05__mark {
transform: rotate(20deg);
}
.rot-05__col:nth-child(2) .rot-05__mark {
transform: rotate(110deg);
}
.rot-05__col:nth-child(3) .rot-05__mark {
transform: rotate(200deg);
}
.rot-05__col:nth-child(4) .rot-05__mark {
transform: rotate(290deg);
}
.rot-05__curve:nth-child(1) .rot-05__mark {
transform: rotate(45deg);
}
.rot-05__curve:nth-child(2) .rot-05__mark {
transform: rotate(135deg);
}
.rot-05__curve:nth-child(3) .rot-05__mark {
transform: rotate(225deg);
}
}
.rot-05:has(.rot-05__pausebox:checked) .rot-05__spin {
animation-play-state: paused;
}
.rot-05__theme {
position: absolute;
inset-block-start: 0.875rem;
inset-inline-end: 0.875rem;
z-index: 9;
display: flex;
align-items: center;
gap: 0.125rem;
margin: 0;
padding: 0.1875rem;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 999px;
background: color-mix(in srgb, currentColor 8%, transparent);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
font-family: system-ui, sans-serif;
}
.rot-05__themelegend {
position: absolute;
inline-size: 1px;
block-size: 1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
}
.rot-05__themeinput {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.rot-05__themeopt {
display: inline-flex;
align-items: center;
justify-content: center;
min-block-size: 2.25rem;
min-inline-size: 2.75rem;
padding: 0 0.6875rem;
border-radius: 999px;
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: inherit;
opacity: 0.55;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.rot-05__themeopt:hover {
opacity: 0.85;
}
.rot-05__themeinput:checked + .rot-05__themeopt {
opacity: 1;
background: color-mix(in srgb, currentColor 15%, transparent);
}
.rot-05__themeinput:focus-visible + .rot-05__themeopt {
outline: 2px solid currentColor;
outline-offset: 2px;
}
.rot-05__pause {
position: absolute;
inset-block-start: 0.875rem;
inset-inline-start: 0.875rem;
z-index: 9;
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin: 0;
padding: 0.1875rem 0.75rem 0.1875rem 0.1875rem;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 999px;
background: color-mix(in srgb, currentColor 8%, transparent);
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
user-select: none;
}
.rot-05__pausebox {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.rot-05__pausedot {
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: 2.25rem;
block-size: 2.25rem;
border-radius: 999px;
background: color-mix(in srgb, currentColor 12%, transparent);
}
.rot-05__pause:has(.rot-05__pausebox:focus-visible) {
outline: 2px solid currentColor;
outline-offset: 2px;
}
.rot-05:has(.rot-05__pausebox:checked) .rot-05__pausedot {
background: var(--accent);
color: #06120a;
}
@media (forced-colors: active) {
.rot-05__theme,
.rot-05__pause {
border-color: CanvasText;
background: Canvas;
}
.rot-05__themeinput:checked + .rot-05__themeopt {
background: Highlight;
color: HighlightText;
}
}
@media (prefers-color-scheme: light) {
.rot-05:not(:has(#rot-05-theme-dark:checked)) {
--page: #f2f4f1;
--surface: #ffffff;
--ink: #12160f;
--muted: #5b6357;
--rule: #dfe4da;
--accent: #3f7d16;
--glow: 0 0 0;
}
}
@media (prefers-color-scheme: dark) {
.rot-05:has(#rot-05-theme-light:checked) {
--page: #f2f4f1;
--surface: #ffffff;
--ink: #12160f;
--muted: #5b6357;
--rule: #dfe4da;
--accent: #3f7d16;
--glow: 0 0 0;
}
}
[data-theme="light"] .rot-05:not(:has(#rot-05-theme-dark:checked)) {
--page: #f2f4f1;
--surface: #ffffff;
--ink: #12160f;
--muted: #5b6357;
--rule: #dfe4da;
--accent: #3f7d16;
--glow: 0 0 0;
}
.rot-05:has(#rot-05-theme-light:checked) {
--page: #f2f4f1;
--surface: #ffffff;
--ink: #12160f;
--muted: #5b6357;
--rule: #dfe4da;
--accent: #3f7d16;
--glow: 0 0 0;
}
.rot-05:has(#rot-05-theme-light:checked) .rot-05__pausedot {
color: #ffffff;
}
.rot-05:has(#rot-05-theme-dark:checked) {
--page: #000000;
--surface: #0a0c0a;
--ink: #f2f7f2;
--muted: #8a968c;
--rule: #1a1f1b;
--accent: #9dff4f;
--glow: 0 0 18px;
}
/* Site-theme bridge: the per-demo page stamps [data-theme] on the iframe
root. Without these rules a dark-by-default demo followed the OS instead
of the site when the visitor was on OS-light + site-dark. Guarded by the
light toggle so the in-demo switch still wins. */
[data-theme="dark"] .rot-05:not(:has(#rot-05-theme-light:checked)) {
--page: #000000;
--surface: #0a0c0a;
--ink: #f2f7f2;
--muted: #8a968c;
--rule: #1a1f1b;
--accent: #9dff4f;
--glow: 0 0 18px;
}Here's a working CSS Rotate Animation from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Continuous Spin Loading Indicator
Source: https://codefronts.com/motion/css-rotate-animation/continuous-spin-loading-indicator/
A timing bench rather than a loading indicator: the same rotating mark shown at four durations — 0.6s, 1.2s, 2.4s and 4.8s — with a linear and an eased pair underneath. Seeing them side by side is the only reliable way to pick a speed, because duration changes what a rotation means: urgent, working, ambient or idle.
## HTML
```html
<section class="rot-05" aria-labelledby="rot-05-h">
<fieldset class="rot-05__theme">
<legend class="rot-05__themelegend">Theme</legend>
<input class="rot-05__themeinput" type="radio" name="rot-05-theme" id="rot-05-theme-auto" checked>
<label class="rot-05__themeopt" for="rot-05-theme-auto">Auto</label>
<input class="rot-05__themeinput" type="radio" name="rot-05-theme" id="rot-05-theme-light">
<label class="rot-05__themeopt" for="rot-05-theme-light">Light</label>
<input class="rot-05__themeinput" type="radio" name="rot-05-theme" id="rot-05-theme-dark">
<label class="rot-05__themeopt" for="rot-05-theme-dark">Dark</label>
</fieldset>
<label class="rot-05__pause">
<input class="rot-05__pausebox" type="checkbox" id="rot-05-pause">
<span class="rot-05__pausedot" aria-hidden="true">
<svg viewBox="0 0 16 16" width="12" height="12" focusable="false"><rect x="4" y="3" width="3" height="10" rx="1" fill="currentColor"></rect><rect x="9" y="3" width="3" height="10" rx="1" fill="currentColor"></rect></svg>
</span>
<span class="rot-05__pausetext">Pause</span>
</label>
<div class="rot-05__stage">
<header class="rot-05__head">
<p class="rot-05__eyebrow">Timing bench</p>
<h2 class="rot-05__h" id="rot-05-h">Same rotation. Four speeds. Four meanings.</h2>
<p class="rot-05__sub">One keyframe drives every mark below. Only <span class="rot-05__code">animation-duration</span> changes — and with it, what the motion says.</p>
</header>
<div class="rot-05__row">
<article class="rot-05__col" style="--d: 0.6s">
<div class="rot-05__well"><span class="rot-05__mark rot-05__spin" aria-hidden="true"></span></div>
<p class="rot-05__dur">0.6s</p>
<p class="rot-05__read">Urgent</p>
</article>
<article class="rot-05__col" style="--d: 1.2s">
<div class="rot-05__well"><span class="rot-05__mark rot-05__spin" aria-hidden="true"></span></div>
<p class="rot-05__dur">1.2s</p>
<p class="rot-05__read">Working</p>
</article>
<article class="rot-05__col" style="--d: 2.4s">
<div class="rot-05__well"><span class="rot-05__mark rot-05__spin" aria-hidden="true"></span></div>
<p class="rot-05__dur">2.4s</p>
<p class="rot-05__read">Ambient</p>
</article>
<article class="rot-05__col" style="--d: 4.8s">
<div class="rot-05__well"><span class="rot-05__mark rot-05__spin" aria-hidden="true"></span></div>
<p class="rot-05__dur">4.8s</p>
<p class="rot-05__read">Idle</p>
</article>
</div>
<div class="rot-05__curves">
<p class="rot-05__curvesh">Now hold the duration at 1.6s and change only the curve</p>
<div class="rot-05__curverow">
<article class="rot-05__curve">
<div class="rot-05__well rot-05__well--sm"><span class="rot-05__mark rot-05__spin rot-05__spin--linear" aria-hidden="true"></span></div>
<p class="rot-05__curvelabel">linear</p>
<p class="rot-05__curvenote">Constant velocity. What a rotating object actually does.</p>
</article>
<article class="rot-05__curve">
<div class="rot-05__well rot-05__well--sm"><span class="rot-05__mark rot-05__spin rot-05__spin--eased" aria-hidden="true"></span></div>
<p class="rot-05__curvelabel">ease-in-out</p>
<p class="rot-05__curvenote">Stalls at every cycle boundary. A pulse, not a spin.</p>
</article>
<article class="rot-05__curve">
<div class="rot-05__well rot-05__well--sm"><span class="rot-05__mark rot-05__spin rot-05__spin--stepped" aria-hidden="true"></span></div>
<p class="rot-05__curvelabel">steps(8)</p>
<p class="rot-05__curvenote">Eight discrete positions. A mechanical detent.</p>
</article>
</div>
</div>
<p class="rot-05__fine">Not a loading indicator. A real one needs a status role, a delay before it appears and a determinate replacement once progress is known — this is a bench for choosing how fast a thing should turn.</p>
</div>
</section>
```
## CSS
```css
.rot-05 {
--page: #000000;
--surface: #0a0c0a;
--ink: #f2f7f2;
--muted: #8a968c;
--rule: #1a1f1b;
--accent: #9dff4f;
--glow: 0 0 18px;
--mark-w: 0.375rem;
--mark-h: 44%;
--font-display: "Avenir Next", "Segoe UI", ui-rounded, "Nunito", system-ui, sans-serif;
position: relative;
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
background: var(--page);
color: var(--ink);
font-family: var(--font-display);
-webkit-font-smoothing: antialiased;
}
@supports (color: oklch(0 0 0)) {
.rot-05 {
--accent: oklch(0.88 0.22 130);
}
}
.rot-05 *,
.rot-05 *::before,
.rot-05 *::after {
box-sizing: border-box;
}
.rot-05__stage {
display: grid;
place-items: center;
gap: clamp(1.25rem, 3.5vw, 2rem);
padding: clamp(1rem, 3vw, 2.25rem);
padding-block-start: 5.25rem;
}
.rot-05__head {
max-inline-size: 38rem;
min-inline-size: 0;
text-align: center;
}
.rot-05__eyebrow {
margin: 0 0 0.5rem;
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--accent);
}
.rot-05__h {
margin: 0 0 0.5rem;
font-size: clamp(1.375rem, 4.6vw, 2.125rem);
font-weight: 600;
letter-spacing: -0.015em;
line-height: 1.15;
}
.rot-05__sub {
margin: 0;
font-size: 0.875rem;
line-height: 1.6;
color: var(--muted);
text-wrap: pretty;
}
.rot-05__code {
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 0.85em;
color: var(--accent);
}
.rot-05__row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(6.5rem, 1fr));
gap: 0.5rem;
inline-size: min(100%, 44rem);
padding: 0.75rem;
border: 1px solid var(--rule);
border-radius: 0.875rem;
background: var(--surface);
}
.rot-05__col {
min-inline-size: 0;
display: grid;
justify-items: center;
gap: 0.35rem;
padding: 0.75rem 0.25rem;
border-radius: 0.625rem;
}
.rot-05__well {
position: relative;
display: grid;
place-items: center;
inline-size: clamp(3.5rem, 12vw, 4.75rem);
aspect-ratio: 1;
}
.rot-05__well--sm {
inline-size: clamp(3rem, 10vw, 3.75rem);
}
.rot-05__well::before {
content: "";
position: absolute;
inset: 12%;
border: 1px solid var(--rule);
border-radius: 50%;
}
.rot-05__mark {
position: relative;
display: block;
inline-size: 100%;
block-size: 100%;
border-radius: 50%;
}
.rot-05__mark::before {
content: "";
position: absolute;
inset-block-start: 6%;
inset-inline-start: 50%;
inline-size: var(--mark-w);
block-size: var(--mark-h);
translate: -50% 0;
border-radius: 999px;
background: var(--accent);
box-shadow: var(--glow) color-mix(in srgb, var(--accent) 55%, transparent);
}
.rot-05__mark::after {
content: "";
position: absolute;
inset: 44%;
border-radius: 50%;
background: color-mix(in srgb, var(--accent) 40%, transparent);
}
.rot-05__spin {
animation: rot-05-turn var(--d, 1.6s) linear infinite;
}
.rot-05__spin--linear {
animation-duration: 1.6s;
animation-timing-function: linear;
}
.rot-05__spin--eased {
animation-duration: 1.6s;
animation-timing-function: ease-in-out;
}
.rot-05__spin--stepped {
animation-duration: 1.6s;
animation-timing-function: steps(8);
}
@keyframes rot-05-turn {
to {
transform: rotate(360deg);
}
}
.rot-05__dur {
margin: 0.2rem 0 0;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 0.9375rem;
font-weight: 600;
color: var(--ink);
}
.rot-05__read {
margin: 0;
font-size: 0.6875rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
}
.rot-05__curves {
inline-size: min(100%, 44rem);
display: grid;
gap: 0.75rem;
padding: 1rem 0.75rem;
border: 1px solid var(--rule);
border-radius: 0.875rem;
background: var(--surface);
}
.rot-05__curvesh {
margin: 0;
text-align: center;
font-size: 0.8125rem;
letter-spacing: 0.02em;
color: var(--muted);
}
.rot-05__curverow {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
gap: 0.75rem;
}
.rot-05__curve {
min-inline-size: 0;
display: grid;
justify-items: center;
gap: 0.3rem;
}
.rot-05__curvelabel {
margin: 0.15rem 0 0;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 0.8125rem;
color: var(--accent);
}
.rot-05__curvenote {
margin: 0;
max-inline-size: 13rem;
font-size: 0.75rem;
line-height: 1.5;
text-align: center;
color: var(--muted);
min-inline-size: 0;
}
.rot-05__fine {
inline-size: min(100%, 40rem);
margin: 0;
font-size: 0.75rem;
line-height: 1.65;
text-align: center;
color: var(--muted);
text-wrap: pretty;
}
@media (prefers-reduced-motion: reduce) {
.rot-05__spin {
animation: none;
}
.rot-05__col:nth-child(1) .rot-05__mark {
transform: rotate(20deg);
}
.rot-05__col:nth-child(2) .rot-05__mark {
transform: rotate(110deg);
}
.rot-05__col:nth-child(3) .rot-05__mark {
transform: rotate(200deg);
}
.rot-05__col:nth-child(4) .rot-05__mark {
transform: rotate(290deg);
}
.rot-05__curve:nth-child(1) .rot-05__mark {
transform: rotate(45deg);
}
.rot-05__curve:nth-child(2) .rot-05__mark {
transform: rotate(135deg);
}
.rot-05__curve:nth-child(3) .rot-05__mark {
transform: rotate(225deg);
}
}
.rot-05:has(.rot-05__pausebox:checked) .rot-05__spin {
animation-play-state: paused;
}
.rot-05__theme {
position: absolute;
inset-block-start: 0.875rem;
inset-inline-end: 0.875rem;
z-index: 9;
display: flex;
align-items: center;
gap: 0.125rem;
margin: 0;
padding: 0.1875rem;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 999px;
background: color-mix(in srgb, currentColor 8%, transparent);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
font-family: system-ui, sans-serif;
}
.rot-05__themelegend {
position: absolute;
inline-size: 1px;
block-size: 1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
}
.rot-05__themeinput {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.rot-05__themeopt {
display: inline-flex;
align-items: center;
justify-content: center;
min-block-size: 2.25rem;
min-inline-size: 2.75rem;
padding: 0 0.6875rem;
border-radius: 999px;
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: inherit;
opacity: 0.55;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.rot-05__themeopt:hover {
opacity: 0.85;
}
.rot-05__themeinput:checked + .rot-05__themeopt {
opacity: 1;
background: color-mix(in srgb, currentColor 15%, transparent);
}
.rot-05__themeinput:focus-visible + .rot-05__themeopt {
outline: 2px solid currentColor;
outline-offset: 2px;
}
.rot-05__pause {
position: absolute;
inset-block-start: 0.875rem;
inset-inline-start: 0.875rem;
z-index: 9;
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin: 0;
padding: 0.1875rem 0.75rem 0.1875rem 0.1875rem;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 999px;
background: color-mix(in srgb, currentColor 8%, transparent);
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
user-select: none;
}
.rot-05__pausebox {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.rot-05__pausedot {
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: 2.25rem;
block-size: 2.25rem;
border-radius: 999px;
background: color-mix(in srgb, currentColor 12%, transparent);
}
.rot-05__pause:has(.rot-05__pausebox:focus-visible) {
outline: 2px solid currentColor;
outline-offset: 2px;
}
.rot-05:has(.rot-05__pausebox:checked) .rot-05__pausedot {
background: var(--accent);
color: #06120a;
}
@media (forced-colors: active) {
.rot-05__theme,
.rot-05__pause {
border-color: CanvasText;
background: Canvas;
}
.rot-05__themeinput:checked + .rot-05__themeopt {
background: Highlight;
color: HighlightText;
}
}
@media (prefers-color-scheme: light) {
.rot-05:not(:has(#rot-05-theme-dark:checked)) {
--page: #f2f4f1;
--surface: #ffffff;
--ink: #12160f;
--muted: #5b6357;
--rule: #dfe4da;
--accent: #3f7d16;
--glow: 0 0 0;
}
}
@media (prefers-color-scheme: dark) {
.rot-05:has(#rot-05-theme-light:checked) {
--page: #f2f4f1;
--surface: #ffffff;
--ink: #12160f;
--muted: #5b6357;
--rule: #dfe4da;
--accent: #3f7d16;
--glow: 0 0 0;
}
}
[data-theme="light"] .rot-05:not(:has(#rot-05-theme-dark:checked)) {
--page: #f2f4f1;
--surface: #ffffff;
--ink: #12160f;
--muted: #5b6357;
--rule: #dfe4da;
--accent: #3f7d16;
--glow: 0 0 0;
}
.rot-05:has(#rot-05-theme-light:checked) {
--page: #f2f4f1;
--surface: #ffffff;
--ink: #12160f;
--muted: #5b6357;
--rule: #dfe4da;
--accent: #3f7d16;
--glow: 0 0 0;
}
.rot-05:has(#rot-05-theme-light:checked) .rot-05__pausedot {
color: #ffffff;
}
.rot-05:has(#rot-05-theme-dark:checked) {
--page: #000000;
--surface: #0a0c0a;
--ink: #f2f7f2;
--muted: #8a968c;
--rule: #1a1f1b;
--accent: #9dff4f;
--glow: 0 0 18px;
}
/* Site-theme bridge: the per-demo page stamps [data-theme] on the iframe
root. Without these rules a dark-by-default demo followed the OS instead
of the site when the visitor was on OS-light + site-dark. Guarded by the
light toggle so the in-demo switch still wins. */
[data-theme="dark"] .rot-05:not(:has(#rot-05-theme-light:checked)) {
--page: #000000;
--surface: #0a0c0a;
--ink: #f2f7f2;
--muted: #8a968c;
--rule: #1a1f1b;
--accent: #9dff4f;
--glow: 0 0 18px;
}
```Here's a working CSS Rotate Animation from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Continuous Spin Loading Indicator
Source: https://codefronts.com/motion/css-rotate-animation/continuous-spin-loading-indicator/
A timing bench rather than a loading indicator: the same rotating mark shown at four durations — 0.6s, 1.2s, 2.4s and 4.8s — with a linear and an eased pair underneath. Seeing them side by side is the only reliable way to pick a speed, because duration changes what a rotation means: urgent, working, ambient or idle.
## HTML
```html
<section class="rot-05" aria-labelledby="rot-05-h">
<fieldset class="rot-05__theme">
<legend class="rot-05__themelegend">Theme</legend>
<input class="rot-05__themeinput" type="radio" name="rot-05-theme" id="rot-05-theme-auto" checked>
<label class="rot-05__themeopt" for="rot-05-theme-auto">Auto</label>
<input class="rot-05__themeinput" type="radio" name="rot-05-theme" id="rot-05-theme-light">
<label class="rot-05__themeopt" for="rot-05-theme-light">Light</label>
<input class="rot-05__themeinput" type="radio" name="rot-05-theme" id="rot-05-theme-dark">
<label class="rot-05__themeopt" for="rot-05-theme-dark">Dark</label>
</fieldset>
<label class="rot-05__pause">
<input class="rot-05__pausebox" type="checkbox" id="rot-05-pause">
<span class="rot-05__pausedot" aria-hidden="true">
<svg viewBox="0 0 16 16" width="12" height="12" focusable="false"><rect x="4" y="3" width="3" height="10" rx="1" fill="currentColor"></rect><rect x="9" y="3" width="3" height="10" rx="1" fill="currentColor"></rect></svg>
</span>
<span class="rot-05__pausetext">Pause</span>
</label>
<div class="rot-05__stage">
<header class="rot-05__head">
<p class="rot-05__eyebrow">Timing bench</p>
<h2 class="rot-05__h" id="rot-05-h">Same rotation. Four speeds. Four meanings.</h2>
<p class="rot-05__sub">One keyframe drives every mark below. Only <span class="rot-05__code">animation-duration</span> changes — and with it, what the motion says.</p>
</header>
<div class="rot-05__row">
<article class="rot-05__col" style="--d: 0.6s">
<div class="rot-05__well"><span class="rot-05__mark rot-05__spin" aria-hidden="true"></span></div>
<p class="rot-05__dur">0.6s</p>
<p class="rot-05__read">Urgent</p>
</article>
<article class="rot-05__col" style="--d: 1.2s">
<div class="rot-05__well"><span class="rot-05__mark rot-05__spin" aria-hidden="true"></span></div>
<p class="rot-05__dur">1.2s</p>
<p class="rot-05__read">Working</p>
</article>
<article class="rot-05__col" style="--d: 2.4s">
<div class="rot-05__well"><span class="rot-05__mark rot-05__spin" aria-hidden="true"></span></div>
<p class="rot-05__dur">2.4s</p>
<p class="rot-05__read">Ambient</p>
</article>
<article class="rot-05__col" style="--d: 4.8s">
<div class="rot-05__well"><span class="rot-05__mark rot-05__spin" aria-hidden="true"></span></div>
<p class="rot-05__dur">4.8s</p>
<p class="rot-05__read">Idle</p>
</article>
</div>
<div class="rot-05__curves">
<p class="rot-05__curvesh">Now hold the duration at 1.6s and change only the curve</p>
<div class="rot-05__curverow">
<article class="rot-05__curve">
<div class="rot-05__well rot-05__well--sm"><span class="rot-05__mark rot-05__spin rot-05__spin--linear" aria-hidden="true"></span></div>
<p class="rot-05__curvelabel">linear</p>
<p class="rot-05__curvenote">Constant velocity. What a rotating object actually does.</p>
</article>
<article class="rot-05__curve">
<div class="rot-05__well rot-05__well--sm"><span class="rot-05__mark rot-05__spin rot-05__spin--eased" aria-hidden="true"></span></div>
<p class="rot-05__curvelabel">ease-in-out</p>
<p class="rot-05__curvenote">Stalls at every cycle boundary. A pulse, not a spin.</p>
</article>
<article class="rot-05__curve">
<div class="rot-05__well rot-05__well--sm"><span class="rot-05__mark rot-05__spin rot-05__spin--stepped" aria-hidden="true"></span></div>
<p class="rot-05__curvelabel">steps(8)</p>
<p class="rot-05__curvenote">Eight discrete positions. A mechanical detent.</p>
</article>
</div>
</div>
<p class="rot-05__fine">Not a loading indicator. A real one needs a status role, a delay before it appears and a determinate replacement once progress is known — this is a bench for choosing how fast a thing should turn.</p>
</div>
</section>
```
## CSS
```css
.rot-05 {
--page: #000000;
--surface: #0a0c0a;
--ink: #f2f7f2;
--muted: #8a968c;
--rule: #1a1f1b;
--accent: #9dff4f;
--glow: 0 0 18px;
--mark-w: 0.375rem;
--mark-h: 44%;
--font-display: "Avenir Next", "Segoe UI", ui-rounded, "Nunito", system-ui, sans-serif;
position: relative;
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
background: var(--page);
color: var(--ink);
font-family: var(--font-display);
-webkit-font-smoothing: antialiased;
}
@supports (color: oklch(0 0 0)) {
.rot-05 {
--accent: oklch(0.88 0.22 130);
}
}
.rot-05 *,
.rot-05 *::before,
.rot-05 *::after {
box-sizing: border-box;
}
.rot-05__stage {
display: grid;
place-items: center;
gap: clamp(1.25rem, 3.5vw, 2rem);
padding: clamp(1rem, 3vw, 2.25rem);
padding-block-start: 5.25rem;
}
.rot-05__head {
max-inline-size: 38rem;
min-inline-size: 0;
text-align: center;
}
.rot-05__eyebrow {
margin: 0 0 0.5rem;
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--accent);
}
.rot-05__h {
margin: 0 0 0.5rem;
font-size: clamp(1.375rem, 4.6vw, 2.125rem);
font-weight: 600;
letter-spacing: -0.015em;
line-height: 1.15;
}
.rot-05__sub {
margin: 0;
font-size: 0.875rem;
line-height: 1.6;
color: var(--muted);
text-wrap: pretty;
}
.rot-05__code {
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 0.85em;
color: var(--accent);
}
.rot-05__row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(6.5rem, 1fr));
gap: 0.5rem;
inline-size: min(100%, 44rem);
padding: 0.75rem;
border: 1px solid var(--rule);
border-radius: 0.875rem;
background: var(--surface);
}
.rot-05__col {
min-inline-size: 0;
display: grid;
justify-items: center;
gap: 0.35rem;
padding: 0.75rem 0.25rem;
border-radius: 0.625rem;
}
.rot-05__well {
position: relative;
display: grid;
place-items: center;
inline-size: clamp(3.5rem, 12vw, 4.75rem);
aspect-ratio: 1;
}
.rot-05__well--sm {
inline-size: clamp(3rem, 10vw, 3.75rem);
}
.rot-05__well::before {
content: "";
position: absolute;
inset: 12%;
border: 1px solid var(--rule);
border-radius: 50%;
}
.rot-05__mark {
position: relative;
display: block;
inline-size: 100%;
block-size: 100%;
border-radius: 50%;
}
.rot-05__mark::before {
content: "";
position: absolute;
inset-block-start: 6%;
inset-inline-start: 50%;
inline-size: var(--mark-w);
block-size: var(--mark-h);
translate: -50% 0;
border-radius: 999px;
background: var(--accent);
box-shadow: var(--glow) color-mix(in srgb, var(--accent) 55%, transparent);
}
.rot-05__mark::after {
content: "";
position: absolute;
inset: 44%;
border-radius: 50%;
background: color-mix(in srgb, var(--accent) 40%, transparent);
}
.rot-05__spin {
animation: rot-05-turn var(--d, 1.6s) linear infinite;
}
.rot-05__spin--linear {
animation-duration: 1.6s;
animation-timing-function: linear;
}
.rot-05__spin--eased {
animation-duration: 1.6s;
animation-timing-function: ease-in-out;
}
.rot-05__spin--stepped {
animation-duration: 1.6s;
animation-timing-function: steps(8);
}
@keyframes rot-05-turn {
to {
transform: rotate(360deg);
}
}
.rot-05__dur {
margin: 0.2rem 0 0;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 0.9375rem;
font-weight: 600;
color: var(--ink);
}
.rot-05__read {
margin: 0;
font-size: 0.6875rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
}
.rot-05__curves {
inline-size: min(100%, 44rem);
display: grid;
gap: 0.75rem;
padding: 1rem 0.75rem;
border: 1px solid var(--rule);
border-radius: 0.875rem;
background: var(--surface);
}
.rot-05__curvesh {
margin: 0;
text-align: center;
font-size: 0.8125rem;
letter-spacing: 0.02em;
color: var(--muted);
}
.rot-05__curverow {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
gap: 0.75rem;
}
.rot-05__curve {
min-inline-size: 0;
display: grid;
justify-items: center;
gap: 0.3rem;
}
.rot-05__curvelabel {
margin: 0.15rem 0 0;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 0.8125rem;
color: var(--accent);
}
.rot-05__curvenote {
margin: 0;
max-inline-size: 13rem;
font-size: 0.75rem;
line-height: 1.5;
text-align: center;
color: var(--muted);
min-inline-size: 0;
}
.rot-05__fine {
inline-size: min(100%, 40rem);
margin: 0;
font-size: 0.75rem;
line-height: 1.65;
text-align: center;
color: var(--muted);
text-wrap: pretty;
}
@media (prefers-reduced-motion: reduce) {
.rot-05__spin {
animation: none;
}
.rot-05__col:nth-child(1) .rot-05__mark {
transform: rotate(20deg);
}
.rot-05__col:nth-child(2) .rot-05__mark {
transform: rotate(110deg);
}
.rot-05__col:nth-child(3) .rot-05__mark {
transform: rotate(200deg);
}
.rot-05__col:nth-child(4) .rot-05__mark {
transform: rotate(290deg);
}
.rot-05__curve:nth-child(1) .rot-05__mark {
transform: rotate(45deg);
}
.rot-05__curve:nth-child(2) .rot-05__mark {
transform: rotate(135deg);
}
.rot-05__curve:nth-child(3) .rot-05__mark {
transform: rotate(225deg);
}
}
.rot-05:has(.rot-05__pausebox:checked) .rot-05__spin {
animation-play-state: paused;
}
.rot-05__theme {
position: absolute;
inset-block-start: 0.875rem;
inset-inline-end: 0.875rem;
z-index: 9;
display: flex;
align-items: center;
gap: 0.125rem;
margin: 0;
padding: 0.1875rem;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 999px;
background: color-mix(in srgb, currentColor 8%, transparent);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
font-family: system-ui, sans-serif;
}
.rot-05__themelegend {
position: absolute;
inline-size: 1px;
block-size: 1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
}
.rot-05__themeinput {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.rot-05__themeopt {
display: inline-flex;
align-items: center;
justify-content: center;
min-block-size: 2.25rem;
min-inline-size: 2.75rem;
padding: 0 0.6875rem;
border-radius: 999px;
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: inherit;
opacity: 0.55;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.rot-05__themeopt:hover {
opacity: 0.85;
}
.rot-05__themeinput:checked + .rot-05__themeopt {
opacity: 1;
background: color-mix(in srgb, currentColor 15%, transparent);
}
.rot-05__themeinput:focus-visible + .rot-05__themeopt {
outline: 2px solid currentColor;
outline-offset: 2px;
}
.rot-05__pause {
position: absolute;
inset-block-start: 0.875rem;
inset-inline-start: 0.875rem;
z-index: 9;
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin: 0;
padding: 0.1875rem 0.75rem 0.1875rem 0.1875rem;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 999px;
background: color-mix(in srgb, currentColor 8%, transparent);
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
user-select: none;
}
.rot-05__pausebox {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.rot-05__pausedot {
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: 2.25rem;
block-size: 2.25rem;
border-radius: 999px;
background: color-mix(in srgb, currentColor 12%, transparent);
}
.rot-05__pause:has(.rot-05__pausebox:focus-visible) {
outline: 2px solid currentColor;
outline-offset: 2px;
}
.rot-05:has(.rot-05__pausebox:checked) .rot-05__pausedot {
background: var(--accent);
color: #06120a;
}
@media (forced-colors: active) {
.rot-05__theme,
.rot-05__pause {
border-color: CanvasText;
background: Canvas;
}
.rot-05__themeinput:checked + .rot-05__themeopt {
background: Highlight;
color: HighlightText;
}
}
@media (prefers-color-scheme: light) {
.rot-05:not(:has(#rot-05-theme-dark:checked)) {
--page: #f2f4f1;
--surface: #ffffff;
--ink: #12160f;
--muted: #5b6357;
--rule: #dfe4da;
--accent: #3f7d16;
--glow: 0 0 0;
}
}
@media (prefers-color-scheme: dark) {
.rot-05:has(#rot-05-theme-light:checked) {
--page: #f2f4f1;
--surface: #ffffff;
--ink: #12160f;
--muted: #5b6357;
--rule: #dfe4da;
--accent: #3f7d16;
--glow: 0 0 0;
}
}
[data-theme="light"] .rot-05:not(:has(#rot-05-theme-dark:checked)) {
--page: #f2f4f1;
--surface: #ffffff;
--ink: #12160f;
--muted: #5b6357;
--rule: #dfe4da;
--accent: #3f7d16;
--glow: 0 0 0;
}
.rot-05:has(#rot-05-theme-light:checked) {
--page: #f2f4f1;
--surface: #ffffff;
--ink: #12160f;
--muted: #5b6357;
--rule: #dfe4da;
--accent: #3f7d16;
--glow: 0 0 0;
}
.rot-05:has(#rot-05-theme-light:checked) .rot-05__pausedot {
color: #ffffff;
}
.rot-05:has(#rot-05-theme-dark:checked) {
--page: #000000;
--surface: #0a0c0a;
--ink: #f2f7f2;
--muted: #8a968c;
--rule: #1a1f1b;
--accent: #9dff4f;
--glow: 0 0 18px;
}
/* Site-theme bridge: the per-demo page stamps [data-theme] on the iframe
root. Without these rules a dark-by-default demo followed the OS instead
of the site when the visitor was on OS-light + site-dark. Guarded by the
light toggle so the in-demo switch still wins. */
[data-theme="dark"] .rot-05:not(:has(#rot-05-theme-light:checked)) {
--page: #000000;
--surface: #0a0c0a;
--ink: #f2f7f2;
--muted: #8a968c;
--rule: #1a1f1b;
--accent: #9dff4f;
--glow: 0 0 18px;
}
```How this works
Duration is the whole personality of a rotation. At 0.6 seconds a mark reads as urgent, almost anxious. At 1.2 it reads as working. At 2.4 it reads as ambient — something is happening but nobody is waiting on it. At 4.8 it reads as idle decoration you stop noticing. None of those is correct in the abstract; they are four different messages, and picking one is a design decision rather than a default.
The timing function is a separate axis. linear gives constant angular velocity, which is what a continuously rotating object physically does. ease-in-out repeated forever gives a pulse: fast through the middle, nearly stopped at each cycle boundary. That is not a subtle difference at 1.2 seconds and it is unmissable at 4.8. steps(8) is the third option nobody reaches for often enough — it quantises the rotation into eight discrete positions, which is exactly how a mechanical detent behaves.
Every column shares one keyframe. @keyframes rot-05-turn { to { transform: rotate(360deg) } } is defined once and each sample only varies --d, which the shorthand consumes as its duration. That is the pattern worth copying: never write four keyframe blocks that differ by a number, because they will drift apart the first time someone edits one of them.
The trap is treating this as a spinner. A loading indicator has to communicate an unknown wait, which means it needs an accessible status role, a delay before it appears, and a determinate replacement once progress is known. This page is a bench for choosing a rotation speed — the marks are aria-hidden decoration and the labels carry the information. Because the samples run indefinitely, the pause toggle is required, not optional.
Make it yours
- Change any column's
--dvalue to bench a different duration; the label reads from the same value you edit. - Add a fifth column by duplicating one
.rot-05__col— nothing else needs to change. - Swap the mark's shape by editing
--mark-wand--mark-h; a thin bar and a fat wedge read very differently at the same speed. - Change
--glowto dial the emissive bloom down to zero for a flat OLED look. - Replace
steps(8)withsteps(12)for a finer detent, orsteps(4)for a coarse one. - Set
--accentonce to retint every mark, label rule and the pause pill.
Gotchas — read before shipping
- An infinite rotation with
ease-in-outstalls at every cycle boundary — it is a pulse, not a spin, and at long durations it looks like dropped frames. - Four near-identical
@keyframesblocks that differ only by a number always drift apart under maintenance; vary the duration, not the keyframe. - If this were a real loading indicator, a decorative
aria-hiddenmark would leave screen reader users with no signal at all — the status text is the accessible layer, not the rotation. - A perpetual animation with no pause control fails WCAG 2.2.2 once it runs past five seconds, and four of them on one page is four failures.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 114+ | 17.5+ | 121+ | 114+ |
Floor set by :has(), oklch(), color-mix(), backdrop-filter, text-wrap as this demo is written. The core technique itself goes back further — Chrome 111+.
Nothing about the rotation is new: transform: rotate(), @keyframes, animation-duration, the steps() timing function and animation-play-state are all Baseline since 2015. The floor comes from presentation and controls — oklch() at Chrome 111, color-mix() in the pills at Safari 16.2, and :has() driving the pure-CSS theme and pause toggles at Firefox 121. Older engines get the sRGB fallbacks and lose the toggles while every timing sample still runs.