Loading
Skeletons, spinners, progress and optimistic updates, chosen by how long people will wait and how much you know.
Waiting feels shorter when people can see that something is happening and roughly what will appear. Choose the indicator by how long the wait is and how much you know about it.
The skeleton matches the final layout exactly, so nothing shifts when data arrives.
"use client"
import * as React from "react"
import { RefreshCwIcon } from "lucide-react"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
const people = [
{ initials: "AL", name: "Ada Lovelace", role: "Owner" },
{ initials: "GH", name: "Grace Hopper", role: "Admin" },
{ initials: "KJ", name: "Katherine Johnson", role: "Member" },
]
export default function LoadingSwap() {
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-md flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Team members</span>
<Button
variant="ghost"
size="xs"
onClick={() => setLoading(true)}
loading={loading}
>
<RefreshCwIcon data-icon="inline-start" />
Reload
</Button>
</div>
<ul
aria-busy={loading}
className="flex flex-col divide-y rounded-xl border bg-card shadow-raised"
>
{people.map((person) => (
<li key={person.name} className="flex h-14 items-center gap-3 px-4">
{loading ? (
<>
<Skeleton className="size-8 rounded-full" />
<div className="flex flex-1 flex-col gap-1.5">
<Skeleton className="h-3 w-32" />
<Skeleton className="h-2.5 w-20" />
</div>
<Skeleton className="h-5 w-14 rounded-md" />
</>
) : (
<>
<Avatar>
<AvatarFallback>{person.initials}</AvatarFallback>
</Avatar>
<div className="flex flex-1 flex-col">
<span className="text-sm font-medium">{person.name}</span>
<span className="text-xs text-muted-foreground">
{person.name.split(" ")[0].toLowerCase()}@acme.co
</span>
</div>
<Badge variant="secondary">{person.role}</Badge>
</>
)}
</li>
))}
</ul>
<p className="text-xs text-muted-foreground">
The skeleton matches the final layout exactly, so nothing shifts when
data arrives.
</p>
</div>
)
}Choosing an indicator
Skeletons
- Match the final layout exactly — same heights, same rhythm — so nothing shifts when content arrives.
- Mark the loading region with
aria-busy="true"; skeletons themselves arearia-hidden. - The shimmer stops with reduced motion; the placeholder shape remains.
Buttons
Actions that take time show their own progress with Button loading: the label is replaced by a spinner without the button changing width, activation is blocked, and focus stays put.
Optimistic updates
When an action almost always succeeds — starring, archiving, reordering — update the interface immediately and reconcile in the background. If it fails, roll back and say so with a toast. Pair destructive optimistic actions with undo rather than confirmation; see Destructive actions.