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
| Prop | Type | Default | Description |
|---|---|---|---|
| Skeleton | div props | One block. Set its size and radius with className. | |
| SkeletonGroup label | string | "Loading" | Text read once by screen readers for the whole group. |
| SkeletonText lines | number | 3 | How 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