EmpireUI
Get Pro
← Blog7 min read#skeleton-loader#react#tailwind

Skeleton Loader Patterns: 10 Loading UI Designs for Every Case

Skeleton loaders beat spinners every time — here are 10 concrete React patterns for building loading UIs that feel instant, cover every data shape, and integrate with Tailwind.

Abstract grey placeholder shapes arranged in a card layout representing skeleton loading UI patterns on a dark background

Why Skeleton Loaders Win Over Spinners

Honestly, spinners are lazy. They tell the user exactly nothing — not what's loading, not how much, not what shape the content will take when it arrives. A skeleton loader does the opposite: it stakes out the space the real content will occupy and moves immediately, so the page feels alive from the first paint.

The psychology here is real. Research on perceived performance consistently shows that users rate experiences with skeleton screens as faster even when the actual network time is identical. That's because a skeleton loader shifts the wait from "nothing is happening" to "something is being prepared". It's the difference between staring at a blank wall and watching a watercolour fill in.

None of this means every loading state needs a skeleton. A 50ms API call doesn't need one at all — just show the data. Skeletons make sense when you're waiting more than 300ms and you know the rough shape of the content coming back. If you don't know the shape, a spinner or a progress bar is still the right answer.

The Anatomy of a Skeleton Block in Tailwind

Every skeleton element is three things: a background fill, an animation, and a shape that mirrors the real content. In Tailwind v4.0.2 you can express all three in about five utility classes. The core pattern is a rounded div with bg-neutral-200 dark:bg-neutral-700 animate-pulse applied. That's it for the simplest case.

Where teams get into trouble is the animation. Tailwind's built-in animate-pulse does a simple opacity fade — it works but it's not the most polished. The shimmer effect (a bright highlight that travels left-to-right) reads as more "loading" to users because it implies motion through time. You get that with a custom @keyframes plus a gradient overlay.

Here's a self-contained shimmer skeleton utility you can paste into any Tailwind project. It uses a CSS custom property for the shimmer width so you can tweak it per breakpoint: ``tsx // SkeletonShimmer.tsx import { cn } from '@/lib/utils'; interface SkeletonProps { className?: string; } export function Skeleton({ className }: SkeletonProps) { return ( <div className={cn( 'relative overflow-hidden rounded-md bg-neutral-200 dark:bg-neutral-800', 'before:absolute before:inset-0', 'before:bg-gradient-to-r before:from-transparent', 'before:via-white/40 dark:before:via-white/10', 'before:to-transparent', 'before:animate-[shimmer_1.4s_infinite]', className )} /> ); } ` `css /* globals.css — add inside @layer utilities or at root */ @keyframes shimmer { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } } ` The before: pseudo-element carries the gradient so the host element's overflow-hidden` clips it cleanly. No extra wrapper, no JS.

Pattern 1–3: Card, List Row, and Avatar Skeletons

These three cover probably 70% of the cases you'll ever hit. A card skeleton mirrors the grid of real cards — a rectangle for the image area, two shorter lines for title and subtitle, and optionally a small avatar in the corner. Match the border-radius of the skeleton blocks to what the real card uses, or the snap from loading to loaded will feel jarring.

A list row skeleton is even simpler: an avatar circle on the left (40px × 40px), two lines of varying width to the right, all stacked in a flex container with an 8px gap. The key is varying the line widths — something like w-3/4 for the heading and w-1/2 for the subline — so the skeleton doesn't look too uniform. Uniform skeletons pattern-match to "placeholder" in the user's brain more than "content on the way".

For avatar-only skeletons (comment sections, user lists, participant chips), a circle with rounded-full is fine, but add a faint ring — ring-2 ring-neutral-300 dark:ring-neutral-700 — so the shape is visible on both light and dark backgrounds. This matters more than it sounds when you're dealing with a theme toggle that switches between light and dark mid-session.

Pattern 4–6: Table, Bento Grid, and Carousel Skeletons

Tables are tricky because they're structured. Don't reach for an actual <table> element in your skeleton — div-based rows are much easier to animate and style. Render 5–8 skeleton rows, each with cells proportional to your real column widths, and let animate-pulse do the work. Stagger the animation delay on each row by 75ms so they don't all pulse in sync; that staggering alone makes the skeleton feel more organic.

Bento-grid skeletons need to respect the grid's span structure. If your real bento grid has a large hero tile spanning 2 columns and 2 rows, your skeleton should too. Using a skeleton that collapses to uniform squares and then snapping to the real asymmetric layout on load is disorienting. Map the spans first, then apply the skeleton fill.

Carousel skeletons are interesting because you're often showing 3 visible cards with partial-visibility hints at the edges. Show exactly that many skeleton cards at the right width. Don't show a single full-width rectangle — it misrepresents the real layout and you lose the spatial promise the skeleton is supposed to make. For reference on how Empire UI handles carousel layouts, see our React carousel component guide.

Pattern 7–8: Text Block and Code Block Skeletons

Long-form content skeletons (articles, product descriptions, documentation) need variable line widths to feel realistic. A common approach: render N skeleton lines where each line has a width sampled from a short array — ['w-full', 'w-11/12', 'w-4/5', 'w-full', 'w-3/4'] — cycling through them. The last line is usually shorter to mimic paragraph endings. Stack lines with a 4px gap inside each paragraph block and 16px between paragraphs.

Code block skeletons are a niche case but worth covering if you're building a documentation site, a code-review tool, or an AI output interface. The key differences: monospace font sizing, a slightly darker background to hint at the code surface, and line widths that vary less (real code lines tend to be more consistent in length than prose). Add a small coloured dot or icon in the top-left corner to telegraph "this is code" even before it loads.

Pattern 9–10: Full-Page and Conditional Skeletons

Full-page skeletons are what you show on the very first render before any data arrives — the entire page scaffolding in grey blocks. They work best for apps where the data shape is predictable on every visit: a dashboard, a user profile, a settings page. For pages where content shape varies per user or route, a full-page skeleton can actually mislead users into expecting layout that won't appear.

Conditional skeletons are the more nuanced version: you show different skeleton shapes depending on what you know at load time. If you have a user object with a plan: 'pro' flag in localStorage, you can immediately render the pro-tier skeleton instead of a generic one. That reduces layout shift when the real data arrives. It also means your skeleton logic becomes a component decision, not just a CSS concern.

The pattern here is a SkeletonProvider that reads cached context and exports a useSkeleton hook. Components ask "what skeleton should I be?" and the provider answers based on whatever partial data is already available. This keeps skeleton logic out of individual components and makes it testable in isolation. Think of it as the loading state equivalent of what animated tabs does for navigation state — a single source of truth for a transient UI concern.

Accessibility and Reduced Motion

Animations trigger vestibular issues for a non-trivial percentage of users. Skeleton animations — especially the shimmer — are persistent and can't be dismissed. You have to respect prefers-reduced-motion. The fix is one CSS media query: wrap your @keyframes shimmer animation in @media (prefers-reduced-motion: no-preference) and fall back to a static bg-neutral-200 for everyone who's opted out.

Screen readers don't care about skeleton loaders and they shouldn't have to. Wrap your skeleton region in aria-hidden="true" and pair it with a visually hidden <span aria-live="polite">Loading content...</span> outside the skeleton container. That way assistive technology announces the loading state via text while sighted users see the visual skeleton. Do not put aria-busy on the skeleton itself — put it on the region that will contain the loaded content.

One more thing: colour contrast. Skeleton blocks need enough contrast against the page background to be visible, but not so much that they look like real content. On a white background, bg-neutral-200 (#e5e7eb) is a 1.3:1 contrast ratio — technically below the WCAG 4.5:1 threshold, which is fine because skeleton blocks are purely decorative. What you want to avoid is using a colour that reads as text or an interactive element to low-vision users.

Integrating Skeletons with React Suspense and Server Components

React 18+ Suspense is the cleanest place to slot a skeleton loader. Your <Suspense fallback={<CardSkeleton />}> boundary means you never manually manage isLoading state — React handles the switch from skeleton to real content. The catch is that Suspense boundaries don't work with client-side data fetching libraries out of the box unless they expose a Suspense-compatible mode. SWR's suspense: true option and TanStack Query's suspense option both work, but check the docs for the version you're on.

With Next.js App Router, loading.tsx is your page-level skeleton. Drop a skeleton component in there and Next.js automatically wraps the page's page.tsx in a Suspense boundary with your skeleton as the fallback. This means the skeleton is server-rendered, arrives with the initial HTML, and transitions to real content without any client-side flash. That's genuinely the best loading UX you can ship with minimal effort.

What about streaming? Next.js App Router with React Server Components lets you stream sections of a page independently. You can have a page where the header and nav render immediately, the main content renders behind a skeleton, and a sidebar renders behind a different skeleton — all from a single request. Each Suspense boundary streams its content when it's ready. The skeleton isn't a placeholder for "everything" anymore; it's a placeholder for a specific data slice. That granularity is where you recover perceived performance on data-heavy pages.

FAQ

Should I use `animate-pulse` or a shimmer animation for skeleton loaders?

Shimmer looks more polished and reads as 'loading' more clearly to users — the moving highlight implies progress through time. Use animate-pulse when you want zero extra CSS and the skeleton is brief (under 1 second). For anything longer or user-facing enough to matter, the custom shimmer @keyframes is worth the extra 8 lines of CSS.

How many skeleton lines should a text block have?

Match what the real content will roughly be. For a card subtitle, 1–2 lines. For a product description, 4–6 lines. Don't show 12 skeleton lines if the real content is 3 sentences — the layout shift when real content arrives will be jarring. If you genuinely don't know the content length, 4 lines at varying widths is a safe default.

Does `backdrop-filter` affect skeleton shimmer performance?

Only if you're stacking a glassmorphism parent on top of a skeleton child, which is an unusual combination. The shimmer itself uses transform: translateX() which runs on the compositor thread and doesn't trigger layout or paint. It's GPU-accelerated and performs fine even with 20–30 skeleton items on screen simultaneously.

What's the right way to handle dark mode in skeleton loaders?

Use Tailwind's dark: prefix on the background: bg-neutral-200 dark:bg-neutral-700 on the skeleton base and before:via-white/40 dark:before:via-white/10 on the shimmer gradient. The shimmer needs to be significantly more subtle in dark mode — rgba(255,255,255,0.40) is fine on a light background but will look like a strobe on dark. Reduce it to rgba(255,255,255,0.08) to rgba(255,255,255,0.12) for dark.

Can I use React Suspense for skeleton loaders in Next.js Pages Router?

Yes, but you're on your own — there's no loading.tsx magic. You can use <Suspense fallback={<YourSkeleton />}> around any client component that suspends. Libraries like SWR and TanStack Query expose suspense: true options that make components suspend while fetching. The Pages Router doesn't support React Server Components streaming, so you'll get the full page skeleton behaviour rather than per-component streaming.

Should skeleton blocks have `border-radius` and if so how much?

Match the real component. If your cards use rounded-xl (12px), use rounded-xl on the skeleton image block. If your avatar is rounded-full, the skeleton circle should be too. A mismatch between skeleton geometry and real component geometry breaks the spatial promise — the user's eye expects content to appear exactly where the skeleton was.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Star Rating Component in React: Accessible, Animated, ThemeableSide Sheet Drawer in React: Slide-In Panels with Animation10 Tailwind Component Patterns Every Developer Should KnowWhat Is Glassmorphism? A Free React + Tailwind Guide