22 CSS Skeleton Loaders03 / 22

Light JSMIT licensed

Skeleton with prefers-reduced-motion Fallback

Shimmer is a repeating motion in a user's peripheral vision — for a vestibular-sensitive reader that is a symptom trigger, not a delight. This demo shows the animated skeleton and its prefers-reduced-motion fallback side by side, with an in-demo toggle so you can preview the reduced state without touching your OS settings.

Published

Live Demo
Try it

The code

<div class="sk-03">
  <div class="sk-03__stage">
    <section class="sk-03__wrap" aria-labelledby="sk-03-h">
      <header class="sk-03__head">
        <p class="sk-03__eyebrow">WCAG 2.2 · SC 2.3.3</p>
        <h2 class="sk-03__h" id="sk-03-h">The fill says loading. The motion is optional.</h2>
        <p class="sk-03__sub">Left runs the 1.4s sheen. Right is the same skeleton under <code>prefers-reduced-motion: reduce</code> — zero moving pixels, still unmistakably a placeholder.</p>
        <button class="sk-03__toggle" id="sk-03-btn" type="button" aria-pressed="false">
          <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M6 5.5v13M18 5.5v13" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
          <span id="sk-03-label">Preview reduced motion for both panels</span>
        </button>
      </header>

      <div class="sk-03__cols" id="sk-03-panels">
        <article class="sk-03__panel" role="status" aria-live="polite">
          <span class="sk-03__vh">Loading report, animated placeholder…</span>
          <header class="sk-03__phead">
            <p class="sk-03__label">default</p>
            <p class="sk-03__ptitle">Animated sheen</p>
            <p class="sk-03__pnote">1.4s linear infinite · translate on a ::after layer</p>
          </header>
          <div class="sk-03__body" aria-hidden="true">
            <div class="sk-03__row">
              <span class="sk-03__bar sk-03__avatar"></span>
              <div class="sk-03__ident"><span class="sk-03__bar" style="inline-size:8.5rem"></span><span class="sk-03__bar sk-03__bar--sm" style="inline-size:5.5rem"></span></div>
            </div>
            <span class="sk-03__bar sk-03__bar--media"></span>
            <span class="sk-03__bar sk-03__bar--sm"></span>
            <span class="sk-03__bar sk-03__bar--sm"></span>
            <span class="sk-03__bar sk-03__bar--sm" style="inline-size:58%"></span>
          </div>
        </article>

        <article class="sk-03__panel sk-03__panel--still" role="status" aria-live="polite">
          <span class="sk-03__vh">Loading report, static placeholder…</span>
          <header class="sk-03__phead">
            <p class="sk-03__label sk-03__label--on">reduce</p>
            <p class="sk-03__ptitle">Static gradient</p>
            <p class="sk-03__pnote">animation: none · linear-gradient(105deg) holds the signal</p>
          </header>
          <div class="sk-03__body" aria-hidden="true">
            <div class="sk-03__row">
              <span class="sk-03__bar sk-03__avatar"></span>
              <div class="sk-03__ident"><span class="sk-03__bar" style="inline-size:8.5rem"></span><span class="sk-03__bar sk-03__bar--sm" style="inline-size:5.5rem"></span></div>
            </div>
            <span class="sk-03__bar sk-03__bar--media"></span>
            <span class="sk-03__bar sk-03__bar--sm"></span>
            <span class="sk-03__bar sk-03__bar--sm"></span>
            <span class="sk-03__bar sk-03__bar--sm" style="inline-size:58%"></span>
          </div>
        </article>
      </div>

      <aside class="sk-03__spec" aria-label="Recipe">
        <p class="sk-03__srow"><span class="sk-03__skey">guard</span><span class="sk-03__sval">@media (prefers-reduced-motion: reduce) — required on every animated skeleton</span></p>
        <p class="sk-03__srow"><span class="sk-03__skey">keep</span><span class="sk-03__sval">a static fill above 3:1 contrast, never a blank box</span></p>
        <p class="sk-03__srow"><span class="sk-03__skey">js</span><span class="sk-03__sval">matchMedia('(prefers-reduced-motion: reduce)') + change listener</span></p>
      </aside>
    </section>
  </div>
</div>
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500&display=swap');

.sk-03 {
  --bg: #0a0806;
  --bg2: #13100c;
  --surface: rgba(255,255,255,.03);
  --line: rgba(255,255,255,.09);
  --ink: #f8f6f3;
  --muted: rgba(248,246,243,.62);
  --faint: rgba(248,246,243,.42);
  --accent: #f0b429;
  --sk: rgba(248,246,243,.1);
  --sheen: rgba(248,246,243,.19);
  --mono: 'Geist Mono',ui-monospace,'SFMono-Regular',Menlo,monospace;
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  position: relative;
  box-sizing: border-box;
  color: var(--ink);
  isolation: isolate;
  font-family: 'Geist',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;
  background: radial-gradient(65% 45% at 15% -5%, rgba(240,180,41,.1) 0%, transparent 60%), linear-gradient(178deg,var(--bg2) 0%,var(--bg) 62%);
}

.sk-03 *,
.sk-03 *::before,
.sk-03 *::after {
  box-sizing: border-box;
}

@supports (color: oklch(0.7 0.1 80)) {
  .sk-03 {
    --ink: oklch(0.975 0.006 80);
    --muted: oklch(0.975 0.006 80 / .62);
    --faint: oklch(0.975 0.006 80 / .42);
    --accent: oklch(0.82 0.14 82);
  }
}

@supports (color: color-mix(in oklab, white 10%, transparent)) {
  .sk-03 {
    --sk: color-mix(in oklab, currentColor 10%, transparent);
    --sheen: color-mix(in oklab, currentColor 19%, transparent);
  }
}

.sk-03__vh {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

.sk-03__stage {
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  padding: clamp(1.15rem,4vw,3.25rem);
}

.sk-03__wrap {
  inline-size: min(74rem,100%);
  display: grid;
  gap: clamp(1rem,2.2vw,1.6rem);
}

.sk-03__head {
  display: grid;
  gap: .6rem;
  max-inline-size: 46rem;
  justify-items: start;
}

.sk-03__eyebrow {
  margin: 0;
  font-family: var(--mono);
  font-size: .72rem;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--accent);
}

.sk-03__h {
  margin: 0;
  font-size: clamp(1.6rem,3.6vw,2.5rem);
  font-weight: 600;
  letter-spacing: -.032em;
  line-height: 1.06;
  text-wrap: balance;
}

.sk-03__sub {
  margin: 0;
  font-size: clamp(.94rem,1.3vw,1.02rem);
  line-height: 1.55;
  color: var(--muted);
  text-wrap: pretty;
}

.sk-03__sub code {
  font-family: var(--mono);
  font-size: .88em;
  color: var(--ink);
}

.sk-03__toggle {
  margin-block-start: .3rem;
  min-block-size: 2.75rem;
  display: inline-flex;
  align-items: center;
  gap: .55rem;
  padding: .6rem 1rem;
  border-radius: 999px;
  cursor: pointer;
  font: inherit;
  font-size: .88rem;
  font-weight: 500;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  transition: background .18s ease, border-color .18s ease;
}

.sk-03__toggle svg {
  inline-size: 1rem;
  block-size: 1rem;
  color: var(--accent);
}

.sk-03__toggle:hover {
  background: rgba(255,255,255,.07);
}

.sk-03__toggle:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

.sk-03__toggle[aria-pressed="true"] {
  background: var(--accent);
  border-color: var(--accent);
  color: #1a1206;
}

.sk-03__toggle[aria-pressed="true"] svg {
  color: #1a1206;
}

.sk-03__cols {
  display: grid;
  gap: clamp(.9rem,1.8vw,1.25rem);
  grid-template-columns: repeat(auto-fit,minmax(17rem,1fr));
}

.sk-03__panel {
  display: grid;
  gap: 1.1rem;
  align-content: start;
  padding: clamp(1.05rem,2vw,1.5rem);
  border: 1px solid var(--line);
  border-radius: 1.1rem;
  background: var(--surface);
  min-block-size: 24rem;
}

.sk-03__panel--still {
  border-color: rgba(240,180,41,.3);
}

.sk-03__phead {
  display: grid;
  gap: .35rem;
}

.sk-03__label {
  margin: 0;
  font-family: var(--mono);
  font-size: .66rem;
  letter-spacing: .13em;
  text-transform: uppercase;
  color: var(--faint);
}

.sk-03__label--on {
  color: var(--accent);
}

.sk-03__ptitle {
  margin: 0;
  font-size: 1.05rem;
  font-weight: 500;
  letter-spacing: -.015em;
}

.sk-03__pnote {
  margin: 0;
  font-family: var(--mono);
  font-size: .7rem;
  line-height: 1.55;
  color: var(--muted);
  text-wrap: pretty;
}

.sk-03__body {
  display: grid;
  gap: .7rem;
}

.sk-03__row {
  display: flex;
  align-items: center;
  gap: .85rem;
  margin-block-end: .2rem;
}

.sk-03__ident {
  display: grid;
  gap: .45rem;
  min-inline-size: 0;
}

.sk-03__bar {
  display: block;
  position: relative;
  overflow: hidden;
  block-size: .85rem;
  inline-size: 100%;
  border-radius: 6px;
  background: var(--sk);
}

.sk-03__bar::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(100deg, transparent 18%, var(--sheen) 50%, transparent 82%);
  translate: -100% 0;
  animation: sk-03-sheen 1.4s linear infinite;
}

@keyframes sk-03-sheen {
  to {
    translate: 100% 0;
  }
}

.sk-03__bar--sm {
  block-size: .7rem;
}

.sk-03__bar--media {
  block-size: auto;
  aspect-ratio: 16/9;
  border-radius: 10px;
  margin-block: .25rem;
}

.sk-03__avatar {
  inline-size: 2.6rem;
  block-size: auto;
  aspect-ratio: 1;
  border-radius: 999px;
  flex: none;
}
/* the fallback: authored once, applied by the media query AND by the preview class */

.sk-03__panel--still .sk-03__bar::after,
.sk-03--still .sk-03__bar::after {
  animation: none;
  background: none;
}

.sk-03__panel--still .sk-03__bar,
.sk-03--still .sk-03__bar {
  background: linear-gradient(105deg, var(--sk) 0%, var(--sheen) 50%, var(--sk) 100%);
}

.sk-03__spec {
  padding: clamp(.85rem,1.5vw,1.1rem) clamp(1rem,1.8vw,1.3rem);
  border: 1px solid var(--line);
  border-radius: .9rem;
  background: var(--surface);
  display: grid;
  gap: .45rem;
}

.sk-03__srow {
  margin: 0;
  display: flex;
  gap: .9rem;
  flex-wrap: wrap;
  font-family: var(--mono);
  font-size: .74rem;
  line-height: 1.5;
}

.sk-03__skey {
  min-inline-size: 3.5rem;
  color: var(--accent);
  letter-spacing: .08em;
  text-transform: uppercase;
  font-size: .66rem;
  padding-block-start: .12rem;
}

.sk-03__sval {
  color: var(--muted);
  word-break: break-word;
}

.sk-03[data-theme="light"] {
  --bg: #fbf9f6;
  --bg2: #fff;
  --surface: rgba(18,12,6,.028);
  --line: rgba(18,12,6,.11);
  --ink: #120c06;
  --muted: rgba(18,12,6,.62);
  --faint: rgba(18,12,6,.44);
  --accent: #9a6100;
  background: linear-gradient(178deg,#fff 0%,#f5f1ea 100%);
}

@media (prefers-color-scheme: light) {
  .sk-03:not([data-theme="dark"]) {
    --bg: #fbf9f6;
    --bg2: #fff;
    --surface: rgba(18,12,6,.028);
    --line: rgba(18,12,6,.11);
    --ink: #120c06;
    --muted: rgba(18,12,6,.62);
    --faint: rgba(18,12,6,.44);
    --accent: #9a6100;
    background: linear-gradient(178deg,#fff 0%,#f5f1ea 100%);
  }
}

@media (prefers-reduced-motion: reduce) {
  .sk-03__bar::after {
    animation: none;
    background: none;
  }

  .sk-03__bar {
    background: linear-gradient(105deg, var(--sk) 0%, var(--sheen) 50%, var(--sk) 100%);
  }

  .sk-03__toggle {
    transition: none;
  }
}

@media (forced-colors: active) {
  .sk-03__bar {
    background: GrayText;
    forced-color-adjust: none;
  }

  .sk-03__bar::after {
    animation: none;
    background: none;
  }

  .sk-03__panel,
    .sk-03__spec,
    .sk-03__toggle {
    border-color: CanvasText;
  }
}
const root = document.querySelector('.sk-03');
const btn = document.getElementById('sk-03-btn');

btn.addEventListener('click', () => {
  const on = root.classList.toggle('sk-03--still');
  btn.setAttribute('aria-pressed', String(on));
});

// keep the preview honest: if the OS already asks for reduced motion, say so
const mq = matchMedia('(prefers-reduced-motion: reduce)');
const label = document.getElementById('sk-03-label');
const sync = () => {
  btn.disabled = mq.matches;
  label.textContent = mq.matches
    ? 'Reduced motion is already on at OS level'
    : 'Preview reduced motion for both panels';
};
mq.addEventListener('change', sync);
sync();
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Skeleton Loader 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: Skeleton with prefers-reduced-motion Fallback
Source: https://codefronts.com/components/css-skeleton-loaders/skeleton-with-prefers-reduced-motion-fallback/

Shimmer is a repeating motion in a user's peripheral vision — for a vestibular-sensitive reader that is a symptom trigger, not a delight. This demo shows the animated skeleton and its prefers-reduced-motion fallback side by side, with an in-demo toggle so you can preview the reduced state without touching your OS settings.
## HTML
```html
<div class="sk-03">
  <div class="sk-03__stage">
    <section class="sk-03__wrap" aria-labelledby="sk-03-h">
      <header class="sk-03__head">
        <p class="sk-03__eyebrow">WCAG 2.2 · SC 2.3.3</p>
        <h2 class="sk-03__h" id="sk-03-h">The fill says loading. The motion is optional.</h2>
        <p class="sk-03__sub">Left runs the 1.4s sheen. Right is the same skeleton under <code>prefers-reduced-motion: reduce</code> — zero moving pixels, still unmistakably a placeholder.</p>
        <button class="sk-03__toggle" id="sk-03-btn" type="button" aria-pressed="false">
          <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M6 5.5v13M18 5.5v13" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
          <span id="sk-03-label">Preview reduced motion for both panels</span>
        </button>
      </header>

      <div class="sk-03__cols" id="sk-03-panels">
        <article class="sk-03__panel" role="status" aria-live="polite">
          <span class="sk-03__vh">Loading report, animated placeholder…</span>
          <header class="sk-03__phead">
            <p class="sk-03__label">default</p>
            <p class="sk-03__ptitle">Animated sheen</p>
            <p class="sk-03__pnote">1.4s linear infinite · translate on a ::after layer</p>
          </header>
          <div class="sk-03__body" aria-hidden="true">
            <div class="sk-03__row">
              <span class="sk-03__bar sk-03__avatar"></span>
              <div class="sk-03__ident"><span class="sk-03__bar" style="inline-size:8.5rem"></span><span class="sk-03__bar sk-03__bar--sm" style="inline-size:5.5rem"></span></div>
            </div>
            <span class="sk-03__bar sk-03__bar--media"></span>
            <span class="sk-03__bar sk-03__bar--sm"></span>
            <span class="sk-03__bar sk-03__bar--sm"></span>
            <span class="sk-03__bar sk-03__bar--sm" style="inline-size:58%"></span>
          </div>
        </article>

        <article class="sk-03__panel sk-03__panel--still" role="status" aria-live="polite">
          <span class="sk-03__vh">Loading report, static placeholder…</span>
          <header class="sk-03__phead">
            <p class="sk-03__label sk-03__label--on">reduce</p>
            <p class="sk-03__ptitle">Static gradient</p>
            <p class="sk-03__pnote">animation: none · linear-gradient(105deg) holds the signal</p>
          </header>
          <div class="sk-03__body" aria-hidden="true">
            <div class="sk-03__row">
              <span class="sk-03__bar sk-03__avatar"></span>
              <div class="sk-03__ident"><span class="sk-03__bar" style="inline-size:8.5rem"></span><span class="sk-03__bar sk-03__bar--sm" style="inline-size:5.5rem"></span></div>
            </div>
            <span class="sk-03__bar sk-03__bar--media"></span>
            <span class="sk-03__bar sk-03__bar--sm"></span>
            <span class="sk-03__bar sk-03__bar--sm"></span>
            <span class="sk-03__bar sk-03__bar--sm" style="inline-size:58%"></span>
          </div>
        </article>
      </div>

      <aside class="sk-03__spec" aria-label="Recipe">
        <p class="sk-03__srow"><span class="sk-03__skey">guard</span><span class="sk-03__sval">@media (prefers-reduced-motion: reduce) — required on every animated skeleton</span></p>
        <p class="sk-03__srow"><span class="sk-03__skey">keep</span><span class="sk-03__sval">a static fill above 3:1 contrast, never a blank box</span></p>
        <p class="sk-03__srow"><span class="sk-03__skey">js</span><span class="sk-03__sval">matchMedia('(prefers-reduced-motion: reduce)') + change listener</span></p>
      </aside>
    </section>
  </div>
</div>
```
## CSS
```css
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500&display=swap');

.sk-03 {
  --bg: #0a0806;
  --bg2: #13100c;
  --surface: rgba(255,255,255,.03);
  --line: rgba(255,255,255,.09);
  --ink: #f8f6f3;
  --muted: rgba(248,246,243,.62);
  --faint: rgba(248,246,243,.42);
  --accent: #f0b429;
  --sk: rgba(248,246,243,.1);
  --sheen: rgba(248,246,243,.19);
  --mono: 'Geist Mono',ui-monospace,'SFMono-Regular',Menlo,monospace;
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  position: relative;
  box-sizing: border-box;
  color: var(--ink);
  isolation: isolate;
  font-family: 'Geist',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;
  background: radial-gradient(65% 45% at 15% -5%, rgba(240,180,41,.1) 0%, transparent 60%), linear-gradient(178deg,var(--bg2) 0%,var(--bg) 62%);
}

.sk-03 *,
.sk-03 *::before,
.sk-03 *::after {
  box-sizing: border-box;
}

@supports (color: oklch(0.7 0.1 80)) {
  .sk-03 {
    --ink: oklch(0.975 0.006 80);
    --muted: oklch(0.975 0.006 80 / .62);
    --faint: oklch(0.975 0.006 80 / .42);
    --accent: oklch(0.82 0.14 82);
  }
}

@supports (color: color-mix(in oklab, white 10%, transparent)) {
  .sk-03 {
    --sk: color-mix(in oklab, currentColor 10%, transparent);
    --sheen: color-mix(in oklab, currentColor 19%, transparent);
  }
}

.sk-03__vh {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

.sk-03__stage {
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  padding: clamp(1.15rem,4vw,3.25rem);
}

.sk-03__wrap {
  inline-size: min(74rem,100%);
  display: grid;
  gap: clamp(1rem,2.2vw,1.6rem);
}

.sk-03__head {
  display: grid;
  gap: .6rem;
  max-inline-size: 46rem;
  justify-items: start;
}

.sk-03__eyebrow {
  margin: 0;
  font-family: var(--mono);
  font-size: .72rem;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--accent);
}

.sk-03__h {
  margin: 0;
  font-size: clamp(1.6rem,3.6vw,2.5rem);
  font-weight: 600;
  letter-spacing: -.032em;
  line-height: 1.06;
  text-wrap: balance;
}

.sk-03__sub {
  margin: 0;
  font-size: clamp(.94rem,1.3vw,1.02rem);
  line-height: 1.55;
  color: var(--muted);
  text-wrap: pretty;
}

.sk-03__sub code {
  font-family: var(--mono);
  font-size: .88em;
  color: var(--ink);
}

.sk-03__toggle {
  margin-block-start: .3rem;
  min-block-size: 2.75rem;
  display: inline-flex;
  align-items: center;
  gap: .55rem;
  padding: .6rem 1rem;
  border-radius: 999px;
  cursor: pointer;
  font: inherit;
  font-size: .88rem;
  font-weight: 500;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  transition: background .18s ease, border-color .18s ease;
}

.sk-03__toggle svg {
  inline-size: 1rem;
  block-size: 1rem;
  color: var(--accent);
}

.sk-03__toggle:hover {
  background: rgba(255,255,255,.07);
}

.sk-03__toggle:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

.sk-03__toggle[aria-pressed="true"] {
  background: var(--accent);
  border-color: var(--accent);
  color: #1a1206;
}

.sk-03__toggle[aria-pressed="true"] svg {
  color: #1a1206;
}

.sk-03__cols {
  display: grid;
  gap: clamp(.9rem,1.8vw,1.25rem);
  grid-template-columns: repeat(auto-fit,minmax(17rem,1fr));
}

.sk-03__panel {
  display: grid;
  gap: 1.1rem;
  align-content: start;
  padding: clamp(1.05rem,2vw,1.5rem);
  border: 1px solid var(--line);
  border-radius: 1.1rem;
  background: var(--surface);
  min-block-size: 24rem;
}

.sk-03__panel--still {
  border-color: rgba(240,180,41,.3);
}

.sk-03__phead {
  display: grid;
  gap: .35rem;
}

.sk-03__label {
  margin: 0;
  font-family: var(--mono);
  font-size: .66rem;
  letter-spacing: .13em;
  text-transform: uppercase;
  color: var(--faint);
}

.sk-03__label--on {
  color: var(--accent);
}

.sk-03__ptitle {
  margin: 0;
  font-size: 1.05rem;
  font-weight: 500;
  letter-spacing: -.015em;
}

.sk-03__pnote {
  margin: 0;
  font-family: var(--mono);
  font-size: .7rem;
  line-height: 1.55;
  color: var(--muted);
  text-wrap: pretty;
}

.sk-03__body {
  display: grid;
  gap: .7rem;
}

.sk-03__row {
  display: flex;
  align-items: center;
  gap: .85rem;
  margin-block-end: .2rem;
}

.sk-03__ident {
  display: grid;
  gap: .45rem;
  min-inline-size: 0;
}

.sk-03__bar {
  display: block;
  position: relative;
  overflow: hidden;
  block-size: .85rem;
  inline-size: 100%;
  border-radius: 6px;
  background: var(--sk);
}

.sk-03__bar::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(100deg, transparent 18%, var(--sheen) 50%, transparent 82%);
  translate: -100% 0;
  animation: sk-03-sheen 1.4s linear infinite;
}

@keyframes sk-03-sheen {
  to {
    translate: 100% 0;
  }
}

.sk-03__bar--sm {
  block-size: .7rem;
}

.sk-03__bar--media {
  block-size: auto;
  aspect-ratio: 16/9;
  border-radius: 10px;
  margin-block: .25rem;
}

.sk-03__avatar {
  inline-size: 2.6rem;
  block-size: auto;
  aspect-ratio: 1;
  border-radius: 999px;
  flex: none;
}
/* the fallback: authored once, applied by the media query AND by the preview class */

.sk-03__panel--still .sk-03__bar::after,
.sk-03--still .sk-03__bar::after {
  animation: none;
  background: none;
}

.sk-03__panel--still .sk-03__bar,
.sk-03--still .sk-03__bar {
  background: linear-gradient(105deg, var(--sk) 0%, var(--sheen) 50%, var(--sk) 100%);
}

.sk-03__spec {
  padding: clamp(.85rem,1.5vw,1.1rem) clamp(1rem,1.8vw,1.3rem);
  border: 1px solid var(--line);
  border-radius: .9rem;
  background: var(--surface);
  display: grid;
  gap: .45rem;
}

.sk-03__srow {
  margin: 0;
  display: flex;
  gap: .9rem;
  flex-wrap: wrap;
  font-family: var(--mono);
  font-size: .74rem;
  line-height: 1.5;
}

.sk-03__skey {
  min-inline-size: 3.5rem;
  color: var(--accent);
  letter-spacing: .08em;
  text-transform: uppercase;
  font-size: .66rem;
  padding-block-start: .12rem;
}

.sk-03__sval {
  color: var(--muted);
  word-break: break-word;
}

.sk-03[data-theme="light"] {
  --bg: #fbf9f6;
  --bg2: #fff;
  --surface: rgba(18,12,6,.028);
  --line: rgba(18,12,6,.11);
  --ink: #120c06;
  --muted: rgba(18,12,6,.62);
  --faint: rgba(18,12,6,.44);
  --accent: #9a6100;
  background: linear-gradient(178deg,#fff 0%,#f5f1ea 100%);
}

@media (prefers-color-scheme: light) {
  .sk-03:not([data-theme="dark"]) {
    --bg: #fbf9f6;
    --bg2: #fff;
    --surface: rgba(18,12,6,.028);
    --line: rgba(18,12,6,.11);
    --ink: #120c06;
    --muted: rgba(18,12,6,.62);
    --faint: rgba(18,12,6,.44);
    --accent: #9a6100;
    background: linear-gradient(178deg,#fff 0%,#f5f1ea 100%);
  }
}

@media (prefers-reduced-motion: reduce) {
  .sk-03__bar::after {
    animation: none;
    background: none;
  }

  .sk-03__bar {
    background: linear-gradient(105deg, var(--sk) 0%, var(--sheen) 50%, var(--sk) 100%);
  }

  .sk-03__toggle {
    transition: none;
  }
}

@media (forced-colors: active) {
  .sk-03__bar {
    background: GrayText;
    forced-color-adjust: none;
  }

  .sk-03__bar::after {
    animation: none;
    background: none;
  }

  .sk-03__panel,
    .sk-03__spec,
    .sk-03__toggle {
    border-color: CanvasText;
  }
}
```

## JavaScript
```js
const root = document.querySelector('.sk-03');
const btn = document.getElementById('sk-03-btn');

btn.addEventListener('click', () => {
  const on = root.classList.toggle('sk-03--still');
  btn.setAttribute('aria-pressed', String(on));
});

// keep the preview honest: if the OS already asks for reduced motion, say so
const mq = matchMedia('(prefers-reduced-motion: reduce)');
const label = document.getElementById('sk-03-label');
const sync = () => {
  btn.disabled = mq.matches;
  label.textContent = mq.matches
    ? 'Reduced motion is already on at OS level'
    : 'Preview reduced motion for both panels';
};
mq.addEventListener('change', sync);
sync();
```

How this works

WCAG 2.2 SC 2.3.3 (Animation from Interactions) and SC 2.2.2 (Pause, Stop, Hide) both point the same way: non-essential motion must be removable. A skeleton's animation is decoration — the fill is what communicates "loading", so the fill must survive when the motion goes:

@media (prefers-reduced-motion: reduce){
  .sk-03__bar::after{ animation:none; background:none }
  .sk-03__bar{
    /* still obviously a placeholder, just not moving */
    background:linear-gradient(105deg, var(--sk) 0%, var(--sheen) 50%, var(--sk) 100%);
  }
}

Do not fall back to a flat block. A single muted rectangle can read as an empty state or a broken image. Keeping a static diagonal gradient preserves the "this is a placeholder" signal while removing every moving pixel.

Previewing the reduced state. A media query cannot be faked, so the demo also authors the reduced rules under a class and toggles the class with a button — the media query still applies for real users, and reviewers can see both states on one machine:

btn.addEventListener('click', () => {
  const on = panel.classList.toggle('sk-03--still');
  btn.setAttribute('aria-pressed', String(on));
});

Detect it in JS when you must. If your loading state is driven by JavaScript (Framer Motion, GSAP, Lottie), read the same signal so the two never disagree: matchMedia('(prefers-reduced-motion: reduce)').matches. Listen for change too — users flip this setting mid-session.

Make it yours

  • Tune the static fallback's contrast with the --sheen stop; keep it above 3:1 against the surface so low-vision users still see the placeholder.
  • Some teams prefer a slow 4s opacity fade under reduced motion instead of a full stop. That is still motion — only do it if your accessibility review signs off.
  • Swap the 105deg angle to 90deg for a straight vertical wash if your bars are tall blocks rather than lines.
  • Reuse the .sk-03--still class as a global kill switch wired to a user preference in your own settings UI.
  • Delete the toggle for production; it exists so the reduced state is reviewable.
  • Apply the same guard to spinners, progress bars and any looping SVG in the same layout — a stopped skeleton next to a spinning ring is inconsistent.

Gotchas — read before shipping

  • animation: none alone is not enough — if your keyframes were also supplying the visible fill, the bar becomes invisible. Always restate a static background.
  • Do not gate the whole skeleton behind the media query and render nothing; the loading state must still exist for reduced-motion users.
  • prefers-reduced-motion also fires on Windows "Show animations" being off, and it changes at runtime — a value read once at boot goes stale.
  • Reduced motion is not reduced transparency or reduced data. Do not overload one query with unrelated changes.
  • Skip links and focus rings must keep working in the reduced branch; a blanket * { transition:none !important } can break focus visibility if you also removed outlines.
  • Testing in DevTools emulation only covers Chrome's rendering — verify on a real device with the OS setting on, especially iOS Safari.

Browser support

ChromeSafariFirefoxEdge
114+17.5+121+114+

Floor set by oklch(), color-mix(), text-wrap as this demo is written. The core technique itself goes back further — Chrome 74+.

prefers-reduced-motion is universally supported (Chrome 74, Safari 10.1, Firefox 63). matchMedia change events need Safari 14+. color-mix() tokens are gated behind @supports with rgba() fallbacks so the demo degrades to Chrome 76-era engines cleanly.

Techniques used in this demo

Search CodeFronts

Loading…