Skeleton
pnpm dlx shadcn@latest add …/r/skeleton.jsonA React skeleton loader for Next.js and Tailwind CSS: placeholder blocks while content loads, with a calm sheen that sweeps every skeleton in step.
- Loading profileProfile
- Loading postCard
import { Skeleton } from "@/components/ui/skeleton"
export function ProfileLoading() {
return (
<div aria-busy="true" className="flex items-center gap-3">
<span className="sr-only">Loading profile</span>
<Skeleton className="size-10 rounded-full" />
<div className="grid gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-4 w-24" />
</div>
</div>
)
}import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
/*
* A profile row and a card, each named underneath. Every skeleton sweeps in step. The skeletons are hidden from
* screen readers; each loading region says so with aria-busy and a visually hidden label.
*/
export default function SkeletonPlaceholders() {
return (
<ul className="flex w-full flex-wrap items-end justify-center gap-x-12 gap-y-8">
<li className="flex flex-col items-center gap-3">
<div aria-busy="true" className="flex w-64 items-center gap-3">
<span className="sr-only">Loading profile</span>
<Skeleton className="size-10 shrink-0 rounded-full" />
<div className="grid flex-1 gap-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
</div>
<span className="text-xs text-muted-foreground">Profile</span>
</li>
<li className="flex flex-col items-center gap-3">
<Card aria-busy="true" className="w-64">
<span className="sr-only">Loading post</span>
<CardHeader>
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-4 w-1/2" />
</CardHeader>
<CardContent>
<Skeleton className="aspect-video w-full" />
</CardContent>
</Card>
<span className="text-xs text-muted-foreground">Card</span>
</li>
</ul>
);
}Installation
Install the dependencies.
$ pnpm add motionAdd the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.
:root { --size-control-sm: 2rem; --size-control-md: 2.25rem; --size-control-lg: 2.5rem; --size-icon-sm: 0.875rem; --size-icon-md: 1rem; --size-icon-lg: 1.25rem; --size-roll-fade: 0.25em; --size-roll-fade-x: 0.5em; --duration-instant: 80ms; --duration-fast: 150ms; --duration-base: 220ms; --duration-slow: 350ms; --duration-slower: 500ms; --z-overlay: 1200; --z-modal: 1300; --z-popover: 1400; --z-toast: 1500; --z-tooltip: 1600; --focus-ring-width: 2px; --focus-ring-offset: 2px; --opacity-disabled: 0.5; --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1); --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent); } .dark { --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent); } @theme inline { --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both; --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both; --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1); --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both; --color-destructive-foreground: var(--destructive-foreground); --color-overlay: var(--overlay); @keyframes popover-in { from { opacity: 0; filter: blur(6px); transform: scale(0.96); } to { opacity: 1; filter: blur(0); transform: scale(1); } } @keyframes popover-out { from { opacity: 1; filter: blur(0); transform: scale(1); } to { opacity: 0; filter: blur(6px); transform: scale(0.96); } } @keyframes collapsible-open { from { height: 0; opacity: 0; filter: blur(6px); } to { height: var(--collapsible-height); opacity: 1; filter: blur(0); } } @keyframes collapsible-close { from { height: var(--collapsible-height); opacity: 1; filter: blur(0); } to { height: 0; opacity: 0; filter: blur(6px); } } } @media (prefers-reduced-motion: reduce) { :root { --duration-instant: 1ms; --duration-fast: 1ms; --duration-base: 1ms; --duration-slow: 1ms; --duration-slower: 1ms; } }Add the motion presets.
import type { Transition } from "motion/react"; /* * Motion tokens for JavaScript animations (motion/react). * Durations and easings mirror the CSS tokens in src/styles/tokens.css; * `pnpm check:tokens` fails if the two drift apart. */ /** Durations in seconds. Mirrors --duration-* in tokens.css. */ export const duration = { instant: 0.08, fast: 0.15, base: 0.22, slow: 0.35, slower: 0.5, } as const; /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */ export const ease = { out: [0.22, 1, 0.36, 1], in: [0.55, 0, 1, 0.45], inOut: [0.65, 0, 0.35, 1], } as const; /** * Named springs: the library's motion voice. * - snappy: quick, almost no overshoot. Toggles, tabs, small controls. * - smooth: slow and settled. Panels, sidebars, page sections. * - bouncy: playful overshoot. Rare, for moments of delight. */ export const spring = { snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 }, smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 }, bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 }, } as const satisfies Record<string, Transition>; export type SpringName = keyof typeof spring; /** * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills, * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only. * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`). */ export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const; /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */ export const swap = { initial: { opacity: 0, filter: blur.hidden, y: 6 }, animate: { opacity: 1, filter: blur.shown, y: 0 }, exit: { opacity: 0, filter: blur.hidden, y: -6 }, } as const; /** * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits' * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in * components/number-ticker/number-ticker.tsx). */ export const flow = { transform: { duration: 900, easing: "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)", }, opacity: { duration: 450, easing: "ease-out" }, } as const satisfies Record<string, EffectTiming>;Copy the source into your project.
import { SkeletonSheen } from "@/components/ui/skeleton-sheen"; import { cn } from "@/lib/utils"; import type { ComponentProps } from "react"; /* * A placeholder block while content loads (owner's roadmap, 2026-10-16), with shadcn's API: one `div` you shape * with classes (`h-4 w-40`, `size-10 rounded-full`). * - A muted block with a calm sheen sweeping across it: one neutral hue, a step lighter, never a colour. The sheen * lives in SkeletonSheen, a small client part, so Skeleton stays a server component, which is where loading * states usually render (loading.tsx, Suspense fallbacks). * - Every skeleton on the page sweeps in step, however late it mounted (see skeleton-sheen.tsx). * - With reduced motion it's a still, muted block. * - Hidden from screen readers (aria-hidden): a grey block says nothing. Mark the region that's loading with * aria-busy="true" and a visually hidden "Loading…" instead (the demos do). */ function Skeleton({ className, children, ...props }: ComponentProps<"div">) { return ( <div data-slot="skeleton" aria-hidden className={cn("relative overflow-hidden rounded-md bg-muted", className)} {...props} > {children} <SkeletonSheen /> </div> ); } export { Skeleton };"use client"; import { duration, ease } from "@/lib/motion"; import { useReducedMotion } from "motion/react"; import { useEffect, useRef } from "react"; /* * The light sweeping across a Skeleton. The only client part of Skeleton, so Skeleton stays server-safe. * - How: a band (a gradient one step lighter than the block, fading out at both sides) sits just outside the * block and slides across it. Only `translate` moves, so the browser composites it on its own. * - In step: every sheen counts from the page's start (startTime 0 on the document timeline), so skeletons that * mount at different moments still sweep together, as one loading surface. * - The pace: a 1.5s sweep, then a 0.5s rest, on ease.inOut. Calm, not a flicker. * - With reduced motion nothing moves: a still, muted block. Before hydration it's still too; the sweep starts * once React runs. * - The Web Animations API directly, rather than motion: motion doesn't expose an animation's start time, and * that's what keeps every sheen in step. */ const period = duration.slower * 4 * 1000; /** The share of each period spent sweeping; the rest is a pause */ const sweep = 0.75; function SkeletonSheen() { const reduce = useReducedMotion() ?? false; const ref = useRef<HTMLSpanElement>(null); useEffect(() => { const node = ref.current; if (!node || reduce || typeof node.animate !== "function") return; // From the start of the line in either writing direction const [from, to] = getComputedStyle(node).direction === "rtl" ? ["100%", "-100%"] : ["-100%", "100%"]; const animation = node.animate( [ { translate: from, easing: `cubic-bezier(${ease.inOut.join(", ")})` }, { translate: to, offset: sweep }, { translate: to }, ], { duration: period, iterations: Number.POSITIVE_INFINITY }, ); animation.startTime = 0; return () => animation.cancel(); }, [reduce]); return ( <span ref={ref} aria-hidden data-slot="skeleton-sheen" className="pointer-events-none absolute inset-0 -translate-x-full bg-linear-to-r from-transparent via-background/60 to-transparent rtl:translate-x-full dark:via-foreground/5" /> ); } export { SkeletonSheen };Update the import paths to match your project.
Examples
Loading, then content
Reload: the skeleton fades in while the region is aria-busy, then the content comes back through the blur in the same place.
"use client";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { blur, duration, ease } from "@/lib/motion";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useState } from "react";
/*
* Reload: the profile gives way to its skeleton, and comes back through the blur when it has "loaded". Both sit
* in one grid cell, so nothing below moves. The skeleton only fades (it's a fill), the content sharpens in.
* While it loads, the region is aria-busy and a status says so.
*/
export default function SkeletonLoading() {
const [loading, setLoading] = useState(false);
const reduce = useReducedMotion() ?? false;
// Stands in for a request
useEffect(() => {
if (!loading) return;
const timer = setTimeout(() => setLoading(false), 1500);
return () => clearTimeout(timer);
}, [loading]);
return (
<div className="grid w-full max-w-xs justify-items-start gap-6">
<div aria-busy={loading} className="grid w-full">
<AnimatePresence initial={false}>
{loading ? (
<motion.div
key="skeleton"
className="col-start-1 row-start-1 flex items-center gap-3"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: duration.fast, ease: ease.out }}
>
<Skeleton className="size-10 shrink-0 rounded-full" />
<div className="grid flex-1 gap-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
</motion.div>
) : (
<motion.div
key="profile"
className="col-start-1 row-start-1 flex items-center gap-3"
initial={{ opacity: 0, filter: reduce ? blur.shown : blur.hidden }}
animate={{ opacity: 1, filter: blur.shown }}
exit={{ opacity: 0, transition: { duration: duration.fast, ease: ease.in } }}
transition={{ duration: duration.base, ease: ease.out }}
>
<Avatar size="lg">
<AvatarFallback>SD</AvatarFallback>
</Avatar>
<div className="grid min-w-0">
<span className="truncate text-sm font-medium">Sofia Davis</span>
<span className="truncate text-sm text-muted-foreground">Design lead, Lisbon</span>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<span role="status" className="sr-only">
{loading ? "Loading profile" : ""}
</span>
<Button variant="outline" size="sm" onClick={() => setLoading(true)}>
Reload
</Button>
</div>
);
}API reference
A muted block you shape with classes, for example "h-4 w-40" or "size-10 rounded-full". Hidden from screen readers: put aria-busy="true" and a visually hidden "Loading…" on the region that's loading. Also accepts every native div prop.
| Prop | Type | Default | Description |
|---|---|---|---|
className? | string | – | Its size and shape. Rounded md by default; rounded-full for a circle. |
Related components
An accessible React spinner for Next.js and Tailwind CSS: one line going round an infinity that fills it once the task is done.
- Team plan8 of 10 seats usedActive
A React card for Next.js and Tailwind CSS: a surface for grouped content with header, action, content and footer parts, a small size and a link style.
- OMJLIN+3
An accessible React avatar for Next.js and Tailwind CSS: a picture or initials in three sizes, alone or in an overlapping group, built on Radix Avatar.