Skip to content
← components

Skeleton Reveal

new

A loading skeleton built from the real content — so it's sized exactly right and nothing shifts on load — that wipes the content in with an animated mask. Pure CSS.

Sean Geng

Sean Geng

@seangeng

Co-founder & CTO at B3. Building a crypto agent & decentralized inference.

9
Components
9
Posts
4
Freebies

Usage

example.tsx
import { SkeletonReveal } from "~/components/skeleton/skeleton-reveal";
// Controlled: flip `loading` false when your data arrives.
<SkeletonReveal loading={isLoading}>
<ProfileCard user={user} />
</SkeletonReveal>
// Or scrub it by hand (0 = skeleton, 1 = revealed).
<SkeletonReveal progress={0.5} direction="left">
<ProfileCard user={user} />
</SkeletonReveal>

How it avoids layout shift

The usual skeleton is a separate set of grey boxes you size by hand to approximately match the real thing — then the content loads, the sizes don't quite agree, and the page jumps. This one keeps the real content mounted the whole time. A skeleton layer (the same content, with its leaf elements and anything marked data-skel turned into shimmering blocks) sits behind the real content. Because it's the actual content, the skeleton is the exact final size — so when it reveals, nothing shifts (zero CLS).

The reveal is an animated mask-image: a soft gradient slides across the content, wiping it in over the skeleton. Drive it with a timer, with a loading flag, or — as in the demo — scrub it by hand with a single --reveal-progress variable. The Motion example drives the same mask through the browser's View Transition API; here it's plain CSS, so there's no dependency and it degrades cleanly under prefers-reduced-motion.

skeleton-reveal.css
/* Two layers in one grid cell: skeleton behind, real content on top. */
.reveal { display: grid; }
.reveal > * { grid-area: 1 / 1; }
/* Skeleton layer: leaf elements become shimmering blocks (exact sizes). */
.reveal__skeleton :where(h1,h2,h3,p,span,a,button,[data-skel]) {
color: transparent !important;
background-color: hsl(var(--muted)) !important;
background-image: linear-gradient(90deg, transparent,
hsl(var(--foreground)/.07) 50%, transparent) !important;
background-size: 220% 100% !important;
border-radius: 8px !important;
animation: reveal-shimmer 1.3s ease-in-out infinite;
}
/* Content layer: a soft gradient mask, driven by --reveal-progress. */
.reveal--scrub .reveal__content {
mask-image: linear-gradient(90deg, #000 0% 35%, transparent 70%);
mask-size: 300% 100%;
mask-repeat: no-repeat;
mask-position: calc((1 - var(--reveal-progress)) * 100%) 0;
}
.reveal--scrub .reveal__skeleton { opacity: calc(1 - var(--reveal-progress)); }

Props

proptypedefaultdescription
childrenReactNodeThe real content. Stays mounted — that's what makes the skeleton exact.
loadingbooleanControlled flag; true → false wipes content in.
progressnumberManual scrub, 0..1 (0 skeleton, 1 revealed). Disables timing.
direction"left" | "right""left"Wipe direction.
anglenumber8Tilt of the wipe edge in degrees (0 = vertical).
delaynumber1400Uncontrolled: ms to hold the skeleton before revealing.
durationnumber700Reveal duration in ms (auto/controlled mode).

The full implementation, across 2 files. Copy it in or grab the zip. It leans on a cn() class helper and the theme tokens (--primary, --border, …).

components/skeleton/skeleton-reveal.tsx
import { useEffect, useState } from "react";
import { cn } from "~/lib/utils";
export interface SkeletonRevealProps {
children: React.ReactNode;
/**
* Controlled loading flag. When it flips `true` → `false`, the content wipes
* in. Omit it (and `progress`) to run uncontrolled (skeleton, then reveal).
*/
loading?: boolean;
/** Uncontrolled: ms to hold the skeleton before revealing. Default 1400. */
delay?: number;
/** Reveal duration in ms (auto/controlled mode). Default 700. */
duration?: number;
/**
* Manual scrub, 0..1. When provided, timing is disabled and the reveal is
* driven directly by this value — 0 = full skeleton, 1 = fully revealed.
*/
progress?: number;
/** Wipe direction. Default "left" (reveals left → right). */
direction?: "left" | "right";
/** Tilt of the wipe edge in degrees (0 = straight vertical). Default 8. */
angle?: number;
className?: string;
}
type Phase = "skeleton" | "revealing" | "done";
/**
* Shows a skeleton built from the *real* children — they stay mounted, so the
* skeleton is sized exactly like the final content and nothing shifts on load.
* A skeleton layer (shimmering blocks) sits behind the real content, which is
* wiped in with an animated `mask-image`. Pass `progress` to scrub it by hand.
*/
export function SkeletonReveal({
children,
loading,
delay = 1400,
duration = 700,
progress,
direction = "left",
angle = 8,
className,
}: SkeletonRevealProps) {
const scrubbing = progress !== undefined;
const controlled = loading !== undefined;
const [phase, setPhase] = useState<Phase>(
controlled ? (loading ? "skeleton" : "done") : "skeleton"
);
useEffect(() => {
if (scrubbing) return; // scrubber owns the visual state
if (controlled) {
if (loading) {
setPhase("skeleton");
return;
}
setPhase("revealing");
const t = setTimeout(() => setPhase("done"), duration);
return () => clearTimeout(t);
}
setPhase("skeleton");
const t1 = setTimeout(() => setPhase("revealing"), delay);
const t2 = setTimeout(() => setPhase("done"), delay + duration);
return () => {
clearTimeout(t1);
clearTimeout(t2);
};
}, [scrubbing, controlled, loading, delay, duration]);
const p = scrubbing ? Math.max(0, Math.min(1, progress)) : 0;
// gradient angle per direction: 90deg is a vertical edge; tilt from there.
const gradientAngle = direction === "right" ? 270 - angle : 90 + angle;
return (
<div
className={cn(
"reveal",
direction === "right" && "reveal--rtl",
scrubbing
? "reveal--scrub"
: phase === "skeleton"
? "reveal--loading"
: phase === "revealing" && "reveal--revealing",
className
)}
style={
{
"--reveal-duration": `${duration}ms`,
"--reveal-angle": `${gradientAngle}deg`,
...(scrubbing ? { "--reveal-progress": String(p) } : {}),
} as React.CSSProperties
}
aria-busy={scrubbing ? p < 1 : phase === "skeleton"}
>
<div className="reveal__skeleton" aria-hidden="true">
{children}
</div>
<div className="reveal__content">{children}</div>
</div>
);
}
app/styles/app.css
/* ─── Skeleton reveal (skeleton → content, mask wipe) ──────────────── */
/* Two layers stacked in the same grid cell: a skeleton built from the real
content (so it's sized exactly — no layout shift) behind, and the real
content on top, wiped in with an animated/ scrubable mask-image. */
.reveal {
position: relative;
display: grid;
}
.reveal > * {
grid-area: 1 / 1;
min-width: 0;
}
.reveal__skeleton {
z-index: 0;
pointer-events: none;
opacity: 0; /* hidden once revealed (done state) */
}
.reveal__content {
z-index: 1;
}
/* skeletonize the skeleton layer's leaf elements */
.reveal__skeleton :where(
h1, h2, h3, h4, h5, h6, p, span, a, strong, em, small, li, label, input,
button, [data-skel]
) {
color: transparent !important;
-webkit-text-fill-color: transparent !important;
border-color: transparent !important;
box-shadow: none !important;
background-color: hsl(var(--muted)) !important;
background-image: linear-gradient(
90deg,
transparent 0%,
hsl(var(--foreground) / 0.07) 50%,
transparent 100%
) !important;
background-size: 220% 100% !important;
background-repeat: no-repeat !important;
animation: reveal-shimmer 1.3s ease-in-out infinite;
}
/* Round bare text blocks; elements tagged [data-skel] keep their own shape
(rounded-full avatars stay circular, rounded-lg tiles stay tiles). */
.reveal__skeleton
:where(h1, h2, h3, h4, h5, h6, p, span, a, strong, em, small, li, label, input, button):not([data-skel]) {
border-radius: 8px;
}
.reveal__skeleton :where(img) {
opacity: 0 !important;
}
@keyframes reveal-shimmer {
0% {
background-position: 120% 0;
}
100% {
background-position: -120% 0;
}
}
/* mask used by both the auto reveal and the scrubber.
--reveal-angle tilts the wipe edge (90deg = vertical); the component sets it
per direction. Wider mask so the angled edge still covers the corners. */
.reveal--revealing .reveal__content,
.reveal--scrub .reveal__content {
-webkit-mask-image: linear-gradient(
var(--reveal-angle, 96deg),
#000 0% 38%,
rgba(0, 0, 0, 0) 62% 100%
);
mask-image: linear-gradient(
var(--reveal-angle, 96deg),
#000 0% 38%,
rgba(0, 0, 0, 0) 62% 100%
);
-webkit-mask-size: 360% 100%;
mask-size: 360% 100%;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
}
/* auto: skeleton phase — show skeleton, hide content */
.reveal--loading .reveal__skeleton {
opacity: 1;
}
.reveal--loading .reveal__content {
opacity: 0;
}
/* auto: revealing — wipe content in, fade skeleton out */
.reveal--revealing .reveal__content {
animation: reveal-wipe var(--reveal-duration, 0.7s) ease forwards;
}
.reveal--rtl.reveal--revealing .reveal__content {
animation-name: reveal-wipe-rtl;
}
.reveal--revealing .reveal__skeleton {
animation: reveal-skelout 0.45s ease forwards;
}
@keyframes reveal-wipe {
from {
-webkit-mask-position: 100% 0;
mask-position: 100% 0;
opacity: 0.35;
}
to {
-webkit-mask-position: 0 0;
mask-position: 0 0;
opacity: 1;
}
}
@keyframes reveal-wipe-rtl {
from {
-webkit-mask-position: 0 0;
mask-position: 0 0;
opacity: 0.35;
}
to {
-webkit-mask-position: 100% 0;
mask-position: 100% 0;
opacity: 1;
}
}
@keyframes reveal-skelout {
to {
opacity: 0;
}
}
/* scrub: progress (0..1) drives the mask + skeleton fade */
.reveal--scrub .reveal__skeleton {
opacity: calc(1 - var(--reveal-progress, 0));
}
.reveal--scrub .reveal__content {
-webkit-mask-position: calc((1 - var(--reveal-progress, 0)) * 100%) 0;
mask-position: calc((1 - var(--reveal-progress, 0)) * 100%) 0;
}
.reveal--rtl.reveal--scrub .reveal__content {
-webkit-mask-position: calc(var(--reveal-progress, 0) * 100%) 0;
mask-position: calc(var(--reveal-progress, 0) * 100%) 0;
}
@media (prefers-reduced-motion: reduce) {
.reveal__skeleton :where(*) {
animation: none !important;
}
.reveal--revealing .reveal__content {
animation: none !important;
-webkit-mask-image: none;
mask-image: none;
}
}