Skeleton
surfacesNeuKostenlosMITEin grauer Platzhalter für Inhalte, die noch laden, mit fester Größe (Breite, Höhe, rund), damit die Seite nicht springt. Pulsiert ruhig und steht bei reduzierter Bewegung still. SkeletonText zeichnet Textzeilen, die letzte ist kürzer.
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/skeleton.jsonVorschau
Live-Demo im dunklen Theme.
Code
Installieren
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/skeleton.jsonMit eingetragener Registry (components.json)
npx shadcn@latest add @manniche/skeletonVerwendung
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>
)
}Quellcode
// 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 | Typ | Standard | Was sie tut |
|---|---|---|---|
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. |
Nimmt auch alle Props von Omit<ComponentPropsWithoutRef<'div'>, 'children' | 'style'>.
A11y
Screenreader
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.
Reduzierte Bewegung
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.