Loading region
surfacesNewFreeMITA box that handles waiting: nothing for 300 ms, then a skeleton when the layout is known or a spinner when it is not, shown for at least 500 ms; keeps old data and shows a small spinner on reload; busy and status text for screen readers; error and empty at the same height.
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/loading-region.jsonPreview
Live demo in the dark theme.
Code
Install
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/loading-region.jsonWith the registry added (components.json)
npx shadcn@latest add @manniche/loading-regionUsage
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import { LoadingRegion, type LoadingRegionStatus } from '@/components/loading-region'
import { Skeleton } from '@/components/skeleton'
// A fake request: `ms` later it answers with `outcome`. `go` starts it again, `pending` is true while it runs.
function useFakeLoad(ms: number, outcome: 'ready' | 'empty' | 'error' = 'ready') {
const [status, setStatus] = useState<LoadingRegionStatus>('loading')
const timer = useRef(0)
const go = useCallback(
(to: 'ready' | 'empty' | 'error' = outcome, wait = ms) => {
window.clearTimeout(timer.current)
setStatus('loading')
timer.current = window.setTimeout(() => setStatus(to), wait)
},
[ms, outcome],
)
useEffect(() => {
go()
return () => window.clearTimeout(timer.current)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return { status, go }
}
const button =
'inline-flex min-h-11 items-center rounded-xl bg-muted px-3.5 text-sm font-medium text-foreground outline-offset-2 hover:bg-muted/70 focus-visible:outline-2 focus-visible:outline-ring'
function Card({ title, note, controls, children }: { title: string; note: string; controls?: ReactNode; children: ReactNode }) {
return (
<section className="rounded-2xl border border-border bg-background p-4" aria-label={title}>
<div className="mb-3 flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="text-[15px] font-semibold text-foreground">{title}</h3>
<p className="text-[13.5px] text-muted-foreground">{note}</p>
</div>
<div className="flex flex-wrap gap-2">{controls}</div>
</div>
{children}
</section>
)
}
const PEOPLE = [
['Ada Lovelace', 'Analyst'],
['Grace Hopper', 'Engineer'],
['Alan Turing', 'Researcher'],
['Katherine Johnson', 'Mathematician'],
]
const ROW = 56
function Row({ name, role }: { name: string; role: string }) {
return (
<li className="flex items-center gap-3 border-b border-border/60 last:border-0" style={{ height: ROW }}>
<span aria-hidden="true" className="flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-foreground">
{name.split(' ').map((w) => w[0]).join('')}
</span>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-foreground">{name}</div>
<div className="truncate text-[13px] text-muted-foreground">{role}</div>
</div>
</li>
)
}
function RowSkeleton() {
return (
<div aria-hidden="true">
{PEOPLE.map(([n]) => (
<div key={n} className="flex items-center gap-3 border-b border-border/60 last:border-0" style={{ height: ROW }}>
<Skeleton circle width={36} />
<div className="grid flex-1 gap-1.5">
<Skeleton width="42%" height={14} />
<Skeleton width="26%" height={12} />
</div>
</div>
))}
</div>
)
}
/** 1. The layout is known: a list of four rows. */
function KnownLayout() {
// Fast and Slow start the region over (a new key), so each one is a first load; Empty and Error load again in place.
const [run, setRun] = useState(0)
const { status, go } = useFakeLoad(1200)
return (
<Card
title="Known layout: skeleton"
note="Every row has the same height as its skeleton, so nothing moves when the list arrives."
controls={
<>
<button type="button" className={button} onClick={() => { setRun((r) => r + 1); go('ready', 120) }}>
Fast (120 ms, no indicator)
</button>
<button type="button" className={button} onClick={() => { setRun((r) => r + 1); go('ready', 1200) }}>
Slow (1.2 s)
</button>
<button type="button" className={button} onClick={() => go('empty', 1000)}>
Empty
</button>
<button type="button" className={button} onClick={() => go('error', 1000)}>
Error
</button>
</>
}
>
<LoadingRegion key={run} status={status} skeleton={<RowSkeleton />} minHeight={ROW * PEOPLE.length} onRetry={() => go('ready', 1000)} labels={{ loading: 'Loading people', loaded: 'People loaded', empty: 'No people yet.' }}>
<ul>
{PEOPLE.map(([n, r]) => (
<Row key={n} name={n} role={r} />
))}
</ul>
</LoadingRegion>
</Card>
)
}
/** 2. The layout is unknown: the screen is either a balance or an offer. */
function UnknownLayout() {
const [kind, setKind] = useState<'balance' | 'offer'>('balance')
const { status, go } = useFakeLoad(1400)
return (
<Card
title="Unknown layout: spinner"
note="The answer decides which screen this is, so a skeleton of either one would be wrong. A spinner and a line of text it is."
controls={
<button type="button" className={button} onClick={() => { setKind((k) => (k === 'balance' ? 'offer' : 'balance')); go('ready', 1400) }}>
Load the other screen
</button>
}
>
<LoadingRegion key={kind} status={status} minHeight={168} labels={{ loading: 'Checking your account' }}>
{kind === 'balance' ? (
<div className="grid h-[168px] content-center gap-1 rounded-xl bg-muted/60 px-5">
<div className="text-[13px] text-muted-foreground">Balance</div>
<div className="text-3xl font-semibold tabular-nums text-foreground">4,280.50 EUR</div>
<div className="text-[13px] text-muted-foreground">Updated just now</div>
</div>
) : (
<div className="grid h-[168px] content-center gap-2 rounded-xl border border-dashed border-foreground/25 px-5">
<div className="text-base font-semibold text-foreground">Open your first account</div>
<div className="text-[13.5px] text-muted-foreground">It takes two minutes and costs nothing.</div>
<span className="inline-flex min-h-11 w-fit items-center rounded-xl bg-primary px-4 text-sm font-medium text-primary-foreground">Start</span>
</div>
)}
</LoadingRegion>
</Card>
)
}
/** 3. Part of the layout is known: the frame is real, the list is a skeleton, the side note is a spinner. */
function PartlyKnown() {
const list = useFakeLoad(1300)
const side = useFakeLoad(2000)
const [run, setRun] = useState(0)
return (
<Card
title="Partly known layout"
note="The title and the table head are drawn for real from the start. The rows are a skeleton (the shape is known); the summary is a spinner (what it will say is not)."
controls={
<button type="button" className={button} onClick={() => { setRun((r) => r + 1); list.go('ready', 1300); side.go('ready', 2000) }}>
Load again
</button>
}
>
<div className="grid gap-4 sm:grid-cols-[1fr_200px]">
<div>
<div className="grid grid-cols-[1fr_auto] border-b border-border pb-2 text-[13px] font-medium text-muted-foreground">
<span>Name</span>
<span>Role</span>
</div>
<LoadingRegion key={`list${run}`} status={list.status} skeleton={<RowSkeleton />} minHeight={ROW * PEOPLE.length} labels={{ loading: 'Loading the table' }}>
<ul>
{PEOPLE.map(([n, r]) => (
<Row key={n} name={n} role={r} />
))}
</ul>
</LoadingRegion>
</div>
<LoadingRegion key={`side${run}`} status={side.status} minHeight={120} className="rounded-xl bg-muted/50" labels={{ loading: 'Loading the summary' }}>
<div className="grid min-h-[120px] content-center gap-1 px-4 text-sm">
<div className="font-medium text-foreground">Next review</div>
<div className="text-muted-foreground">Needs a decision before Friday.</div>
</div>
</LoadingRegion>
</div>
</Card>
)
}
/** 4. Fetching again: the old data stays, a small spinner shows. */
function Reloading() {
const { status, go } = useFakeLoad(1000)
const [round, setRound] = useState(1)
return (
<Card
title="Loading again"
note="Data that is already there stays put. Only a small spinner shows, and the skeleton is never used again."
controls={
<button type="button" className={button} onClick={() => { setRound((r) => r + 1); go('ready', 1600) }}>
Refresh
</button>
}
>
<LoadingRegion status={status} skeleton={<RowSkeleton />} minHeight={ROW * PEOPLE.length}>
<ul>
{PEOPLE.map(([n, r]) => (
<Row key={n} name={n} role={`${r} · fetched ${round}`} />
))}
</ul>
</LoadingRegion>
</Card>
)
}
export default function LoadingRegionDemo() {
return (
<div className="grid w-full gap-4">
<KnownLayout />
<UnknownLayout />
<PartlyKnown />
<Reloading />
</div>
)
}Source
// A box that handles the waiting for you. It shows nothing for the first `delay` ms (most answers come faster, and a
// flash of grey is worse than no grey), then a skeleton when you know what the content looks like, or a spinner when
// you do not (a screen that may turn out to be A or B must not wear a skeleton of A). Once an indicator has appeared
// it stays for at least `minDuration`, so it never blinks. When data that is already on screen is fetched again, the
// old data stays and only a small spinner shows. The box keeps `minHeight` in every state, including error and empty,
// so nothing around it jumps.
// For screen readers the box is `aria-busy` while it waits, and a persistent `role="status"` line says "Loading",
// then "Loaded" (or the error), and says nothing at all when the answer came before the delay.
import { LoaderCircle } from 'lucide-react'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { MotionConfig, motion } from 'motion/react'
import { cn } from '@/lib/utils'
export type LoadingRegionStatus = 'loading' | 'ready' | 'empty' | 'error'
export type LoadingRegionLabels = {
/** Spoken while the first load runs, and the name of the spinner. Default "Loading". */
loading?: string
/** Spoken while data that is already shown is fetched again. Default "Updating". */
reloading?: string
/** Spoken when a load that was announced has finished. Default "Loaded". */
loaded?: string
/** Shown and spoken when the load failed and no `error` content is given. Default "Could not load this." */
error?: string
/** Shown when `status` is "empty" and no `empty` content is given. Default "Nothing here yet." */
empty?: string
/** The text of the retry button. Default "Try again". */
retry?: string
}
export type LoadingRegionProps = {
/** Where the data is: "loading" (fetching), "ready" (show the children), "empty" (nothing came) or "error". */
status: LoadingRegionStatus
/** The real content. It stays on screen while the same region loads again. */
children: ReactNode
/**
* A skeleton with the shape and size of the content. Give it when the layout is known before the data is there;
* leave it out when it is not, and the region shows a spinner instead.
*/
skeleton?: ReactNode
/** The least height of the box in pixels (or any CSS length), kept in every state. Match it to the content. Default 160. */
minHeight?: number | string
/** Content for "empty", such as an `EmptyState`. Default is a line of text. */
empty?: ReactNode
/** Content for "error". Default is the `labels.error` line. */
error?: ReactNode
/** Called by the retry button. With no handler there is no button. */
onRetry?: () => void
/** Milliseconds to wait before any indicator shows. Default 300. */
delay?: number
/** Milliseconds an indicator that has appeared stays at least. Default 500. */
minDuration?: number
/** Text overrides, with English defaults. */
labels?: LoadingRegionLabels
/** Classes for the box. */
className?: string
}
const DEFAULT_LABELS: Required<LoadingRegionLabels> = {
loading: 'Loading',
reloading: 'Updating',
loaded: 'Loaded',
error: 'Could not load this.',
empty: 'Nothing here yet.',
retry: 'Try again',
}
function Spinner({ className }: { className?: string }) {
return <LoaderCircle aria-hidden="true" className={cn('motion-safe:animate-spin', className)} />
}
export function LoadingRegion({
status,
children,
skeleton,
minHeight = 160,
empty,
error,
onRetry,
delay = 300,
minDuration = 500,
labels,
className,
}: LoadingRegionProps) {
const t = { ...DEFAULT_LABELS, ...labels }
const loading = status === 'loading'
// Has real content been shown? From then on a new load is a reload, and the old data stays.
const [hadContent, setHadContent] = useState(false)
// Is an indicator on screen (or held for its least time)? Only a timer turns it on or off, never a render.
const [indicator, setIndicator] = useState(false)
// Has the load been announced? Then its end is announced too.
const [spoke, setSpoke] = useState(false)
const [prev, setPrev] = useState(status)
const shownAt = useRef(0)
if (prev !== status) {
setPrev(status)
if (loading) setSpoke(false)
}
const waiting = loading || indicator
if (!waiting && status === 'ready' && !hadContent) setHadContent(true)
useEffect(() => {
if (loading) {
if (indicator) return
const id = window.setTimeout(() => {
shownAt.current = performance.now()
setIndicator(true)
setSpoke(true)
}, delay)
return () => window.clearTimeout(id)
}
if (!indicator) return
const rest = Math.max(0, minDuration - (performance.now() - shownAt.current))
const id = window.setTimeout(() => setIndicator(false), rest)
return () => window.clearTimeout(id)
}, [loading, indicator, delay, minDuration])
const reload = hadContent && waiting
const first = waiting && !hadContent
let body: ReactNode = null
let key = 'blank'
if (first) {
// Before the delay is over the box is simply empty, at full height.
if (indicator) {
key = skeleton ? 'skeleton' : 'spinner'
body = skeleton ?? (
<div className="flex h-full min-h-[inherit] items-center justify-center gap-2.5 text-sm text-muted-foreground">
<Spinner className="size-5" />
<span aria-hidden="true">{t.loading}</span>
</div>
)
}
} else if (status === 'error') {
key = 'error'
body = (
<div className="flex min-h-[inherit] flex-col items-center justify-center gap-3 px-4 py-6 text-center text-sm text-muted-foreground">
{error ?? <p>{t.error}</p>}
{onRetry && (
<button
type="button"
onClick={onRetry}
className="inline-flex min-h-11 items-center rounded-xl bg-primary px-4 text-sm font-medium text-primary-foreground outline-offset-2 focus-visible:outline-2 focus-visible:outline-ring"
>
{t.retry}
</button>
)}
</div>
)
} else if (status === 'empty') {
key = 'empty'
body = (
<div className="flex min-h-[inherit] items-center justify-center px-4 py-6 text-center text-sm text-muted-foreground">
{empty ?? <p>{t.empty}</p>}
</div>
)
} else {
key = 'content'
body = children
}
const announce = indicator
? reload
? t.reloading
: t.loading
: spoke && !loading
? status === 'error'
? t.error
: status === 'empty'
? t.empty
: t.loaded
: ''
return (
<MotionConfig reducedMotion="user">
<div
aria-busy={waiting}
className={cn('relative', className)}
style={{ minHeight: typeof minHeight === 'number' ? `${minHeight}px` : minHeight }}
>
<motion.div
key={key}
initial={key === 'blank' || key === 'content' ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="min-h-[inherit]"
>
{body}
</motion.div>
{reload && indicator && (
<span className="pointer-events-none absolute right-2 top-2 flex size-7 items-center justify-center rounded-full bg-background/80 text-muted-foreground shadow-[0_0_0_1px_color-mix(in_oklab,var(--foreground)_10%,transparent)]">
<Spinner className="size-4" />
</span>
)}
<div role="status" className="sr-only">
{announce}
</div>
</div>
</MotionConfig>
)
}Props
LoadingRegionProps
| Prop | Type | Default | What it does |
|---|---|---|---|
status required | LoadingRegionStatus | – | Where the data is: "loading" (fetching), "ready" (show the children), "empty" (nothing came) or "error". |
children required | ReactNode | – | The real content. It stays on screen while the same region loads again. |
skeleton | ReactNode | – | A skeleton with the shape and size of the content. Give it when the layout is known before the data is there; leave it out when it is not, and the region shows a spinner instead. |
minHeight | number | string | 160 | The least height of the box in pixels (or any CSS length), kept in every state. Match it to the content. Default 160. |
empty | ReactNode | – | Content for "empty", such as an EmptyState. Default is a line of text. |
error | ReactNode | – | Content for "error". Default is the labels.error line. |
onRetry | () => void | – | Called by the retry button. With no handler there is no button. |
delay | number | 300 | Milliseconds to wait before any indicator shows. Default 300. |
minDuration | number | 500 | Milliseconds an indicator that has appeared stays at least. Default 500. |
labels | LoadingRegionLabels | – | Text overrides, with English defaults. |
className | string | – | Classes for the box. |
A11y
Screen reader
The box is aria-busy while it waits. A persistent role="status" line says Loading (or Updating on a reload) once an indicator shows, then Loaded, the error or the empty text. A load that finishes before the delay announces nothing. The skeleton is aria-hidden; the error is plain text with a real button.
Keyboard
The retry button is a native button with a 44 px target and a visible focus ring. Nothing else in the region takes focus, and the old content stays focusable while it reloads.
Reduced motion
The spinner stops and the fade between states is off under reduced motion (MotionConfig reducedMotion is user). Only opacity animates.