Skeleton
Placeholder loading states for content that is loading.
Basic Usage
"use client";
import { Skeleton } from "@repo/ui/components/skeleton";
import { SkeletonWrapper } from "./skeleton-wrapper";
export const Example1 = () => {
return (
<SkeletonWrapper>
<div className="flex items-center gap-4">
<Skeleton variant="circular" className="h-12 w-12" />
<div className="flex-1 space-y-2">
<Skeleton variant="text" width="50%" />
<Skeleton variant="text" width="30%" size="xs" />
</div>
</div>
<Skeleton variant="default" height={120} />
<div className="space-y-2">
<Skeleton variant="text" />
<Skeleton variant="text" />
<Skeleton variant="text" width="60%" />
</div>
</SkeletonWrapper>
);
};
Sizes
XS
SM
MD
LG
XL
"use client";
import { Skeleton } from "@repo/ui/components/skeleton";
import { SkeletonWrapper } from "./skeleton-wrapper";
export const Example2 = () => {
return (
<SkeletonWrapper>
<div className="flex items-center gap-4">
<span className="text-muted-foreground w-8 text-sm">XS</span>
<Skeleton size="xs" className="w-full" />
</div>
<div className="flex items-center gap-4">
<span className="text-muted-foreground w-8 text-sm">SM</span>
<Skeleton size="sm" className="w-full" />
</div>
<div className="flex items-center gap-4">
<span className="text-muted-foreground w-8 text-sm">MD</span>
<Skeleton size="md" className="w-full" />
</div>
<div className="flex items-center gap-4">
<span className="text-muted-foreground w-8 text-sm">LG</span>
<Skeleton size="lg" className="w-full" />
</div>
<div className="flex items-center gap-4">
<span className="text-muted-foreground w-8 text-sm">XL</span>
<Skeleton size="xl" className="w-full" />
</div>
</SkeletonWrapper>
);
};
Animations
Pulse (default)
Wave
None
"use client";
import { Skeleton } from "@repo/ui/components/skeleton";
import { SkeletonWrapper } from "./skeleton-wrapper";
export const Example3 = () => {
return (
<SkeletonWrapper>
<div>
<p className="text-muted-foreground mb-2 text-sm">
Pulse (default)
</p>
<Skeleton animation="pulse" height={60} />
</div>
<div>
<p className="text-muted-foreground mb-2 text-sm">Wave</p>
<Skeleton animation="wave" height={60} />
</div>
<div>
<p className="text-muted-foreground mb-2 text-sm">None</p>
<Skeleton animation="none" height={60} />
</div>
</SkeletonWrapper>
);
};
Card Skeleton
"use client";
import { SkeletonCard } from "@repo/ui/components/skeleton";
import { SkeletonWrapper } from "./skeleton-wrapper";
export const Example4 = () => {
return (
<SkeletonWrapper className="grid w-full gap-4 sm:grid-cols-2">
<SkeletonCard />
<SkeletonCard animation="wave" />
</SkeletonWrapper>
);
};
Profile Skeleton
"use client";
import {
Skeleton,
SkeletonAvatar,
SkeletonText,
SkeletonButton,
} from "@repo/ui/components/skeleton";
import { SkeletonWrapper } from "./skeleton-wrapper";
export const Example5 = () => {
return (
<SkeletonWrapper>
<div className="border-border w-full rounded-lg border p-6">
<div className="flex items-start gap-4">
<SkeletonAvatar avatarSize="xl" />
<div className="flex-1">
<Skeleton variant="text" width="40%" className="mb-1" />
<Skeleton
variant="text"
width="25%"
size="xs"
className="mb-4"
/>
<SkeletonText lines={2} />
<div className="mt-4 flex gap-2">
<SkeletonButton buttonSize="sm" />
<SkeletonButton buttonSize="sm" />
</div>
</div>
</div>
</div>
</SkeletonWrapper>
);
};
Data Loading
"use client";
import { useState, useEffect } from "react";
import { Skeleton, SkeletonAvatar } from "@repo/ui/components/skeleton";
import { SkeletonWrapper } from "./skeleton-wrapper";
interface User {
name: string;
email: string;
avatar: string;
}
const mockUsers: User[] = [
{ name: "Alice Johnson", email: "alice@example.com", avatar: "AJ" },
{ name: "Bob Smith", email: "bob@example.com", avatar: "BS" },
{ name: "Carol Williams", email: "carol@example.com", avatar: "CW" },
];
export const Example6 = () => {
const [loading, setLoading] = useState(true);
const [users, setUsers] = useState<User[]>([]);
useEffect(() => {
const timer = setTimeout(() => {
setUsers(mockUsers);
setLoading(false);
}, 2000);
return () => clearTimeout(timer);
}, []);
const handleReload = () => {
setLoading(true);
setUsers([]);
setTimeout(() => {
setUsers(mockUsers);
setLoading(false);
}, 2000);
};
return (
<SkeletonWrapper>
<div className="w-full space-y-4">
<button
onClick={handleReload}
className="bg-primary text-primary-foreground rounded-lg px-4 py-2 text-sm"
>
Reload Data
</button>
<div className="divide-border divide-y">
{loading
? Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className="flex items-center gap-3 py-3"
>
<SkeletonAvatar avatarSize="md" />
<div className="flex-1 space-y-1">
<Skeleton variant="text" width="30%" />
<Skeleton
variant="text"
width="50%"
size="xs"
/>
</div>
</div>
))
: users.map((user) => (
<div
key={user.email}
className="flex items-center gap-3 py-3"
>
<div className="bg-primary text-primary-foreground flex h-10 w-10 items-center justify-center rounded-full text-sm font-medium">
{user.avatar}
</div>
<div>
<p className="font-medium">{user.name}</p>
<p className="text-muted-foreground text-sm">
{user.email}
</p>
</div>
</div>
))}
</div>
</div>
</SkeletonWrapper>
);
};
Component Code
"use client";
import { forwardRef } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "tailwind-variants";
export const skeletonVariants = cva("bg-muted relative overflow-hidden", {
variants: {
variant: {
default: "rounded-md",
circular: "rounded-full",
text: "rounded h-4 w-full",
button: "rounded-lg h-9",
avatar: "rounded-full aspect-square",
card: "rounded-lg",
},
size: {
xs: "h-2",
sm: "h-4",
md: "h-6",
lg: "h-8",
xl: "h-12",
full: "h-full w-full",
},
animation: {
pulse: "animate-pulse",
wave: "skeleton-wave",
none: "",
},
},
defaultVariants: {
variant: "default",
size: "md",
animation: "pulse",
},
});
interface SkeletonProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof skeletonVariants> {
/** Width of the skeleton (CSS value) */
width?: string | number;
/** Height of the skeleton (CSS value) */
height?: string | number;
}
const Skeleton = forwardRef<HTMLDivElement, SkeletonProps>(
(
{ className, variant, size, animation, width, height, style, ...props },
ref,
) => {
const computedStyle = {
...style,
width:
width !== undefined
? typeof width === "number"
? `${width}px`
: width
: undefined,
height:
height !== undefined
? typeof height === "number"
? `${height}px`
: height
: undefined,
};
return (
<>
{animation === "wave" && (
<style>{`
@keyframes skeleton-wave-animation {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.skeleton-wave::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.15),
transparent
);
animation: skeleton-wave-animation 1.5s ease-in-out infinite;
}
`}</style>
)}
<div
ref={ref}
role="status"
aria-busy="true"
aria-label="Loading..."
className={cn(
skeletonVariants({ variant, size, animation }),
className,
)}
style={computedStyle}
{...props}
/>
</>
);
},
);
Skeleton.displayName = "Skeleton";
interface SkeletonTextProps extends Omit<SkeletonProps, "variant"> {
/** Number of text lines to render */
lines?: number;
/** Gap between lines */
gap?: string;
}
const SkeletonText = forwardRef<HTMLDivElement, SkeletonTextProps>(
({ lines = 3, gap = "0.5rem", className, ...props }, ref) => {
return (
<div
ref={ref}
className={cn("flex flex-col", className)}
style={{ gap }}
>
{Array.from({ length: lines }).map((_, i) => (
<Skeleton
key={i}
variant="text"
width={i === lines - 1 ? "60%" : "100%"}
{...props}
/>
))}
</div>
);
},
);
SkeletonText.displayName = "SkeletonText";
interface SkeletonAvatarProps extends Omit<SkeletonProps, "variant"> {
/** Size of the avatar */
avatarSize?: "sm" | "md" | "lg" | "xl";
}
const avatarSizes = {
sm: "h-8 w-8",
md: "h-10 w-10",
lg: "h-12 w-12",
xl: "h-16 w-16",
};
const SkeletonAvatar = forwardRef<HTMLDivElement, SkeletonAvatarProps>(
({ avatarSize = "md", className, ...props }, ref) => {
return (
<Skeleton
ref={ref}
variant="circular"
size="full"
className={cn(avatarSizes[avatarSize], className)}
{...props}
/>
);
},
);
SkeletonAvatar.displayName = "SkeletonAvatar";
interface SkeletonButtonProps extends Omit<SkeletonProps, "variant"> {
/** Size of the button skeleton */
buttonSize?: "sm" | "md" | "lg";
}
const buttonSizes = {
sm: "h-8 w-20",
md: "h-9 w-24",
lg: "h-11 w-32",
};
const SkeletonButton = forwardRef<HTMLDivElement, SkeletonButtonProps>(
({ buttonSize = "md", className, ...props }, ref) => {
return (
<Skeleton
ref={ref}
variant="button"
size="full"
className={cn(buttonSizes[buttonSize], className)}
{...props}
/>
);
},
);
SkeletonButton.displayName = "SkeletonButton";
interface SkeletonCardProps extends Omit<SkeletonProps, "variant"> {
/** Show header placeholder */
showHeader?: boolean;
/** Show image placeholder */
showImage?: boolean;
/** Show action buttons */
showActions?: boolean;
}
const SkeletonCard = forwardRef<HTMLDivElement, SkeletonCardProps>(
(
{
showHeader = true,
showImage = true,
showActions = true,
className,
animation,
...props
},
ref,
) => {
return (
<div
ref={ref}
className={cn("border-border rounded-lg border p-4", className)}
{...props}
>
{showHeader && (
<div className="mb-4 flex items-center gap-3">
<SkeletonAvatar avatarSize="md" animation={animation} />
<div className="flex-1 space-y-2">
<Skeleton
variant="text"
width="40%"
animation={animation}
/>
<Skeleton
variant="text"
width="25%"
size="xs"
animation={animation}
/>
</div>
</div>
)}
{showImage && (
<Skeleton
variant="card"
className="mb-4 aspect-video w-full"
animation={animation}
/>
)}
<SkeletonText lines={3} animation={animation} />
{showActions && (
<div className="mt-4 flex gap-2">
<SkeletonButton buttonSize="sm" animation={animation} />
<SkeletonButton buttonSize="sm" animation={animation} />
</div>
)}
</div>
);
},
);
SkeletonCard.displayName = "SkeletonCard";
export {
Skeleton,
SkeletonText,
SkeletonAvatar,
SkeletonButton,
SkeletonCard,
type SkeletonProps,
type SkeletonTextProps,
type SkeletonAvatarProps,
type SkeletonButtonProps,
type SkeletonCardProps,
};