Skeleton
surfacesNewFreeMITA grey stand-in for content on its way, with a fixed size so nothing moves when the real content arrives; pulses slowly and stands still under reduced motion. Also SkeletonText for lines of text.
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/skeleton.jsonPreview
The preview could not load.
Live demo in the dark theme.
Code
Install
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/skeleton.jsonWith the registry added (components.json)
npx shadcn@latest add @manniche/skeletonUsage
skeleton-demo.tsx
import { Skeleton, SkeletonText } from '@/components/skeleton'
export default function SkeletonDemo() {
return (
<section aria-label="Skeleton" className="grid w-full gap-6">
<div className="flex items-center gap-3">
<Skeleton circle width={44} />
<div className="grid flex-1 gap-2">
<Skeleton width="45%" height={14} />
<Skeleton width="30%" height={12} />
</div>
</div>
<SkeletonText lines={3} />
<Skeleton height={120} className="rounded-xl" />
<p className="text-[13.5px] text-muted-foreground">
Fixed sizes, the theme's muted colour, and a slow pulse that stands still under reduced motion.
</p>
</section>
)
}Source
skeleton.tsx
// A grey stand-in for content that has not arrived yet. It has a fixed size, so the page does not move when the real
// content replaces it: give it the width and height of what it stands for. It pulses slowly; under reduced motion it
// stands still as a flat grey block. The colour is the theme's `muted`, so it follows light and dark by itself.
// Screen readers skip it (`aria-hidden`): the region around it says that something is loading (see loading-region).
import type { ComponentPropsWithoutRef, CSSProperties } from 'react'
import { cn } from '@/lib/utils'
type Size = number | string
const px = (v: Size | undefined) => (typeof v === 'number' ? `${v}px` : v)
export type SkeletonProps = Omit<ComponentPropsWithoutRef<'div'>, 'children' | 'style'> & {
/** Width of the block. A number is pixels; a string is any CSS length ("100%", "12rem"). Default "100%". */
width?: Size
/** Height of the block, the same units. Default 16. */
height?: Size
/** A circle of the given `width` (and the same height), for avatars. */
circle?: boolean
/** Classes for the block. */
className?: string
}
export function Skeleton({ width = '100%', height = 16, circle = false, className, ...rest }: SkeletonProps) {
const style: CSSProperties = { width: px(width), height: px(circle ? width : height) }
return (
<div
aria-hidden="true"
{...rest}
style={style}
className={cn('max-w-full shrink-0 bg-muted motion-safe:animate-pulse', circle ? 'rounded-full' : 'rounded-md', className)}
/>
)
}
export type SkeletonTextProps = Omit<ComponentPropsWithoutRef<'div'>, 'children' | 'style'> & {
/** How many lines. Default 3. */
lines?: number
/** Height of each line in pixels. Default 14. */
lineHeight?: number
/** Space between the lines in pixels. Default 8. */
gap?: number
/** Width of the last line, as a share of the box. Default "60%". */
lastLineWidth?: Size
/** Classes for the box. */
className?: string
}
/** Lines of text, the last one shorter. Its height is lines x lineHeight + gaps, so it can match a paragraph. */
export function SkeletonText({ lines = 3, lineHeight = 14, gap = 8, lastLineWidth = '60%', className, ...rest }: SkeletonTextProps) {
const count = Math.max(1, Math.floor(lines))
return (
<div aria-hidden="true" {...rest} className={cn('flex w-full flex-col', className)} style={{ gap }}>
{Array.from({ length: count }, (_, i) => (
<Skeleton key={i} height={lineHeight} width={i === count - 1 && count > 1 ? lastLineWidth : '100%'} />
))}
</div>
)
}Props
SkeletonProps
| Prop | Type | Default | What it does |
|---|---|---|---|
width | Size | '100%' | Width of the block. A number is pixels; a string is any CSS length ("100%", "12rem"). Default "100%". |
height | Size | 16 | Height of the block, the same units. Default 16. |
circle | boolean | false | A circle of the given width (and the same height), for avatars. |
className | string | – | Classes for the block. |
Also takes every prop from Omit<ComponentPropsWithoutRef<'div'>, 'children' | 'style'>.
A11y
Screen reader
Every block is aria-hidden, so a screen reader hears nothing from it. The region around it (loading-region) says that something is loading; used alone, give the container aria-busy and a role="status" line.
Reduced motion
The slow pulse is motion-safe only: under reduced motion the block is a flat grey, with no shimmer and no movement. Sizes are fixed, so the swap to real content moves nothing.