Skip to content
goodtabs UI
BrowseBrowse components

Skeleton set

Placeholder blocks shaped like a card, a list row, text and an article.

npx shadcn@latest add https://ui.goodtabs.co/r/skeleton-set.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/skeleton-set

Loading resource

Matches a resource card: icon, name and two lines.

List rows

Loading activity

Avatar, two lines and a trailing value, three times.

Article

Loading article

Compose your own from Skeleton and SkeletonText.

Usage

import { SkeletonGroup, SkeletonCard } from "@/components/ui/skeleton-set"

<SkeletonGroup label="Loading resources">
  <SkeletonCard />
</SkeletonGroup>

Props

PropTypeDefaultDescription
Skeletondiv propsOne block. Set its size and radius with className.
SkeletonGroup labelstring"Loading"Text read once by screen readers for the whole group.
SkeletonText linesnumber3How many lines. The last one is two thirds wide.

Source

skeleton-set/skeleton-set.tsx
import * as React from "react";
import { cn } from "@/lib/utils";

/** A single placeholder block. The pulse stops under reduced motion and the block stays visible. */
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="skeleton"
      aria-hidden="true"
      className={cn("rounded-md bg-muted motion-safe:animate-pulse", className)}
      {...props}
    />
  );
}

/** Wraps a group of skeletons so assistive tech hears one status instead of many empty blocks. */
function SkeletonGroup({
  label = "Loading",
  className,
  children,
  ...props
}: React.ComponentProps<"div"> & { label?: string }) {
  return (
    <div role="status" aria-busy="true" className={className} {...props}>
      <span className="sr-only">{label}</span>
      {children}
    </div>
  );
}

/** Placeholder lines. The last line is shorter, like a real paragraph. */
function SkeletonText({ lines = 3, className }: { lines?: number; className?: string }) {
  return (
    <div className={cn("flex flex-col gap-2", className)}>
      {Array.from({ length: lines }, (_, i) => (
        <Skeleton key={i} className={cn("h-3", i === lines - 1 && lines > 1 ? "w-2/3" : "w-full")} />
      ))}
    </div>
  );
}

/** Matches a resource card: icon, a name line and a description line. */
function SkeletonCard({ className }: { className?: string }) {
  return (
    <div className={cn("flex items-start gap-3 rounded-lg border bg-background p-3", className)}>
      <Skeleton className="size-8 shrink-0 rounded-md" />
      <div className="flex min-w-0 flex-1 flex-col gap-2 pt-0.5">
        <Skeleton className="h-3.5 w-1/2" />
        <SkeletonText lines={2} />
      </div>
    </div>
  );
}

/** Matches a list row: avatar, two lines and a trailing value. */
function SkeletonRow({ className }: { className?: string }) {
  return (
    <div className={cn("flex items-center gap-3 py-2", className)}>
      <Skeleton className="size-8 shrink-0 rounded-full" />
      <div className="flex flex-1 flex-col gap-1.5">
        <Skeleton className="h-3 w-2/5" />
        <Skeleton className="h-3 w-3/5" />
      </div>
      <Skeleton className="h-3 w-8" />
    </div>
  );
}

export { Skeleton, SkeletonGroup, SkeletonText, SkeletonCard, SkeletonRow };

For AI agents

Hand your agent this page, the markdown at /components/skeleton-set/prompt.md, or the Copy AI prompt button above.

When to use

  • Loads longer than a moment, where the final layout is known
  • Lists and cards that should not jump when the data arrives

When not to use

  • Short waits under 300 milliseconds: show nothing
  • Actions in progress: use a spinner on the button

Agent notes

  • Match the real content's size so nothing shifts when it replaces the skeleton
  • Skeleton is aria-hidden; wrap a group in SkeletonGroup so one status is announced
  • The pulse uses motion-safe, so under reduced motion the blocks sit still and visible
  • Server component, no JavaScript