Accessibility pattern · Feedback and status
Skeleton loader
The grey shapes are for eyes only: they are aria-hidden, sized like the real cards so nothing jumps, and they stop shimmering when the system asks for less motion. Screen readers get the same story in words: a list marked busy, a hidden Loading profiles status, and one polite message when the people arrive.
- WCAG criteria
- 3
- Keyboard rules
- 2
- Checked with
- axe, keyboard and the inspector
Live demo
Try it
Use it with a mouse, a keyboard or a screen reader. The inspector beside it shows what the browser tells assistive technology as you go: focus, state changes and announcements.
Design team
Pune and Bengaluru studios
Loading profiles
Asha Rao
Lead product designer
Pune
Vikram Nair
UX researcher
Bengaluru
Zoya Khan
Visual designer
Pune
Dev Malhotra
Accessibility lead
Bengaluru
The demo works without JavaScript only as far as its HTML does; the inspector needs JavaScript.
Keyboard
Keys it answers to
Every action works without a pointer.
| Key | What it does |
|---|---|
| Tab | Reaches Reload; the placeholders take no tab stop and are not read. |
| Enter or Space | On Reload, shows the placeholders again and loads the team; focus stays on the button. |
Screen readers
What it announces
Written from the roles, names and states in the markup.
| When | Expected announcement |
|---|---|
| Reading reaches the list while it loads | Design team, list, busy. Loading profiles |
| The profiles arrive | 4 profiles loaded |
| Reload is pressed | Loading profiles, then 4 profiles loaded |
| Reading reaches a placeholder | Nothing: placeholders are aria-hidden |
These are expected announcements, not recordings. Wording and order differ between screen readers and browsers.
Code
Copy the code
The exact files this demo runs on. The styles are served with a prefix that keeps this site's own styles out of the demo; what you copy is the original.
<div class="ap-skeleton" data-ap-skeleton>
<div class="ap-skeleton__top">
<div>
<h3 class="ap-skeleton__h" id="skeleton-h">Design team</h3>
<p class="ap-skeleton__sub">Pune and Bengaluru studios</p>
</div>
<button type="button" class="ap-btn" data-ap-reload>
<svg class="ap-btn__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M20 11a8 8 0 0 0-14.3-4.9L4 8"/><path d="M4 4v4h4"/><path d="M4 13a8 8 0 0 0 14.3 4.9L20 16"/><path d="M20 20v-4h-4"/></svg>
Reload
</button>
</div>
<ul class="ap-skeleton__list" aria-labelledby="skeleton-h" aria-busy="true" data-ap-list>
<li class="ap-skeleton__card ap-skeleton__card--ghost" aria-hidden="true">
<span class="ap-skeleton__avatar ap-skeleton__bone"></span>
<div class="ap-skeleton__body">
<p class="ap-skeleton__name"><span class="ap-skeleton__bone ap-skeleton__bone--60"></span></p>
<p class="ap-skeleton__role"><span class="ap-skeleton__bone ap-skeleton__bone--80"></span></p>
<p class="ap-skeleton__place"><span class="ap-skeleton__bone ap-skeleton__bone--40"></span></p>
<p class="ap-skeleton__tags"><span class="ap-skeleton__tag ap-skeleton__bone"></span> <span class="ap-skeleton__tag ap-skeleton__bone ap-skeleton__bone--wide"></span></p>
</div>
</li>
<li class="ap-skeleton__card ap-skeleton__card--ghost" aria-hidden="true">
<span class="ap-skeleton__avatar ap-skeleton__bone"></span>
<div class="ap-skeleton__body">
<p class="ap-skeleton__name"><span class="ap-skeleton__bone ap-skeleton__bone--60"></span></p>
<p class="ap-skeleton__role"><span class="ap-skeleton__bone ap-skeleton__bone--80"></span></p>
<p class="ap-skeleton__place"><span class="ap-skeleton__bone ap-skeleton__bone--40"></span></p>
<p class="ap-skeleton__tags"><span class="ap-skeleton__tag ap-skeleton__bone"></span> <span class="ap-skeleton__tag ap-skeleton__bone ap-skeleton__bone--wide"></span></p>
</div>
</li>
<li class="ap-skeleton__card ap-skeleton__card--ghost" aria-hidden="true">
<span class="ap-skeleton__avatar ap-skeleton__bone"></span>
<div class="ap-skeleton__body">
<p class="ap-skeleton__name"><span class="ap-skeleton__bone ap-skeleton__bone--60"></span></p>
<p class="ap-skeleton__role"><span class="ap-skeleton__bone ap-skeleton__bone--80"></span></p>
<p class="ap-skeleton__place"><span class="ap-skeleton__bone ap-skeleton__bone--40"></span></p>
<p class="ap-skeleton__tags"><span class="ap-skeleton__tag ap-skeleton__bone"></span> <span class="ap-skeleton__tag ap-skeleton__bone ap-skeleton__bone--wide"></span></p>
</div>
</li>
<li class="ap-skeleton__card ap-skeleton__card--ghost" aria-hidden="true">
<span class="ap-skeleton__avatar ap-skeleton__bone"></span>
<div class="ap-skeleton__body">
<p class="ap-skeleton__name"><span class="ap-skeleton__bone ap-skeleton__bone--60"></span></p>
<p class="ap-skeleton__role"><span class="ap-skeleton__bone ap-skeleton__bone--80"></span></p>
<p class="ap-skeleton__place"><span class="ap-skeleton__bone ap-skeleton__bone--40"></span></p>
<p class="ap-skeleton__tags"><span class="ap-skeleton__tag ap-skeleton__bone"></span> <span class="ap-skeleton__tag ap-skeleton__bone ap-skeleton__bone--wide"></span></p>
</div>
</li>
</ul>
<p class="ap-skeleton__sr" role="status" data-ap-say>Loading profiles</p>
<template data-ap-people>
<li class="ap-skeleton__card">
<span class="ap-skeleton__avatar ap-skeleton__avatar--a" aria-hidden="true">AR</span>
<div class="ap-skeleton__body">
<p class="ap-skeleton__name">Asha Rao</p>
<p class="ap-skeleton__role">Lead product designer</p>
<p class="ap-skeleton__place"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 21s-7-6.2-7-11.5a7 7 0 0 1 14 0C19 14.8 12 21 12 21Z"/><circle cx="12" cy="9.5" r="2.5"/></svg><span>Pune</span></p>
<p class="ap-skeleton__tags"><span class="ap-skeleton__tag">Figma</span> <span class="ap-skeleton__tag">Design systems</span></p>
</div>
</li>
<li class="ap-skeleton__card">
<span class="ap-skeleton__avatar ap-skeleton__avatar--b" aria-hidden="true">VN</span>
<div class="ap-skeleton__body">
<p class="ap-skeleton__name">Vikram Nair</p>
<p class="ap-skeleton__role">UX researcher</p>
<p class="ap-skeleton__place"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 21s-7-6.2-7-11.5a7 7 0 0 1 14 0C19 14.8 12 21 12 21Z"/><circle cx="12" cy="9.5" r="2.5"/></svg><span>Bengaluru</span></p>
<p class="ap-skeleton__tags"><span class="ap-skeleton__tag">Interviews</span> <span class="ap-skeleton__tag">Surveys</span></p>
</div>
</li>
<li class="ap-skeleton__card">
<span class="ap-skeleton__avatar ap-skeleton__avatar--c" aria-hidden="true">ZK</span>
<div class="ap-skeleton__body">
<p class="ap-skeleton__name">Zoya Khan</p>
<p class="ap-skeleton__role">Visual designer</p>
<p class="ap-skeleton__place"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 21s-7-6.2-7-11.5a7 7 0 0 1 14 0C19 14.8 12 21 12 21Z"/><circle cx="12" cy="9.5" r="2.5"/></svg><span>Pune</span></p>
<p class="ap-skeleton__tags"><span class="ap-skeleton__tag">Illustration</span> <span class="ap-skeleton__tag">Motion</span></p>
</div>
</li>
<li class="ap-skeleton__card">
<span class="ap-skeleton__avatar ap-skeleton__avatar--d" aria-hidden="true">DM</span>
<div class="ap-skeleton__body">
<p class="ap-skeleton__name">Dev Malhotra</p>
<p class="ap-skeleton__role">Accessibility lead</p>
<p class="ap-skeleton__place"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 21s-7-6.2-7-11.5a7 7 0 0 1 14 0C19 14.8 12 21 12 21Z"/><circle cx="12" cy="9.5" r="2.5"/></svg><span>Bengaluru</span></p>
<p class="ap-skeleton__tags"><span class="ap-skeleton__tag">WCAG</span> <span class="ap-skeleton__tag">Audits</span></p>
</div>
</li>
</template>
</div>
/* Shared primitives: buttons and form fields used across the patterns.
Uses the --ap-* design tokens. */
.ap-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 40px;
padding: 0 16px;
border: 1px solid var(--ap-border-strong);
border-radius: var(--ap-radius-sm);
background: var(--ap-surface);
color: var(--ap-text);
font: inherit;
font-weight: 600;
line-height: 1.2;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
transition: background-color var(--ap-duration) var(--ap-ease), border-color var(--ap-duration) var(--ap-ease), box-shadow var(--ap-duration) var(--ap-ease);
}
.ap-btn:hover {
background: var(--ap-surface-2);
}
.ap-btn:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 2px;
}
.ap-btn:disabled,
.ap-btn[aria-disabled="true"] {
opacity: .55;
cursor: not-allowed;
}
.ap-btn--primary {
border-color: var(--ap-accent);
background: var(--ap-accent);
color: var(--ap-on-accent);
box-shadow: var(--ap-shadow-sm);
}
.ap-btn--primary:hover {
border-color: var(--ap-accent-hover);
background: var(--ap-accent-hover);
}
.ap-btn--danger {
border-color: var(--ap-danger);
background: var(--ap-danger);
color: var(--ap-surface);
}
.ap-btn--danger:hover {
filter: brightness(1.08);
}
.ap-btn--ghost {
border-color: transparent;
background: transparent;
}
.ap-btn--ghost:hover {
background: var(--ap-surface-2);
}
.ap-btn__icon {
width: 18px;
height: 18px;
flex-shrink: 0;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.ap-field {
display: grid;
gap: 6px;
}
.ap-label {
color: var(--ap-text);
font-weight: 600;
font-size: .9375rem;
}
.ap-hint {
margin: 0;
color: var(--ap-text-3);
font-size: .875rem;
line-height: 1.45;
}
.ap-error {
display: flex;
align-items: flex-start;
gap: 6px;
margin: 0;
color: var(--ap-danger);
font-size: .875rem;
font-weight: 600;
line-height: 1.45;
}
.ap-input {
width: 100%;
min-height: 44px;
padding: 10px 12px;
border: 1px solid var(--ap-border-strong);
border-radius: var(--ap-radius-sm);
background: var(--ap-surface);
color: var(--ap-text);
font: inherit;
line-height: 1.4;
transition: border-color var(--ap-duration) var(--ap-ease), box-shadow var(--ap-duration) var(--ap-ease);
}
.ap-input::placeholder {
color: var(--ap-text-3);
}
.ap-input:hover {
border-color: var(--ap-text-2);
}
.ap-input:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 1px;
border-color: var(--ap-focus);
}
.ap-input[aria-invalid="true"] {
border-color: var(--ap-danger);
box-shadow: inset 4px 0 0 var(--ap-danger);
}
textarea.ap-input {
resize: vertical;
min-height: 88px;
}
@media (prefers-reduced-motion: reduce) {
.ap-btn,
.ap-input { transition: none; }
}
/* Skeleton loader. Uses the --ap-* design tokens and the shared primitives. */
.ap-skeleton {
display: grid;
gap: 18px;
width: min(100%, 600px);
margin-inline: auto;
padding: 22px 24px 24px;
border: 1px solid var(--ap-border);
border-radius: var(--ap-radius-lg);
background: var(--ap-surface);
color: var(--ap-text);
box-shadow: var(--ap-shadow-md);
container-type: inline-size;
}
.ap-skeleton__top {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.ap-skeleton__h {
margin: 0;
font-size: 1.125rem;
font-weight: 650;
line-height: 1.3;
}
.ap-skeleton__sub {
margin: 2px 0 0;
color: var(--ap-text-3);
font-size: .875rem;
}
/* Cards. The placeholder and the real card share every size below, each
line a fixed height, so the layout does not move when one replaces the
other. */
.ap-skeleton__list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin: 0;
padding: 0;
list-style: none;
}
.ap-skeleton__card {
display: grid;
grid-template-columns: 48px minmax(0, 1fr);
align-items: start;
gap: 14px;
padding: 16px;
border: 1px solid var(--ap-border);
border-radius: var(--ap-radius);
background: var(--ap-surface);
}
.ap-skeleton__card:not(.ap-skeleton__card--ghost) {
animation: ap-skeleton-in 260ms var(--ap-ease);
}
.ap-skeleton__avatar {
display: grid;
place-items: center;
width: 48px;
height: 48px;
border-radius: var(--ap-radius-full);
font-size: .9375rem;
font-weight: 700;
letter-spacing: .02em;
}
.ap-skeleton__avatar--a {
background: var(--ap-accent-soft);
color: var(--ap-accent-soft-text);
}
.ap-skeleton__avatar--b {
background: var(--ap-info-soft);
color: var(--ap-info);
}
.ap-skeleton__avatar--c {
background: var(--ap-success-soft);
color: var(--ap-success);
}
.ap-skeleton__avatar--d {
background: var(--ap-warning-soft);
color: var(--ap-warning);
}
.ap-skeleton__body {
display: grid;
min-width: 0;
}
.ap-skeleton__body p {
margin: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.ap-skeleton__name {
font-size: 1rem;
font-weight: 650;
line-height: 24px;
height: 24px;
}
.ap-skeleton__role {
height: 20px;
color: var(--ap-text-2);
font-size: .875rem;
line-height: 20px;
}
.ap-skeleton__place {
display: flex;
align-items: center;
gap: 4px;
height: 20px;
color: var(--ap-text-3);
font-size: .875rem;
line-height: 20px;
}
.ap-skeleton__place svg {
width: 14px;
height: 14px;
flex-shrink: 0;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.ap-skeleton__body .ap-skeleton__tags {
display: flex;
gap: 6px;
height: 24px;
margin-top: 10px;
}
.ap-skeleton__tag {
display: inline-block;
height: 24px;
padding: 0 9px;
border: 1px solid var(--ap-border);
border-radius: var(--ap-radius-full);
background: var(--ap-surface-2);
color: var(--ap-text-2);
font-size: .75rem;
font-weight: 600;
line-height: 22px;
}
/* Placeholder shapes. Each bar sits inside a line of the real size. */
.ap-skeleton__bone {
display: inline-block;
height: .75em;
border-radius: 6px;
vertical-align: middle;
background: linear-gradient(90deg, var(--ap-surface-3) 25%, var(--ap-surface-2) 50%, var(--ap-surface-3) 75%);
background-size: 200% 100%;
animation: ap-skeleton-shimmer 1.5s linear infinite;
}
.ap-skeleton__avatar.ap-skeleton__bone {
display: block;
height: 48px;
border-radius: var(--ap-radius-full);
}
.ap-skeleton__tag.ap-skeleton__bone {
width: 58px;
height: 24px;
padding: 0;
border-color: transparent;
border-radius: var(--ap-radius-full);
}
.ap-skeleton__bone--wide.ap-skeleton__tag {
width: 84px;
}
.ap-skeleton__bone--60 {
width: 60%;
}
.ap-skeleton__bone--80 {
width: 80%;
}
.ap-skeleton__bone--40 {
width: 40%;
}
/* Read by screen readers, not shown. */
.ap-skeleton__sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@keyframes ap-skeleton-shimmer {
from { background-position: 100% 0; }
to { background-position: -100% 0; }
}
@keyframes ap-skeleton-in {
from { opacity: 0; }
}
@container (max-width: 520px) {
.ap-skeleton__list {
grid-template-columns: minmax(0, 1fr);
}
}
@container (max-width: 440px) {
.ap-skeleton {
padding: 18px 16px 20px;
}
}
/* Reduced motion: the placeholders stay, still. */
@media (prefers-reduced-motion: reduce) {
.ap-skeleton__bone {
animation: none;
background: var(--ap-surface-3);
}
.ap-skeleton__card:not(.ap-skeleton__card--ghost) { animation: none; }
}
/**
* Skeleton loader: aria-hidden placeholder cards in a list marked aria-busy,
* a visually hidden status that says Loading profiles, and one polite
* message when the real cards replace the placeholders.
*
* Markup: [data-ap-skeleton] holding button[data-ap-reload],
* ul[data-ap-list][aria-busy="true"] with the placeholder cards (each
* aria-hidden), p[role=status][data-ap-say] already reading "Loading
* profiles", and template[data-ap-people] with the cards that arrive.
* Add data-slow to the root for a slow network.
*/
const LOAD_MS = 1400;
const SLOW_MS = 5000;
export function init(root) {
const list = root.querySelector("[data-ap-list]");
const say = root.querySelector("[data-ap-say]");
const people = root.querySelector("template[data-ap-people]");
const reload = root.querySelector("[data-ap-reload]");
// The placeholders as the page shipped them, to put back on Reload.
const ghosts = [...list.children].map((li) => li.cloneNode(true));
let loadTimer = 0;
let sayTimer = 0;
const span = (text) => {
const s = document.createElement("span");
s.textContent = text;
return s;
};
// The status element has been in the page from the start, so a change to
// it is announced; emptying it first makes a repeat news too.
function write(...parts) {
clearTimeout(sayTimer);
say.replaceChildren();
sayTimer = setTimeout(() => say.replaceChildren(...parts), 80);
}
function arrive() {
list.replaceChildren(people.content.cloneNode(true));
list.setAttribute("aria-busy", "false");
// One message for the whole list, not one per card.
write(span(String(list.children.length)), " ", span("profiles loaded"));
}
function load() {
clearTimeout(loadTimer);
loadTimer = setTimeout(arrive, root.hasAttribute("data-slow") ? SLOW_MS : LOAD_MS);
}
function onReload() {
list.setAttribute("aria-busy", "true");
list.replaceChildren(...ghosts.map((li) => li.cloneNode(true)));
write(span("Loading profiles"));
load();
}
reload.addEventListener("click", onReload);
// The page arrives with the placeholders showing and the status already
// saying Loading profiles, so the first load only has to finish.
load();
return () => {
clearTimeout(loadTimer);
clearTimeout(sayTimer);
reload.removeEventListener("click", onReload);
};
}
for (const root of document.querySelectorAll("[data-ap-skeleton]")) init(root);
/* Design tokens for the pattern components. Light by default, dark when the
system asks for it; set data-theme="dark" on :root to force dark. */
:root {
--ap-radius-sm: 8px;
--ap-radius: 12px;
--ap-radius-lg: 16px;
--ap-radius-full: 999px;
--ap-font: "Instrument Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--ap-mono: "JetBrains Mono", ui-monospace, "Cascadia Mono", "SF Mono", Consolas, monospace;
--ap-ease: cubic-bezier(.2, .8, .2, 1);
--ap-duration: 180ms;
--ap-target: 44px;
--ap-bg: #F4F4F5;
--ap-surface: #FFFFFF;
--ap-surface-2: #F4F4F5;
--ap-surface-3: #E4E4E7;
--ap-border: #E4E4E7;
--ap-border-strong: #76767F;
--ap-text: #18181B;
--ap-text-2: #3F3F46;
--ap-text-3: #5E5E66;
--ap-accent: #4F46E5;
--ap-accent-hover: #4338CA;
--ap-on-accent: #FFFFFF;
--ap-accent-text: #4338CA;
--ap-accent-soft: #EEF2FF;
--ap-accent-soft-text: #3730A3;
--ap-focus: #4F46E5;
--ap-danger: #B91C1C;
--ap-danger-soft: #FEF2F2;
--ap-success: #15803D;
--ap-success-soft: #F0FDF4;
--ap-warning: #A15C07;
--ap-warning-soft: #FEFCE8;
--ap-info: #1D4ED8;
--ap-info-soft: #EFF6FF;
--ap-scrim: rgb(9 9 11 / .48);
--ap-shadow-sm: 0 1px 2px rgb(9 9 11 / .06);
--ap-shadow-md: 0 1px 2px rgb(9 9 11 / .05), 0 6px 16px -4px rgb(9 9 11 / .1);
--ap-shadow-lg: 0 2px 6px rgb(9 9 11 / .06), 0 20px 40px -12px rgb(9 9 11 / .22);
--ap-light-bg: #F4F4F5;
--ap-light-surface: #FFFFFF;
--ap-light-surface-2: #F4F4F5;
--ap-light-surface-3: #E4E4E7;
--ap-light-border: #E4E4E7;
--ap-light-border-strong: #76767F;
--ap-light-text: #18181B;
--ap-light-text-2: #3F3F46;
--ap-light-text-3: #5E5E66;
--ap-light-accent: #4F46E5;
--ap-light-accent-hover: #4338CA;
--ap-light-on-accent: #FFFFFF;
--ap-light-accent-text: #4338CA;
--ap-light-accent-soft: #EEF2FF;
--ap-light-accent-soft-text: #3730A3;
--ap-light-focus: #4F46E5;
--ap-light-danger: #B91C1C;
--ap-light-danger-soft: #FEF2F2;
--ap-light-success: #15803D;
--ap-light-success-soft: #F0FDF4;
--ap-light-warning: #A15C07;
--ap-light-warning-soft: #FEFCE8;
--ap-light-info: #1D4ED8;
--ap-light-info-soft: #EFF6FF;
--ap-light-scrim: rgb(9 9 11 / .48);
--ap-light-shadow-sm: 0 1px 2px rgb(9 9 11 / .06);
--ap-light-shadow-md: 0 1px 2px rgb(9 9 11 / .05), 0 6px 16px -4px rgb(9 9 11 / .1);
--ap-light-shadow-lg: 0 2px 6px rgb(9 9 11 / .06), 0 20px 40px -12px rgb(9 9 11 / .22);
--ap-dark-bg: #09090B;
--ap-dark-surface: #18181B;
--ap-dark-surface-2: #27272A;
--ap-dark-surface-3: #3F3F46;
--ap-dark-border: #2E2E33;
--ap-dark-border-strong: #8E8E97;
--ap-dark-text: #FAFAFA;
--ap-dark-text-2: #D4D4D8;
--ap-dark-text-3: #A1A1AA;
--ap-dark-accent: #818CF8;
--ap-dark-accent-hover: #A5B4FC;
--ap-dark-on-accent: #0C0A1F;
--ap-dark-accent-text: #A5B4FC;
--ap-dark-accent-soft: #1E1B4B;
--ap-dark-accent-soft-text: #C7D2FE;
--ap-dark-focus: #A5B4FC;
--ap-dark-danger: #F87171;
--ap-dark-danger-soft: #2A1215;
--ap-dark-success: #4ADE80;
--ap-dark-success-soft: #0F2A1A;
--ap-dark-warning: #FACC15;
--ap-dark-warning-soft: #2A2410;
--ap-dark-info: #60A5FA;
--ap-dark-info-soft: #0F1D33;
--ap-dark-scrim: rgb(0 0 0 / .62);
--ap-dark-shadow-sm: 0 1px 2px rgb(0 0 0 / .4);
--ap-dark-shadow-md: 0 1px 2px rgb(0 0 0 / .4), 0 8px 20px -6px rgb(0 0 0 / .5);
--ap-dark-shadow-lg: 0 2px 8px rgb(0 0 0 / .45), 0 24px 48px -12px rgb(0 0 0 / .7);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ap-bg: #09090B;
--ap-surface: #18181B;
--ap-surface-2: #27272A;
--ap-surface-3: #3F3F46;
--ap-border: #2E2E33;
--ap-border-strong: #8E8E97;
--ap-text: #FAFAFA;
--ap-text-2: #D4D4D8;
--ap-text-3: #A1A1AA;
--ap-accent: #818CF8;
--ap-accent-hover: #A5B4FC;
--ap-on-accent: #0C0A1F;
--ap-accent-text: #A5B4FC;
--ap-accent-soft: #1E1B4B;
--ap-accent-soft-text: #C7D2FE;
--ap-focus: #A5B4FC;
--ap-danger: #F87171;
--ap-danger-soft: #2A1215;
--ap-success: #4ADE80;
--ap-success-soft: #0F2A1A;
--ap-warning: #FACC15;
--ap-warning-soft: #2A2410;
--ap-info: #60A5FA;
--ap-info-soft: #0F1D33;
--ap-scrim: rgb(0 0 0 / .62);
--ap-shadow-sm: 0 1px 2px rgb(0 0 0 / .4);
--ap-shadow-md: 0 1px 2px rgb(0 0 0 / .4), 0 8px 20px -6px rgb(0 0 0 / .5);
--ap-shadow-lg: 0 2px 8px rgb(0 0 0 / .45), 0 24px 48px -12px rgb(0 0 0 / .7);
}
}
:root[data-theme="dark"] {
--ap-bg: #09090B;
--ap-surface: #18181B;
--ap-surface-2: #27272A;
--ap-surface-3: #3F3F46;
--ap-border: #2E2E33;
--ap-border-strong: #8E8E97;
--ap-text: #FAFAFA;
--ap-text-2: #D4D4D8;
--ap-text-3: #A1A1AA;
--ap-accent: #818CF8;
--ap-accent-hover: #A5B4FC;
--ap-on-accent: #0C0A1F;
--ap-accent-text: #A5B4FC;
--ap-accent-soft: #1E1B4B;
--ap-accent-soft-text: #C7D2FE;
--ap-focus: #A5B4FC;
--ap-danger: #F87171;
--ap-danger-soft: #2A1215;
--ap-success: #4ADE80;
--ap-success-soft: #0F2A1A;
--ap-warning: #FACC15;
--ap-warning-soft: #2A2410;
--ap-info: #60A5FA;
--ap-info-soft: #0F1D33;
--ap-scrim: rgb(0 0 0 / .62);
--ap-shadow-sm: 0 1px 2px rgb(0 0 0 / .4);
--ap-shadow-md: 0 1px 2px rgb(0 0 0 / .4), 0 8px 20px -6px rgb(0 0 0 / .5);
--ap-shadow-lg: 0 2px 8px rgb(0 0 0 / .45), 0 24px 48px -12px rgb(0 0 0 / .7);
}
WCAG 2.2
What it meets
The success criteria this pattern takes care of, and how.
-
1.3.1 Info and Relationships
Level A
The placeholders are hidden from assistive technology, so the list exposes only real people; while loading it is marked busy and named by its heading.
-
2.3.3 Animation from Interactions
Level AAA
The shimmer stops when the system asks for reduced motion; the placeholders stay, still and grey.
-
4.1.3 Status Messages
Level AA
A visually hidden status says Loading profiles and then how many arrived, once, without moving focus.
Usage
When to use it
Use it
- Content with a known shape that takes a moment to load: cards, lists, profiles, feeds.
- First loads of a page or panel, where a blank space or a jumping layout would be worse.
Use something else
- Short waits on an action someone just took, like a save: a busy button says more.
- Content whose shape you cannot predict: placeholders that do not match make the layout jump anyway.
- Long tasks with a known size: show a progress bar.
Common failures
How it usually goes wrong
Placeholders read aloud
Empty boxes exposed to a screen reader become a list of blank items, or a string of "group". The placeholders here are aria-hidden.
Silence until it is done
Hiding the placeholders without saying anything leaves screen reader users with an empty list. A hidden status says Loading profiles, then how many arrived.
One message per card
Announcing each card as it arrives reads the whole team aloud. One message gives the count; people read the list when they want.
Shapes that do not match
Placeholders shorter than the real cards make the page jump when content lands, and people lose their place or misclick. These share the real cards' sizes.
A shimmer that never stops
Constant shimmer is motion some people cannot tolerate. It stops under reduced motion and ends as soon as the content arrives.
Notes
Building it
- The status element is in the HTML from the start, holding Loading profiles. A live region announces changes only once the browser knows about it; a status added together with its words, or unhidden as they change, is often silent.
- aria-busy="true" on the list says its contents are being replaced. Screen readers treat it differently, so the status message carries the news.
- The placeholders use the real cards' grid, avatar size and line heights, with each line holding one bar, so the cards land in exactly the same space.
- Reload while loading starts again: the placeholders return, the status says Loading profiles, and the timer restarts.
- The data is simulated; the Slow network option keeps the placeholders on screen for five seconds.
Sources: WAI-ARIA 1.2: aria-busy · Understanding SC 4.1.3: Status Messages · Understanding SC 2.3.3: Animation from Interactions
Checked with axe in light and dark themes, at desktop and phone widths, and by keyboard. Not yet tested with every screen reader and browser pair. Report a correction