Components

Getting started

Components144

More

The shape of what's coming, with one soft band of light sweeping across, and a swap that hands over to the real content without a pop.

Used forDashboardsLists and tablesProfilesFeeds

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/skeleton.json

Templates

Need the whole page?

43 Next.js landing pages, each designed from scratch. Try any of them live; one is $39, all of them $99, and 5 are free.

All 43 templates

Where it’s used

  • Dashboards

    Cards and charts while data loads.

  • Lists and tables

    Rows that keep their place.

  • Profiles

    An avatar and two lines of text.

  • Feeds

    Posts that arrive without shifting the page.

What it handles for you

The worst part of a skeleton is the moment it is replaced; this one owns that moment.

Instead of wiring uploading placeholdercontent shimmersuspense fallback
  1. One sweep

    A light band crosses each shape in 1.6s.

  2. Hand-over

    SkeletonSwap crossfades to content through a blur (300ms).

  3. No jump

    The box eases between the skeleton's height and the content's.

  4. Shapes

    line, circle and block with any size.

Overview

Skeletons usually pulse out of step and then vanish in one frame when the content lands, so the page jumps. Here one band of light sweeps across the shapes, and SkeletonSwap fades them out as the content rises out of a 4px blur in their place while the box eases to the content's height.

States

waiting
Muted shapes with a sweep (1.6s loop).
arriving
Content rises 4px out of a 4px blur (300–420ms); height eases (360ms).

Usage

usage.tsx
import { Skeleton, SkeletonSwap } from "@/components/ui/skeleton";<SkeletonSwap loading={!data} fallback={<><Skeleton shape="circle" /><Skeleton width="60%" /></>}>  <Profile user={data} /></SkeletonSwap>

Props

PropTypeDescription
shape"line" | "circle" | "block"default "line"Skeleton shape.
width / heightnumber | stringSize.
SkeletonSwap{ loading, fallback, children, label }Holds the fallback, then hands over.

Accessibility and motion

  • Skeleton shapes are aria-hidden; SkeletonSwap announces its label and sets aria-busy.
  • Reduced motion: no sweep; the content fades in.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs ui-interaction-motion.

Source

components/skeleton.tsx
"use client";import * as React from "react";import { EASE_OUT, useInteractionReducedMotion } from "./ui-interaction-motion";/* ───────────────────────────────────────────────────────── * SKELETON: the shape of what's coming * *   wait     muted shapes with one soft band of light sweeping *            across them all together, left to right *   arrive   with <Skeleton.Swap loading>, the shapes fade out as *            the real content rises out of a 4px blur in their *            place, so nothing pops; the box eases to the *            content's height * ───────────────────────────────────────────────────────── */export interface SkeletonProps extends React.HTMLAttributes<HTMLSpanElement> {  /** "line" is text height, "circle" a round avatar, "block" a box. */  shape?: "line" | "circle" | "block";  width?: number | string;  height?: number | string;}export function Skeleton({ shape = "line", width, height, className = "", style, ...props }: SkeletonProps) {  const reduced = useInteractionReducedMotion();  const dims = shape === "circle" ? { width: width ?? 36, height: height ?? width ?? 36 } : { width: width ?? "100%", height: height ?? (shape === "line" ? 12 : 80) };  return (    <span      aria-hidden="true"      className={`relative block overflow-hidden bg-muted ${shape === "circle" ? "rounded-full" : shape === "line" ? "rounded-full" : "rounded-[12px]"} ${className}`}      style={{ ...dims, ...style }}      {...props}    >      {!reduced && (        <span className="absolute inset-0 [animation:ui-skeleton_1.6s_cubic-bezier(0.4,0,0.2,1)_infinite] bg-[linear-gradient(90deg,transparent,color-mix(in_oklab,var(--background)_55%,transparent),transparent)]" />      )}    </span>  );}export interface SkeletonSwapProps {  loading: boolean;  /** The skeleton shown while loading. */  fallback: React.ReactNode;  children: React.ReactNode;  /** Accessible text while loading. */  label?: string;  className?: string;}/** Holds a skeleton while `loading`, then hands over to the content in one motion. */export function SkeletonSwap({ loading, fallback, children, label = "Loading", className = "" }: SkeletonSwapProps) {  const reduced = useInteractionReducedMotion();  const inner = React.useRef<HTMLDivElement>(null);  const [height, setHeight] = React.useState<number | null>(null);  // True for the first frames after loading ends, so the content starts from its hidden pose.  const [arriving, setArriving] = React.useState(false);  const [wasLoading, setWasLoading] = React.useState(loading);  if (wasLoading !== loading) {    setWasLoading(loading);    setArriving(!loading);  }  React.useEffect(() => {    if (!arriving) return;    let second = 0;    const first = requestAnimationFrame(() => {      second = requestAnimationFrame(() => setArriving(false));    });    return () => {      cancelAnimationFrame(first);      cancelAnimationFrame(second);    };  }, [arriving]);  // The box eases between the skeleton's height and the content's.  React.useLayoutEffect(() => {    const el = inner.current;    if (!el) return;    const measure = () => setHeight((h) => (h === el.offsetHeight ? h : el.offsetHeight));    measure();    const ro = new ResizeObserver(measure);    ro.observe(el);    return () => ro.disconnect();  }, []);  const t = (ms: number, delay = 0) => (reduced ? "none" : `${ms}ms ${EASE_OUT} ${delay}ms`);  return (    <div aria-busy={loading || undefined} className={`relative overflow-hidden ${className}`} style={{ height: height ?? undefined, transition: reduced ? "none" : `height 360ms ${EASE_OUT}` }}>      {loading && (        <span className="sr-only" role="status">          {label}        </span>      )}      <div ref={inner}>        {loading ? (          fallback        ) : (          <div            style={{              opacity: arriving ? 0 : 1,              filter: arriving && !reduced ? "blur(4px)" : "blur(0px)",              translate: arriving && !reduced ? "0 4px" : "0 0",              transition: arriving ? "none" : `opacity ${t(300, 40)}, filter ${t(300, 40)}, translate ${t(420, 40)}`,            }}          >            {children}          </div>        )}      </div>    </div>  );}export default Skeleton;

Registry item: /r/skeleton.json