Skeleton
Element zastępczy, który podczas ładowania pokazuje kształt treści, dzięki czemu strona spokojnie się układa, zamiast skakać.
import { Skeleton } from "@/components/ui/skeleton"
export default function SkeletonDemo() {
return (
<div className="flex w-full max-w-xs items-center gap-4">
<Skeleton className="size-11 shrink-0 rounded-full" />
<div className="flex flex-1 flex-col gap-2.5">
<Skeleton className="h-3.5 w-3/4" />
<Skeleton className="h-3.5 w-1/2" />
</div>
</div>
)
}Instalacja
$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/skeleton.jsonUżycie
import { Skeleton } from "@/components/ui/skeleton"<Skeleton className="h-4 w-48" />Szkielet to kształt i nic więcej. Nadaj mu rozmiar i zaokrąglenie treści, którą zastępuje — rounded-full dla awatarów, rounded-lg dla przycisków — a resztę zostaw układowi.
Przykłady
Karta
Odwzoruj strukturę prawdziwego komponentu. Użycie Card i jego części gwarantuje, że szkielet zajmie dokładnie tyle miejsca, ile zajmie załadowana karta.
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
export default function SkeletonCard() {
return (
<Card className="w-full max-w-xs pt-0">
<Skeleton className="aspect-video w-full rounded-none" />
<CardHeader>
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-3.5 w-full" />
</CardHeader>
<CardContent className="flex flex-col gap-2">
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-5/6" />
</CardContent>
<CardFooter className="justify-between">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-8 w-24 rounded-lg" />
</CardFooter>
</Card>
)
}Lista
Różnicuj długość linii. Identyczne paski wyglądają jak deseń, a nie jak treść, przez co podmiana na prawdziwy tekst razi bardziej.
import { Skeleton } from "@/components/ui/skeleton"
export default function SkeletonList() {
return (
<ul className="flex w-full max-w-sm flex-col divide-y rounded-xl border bg-card">
{[72, 56, 64, 48].map((width, index) => (
<li key={index} className="flex items-center gap-3 px-4 py-3">
<Skeleton className="size-8 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-3" style={{ width: `${width}%` }} />
<Skeleton className="h-2.5 w-1/3" />
</div>
<Skeleton className="h-5 w-12 rounded-md" />
</li>
))}
</ul>
)
}Tabela
Zostaw prawdziwy nagłówek i zastąp tylko komórki. Ludzie mogą czytać nazwy kolumn, zanim dotrą dane.
import { Skeleton } from "@/components/ui/skeleton"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
export default function SkeletonTable() {
return (
<div className="w-full max-w-lg rounded-xl border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead scope="col">Faktura</TableHead>
<TableHead scope="col">Klient</TableHead>
<TableHead scope="col">Status</TableHead>
<TableHead scope="col" className="text-right">
Kwota
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 4 }, (_, row) => (
<TableRow key={row}>
<TableCell>
<Skeleton className="h-3 w-16" />
</TableCell>
<TableCell>
<Skeleton className="h-3 w-28" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-14 rounded-md" />
</TableCell>
<TableCell>
<Skeleton className="ml-auto h-3 w-12" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}Stan ładowania
Podmieniaj szkielety na treść w tym samym miejscu. Na czas ładowania oznacz obszar atrybutem aria-busy i dodaj wizualnie ukryty komunikat, żeby użytkownicy czytników ekranu wiedzieli, że coś jest w drodze.
"use client"
import * as React from "react"
import { RotateCwIcon } from "lucide-react"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
const people = [
{ name: "Ada Lovelace", role: "Inżynieria", initials: "AL" },
{ name: "Grace Hopper", role: "Platforma", initials: "GH" },
{ name: "Alan Turing", role: "Badania", initials: "AT" },
]
export default function SkeletonLoadingState() {
const [loading, setLoading] = React.useState(true)
React.useEffect(() => {
if (!loading) return
const timer = setTimeout(() => setLoading(false), 1600)
return () => clearTimeout(timer)
}, [loading])
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<div
aria-busy={loading}
aria-live="polite"
className="flex flex-col gap-4 rounded-xl border bg-card p-4"
>
{loading && <span className="sr-only">Ładowanie członków zespołu…</span>}
{people.map((person) =>
loading ? (
<div key={person.name} className="flex items-center gap-3">
<Skeleton className="size-8 shrink-0 rounded-full" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-3 w-32" />
<Skeleton className="h-2.5 w-20" />
</div>
</div>
) : (
<div key={person.name} className="flex items-center gap-3">
<Avatar>
<AvatarFallback>{person.initials}</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span className="text-sm font-medium">{person.name}</span>
<span className="text-xs text-muted-foreground">
{person.role}
</span>
</div>
</div>
)
)}
</div>
<Button
variant="outline"
size="xs"
className="self-end"
disabled={loading}
onClick={() => setLoading(true)}
>
<RotateCwIcon data-icon="inline-start" />
Odśwież
</Button>
</div>
)
}<div aria-busy={loading} aria-live="polite">
{loading && <span className="sr-only">Loading team members…</span>}
{loading ? <MembersSkeleton /> : <MembersList />}
</div>Wytyczne
Kiedy używać
- Przy pierwszym ładowaniu treści o znanym układzie: kart, list, tabel, profili.
- Przy treści ładowanej częściami, żeby każda sekcja mogła pojawić się niezależnie od pozostałych.
Kiedy nie używać
- Przy oczekiwaniu krótszym niż około sekunda — szkielet tylko mignie, a strona wyda się wolniejsza. Pokaż go z opóźnieniem albo nie pokazuj niczego.
- Przy akcjach takich jak zapisywanie czy wysyłanie — użyj stanu
loadingkomponentu Button albo komponentu Spinner. - Przy zadaniach, których postęp da się zmierzyć — użyj komponentu Progress.
- Przy treści, której układu nie da się przewidzieć. Błędny szkielet jest gorszy niż żaden.
Odwzoruj układ, nie piksele
Szkielet powinien zajmować tyle samo miejsca co treść, którą zastępuje, żeby nic się nie przesunęło, gdy dotrą dane. Nie potrzebuje każdego szczegółu: wystarczy awatar, linia na nazwę i krótsza linia na metadane.
Dostępność
- Każdy szkielet renderuje
aria-hidden="true": elementy zastępcze nic nie znaczą dla czytnika ekranu. Zamiast tego opisz stan ładowania na kontenerze — atrybutemaria-busy="true"i wizualnie ukrytym komunikatem. - Gdy treść się pojawi, usuń
aria-busy. Dziękiaria-live="polite"na kontenerze nowa treść zostanie odczytana bez przenoszenia fokusu. - Połysk animuje wyłącznie
translatena pseudoelemencie, więc nigdy nie wymusza przeliczenia układu. Przyprefers-reduced-motionprzesuwający się refleks znika, a szkielet pozostaje nieruchomą plamą koloru. - Wypełnienia szkieletów używają tokenu
mutedi nigdy nie przekazują informacji, więc nie dotyczą ich wymagania kontrastu — mimo to pozostają widoczne w obu motywach i w trybie wysokiego kontrastu.
Dokumentacja API
Skeleton
Renderuje <div> z data-slot="skeleton" i aria-hidden="true". Przyjmuje wszystkie propsy elementu div.