Loading UI
Custom loading states with loading.tsx - Suspense boundary that shows instantly on navigation. Layouts stay visible.
Overview
Create a loading.tsx file to show a custom fallback while a page loads. It is used as the Suspense fallback for that segment. Parent layouts stay mounted - only the page slot shows the loading UI.
| File | Creates URL? | Purpose |
|---|---|---|
| app/loading.tsx | No - special file | Global loading fallback |
| app/blog/loading.tsx | No - special file | Blog-specific loading |
| app/dashboard/loading.tsx | No - special file | Dashboard-specific loading |
loading.tsx does not create a URL. Nearest-wins - the closest file to the navigated page is used. Layouts remain interactive while content loads.How it Works
loading.tsx wraps the page in a Suspense boundary. On navigation the fallback shows immediately while the page chunk loads.
export default function Loading() {
return "Loading..."
}<Layout>
<Suspense fallback={<Loading />}>
<Page />
</Suspense>
</Layout>- User navigates to a route
- Loading UI appears in the page slot (layouts stay visible)
- Page content loads in the background via React.lazy
- Loading UI is replaced with the actual page
Global Loading UI
Place loading.tsx at the root of app for a default fallback for all routes that do not define their own.
export default function Loading() {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="h-12 w-12 animate-spin rounded-full border-t-2 border-b-2 border-black dark:border-white" />
</div>
)
}Nested Loading UI
Route-specific loading by placing loading.tsx in subdirectories. Closest file to the page wins.
| Navigation | Loading UI Used |
|---|---|
| / → /about | app/loading.tsx - global |
| / → /blog | app/blog/loading.tsx |
| / → /blog/hello-world | app/blog/[slug]/loading.tsx |
| / → /dashboard | app/dashboard/loading.tsx |
Skeleton Examples
Skeletons show approximate layout and usually feel better than a spinner alone.
Blog Post Skeleton
export default function BlogPostLoading() {
return (
<article className="mx-auto max-w-3xl animate-pulse py-8">
<div className="mb-4 h-10 w-3/4 rounded bg-neutral-200 dark:bg-neutral-800" />
<div className="mb-8 flex gap-4">
<div className="h-4 w-24 rounded bg-neutral-200 dark:bg-neutral-800" />
<div className="h-4 w-32 rounded bg-neutral-200 dark:bg-neutral-800" />
</div>
<div className="space-y-3">
<div className="h-4 w-full rounded bg-neutral-200 dark:bg-neutral-800" />
<div className="h-4 w-full rounded bg-neutral-200 dark:bg-neutral-800" />
<div className="h-4 w-5/6 rounded bg-neutral-200 dark:bg-neutral-800" />
</div>
</article>
)
}Dashboard Skeleton
export default function DashboardLoading() {
return (
<div className="flex gap-6 p-6 animate-pulse">
<div className="w-64 space-y-3">
<div className="h-8 rounded bg-neutral-200 dark:bg-neutral-800" />
<div className="h-4 w-3/4 rounded bg-neutral-200 dark:bg-neutral-800" />
<div className="h-4 w-2/3 rounded bg-neutral-200 dark:bg-neutral-800" />
</div>
<div className="flex-1 space-y-4">
<div className="h-8 w-1/3 rounded bg-neutral-200 dark:bg-neutral-800" />
<div className="grid grid-cols-3 gap-4">
<div className="h-24 rounded bg-neutral-200 dark:bg-neutral-800" />
<div className="h-24 rounded bg-neutral-200 dark:bg-neutral-800" />
<div className="h-24 rounded bg-neutral-200 dark:bg-neutral-800" />
</div>
<div className="h-64 rounded bg-neutral-200 dark:bg-neutral-800" />
</div>
</div>
)
}Card Grid Skeleton
export default function ProductsLoading() {
return (
<div className="container mx-auto p-6">
<div className="mb-6 h-8 w-48 animate-pulse rounded bg-neutral-200 dark:bg-neutral-800" />
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{[...Array(6)].map((_, i) => (
<div key={i} className="animate-pulse">
<div className="mb-3 h-48 rounded-lg bg-neutral-200 dark:bg-neutral-800" />
<div className="mb-2 h-4 w-3/4 rounded bg-neutral-200 dark:bg-neutral-800" />
<div className="h-4 w-1/2 rounded bg-neutral-200 dark:bg-neutral-800" />
</div>
))}
</div>
</div>
)
}Loading with Layout
Loading UI is shown inside the layout hierarchy. Headers, sidebars, and nav stay visible and interactive while only the page content shows the fallback.
export default function BlogLayout() {
return (
<div>
<header className="mb-8">
<h1>Blog</h1>
<nav>{/* Navigation stays visible */}</nav>
</header>
<main>
<Outlet /> {/* loading.tsx or page.tsx */}
</main>
</div>
)
}Custom Spinners
Build branded spinners that match your design system.
export default function Loading() {
return (
<div className="fixed inset-0 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-black/50">
<div className="rounded-2xl border border-neutral-200 bg-white p-8 shadow-2xl dark:border-neutral-800 dark:bg-black">
<div className="mx-auto h-10 w-10 animate-spin rounded-full border-2 border-neutral-300 border-t-black dark:border-neutral-700 dark:border-t-white" />
<p className="mt-3 text-center text-sm text-neutral-500">Loading...</p>
</div>
</div>
)
}export default function Loading() {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="flex space-x-2">
<div className="h-3 w-3 animate-bounce rounded-full bg-black dark:bg-white" />
<div className="h-3 w-3 animate-bounce rounded-full bg-black [animation-delay:0.15s] dark:bg-white" />
<div className="h-3 w-3 animate-bounce rounded-full bg-black [animation-delay:0.3s] dark:bg-white" />
</div>
</div>
)
}Built-in Fallback
If no loading.tsx exists, Bini.js uses a built-in spinner. It follows the dark class and prefers-color-scheme.
- Dark mode aware - adapts to theme
- Centered on screen
- Minimal design
- Used automatically when no custom loading UI is defined