How to Build a Shadcn Skeleton Component with React for Multiple Use Cases

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 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 1: Profile Card with Stats


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 2: Table with Avatars


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:​


Use Case 3: List with Icons


How These Three Patterns Compare​

ComponentBest forRow shape
Profile skeletonSingle entity, centered layoutCircle avatar, stacked text, stat grid
Table skeletonMulti-column structured dataGrid rows with fixed columns
List skeletonSimple repeated itemsIcon 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 hidden aria-live region 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-20 but 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:

 

Thread statistics

Created
Vaibhav Gupta,
Replies
0
Views
2
Back
Top