Loading UI

Learn how to create custom loading states with loading.tsx for a better user experience.

Bini.js provides a built-in loading spinner, but you can create custom loading.tsx files to show your own loading UI while page content loads.

How it Works

The loading.tsx file automatically wraps the page in a Suspense boundary. The loading UI is shown immediately on navigation while the page content streams in.

  1. User clicks a link or navigates to a route
  2. Loading UI appears instantly
  3. Page content loads in the background
  4. Once ready, the loading UI is replaced with the actual page

Global Loading UI

Create a loading.tsx file in the root of your app directory to show a loading state for all routes.

src/app/
├── layout.tsx
├── page.tsx
└── loading.tsx           ← Global loading UI
app/loading.tsx
// src/app/loading.tsx
export default function Loading() {
  return (
    <div className="flex items-center justify-center min-h-screen">
      <div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-cyan-500" />
    </div>
  )
}

Nested Loading UI

You can create route-specific loading states by placing loading.tsx in subdirectories. The closest loading file to the page being navigated to will be used.

src/app/
├── loading.tsx               ← Global loading (fallback)
├── page.tsx
├── blog/
│   ├── loading.tsx           ← Blog-specific loading
│   ├── page.tsx
│   └── [slug]/
│       ├── loading.tsx       ← Post-specific loading
│       └── page.tsx
└── dashboard/
    ├── loading.tsx           ← Dashboard-specific loading
    └── page.tsx
NavigationLoading UI Used
/ → /aboutapp/loading.tsx
/ → /blogapp/blog/loading.tsx
/ → /blog/hello-worldapp/blog/[slug]/loading.tsx
/ → /dashboardapp/dashboard/loading.tsx

Skeleton Examples

Skeletons provide a better user experience than spinners by showing the approximate layout of the content.

Blog Post Skeleton

app/blog/[slug]/loading.tsx
// src/app/blog/[slug]/loading.tsx
export default function BlogPostLoading() {
  return (
    <article className="max-w-3xl mx-auto py-8 animate-pulse">
      {/* Title skeleton */}
      <div className="h-10 bg-slate-800 rounded w-3/4 mb-4" />
      
      {/* Meta skeleton */}
      <div className="flex gap-4 mb-8">
        <div className="h-4 bg-slate-800 rounded w-24" />
        <div className="h-4 bg-slate-800 rounded w-32" />
      </div>
      
      {/* Content skeleton */}
      <div className="space-y-3">
        <div className="h-4 bg-slate-800 rounded w-full" />
        <div className="h-4 bg-slate-800 rounded w-full" />
        <div className="h-4 bg-slate-800 rounded w-5/6" />
        <div className="h-4 bg-slate-800 rounded w-full" />
        <div className="h-4 bg-slate-800 rounded w-4/5" />
      </div>
    </article>
  )
}

Dashboard Skeleton

app/dashboard/loading.tsx
// src/app/dashboard/loading.tsx
export default function DashboardLoading() {
  return (
    <div className="flex gap-6 p-6 animate-pulse">
      {/* Sidebar skeleton */}
      <div className="w-64 space-y-3">
        <div className="h-8 bg-slate-800 rounded" />
        <div className="h-4 bg-slate-800 rounded w-3/4" />
        <div className="h-4 bg-slate-800 rounded w-2/3" />
        <div className="h-4 bg-slate-800 rounded w-4/5" />
      </div>
      
      {/* Main content skeleton */}
      <div className="flex-1 space-y-4">
        <div className="h-8 bg-slate-800 rounded w-1/3" />
        <div className="grid grid-cols-3 gap-4">
          <div className="h-24 bg-slate-800 rounded" />
          <div className="h-24 bg-slate-800 rounded" />
          <div className="h-24 bg-slate-800 rounded" />
        </div>
        <div className="h-64 bg-slate-800 rounded" />
      </div>
    </div>
  )
}

Card Grid Skeleton

app/products/loading.tsx
// src/app/products/loading.tsx
export default function ProductsLoading() {
  return (
    <div className="container mx-auto p-6">
      <div className="h-8 bg-slate-800 rounded w-48 mb-6 animate-pulse" />
      
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        {[...Array(6)].map((_, i) => (
          <div key={i} className="animate-pulse">
            <div className="h-48 bg-slate-800 rounded-lg mb-3" />
            <div className="h-4 bg-slate-800 rounded w-3/4 mb-2" />
            <div className="h-4 bg-slate-800 rounded w-1/2" />
          </div>
        ))}
      </div>
    </div>
  )
}

Loading with Layout

Loading UI is shown inside the layout hierarchy. Layouts remain visible and interactive while the page loads.

src/app/
├── layout.tsx                 ← Root layout (always visible)
├── loading.tsx                ← Global loading (shown inside layout)
└── blog/
    ├── layout.tsx             ← Blog layout (always visible)
    ├── loading.tsx            ← Blog loading (shown inside blog layout)
    └── page.tsx

This means headers, sidebars, and navigation remain usable while the main content loads.

app/blog/layout.tsx
// src/app/blog/layout.tsx
export default function BlogLayout() {
  return (
    <div>
      <header className="mb-8">
        <h1 className="text-2xl font-bold">Blog</h1>
        <nav>{/* Navigation links */}</nav>
      </header>
      <main>
        <Outlet />  {/* This will be either loading.tsx or page.tsx */}
      </main>
    </div>
  )
}

Custom Spinners

Create branded spinners that match your design system:

app/loading.tsx
// src/app/loading.tsx
export default function Loading() {
  return (
    <div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center">
      <div className="bg-slate-900 p-8 rounded-2xl shadow-2xl">
        <svg className="animate-spin h-10 w-10 text-cyan-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
          <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
          <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
        </svg>
        <p className="text-slate-400 text-sm mt-3 text-center">Loading...</p>
      </div>
    </div>
  )
}
// Alternative: Minimal spinner
export default function Loading() {
  return (
    <div className="flex items-center justify-center min-h-screen">
      <div className="flex space-x-2">
        <div className="w-3 h-3 bg-cyan-500 rounded-full animate-bounce" />
        <div className="w-3 h-3 bg-cyan-500 rounded-full animate-bounce [animation-delay:0.15s]" />
        <div className="w-3 h-3 bg-cyan-500 rounded-full animate-bounce [animation-delay:0.3s]" />
      </div>
    </div>
  )
}

Built-in Fallback

If you don't create a loading.tsx file, Bini.js uses a built-in spinner:

  • Dark mode aware — adapts to your theme
  • Centered on the screen
  • Clean, minimal design
  • Automatically used when no custom loading UI exists

The built-in spinner is a good starting point, but creating custom loading UI is recommended for production applications.

Best Practices

  • Use skeletons for content-heavy pages — They provide better UX than spinners.
  • Create nested loading states — Different sections can have different loading UIs.
  • Keep loading UI lightweight — Fast to render and minimal DOM impact.
  • Use animations sparingly — Too much animation can be distracting.
  • Match your brand — Use your brand colors and design language.