Skeleton shimmer
A listing card shows shimmering placeholder blocks, then cross-fades into the real content.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { useInView } from "@/lib/motion";
import styles from "./demo.module.css";
const LOAD_MS = 1800;
export default function Demo() {
const root = useRef<HTMLDivElement>(null);
const inView = useInView(root);
const [loading, setLoading] = useState(true);
// The fake request only runs while the card is on screen, so it is never finished unseen.
useEffect(() => {
if (!loading || !inView) return;
const timer = setTimeout(() => setLoading(false), LOAD_MS);
return () => clearTimeout(timer);
}, [loading, inView]);
return (
<div ref={root} className={styles.root}>
<article className={`${styles.card} ${loading ? styles.loading : ""}`} aria-busy={loading}>
<div className={styles.skeleton} aria-hidden="true">
<span className={`${styles.block} ${styles.photo}`} />
<span className={styles.lines}>
<span className={styles.block} style={{ width: "70%" }} />
<span className={styles.block} style={{ width: "90%" }} />
<span className={styles.block} style={{ width: "45%" }} />
</span>
</div>
<div className={styles.content} aria-hidden={loading}>
<span className={styles.photo} role="img" aria-label="Villa Azul pool at dusk">
<svg viewBox="0 0 88 88" aria-hidden="true">
<path d="M24 46 44 30l20 16v18H24Z" />
<path d="M38 64V52h12v12" />
</svg>
</span>
<span className={styles.lines}>
<b className={styles.name}>Villa Azul</b>
<span className={styles.meta}>Goa · 3 bedrooms · Private pool</span>
<span className={styles.price}>
<b>€180</b> per night <em>Wed 14 open</em>
</span>
</span>
</div>
<span className={styles.status} role="status">
{loading ? "Loading Villa Azul" : ""}
</span>
</article>
<button type="button" className={styles.reload} onClick={() => setLoading(true)} disabled={loading}>
{loading ? "Loading" : "Reload"}
</button>
</div>
);
}
demo.module.css
.root { width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; padding: 16px; }
.card { position: relative; display: grid; width: min(380px, 100%); padding: 14px; border: 1px solid var(--line); border-radius: 16px; background: var(--white); }
.skeleton, .content { grid-area: 1 / 1; display: grid; grid-template-columns: 88px 1fr; align-items: center; gap: 14px; transition: opacity .45s var(--out); }
.skeleton { opacity: 0; pointer-events: none; }
.content { opacity: 1; transition-delay: .1s; }
.loading .skeleton { opacity: 1; transition-delay: 0s; }
.loading .content { opacity: 0; transition-delay: 0s; }
.photo { display: grid; place-items: center; width: 88px; height: 88px; border-radius: 12px; background: linear-gradient(160deg, var(--sky), var(--accent-soft) 70%, var(--accent)); }
.photo svg { width: 60px; height: 60px; fill: none; stroke: var(--white); stroke-width: 3; stroke-linejoin: round; stroke-linecap: round; }
.lines { display: grid; gap: 8px; min-width: 0; }
.name { font-size: 17px; letter-spacing: -0.01em; }
.meta { font-size: 13px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.price { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 6px; font-size: 13px; color: var(--muted); }
.price b { font-size: 15px; color: var(--ink); }
.price em { font-style: normal; font-weight: 600; font-size: 12px; color: var(--accent-text); background: var(--accent-pale); padding: 2px 8px; border-radius: 999px; }
.block { display: block; height: 12px; border-radius: 6px; background: linear-gradient(100deg, var(--line) 30%, var(--surface) 50%, var(--line) 70%) 0 0 / 300% 100%; animation: shimmer 1.4s linear infinite; }
.skeleton .photo { height: 88px; border-radius: 12px; }
.skeleton .lines { gap: 10px; }
.skeleton .lines .block:first-child { height: 16px; }
@keyframes shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } }
.status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
.reload { min-height: 40px; min-width: 96px; padding: 0 18px; border: 1px solid var(--line); border-radius: 999px; background: var(--white); font-weight: 600; font-size: 14px; cursor: pointer; transition: border-color .18s, scale .18s var(--out); }
.reload:hover:not(:disabled) { border-color: var(--ink); }
.reload:active:not(:disabled) { scale: .97; }
.reload:disabled { color: var(--muted); cursor: default; }
@media (prefers-reduced-motion: reduce) {
.block { animation: none; }
.skeleton, .content { transition: none; }
}
Use it for
- Cards and lists whose layout is known before the data arrives
- Loads that take between half a second and a few seconds
Avoid
- Instant loads, where a skeleton only adds a flash
- Placeholders that do not match the final layout, which makes content jump
How it works
The skeleton and the content sit in the same grid cell, so they overlap exactly and swapping them is an opacity cross-fade with no layout shift. The shimmer is one wide linear gradient per block whose background-position animates, so every block sweeps in sync. The fake request only starts while the card is in view.
Accessibility
The card sets aria-busy while loading and a status line names what is loading. The hidden layer is aria-hidden. Reduced motion stops the shimmer and swaps without a fade.