Next.js Bootstrap Spinners
Next.js Bootstrap spinner markup in AdminKit PRO: border and grow spinners in server components, client-side loading states and where loading.tsx fits.
This component is only available in AdminKit PRO. Learn more.
A spinner tells people that something is on its way when you cannot say how long it will take, such as data from your API or a form that is saving. It is Bootstrap's .spinner-border or .spinner-grow markup in JSX, with no component to import. Spinners are part of the PRO version of AdminKit for Next.js.
The spinner markup has no state, so it renders fine in a server component, with no "use client". Deciding when to show it is another matter. "Show it while this fetch runs" is state, and state lives in a client component, a file that starts with "use client".
Example
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Loading...</span>
</div>
<div className="spinner-grow text-primary" role="status">
<span className="visually-hidden">Loading...</span>
</div>Classes
| Class | Effect |
|---|---|
spinner-border | A turning ring |
spinner-grow | A dot that grows and fades out |
spinner-border-sm, spinner-grow-sm | A small spinner for buttons and text |
text-<color> | The color, since spinners draw in currentColor |
Other sizes take a style object, such as style={{ width: "3rem", height: "3rem" }}.
When the operating system asks for reduced motion, AdminKit PRO fades the spinners in and out instead of turning or scaling them. It adds that rule in src/scss/3-components/_spinner.scss.
Colors and sizes
Because the markup has no state, a server component can render as many spinners as you need. The general UI demo page maps a list of colors to spinners, on the server:
import { Fragment } from "react";
{["primary", "success", "danger"].map((color) => (
<Fragment key={color}>
<div className={`spinner-border text-${color} me-2`} role="status">
<span className="visually-hidden">Loading...</span>
</div>{" "}
</Fragment>
))}
<div className="spinner-border spinner-border-sm text-primary" role="status">
<span className="visually-hidden">Loading...</span>
</div>
<div className="spinner-border text-primary" style={{ width: "3rem", height: "3rem" }} role="status">
<span className="visually-hidden">Loading...</span>
</div>While data loads in the browser
AdminKit for Next.js is a static export (output: "export"). Its pages are built once, so data that changes after the build is fetched in the browser. The component that fetches it is a client component, and it shows the spinner until the data is there:
"use client";
import { useEffect, useState } from "react";
type Order = { id: string; customer: string };
export function LatestOrders() {
const [orders, setOrders] = useState<Order[] | null>(null);
useEffect(() => {
fetch("/api/orders?limit=5")
.then((response) => response.json())
.then(setOrders);
}, []);
return (
<div className="card">
<div className="card-header">
<h2 className="card-title mb-0">Latest orders</h2>
</div>
{orders === null ? (
<div className="card-body d-flex justify-content-center py-5">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Loading orders...</span>
</div>
</div>
) : (
<ul className="list-group list-group-flush">
{orders.map((order) => (
<li key={order.id} className="list-group-item">
{order.customer}
</li>
))}
</ul>
)}
</div>
);
}The static HTML of the page contains the first render, the one with the spinner. When the browser takes over that HTML (hydration), it renders the same spinner. The server and the browser agree, so there is no hydration mismatch.
For content with a known shape, placeholders keep the layout from jumping when the data arrives.
A page-level spinner in loading.tsx
A loading.tsx file in a route folder is the App Router's place for a spinner that stands in for a whole page. Next.js shows it while a page of that folder loads during client-side navigation, when the user moves between pages without a full reload. In src/app/(dashboard)/, it covers every dashboard page.
The template has no loading.tsx. With a static export, every page is built ahead of time and next/link prefetches it, so a loading.tsx shows at most briefly. It also does not wait for data you fetch in the browser. For that, show the spinner from state, as above.
export default function Loading() {
return (
<div className="d-flex justify-content-center py-5">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Loading page...</span>
</div>
</div>
);
}In a button
A spinner in a Save button shows that the click is being handled. There it is decoration next to a visible text, so it gets aria-hidden="true". The full client component is the loading button on the Buttons page:
<button type="button" className="btn btn-primary" disabled={saving}>
{saving && <span className="spinner-border spinner-border-sm me-2" aria-hidden="true" />}
{saving ? "Saving..." : "Save"}
</button>Accessibility
- A spinner on its own has
role="status"and avisually-hiddentext that names what is loading. - Next to a visible text, the spinner gets
aria-hidden="true"instead, so the text is read once. - Disable a button while its request runs, so it is not sent twice.
Related
- React Bootstrap spinners: the same markup in AdminKit for React (Vite)
- Bootstrap 5 spinners: the spinners of the HTML version
- Placeholders: loading shapes for cards and text
- General UI demo
Last updated on
ProgressPro
A Next.js Bootstrap progress bar in AdminKit PRO: server-rendered quotas, a live value from client state, aria-valuenow, labels, colors and stacked bars.
Tables
Next.js Bootstrap table markup rendered on the server: rows from an array with map and key, striped and hover tables, row colors, tables in cards.