m 'react';
interface PlaceholderBlockProps {
width?: string | number;
height?: string | number;
className?: string;
style?: React.CSSProperties;
}
export const PlaceholderBlock: React.FC<PlaceholderBlockProps> = ({
width = '100%',
height = '1em',
className = '',
style = {},
}) => {
return (
<span
role="presentation"
aria-hidden="true"
className={placeholder-shimmer ${className}}
style={{
display: 'block',
width: typeof width === 'number' ? ${width}px : width,
height: typeof height === 'number' ? ${height}px : height,
...style,
}}
/>
);
};
**Architecture Rationale**: Using `role="presentation"` and `aria-hidden="true"` ensures assistive technology ignores individual blocks. The component accepts flexible dimension types to support both CSS units and pixel values. Inline styles are used for dynamic sizing, while animation classes remain in CSS modules for performance.
### Step 2: Shimmer Animation & Motion Preferences
The animation must be GPU-accelerated and respect system accessibility settings. Avoid `opacity` or `transform` animations that trigger repaints; use `background-position` on a linear gradient instead.
```css
/* placeholders.css */
@keyframes shimmer-flow {
0% { background-position: -300px 0; }
100% { background-position: 300px 0; }
}
.placeholder-shimmer {
background: linear-gradient(
90deg,
rgba(200, 200, 200, 0.4) 25%,
rgba(220, 220, 220, 0.6) 50%,
rgba(200, 200, 200, 0.4) 75%
);
background-size: 600px 100%;
animation: shimmer-flow 1.3s infinite linear;
border-radius: 4px;
}
@media (prefers-reduced-motion: reduce) {
.placeholder-shimmer {
animation: none;
background: rgba(210, 210, 210, 0.5);
}
}
Architecture Rationale: The gradient uses semi-transparent grays to adapt to light/dark themes via CSS variables or parent context. The 1.3s duration balances perceived motion without causing vestibular discomfort. The media query fallback is mandatory; continuous motion triggers accessibility violations and can cause nausea for sensitive users.
Step 3: Component-Specific Layout Shell
Each data component requires a matching shell that mirrors its exact DOM structure. Vary placeholder widths to simulate natural text wrapping and content hierarchy.
import { PlaceholderBlock } from './PlaceholderBlock';
import './UserProfileShell.css';
export const UserProfileShell: React.FC = () => {
return (
<article className="profile-card">
<PlaceholderBlock width="100%" height="180px" className="profile-avatar" />
<div className="profile-details">
<PlaceholderBlock width="65%" height="1.4em" className="profile-name" />
<PlaceholderBlock width="90%" height="1em" className="profile-role" />
<PlaceholderBlock width="75%" height="1em" className="profile-bio" />
</div>
</article>
);
};
Architecture Rationale: The shell uses semantic HTML (<article>) to match the loaded component. Widths decrease progressively to mimic paragraph flow. Identical-height bars communicate zero structural information and defeat the purpose of predictive loading.
Step 4: State Routing & Accessibility Orchestration
Loading states must route through three branches: loading, success, and failure/empty. The container manages ARIA routing while children remain presentation-focused.
import { useAsyncResource } from './hooks/useAsyncResource';
import { UserProfileShell } from './UserProfileShell';
import { UserProfileCard } from './UserProfileCard';
import { ErrorBoundary } from './ErrorBoundary';
interface UserProfileContainerProps {
userId: string;
}
export const UserProfileContainer: React.FC<UserProfileContainerProps> = ({ userId }) => {
const { data, isLoading, error, refetch } = useAsyncResource(`/api/users/${userId}`);
return (
<section aria-busy={isLoading} aria-label="User profile">
{isLoading && <UserProfileShell />}
{!isLoading && error && (
<div className="profile-card profile-error" role="alert">
<p>Unable to retrieve profile data.</p>
<button onClick={refetch}>Retry request</button>
</div>
)}
{!isLoading && !error && data && (
<UserProfileCard user={data} />
)}
{!isLoading && !error && !data && (
<div className="profile-card profile-empty">
<p>No profile exists for this identifier.</p>
</div>
)}
</section>
);
};
Architecture Rationale: aria-busy="true" signals to assistive technology that the region is updating. When isLoading flips to false, the attribute updates, triggering screen reader re-evaluation. Error and empty states occupy identical container dimensions to prevent CLS. The role="alert" on the error container forces immediate announcement without focus movement.
Step 5: Live Region for Supplemental Announcements
For list views or batch operations, add a visually hidden live region to announce completion without disrupting focus.
export const StatusAnnouncer: React.FC<{ message: string }> = ({ message }) => {
if (!message) return null;
return (
<div
role="status"
aria-live="polite"
className="sr-only"
>
{message}
</div>
);
};
Architecture Rationale: aria-live="polite" queues announcements until the user is idle, preventing interruption of current screen reader tasks. The sr-only class removes visual rendering while preserving DOM accessibility.
Pitfall Guide
Explanation: Rendering identical-height bars for titles, body text, and metadata communicates zero structural information. Users cannot distinguish content hierarchy.
Fix: Vary widths and heights to match the loaded component. Use 60β80% for secondary text, 90β100% for primary blocks, and distinct heights for avatars vs. paragraphs.
2. Ignoring prefers-reduced-motion
Explanation: Continuous shimmer animations trigger vestibular disorders and violate WCAG 2.2.1. Browsers will not automatically disable them without explicit media queries.
Fix: Always include @media (prefers-reduced-motion: reduce) to disable animation and apply a static background. Test with OS-level motion reduction enabled.
3. Dynamic Skeleton Counts in Lists
Explanation: Deriving skeleton count from API pagination or query parameters causes layout shifts when the actual data length differs.
Fix: Use a fixed array length (typically 4β8) for list placeholders. This communicates grid structure without committing to exact row counts.
4. Missing Container ARIA Routing
Explanation: Individual placeholders are hidden from screen readers, but the container must broadcast loading state. Without aria-busy, assistive technology treats the region as static.
Fix: Apply aria-busy={isLoading} to the parent wrapper. Pair with a live region for completion announcements.
5. Error/Empty State Layout Jumps
Explanation: Rendering error messages or empty containers with different dimensions than the skeleton causes CLS spikes and breaks visual rhythm.
Fix: Wrap error/empty states in the same CSS class as the skeleton. Use min-height or aspect-ratio to reserve space. Ensure button/text elements align with placeholder positions.
6. Over-Animating Shimmer Intensity
Explanation: High-contrast gradients or fast animation durations (>1.5s) cause visual fatigue and distract from the actual content once loaded.
Fix: Use low-contrast grays (rgba(200,200,200,0.4) to rgba(220,220,220,0.6)). Keep duration between 1.2sβ1.4s. Avoid ease-in-out timing; use linear for consistent motion.
7. Skipping Hydration Contracts in SSR
Explanation: Server-side rendering outputs the skeleton, but client hydration expects the loaded component. Mismatched DOM trees trigger React hydration warnings and force full re-renders.
Fix: Use suppressHydrationWarning on skeleton containers or defer skeleton rendering to useEffect. Alternatively, render a static placeholder on the server and switch to animated skeletons only after client mount.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Content feed (articles, products) | Structural skeletons | Pre-renders layout, eliminates CLS, reduces perceived wait | Medium (requires shell components per card) |
| Form submission / API mutation | Progress bar or spinner | Duration is estimable, structure doesn't change | Low (single component) |
| Real-time dashboard / streaming data | Skeletons + incremental updates | Handles partial data arrival without layout shifts | High (requires state diffing & atomic placeholders) |
| Mobile / low-bandwidth networks | Static placeholders (no animation) | Reduces GPU load, respects motion prefs, faster paint | Low (CSS-only fallback) |
Configuration Template
// design-system/placeholders.ts
export const PLACEHOLDER_CONFIG = {
animation: {
duration: '1.3s',
timing: 'linear',
iteration: 'infinite',
},
colors: {
light: {
base: 'rgba(200, 200, 200, 0.4)',
highlight: 'rgba(220, 220, 220, 0.6)',
},
dark: {
base: 'rgba(60, 60, 60, 0.4)',
highlight: 'rgba(80, 80, 80, 0.6)',
},
},
dimensions: {
avatar: { width: '100%', height: '180px' },
title: { width: '65%', height: '1.4em' },
body: { width: '90%', height: '1em' },
metadata: { width: '75%', height: '0.9em' },
},
};
// usage in CSS module
.placeholder-shimmer {
background: linear-gradient(
90deg,
var(--placeholder-base) 25%,
var(--placeholder-highlight) 50%,
var(--placeholder-base) 75%
);
background-size: 600px 100%;
animation: shimmer-flow var(--anim-duration) var(--anim-timing) var(--anim-iteration);
}
Quick Start Guide
- Install base primitive: Copy
PlaceholderBlock.tsx into your component library. Ensure it exports aria-hidden and accepts width/height props.
- Add animation stylesheet: Paste the shimmer CSS into your global or module stylesheet. Verify
prefers-reduced-motion media query is present.
- Create first shell: Duplicate your target component's DOM structure. Replace content with
PlaceholderBlock instances. Match widths to simulate text flow.
- Wire state routing: Wrap your data component in a container that toggles between shell, success, error, and empty states. Apply
aria-busy to the wrapper.
- Validate: Run Lighthouse performance audit. Check CLS score. Test with screen reader and reduced motion enabled. Adjust dimensions if layout shifts occur.