Skeleton Reveal
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
@seangeng
Co-founder & CTO at B3. Building a crypto agent & decentralized inference.
Usage
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.
/* 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
| prop | type | default | description |
|---|---|---|---|
| children | ReactNode | — | The real content. Stays mounted — that's what makes the skeleton exact. |
| loading | boolean | — | Controlled flag; true → false wipes content in. |
| progress | number | — | Manual scrub, 0..1 (0 skeleton, 1 revealed). Disables timing. |
| direction | "left" | "right" | "left" | Wipe direction. |
| angle | number | 8 | Tilt of the wipe edge in degrees (0 = vertical). |
| delay | number | 1400 | Uncontrolled: ms to hold the skeleton before revealing. |
| duration | number | 700 | Reveal duration in ms (auto/controlled mode). |
Source
download .zipThe 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, …).
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 stateif (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 (<divclassName={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>);}
/* ─── Skeleton reveal (skeleton → content, mask wipe) ──────────────── *//* Two layers stacked in the same grid cell: a skeleton built from the realcontent (so it's sized exactly — no layout shift) behind, and the realcontent 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 itper 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;}}