A blank screen gives no context and little comfort. A strong loading state sets expectations, signals progress, and buys time. In this tutorial you will design two reliable patterns: a spinner card with a progress hint and a content skeleton. By the end you will have a production-ready loading system with clean HTML, modern CSS, motion that respects user settings, and accessible live regions.
Why Designing a “Loading” State Matters
Perceived speed shapes trust. When data fetches, a thoughtful loading state keeps the layout stable, communicates status, and reduces bounce. Many teams reach for JavaScript-heavy libraries for this, yet CSS handles most of the job with fewer bytes and less complexity. CSS animations run on the compositor thread, border-based spinners render crisply on any scale, and skeletons can match your real content structure so the shift into the final UI feels natural. Good loading feedback is not decoration. It is part of the interface contract.
Prerequisites
You do not need a framework or build step for this article. We will write semantic HTML and modern CSS with variables and pseudo-elements. A basic comfort with selectors and properties is enough.
- Basic HTML
- CSS custom properties
- CSS pseudo-elements (::before / ::after)
Step 1: The HTML Structure
The markup contains one demo wrapper with two sections. The first section shows a border-based spinner, a live status message, and a pill progress hint. The second section shows a skeleton card that mirrors a real content layout: avatar plus text lines. Decorative pieces use aria-hidden so they do not flood assistive tech. The status text uses role=”status” and aria-live so screen readers get a polite update. The skeleton region uses aria-busy to reflect a busy state while content streams in.
<!-- HTML -->
<div class="loading-demo">
<section class="spinner-card">
<div class="spinner" aria-hidden="true"></div>
<p class="status" role="status" aria-live="polite">Loading data…</p>
<div class="progress" aria-hidden="true" aria-label="Loading progress">
<div class="progress__bar"></div>
</div>
</section>
<section class="skeleton-card" aria-busy="true" aria-labelledby="skeleton-title">
<h2 id="skeleton-title" class="visually-hidden">Content loading</h2>
<div class="skeleton avatar" aria-hidden="true"></div>
<div class="skeleton line short" aria-hidden="true"></div>
<div class="skeleton line" aria-hidden="true"></div>
<div class="skeleton line" aria-hidden="true"></div>
</section>
</div>
Step 2: The Basic CSS & Styling
Start with CSS variables for color and sizing, set a sensible page baseline, and define a flexible card layout. A visually-hidden utility helps keep text available to assistive tech without drawing it on screen. The grid aligns both examples side by side on wide screens and stacks them on small screens.
/* CSS */
:root {
--bg: #0f1221;
--panel: #151935;
--text: #e6e8ef;
--muted: #7b81a6;
--accent: #4f7cff;
--accent-2: #22d3ee;
--radius: 14px;
--gap: 20px;
--shadow: 0 6px 30px rgba(0,0,0,0.35);
}
*,
*::before,
*::after { box-sizing: border-box; }
html, body {
height: 100%;
}
body {
margin: 0;
background: radial-gradient(1200px 600px at 15% 0%, #1a1f44, #0b0d1a) fixed, var(--bg);
color: var(--text);
font: 16px/1.5 system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
display: grid;
place-items: center;
padding: 32px;
}
.loading-demo {
display: grid;
grid-template-columns: minmax(260px, 360px);
gap: var(--gap);
}
@media (min-width: 820px) {
.loading-demo {
grid-template-columns: repeat(2, minmax(320px, 420px));
}
}
.spinner-card,
.skeleton-card {
background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(0,0,0,0.2)), var(--panel);
border-radius: var(--radius);
padding: 24px;
box-shadow: var(--shadow);
border: 1px solid rgba(255,255,255,0.06);
}
.visually-hidden {
position: absolute !important;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 1px, 1px);
white-space: nowrap; border: 0;
}
.status {
margin: 12px 0 16px 0;
color: var(--text);
letter-spacing: 0.2px;
}
Advanced Tip: Bind theme and motion knobs to CSS variables. You can pivot the accent color for dark or light themes, scale the spinner size, or slow animations for testing by changing a single value at the root.
Step 3: Building the Spinner and Progress Hint
The spinner uses the classic border trick. A full ring matches the container background, and one colored segment signals rotation. The progress hint is an indeterminate pill that sweeps from left to right. Both use transform-based animation for smooth frames.
/* CSS */
.spinner {
--size: 56px;
width: var(--size);
height: var(--size);
border: 4px solid rgba(255,255,255,0.08);
border-top-color: var(--accent);
border-radius: 50%;
display: inline-block;
animation: spin 0.9s linear infinite;
will-change: transform;
}
.progress {
--h: 10px;
position: relative;
height: var(--h);
width: 100%;
background: rgba(255,255,255,0.07);
border-radius: 999px;
overflow: hidden;
}
.progress__bar {
position: absolute;
inset: 0;
width: 40%;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
border-radius: inherit;
transform-origin: left center;
animation: progress-indeterminate 1.2s ease-in-out infinite;
will-change: transform, left;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes progress-indeterminate {
0% { left: -45%; transform: scaleX(0.6); }
50% { left: 30%; transform: scaleX(1.0); }
100% { left: 105%; transform: scaleX(0.6); }
}
/* Minor composition tweaks */
.spinner-card {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
column-gap: 16px;
}
.spinner-card .status,
.spinner-card .progress {
grid-column: 1 / -1;
}
How This Works (Code Breakdown)
The spinner’s border forms a full ring. Three segments use a subtle translucent color so they blend with the panel, and one segment uses the accent color. Because the ring is round, border-radius: 50% is mandatory. If you want a primer on forming a perfect ring or avatar frame, study how to build a circle with CSS. The ring then rotates with a 0.9s linear animation. Rotation uses the compositor-friendly transform property, which keeps frames smooth on lower-end devices.
The progress hint uses a pill track and a moving bar. The track has border-radius: 999px to form a capsule. That capsule is just a stretched rectangle, so the technique mirrors any guide on creating a rectangle with CSS, then rounding the corners. The inner bar moves from left to right using the left property and scales on the X axis as it travels. The combo avoids abrupt edges and suggests motion even when there is no precise progress percentage yet. The animation uses ease-in-out to avoid mechanical movement.
The layout grid in .spinner-card puts the spinner to the left and lets the label and pill span full width beneath. This keeps the motion separate from text, which helps readability.
Step 4: Building the Skeleton Loader
Skeletons work best when they match the final content structure and alignment. The following CSS sets a gentle shimmer across placeholder blocks: an avatar circle and a few text lines. The gradient creates a light sweep rather than stark blinking. Each block uses relative dimensions so the pattern adapts across cards.
/* CSS */
.skeleton-card {
display: grid;
grid-template-columns: 64px 1fr;
grid-auto-rows: min-content;
gap: 14px 18px;
}
.skeleton {
--sheen: linear-gradient(90deg,
rgba(255,255,255,0.05) 0%,
rgba(255,255,255,0.16) 40%,
rgba(255,255,255,0.05) 80%);
background:
var(--sheen),
rgba(255,255,255,0.06);
background-size: 200% 100%, cover;
background-position: -80% 0, center;
border-radius: 10px;
animation: shimmer 1.25s ease-in-out infinite;
will-change: background-position;
}
.skeleton.avatar {
grid-row: 1 / span 3;
width: 56px;
height: 56px;
border-radius: 50%;
}
.skeleton.line {
height: 12px;
width: 100%;
}
.skeleton.line.short {
width: 40%;
}
@keyframes shimmer {
0% { background-position: -80% 0, center; }
60% { background-position: 120% 0, center; }
100% { background-position: 120% 0, center; }
}
How This Works (Code Breakdown)
The skeletons layer a soft highlight over a muted base. background-size: 200% sets the highlight wider than the element, so the shimmer can travel. background-position animates from left to right, which yields a steady sweep that suggests loading without flashing. The avatar uses border-radius: 50% to form a perfect circle. If you want to revisit the math of circular shapes or see other approaches, check this reference on making a circle with CSS. The text lines are rounded rectangles, which share the same foundation as building a rectangle with CSS and then softening corners.
Grid placement puts the avatar in the first column across three rows while text lines fill the second column. That choice mirrors many list and card designs, which reduces layout shift when real content replaces the skeleton. The animation sits on background-position, which is cheap to update. will-change hints to the browser that it will animate, so the browser can prepare a layer and avoid jank.
Advanced Techniques: Adding Motion Controls and Theming
The core patterns are ready, but two upgrades will make them friendlier: pausing on user intent and respecting reduced motion. You can also theme colors without touching component-level CSS by scoping variables.
/* CSS */
/* Pause animations on pointer hover or keyboard focus within */
.loading-demo:hover .spinner,
.loading-demo:focus-within .spinner,
.loading-demo:hover .progress__bar,
.loading-demo:focus-within .progress__bar,
.loading-demo:hover .skeleton,
.loading-demo:focus-within .skeleton {
animation-play-state: paused;
}
/* Respect user motion preferences */
@media (prefers-reduced-motion: reduce) {
.spinner { animation: none; }
.progress__bar { animation: none; left: 0; width: 35%; transform: none; }
.skeleton { animation: none; background-position: 0 0, center; }
}
/* Quick theme switch by scope */
[data-theme="dark"] {
--panel: #0f1225;
--text: #f3f6ff;
--muted: #8a90b3;
--accent: #7aa2ff;
--accent-2: #4de8e8;
}
[data-theme="light"] {
--bg: #eef2ff;
--panel: #ffffff;
--text: #1a1b2e;
--muted: #5a5f7a;
--accent: #335dff;
--accent-2: #0bbbdc;
}
The pause rule gives users a way to stop motion during inspection or copy. The prefers-reduced-motion media query disables rotation and shimmer, and the progress bar falls back to a small static fill. Scoping themes under data-theme attributes shows how easily these components inherit a palette without refactoring selectors.
Accessibility & Performance
Accessibility
A spinner alone does not convey meaning to non-visual users. The status text with role=”status” and aria-live=”polite” provides a timely announcement like “Loading data…” that does not interrupt ongoing output. Keep such messages short. If your app can estimate progress, update that same region with a real percentage and remove aria-hidden from the progress bar while setting role=”progressbar” with aria-valuenow and aria-valuemax. While loading, set aria-busy=”true” on containers that should be treated as incomplete. Decorative spinners and skeleton blocks should keep aria-hidden=”true” so assistive tech does not enumerate them.
Honor user motion preferences. The media query above removes continuous movement and switches to static indicators. Also watch contrast. Spinners and skeletons should still be visible at a glance, but they should not overpower text. If your brand palette is low contrast, strengthen the accent for loading affordances only.
Performance
These patterns use CSS-only motion on properties that the compositor handles well: transform and background-position. Avoid animating width on the track itself or relying on box-shadow pulsing, since that causes extra painting. The indeterminate progress bar uses a single absolutely positioned child instead of many segments, which keeps the DOM light. If you render many cards at once, limit the number of concurrent shimmers or stagger them with animation-delay. Use will-change sparingly. It helps for frequently animating parts like the spinner, but sprinkling it across dozens of elements can waste memory.
Ship Feedback First
You built a crisp spinner with a progress hint and a skeleton layout that mirrors real content. You wired live regions, respected reduced motion, and kept the motion butter-smooth with transform and gradients. Now you have a toolkit to drop into any view so users always get immediate feedback while your data loads.