V
Vaibhav Gupta
Guest
TL;DR: Skeleton loaders tell users that content is coming instead of leaving them staring at a blank screen. This guide shows how to build three practical Shadcn skeleton components in React: a profile card, a data table, and a list with icons. Each one uses the base Shadcn
Every app has a moment where data has not arrived yet. A dashboard is fetching numbers. A profile page is waiting on an API. A table is loading rows from a database. What you show during that gap matters more than most teams think.
A blank white screen feels broken. A spinning circle feels slow, even when it is not. A skeleton loader does something different: it shows the shape of the content before the content exists. The user's eyes already know where the avatar, the title, and the buttons will land. When the real data shows up, nothing jumps around. The layout was already there.
This is not a new idea. Facebook and LinkedIn popularized it years ago. What is new is how easy it has become to build these patterns properly in React, especially with Shadcn's component approach, where you own the code instead of importing a black-box library.
Before writing any code, it helps to be clear on the job a skeleton is doing. A good skeleton component should:
A skeleton that does not match the final layout is worse than no skeleton at all. It sets an expectation that gets broken the moment real content appears.
Shadcn skeleton component is built on top of Radix UI and Base UI primitives. In this guide, we are using the Base UI version, since it plays well with newer Shadcn setups and keeps the styling logic simple.
You can add the base skeleton primitive to your project with your package manager of choice:
This drops a
A profile skeleton is one of the most common patterns you will build. Think of a user page that shows an avatar, a name, a short bio, and a row of stats like followers or posts. Users check profile pages constantly, so this loading state needs to feel calm and predictable.
Here is a complete profile skeleton component, built with an avatar, name, and bio placeholders, a three-column stats grid, and a call-to-action button:
A few details worth noticing here. The
The delay values are staggered on purpose. The avatar appears first, then the name and bio, then the stats grid, then the button. This mirrors how a real profile would visually load, top to bottom, left to right.
Where to use this: user profile pages, author bios on a blog, team member cards, or any single-entity summary view.
Tables are harder to fake well. A table skeleton needs to communicate structure, meaning columns, rows, and how many rows are coming, without looking like a wall of gray bars.
Here is a table skeleton built for a user list, with an avatar and name in the first column, two data columns, and a status pill in the last column:
Notice the header row uses a slightly darker background (
The row count here is hardcoded to four with
Where to use this: admin panels, order history, user management screens, or any data-heavy list where rows have multiple columns.
Not every list needs full table columns. Sometimes you just need rows with an icon or thumbnail, a title, a subtitle, and a small tag or badge. Notification feeds, file browsers, and settings lists are good examples.
This one uses
Where to use this: notification panels, file or document lists, integration or plugin lists, and settings pages with toggle rows.
If you are not sure which one fits your screen, ask what the real content looks like once loaded. If it is one entity with a few facts about it, use the profile pattern. If it has more than three columns of comparable data, use the table pattern. If it is a simple repeated row, the list pattern is usually enough, and it is the lightest of the three to render.
A few mistakes show up often when developers first add skeleton loaders:
These three components are the visual layer. In a real app, you would pair each one with a loading state check, something like this:
This pattern keeps the skeleton component fully separate from your data-fetching logic. The skeleton does not know or care where the data comes from, whether that is a REST call, GraphQL, or a server component. It only needs to know when to show up and when to step aside.
A good skeleton component is a small piece of UI, but it does real work. It tells the user the app has not stalled, it holds the page layout steady, and it makes the moment data arrives feel smooth instead of sudden. The three patterns here, profile, table, and list, cover most of what a typical dashboard or content app needs.
Start by matching the real content's shape closely, keep the motion subtle, and only add a skeleton where the wait is actually long enough to need one. Once you have a small set of skeleton components like these, reusing them across new screens becomes fast, since most loading states in a typical app are really just variations on a card, a table, or a list.
If you want to explore more Shadcn components, templates, and tooling beyond what is covered here:
Skeleton primitive plus Motion for a small fade-in effect, and each is written so you can drop it into a real app today. You will also learn where skeletons help, where they hurt, and how to decide which layout fits your use case.Why Loading States Still Break Apps in 2026
Every app has a moment where data has not arrived yet. A dashboard is fetching numbers. A profile page is waiting on an API. A table is loading rows from a database. What you show during that gap matters more than most teams think.
A blank white screen feels broken. A spinning circle feels slow, even when it is not. A skeleton loader does something different: it shows the shape of the content before the content exists. The user's eyes already know where the avatar, the title, and the buttons will land. When the real data shows up, nothing jumps around. The layout was already there.
This is not a new idea. Facebook and LinkedIn popularized it years ago. What is new is how easy it has become to build these patterns properly in React, especially with Shadcn's component approach, where you own the code instead of importing a black-box library.
What a Skeleton Component Actually Needs to Do
Before writing any code, it helps to be clear on the job a skeleton is doing. A good skeleton component should:
- Match the real layout closely, so there is no shift when data loads
- Use subtle motion, not distracting animation
- Stay accessible, so screen readers do not read out meaningless placeholder blocks
- Be reusable across different data shapes, like cards, tables, and lists
A skeleton that does not match the final layout is worse than no skeleton at all. It sets an expectation that gets broken the moment real content appears.
Setting Up the Base Skeleton Primitive
Shadcn skeleton component is built on top of Radix UI and Base UI primitives. In this guide, we are using the Base UI version, since it plays well with newer Shadcn setups and keeps the styling logic simple.
You can add the base skeleton primitive to your project with your package manager of choice:
Code:
# pnpm
pnpm dlx shadcn@latest add @shadcn-space/skeleton-01
# npm
npx shadcn@latest add @shadcn-space/skeleton-01
# yarn
yarn dlx shadcn@latest add @shadcn-space/skeleton-01
# bun
bunx --bun shadcn@latest add @shadcn-space/skeleton-01
This drops a
Skeleton component into your components/ui folder, styled with Tailwind and ready to compose into larger layouts. From here, everything else in this guide is about combining that primitive into layouts that match real use cases: profiles, tables, and lists.Use Case 1: Profile Card with Stats
A profile skeleton is one of the most common patterns you will build. Think of a user page that shows an avatar, a name, a short bio, and a row of stats like followers or posts. Users check profile pages constantly, so this loading state needs to feel calm and predictable.
Here is a complete profile skeleton component, built with an avatar, name, and bio placeholders, a three-column stats grid, and a call-to-action button:
Code:
"use client"
import { useRef } from "react"
import { motion, useInView } from "motion/react"
import { Skeleton } from "@/components/ui/skeleton"
const fadeUp = (inView: boolean, delay: number) => ({
initial: { opacity: 0, y: 8 },
animate: inView ? { opacity: 1, y: 0 } : { opacity: 0, y: 8 },
transition: { duration: 0.4, ease: "easeOut", delay },
})
const ProfileSkeleton = () => {
const ref = useRef(null)
const inView = useInView(ref, { once: true, amount: 0.3 })
return (
<motion.div
ref={ref}
initial={{ opacity: 0, scale: 0.98 }}
animate={inView ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0.98 }}
transition={{ duration: 0.35, ease: "easeOut" }}
className="flex w-full max-w-sm flex-col items-center gap-6 rounded-md border p-6"
>
{/* Centered avatar */}
<motion.div {...fadeUp(inView, 0.05)}>
<Skeleton className="size-20 rounded-full" />
</motion.div>
{/* Name + bio lines centered */}
<div className="flex w-full flex-col items-center gap-2">
<motion.div {...fadeUp(inView, 0.1)}>
<Skeleton className="h-5 w-36" />
</motion.div>
<motion.div {...fadeUp(inView, 0.15)}>
<Skeleton className="h-4 w-24" />
</motion.div>
<motion.div {...fadeUp(inView, 0.2)}>
<Skeleton className="h-3 w-52" />
</motion.div>
</div>
{/* Stats grid */}
<motion.div {...fadeUp(inView, 0.25)} className="grid w-full grid-cols-3 gap-4">
{[0, 1, 2].map((i) => (
<motion.div
key={i}
{...fadeUp(inView, 0.3 + i * 0.07)}
className="flex flex-col items-center gap-2 rounded-md border p-3"
>
<Skeleton className="h-8 w-16" />
<Skeleton className="h-3 w-12" />
</motion.div>
))}
</motion.div>
{/* CTA button */}
<motion.div {...fadeUp(inView, 0.52)} className="w-full">
<Skeleton className="h-10 w-full" />
</motion.div>
</motion.div>
)
}
export default ProfileSkeleton
A few details worth noticing here. The
fadeUp helper is a small function, not a full animation library wrapper. It keeps every block's entrance consistent: fade in, move up slightly, done. The useInView hook means the animation only fires once the component is actually visible on screen, which avoids wasted motion on parts of the page the user has not scrolled to yet.The delay values are staggered on purpose. The avatar appears first, then the name and bio, then the stats grid, then the button. This mirrors how a real profile would visually load, top to bottom, left to right.
Where to use this: user profile pages, author bios on a blog, team member cards, or any single-entity summary view.
Live Preview:
Use Case 2: Table with Avatars
Tables are harder to fake well. A table skeleton needs to communicate structure, meaning columns, rows, and how many rows are coming, without looking like a wall of gray bars.
Here is a table skeleton built for a user list, with an avatar and name in the first column, two data columns, and a status pill in the last column:
Code:
"use client"
import { useRef } from "react"
import { motion, useInView } from "motion/react"
import { Skeleton } from "@/components/ui/skeleton"
const fadeUp = (inView: boolean, delay: number) => ({
initial: { opacity: 0, y: 8 },
animate: inView ? { opacity: 1, y: 0 } : { opacity: 0, y: 8 },
transition: { duration: 0.4, ease: "easeOut", delay },
})
const TableSkeleton = () => {
const ref = useRef(null)
const inView = useInView(ref, { once: true, amount: 0.3 })
return (
<motion.div
ref={ref}
initial={{ opacity: 0, scale: 0.98 }}
animate={inView ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0.98 }}
transition={{ duration: 0.35, ease: "easeOut" }}
className="flex w-full max-w-2xl flex-col gap-4 rounded-md border p-4"
>
{/* Table title + action */}
<motion.div {...fadeUp(inView, 0.05)} className="flex items-center justify-between">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-8 w-20 rounded-md" />
</motion.div>
{/* Header row */}
<motion.div {...fadeUp(inView, 0.1)} className="grid grid-cols-4 gap-4 rounded-md bg-muted px-3 py-2">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-20" />
<Skeleton className="h-3 w-16" />
</motion.div>
{/* Data rows */}
<div className="flex flex-col gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<motion.div
key={i}
{...fadeUp(inView, 0.15 + i * 0.08)}
className="grid grid-cols-4 items-center gap-4 border-b pb-3 last:border-0 last:pb-0"
>
<div className="flex items-center gap-3">
<Skeleton className="size-8 shrink-0 rounded-full" />
<Skeleton className="h-4 flex-1" />
</div>
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-6 w-16 rounded-full" />
</motion.div>
))}
</div>
</motion.div>
)
}
export default TableSkeleton
Notice the header row uses a slightly darker background (
bg-muted) with shorter skeleton bars. This is a small but important detail: it visually separates the header from the data rows before any real text exists, so the table still reads as a table.The row count here is hardcoded to four with
Array.from({ length: 4 }). In a real app, you would usually match this to your default page size, so the skeleton table has the same number of rows as the real table will show once data loads.Where to use this: admin panels, order history, user management screens, or any data-heavy list where rows have multiple columns.
Live Preview:
Use Case 3: List with Icons
Not every list needs full table columns. Sometimes you just need rows with an icon or thumbnail, a title, a subtitle, and a small tag or badge. Notification feeds, file browsers, and settings lists are good examples.
Code:
"use client"
import { useRef } from "react"
import { motion, useInView } from "motion/react"
import { Skeleton } from "@/components/ui/skeleton"
const fadeUp = (inView: boolean, delay: number) => ({
initial: { opacity: 0, y: 8 },
animate: inView ? { opacity: 1, y: 0 } : { opacity: 0, y: 8 },
transition: { duration: 0.4, ease: "easeOut", delay },
})
const ListSkeleton = () => {
const ref = useRef(null)
const inView = useInView(ref, { once: true, amount: 0.3 })
return (
<motion.div
ref={ref}
initial={{ opacity: 0, scale: 0.98 }}
animate={inView ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0.98 }}
transition={{ duration: 0.35, ease: "easeOut" }}
className="flex w-full max-w-md flex-col gap-4 rounded-md border p-4"
>
{/* List title + action */}
<motion.div {...fadeUp(inView, 0.05)} className="flex items-center justify-between">
<Skeleton className="h-5 w-28" />
<Skeleton className="h-7 w-16 rounded-md" />
</motion.div>
{/* List rows */}
<div className="flex flex-col gap-3">
{Array.from({ length: 5 }).map((_, i) => (
<motion.div
key={i}
{...fadeUp(inView, 0.1 + i * 0.08)}
className="flex items-center gap-3 rounded-md border p-3"
>
<Skeleton className="size-9 shrink-0 rounded-md" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-3 w-1/2" />
</div>
<Skeleton className="h-6 w-14 shrink-0 rounded-full" />
</motion.div>
))}
</div>
</motion.div>
)
}
export default ListSkeleton
This one uses
size-9 for a slightly smaller icon block compared to the table's size-8 avatar circle, and a rounded-md icon shape instead of rounded-full, since list icons are often square logos or file-type icons rather than round profile photos. Small shape choices like this make the skeleton feel tailored to its content instead of copy-pasted.Where to use this: notification panels, file or document lists, integration or plugin lists, and settings pages with toggle rows.
Live Preview:
How These Three Patterns Compare
| Component | Best for | Row shape |
|---|---|---|
| Profile skeleton | Single entity, centered layout | Circle avatar, stacked text, stat grid |
| Table skeleton | Multi-column structured data | Grid rows with fixed columns |
| List skeleton | Simple repeated items | Icon plus two lines of text |
If you are not sure which one fits your screen, ask what the real content looks like once loaded. If it is one entity with a few facts about it, use the profile pattern. If it has more than three columns of comparable data, use the table pattern. If it is a simple repeated row, the list pattern is usually enough, and it is the lightest of the three to render.
Common Mistakes to Avoid
A few mistakes show up often when developers first add skeleton loaders:
- Making the skeleton too generic: A single gray rectangle repeated five times does not tell the user anything about what is loading. Match the actual shapes: circles for avatars, pills for tags, full-width bars for paragraph text.
- Skipping accessibility: Skeleton elements are visual only. Make sure screen readers do not announce a string of meaningless "loading" divs. Use
aria-hidden="true"on skeleton wrappers, and announce the loading state separately with something like a visually hiddenaria-liveregion if the wait is long.
- Overdoing the animation: A subtle pulse or fade is enough. Heavy bouncing or spinning skeletons pull attention away from the fact that the app is working, and can make short loading times feel longer than they are.
- Leaving skeletons on screen too long: If your data usually loads in under 300 milliseconds, you may not need a skeleton at all. Flashing a skeleton for a fraction of a second can look more like a glitch than a loading state. A short delay before showing the skeleton, say 200 milliseconds, avoids this.
- Not matching real content dimensions: If your skeleton avatar is
size-20but your real avatar renders at a different size, the layout will shift the moment data arrives. Measure your real components first, then build the skeleton to match.
Wrapping a Skeleton with Real Loading Logic
These three components are the visual layer. In a real app, you would pair each one with a loading state check, something like this:
Code:
function ProfilePage() {
const { data, isLoading } = useUserProfile()
if (isLoading) {
return <ProfileSkeleton />
}
return <ProfileCard user={data} />
}
This pattern keeps the skeleton component fully separate from your data-fetching logic. The skeleton does not know or care where the data comes from, whether that is a REST call, GraphQL, or a server component. It only needs to know when to show up and when to step aside.
Final Thoughts
A good skeleton component is a small piece of UI, but it does real work. It tells the user the app has not stalled, it holds the page layout steady, and it makes the moment data arrives feel smooth instead of sudden. The three patterns here, profile, table, and list, cover most of what a typical dashboard or content app needs.
Start by matching the real content's shape closely, keep the motion subtle, and only add a skeleton where the wait is actually long enough to need one. Once you have a small set of skeleton components like these, reusing them across new screens becomes fast, since most loading states in a typical app are really just variations on a card, a table, or a list.
Further Shadcn Resources
If you want to explore more Shadcn components, templates, and tooling beyond what is covered here:
- Shadcn skeleton components - a browsable gallery of additional skeleton variations
- Shadcn component library - the full set of components built on Radix and Base UI primitives
- Shadcn loader and spinner - spinners are simple, but they solve a very real problem in apps
- Shadcn admin dashboard - pre-built dashboard layouts if you want a starting point beyond individual components
- Shadcn templates- full-page and app templates