Skeleton
Loading placeholder with one shimmer shared across the page. Wrap real content and it takes the same shape, then crossfades away.
"use client";
import { RotateCw } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/motion/button";
import { Skeleton } from "@/components/motion/skeleton";
/** How long the pretend request takes, in ms. */
const LOAD_MS = 1600;
export function SkeletonPreview() {
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!loading) return;
const timeout = setTimeout(() => setLoading(false), LOAD_MS);
return () => clearTimeout(timeout);
}, [loading]);
return (
<div className="flex w-full max-w-xs flex-col items-center gap-5">
<div className="flex w-full items-center gap-3 rounded-2xl bg-background p-4 shadow-[0_0_0_1px_var(--border)]">
<Skeleton loading={loading} className="shrink-0 [&>span]:rounded-full">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-accent text-sm font-semibold text-accent-foreground">
MR
</div>
</Skeleton>
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<Skeleton loading={loading} className="w-fit [&>span]:rounded-md">
<p className="text-sm font-medium text-foreground">Maya Rivera</p>
</Skeleton>
<Skeleton loading={loading} className="w-fit [&>span]:rounded-md">
<p className="text-xs text-muted-foreground">Design lead, joined in March</p>
</Skeleton>
</div>
</div>
<Button variant="secondary" size="sm" onClick={() => setLoading(true)} disabled={loading}>
<RotateCw aria-hidden="true" className="h-3.5 w-3.5" />
Reload
</Button>
</div>
);
}
"use client";
// easeui.dev/components/motion/skeleton
import { type ComponentProps, type ReactNode, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
/** A soft light band. Fixed to the viewport, so every skeleton on the page shimmers as one. */
const BAND =
"linear-gradient(90deg, transparent, color-mix(in oklch, var(--foreground) 7%, transparent), transparent)";
const SWEEP: Keyframe[] = [{ backgroundPosition: "-50vw 0" }, { backgroundPosition: "150vw 0" }];
const SWEEP_MS = 1800;
export interface SkeletonProps extends Omit<ComponentProps<"div">, "ref" | "children"> {
/** Shows the placeholder. With children, false crossfades to the real content. Default true. */
loading?: boolean;
/** Real content. It keeps its space while loading, so nothing shifts when it appears. */
children?: ReactNode;
}
function useShimmer(active: boolean) {
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
const element = ref.current;
if (!active || !element || typeof element.animate !== "function") return;
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
const animation = element.animate(SWEEP, {
duration: SWEEP_MS,
iterations: Number.POSITIVE_INFINITY,
easing: "ease-in-out",
});
// Starting every skeleton at the same moment keeps their bands lined up.
animation.startTime = 0;
const sync = () => (reducedMotion.matches ? animation.pause() : animation.play());
sync();
reducedMotion.addEventListener("change", sync);
return () => {
reducedMotion.removeEventListener("change", sync);
animation.cancel();
};
}, [active]);
return ref;
}
/**
* A loading placeholder with a quiet shimmer. Size it with classes, or wrap
* the real content so the placeholder takes its exact shape and fades away
* when loading ends.
*/
export function Skeleton({ loading = true, children, className, ...props }: SkeletonProps) {
const shimmer = useShimmer(loading);
const placeholder = (
<span
ref={shimmer}
aria-hidden="true"
className={cn(
"block rounded-lg bg-muted transition-opacity duration-200 ease-out motion-reduce:transition-none",
children ? "absolute inset-0" : "h-4 w-full",
!loading && "opacity-0",
!children && className,
)}
style={{
backgroundImage: BAND,
backgroundSize: "50vw 100%",
backgroundRepeat: "no-repeat",
backgroundAttachment: "fixed",
}}
/>
);
if (!children) return placeholder;
return (
<div
aria-busy={loading}
className={cn("relative", className)}
{...props}
>
<div
aria-hidden={loading}
inert={loading}
className={cn(
"transition-opacity duration-200 ease-out motion-reduce:transition-none",
loading ? "opacity-0" : "opacity-100",
)}
>
{children}
</div>
{placeholder}
</div>
);
}
Installation
$ bunx --bun shadcn add @easeui/skeleton
- 1
Set up the theme tokens
Do this once per project. Follow the theme setup or skip it if you already ran shadcn init.
- 2
Install the dependencies
terminal npm install clsx lucide-react motion tailwind-merge - 3
Add the source files
components/motion/skeleton.tsx "use client"; // easeui.dev/components/motion/skeleton import { type ComponentProps, type ReactNode, useEffect, useRef } from "react"; import { cn } from "@/lib/utils"; /** A soft light band. Fixed to the viewport, so every skeleton on the page shimmers as one. */ const BAND = "linear-gradient(90deg, transparent, color-mix(in oklch, var(--foreground) 7%, transparent), transparent)"; const SWEEP: Keyframe[] = [{ backgroundPosition: "-50vw 0" }, { backgroundPosition: "150vw 0" }]; const SWEEP_MS = 1800; export interface SkeletonProps extends Omit<ComponentProps<"div">, "ref" | "children"> { /** Shows the placeholder. With children, false crossfades to the real content. Default true. */ loading?: boolean; /** Real content. It keeps its space while loading, so nothing shifts when it appears. */ children?: ReactNode; } function useShimmer(active: boolean) { const ref = useRef<HTMLSpanElement>(null); useEffect(() => { const element = ref.current; if (!active || !element || typeof element.animate !== "function") return; const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); const animation = element.animate(SWEEP, { duration: SWEEP_MS, iterations: Number.POSITIVE_INFINITY, easing: "ease-in-out", }); // Starting every skeleton at the same moment keeps their bands lined up. animation.startTime = 0; const sync = () => (reducedMotion.matches ? animation.pause() : animation.play()); sync(); reducedMotion.addEventListener("change", sync); return () => { reducedMotion.removeEventListener("change", sync); animation.cancel(); }; }, [active]); return ref; } /** * A loading placeholder with a quiet shimmer. Size it with classes, or wrap * the real content so the placeholder takes its exact shape and fades away * when loading ends. */ export function Skeleton({ loading = true, children, className, ...props }: SkeletonProps) { const shimmer = useShimmer(loading); const placeholder = ( <span ref={shimmer} aria-hidden="true" className={cn( "block rounded-lg bg-muted transition-opacity duration-200 ease-out motion-reduce:transition-none", children ? "absolute inset-0" : "h-4 w-full", !loading && "opacity-0", !children && className, )} style={{ backgroundImage: BAND, backgroundSize: "50vw 100%", backgroundRepeat: "no-repeat", backgroundAttachment: "fixed", }} /> ); if (!children) return placeholder; return ( <div aria-busy={loading} className={cn("relative", className)} {...props} > <div aria-hidden={loading} inert={loading} className={cn( "transition-opacity duration-200 ease-out motion-reduce:transition-none", loading ? "opacity-0" : "opacity-100", )} > {children} </div> {placeholder} </div> ); }lib/utils.ts import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }components/motion/button.tsx "use client"; import { type HTMLMotionProps, motion, useReducedMotion } from "motion/react"; import { forwardRef, type ReactNode } from "react"; import { cn } from "@/lib/utils"; export type ButtonVariant = "primary" | "secondary" | "outline" | "ghost"; export type ButtonSize = "sm" | "md" | "lg" | "icon"; export interface ButtonProps extends Omit<HTMLMotionProps<"button">, "children"> { /** Visual style. Default "primary". */ variant?: ButtonVariant; /** Height and padding. "icon" is a square button for a single icon. Default "md". */ size?: ButtonSize; children?: ReactNode; } // A quick press confirms the tap before the action finishes. const PRESS = { scale: 0.97 }; const NO_PRESS = { scale: 1 }; const PRESS_TRANSITION = { duration: 0.15, ease: [0.23, 1, 0.32, 1] } as const; // Hairline rings are drawn with box-shadow so they blend with any background. const VARIANT_CLASS: Record<ButtonVariant, string> = { primary: "bg-foreground text-background hover:bg-foreground/90", secondary: "bg-card text-foreground shadow-[0_0_0_1px_var(--border)] hover:bg-muted", outline: "text-foreground shadow-[0_0_0_1px_var(--border-strong)] hover:bg-foreground/5", ghost: "text-muted-foreground hover:bg-foreground/5 hover:text-foreground", }; // Small sizes grow an invisible hit area so the tap target stays around 44px. const SIZE_CLASS: Record<ButtonSize, string> = { sm: "h-8 gap-1.5 px-3 text-xs after:absolute after:-inset-1.5", md: "h-10 gap-2 px-4 text-sm", lg: "h-12 gap-2 px-5 text-base", icon: "h-9 w-9 after:absolute after:-inset-1", }; export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button( { variant = "primary", size = "md", type = "button", className, children, ...props }, ref, ) { const reduce = useReducedMotion(); return ( <motion.button ref={ref} type={type} // Always pass a gesture so the server and client render the same attributes. whileTap={reduce ? NO_PRESS : PRESS} transition={PRESS_TRANSITION} className={cn( "relative inline-flex shrink-0 touch-manipulation select-none items-center justify-center rounded-full font-medium outline-none", "transition-[background-color,color,box-shadow] duration-150 ease-out", "focus-visible:ring-2 focus-visible:ring-foreground/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background", "disabled:pointer-events-none disabled:opacity-50", VARIANT_CLASS[variant], SIZE_CLASS[size], className, )} {...props} > {children} </motion.button> ); });
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
| loading? | boolean | true | Shows the placeholder. With children, false crossfades to the real content. Default true. |
| children? | ReactNode | - | Real content. It keeps its space while loading, so nothing shifts when it appears. |
| className? | string | - | - |
Updated