# 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](/pricing/#nextjs).

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](/nextjs/).

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

```html
<div class="spinner-border text-primary me-3" role="status">
  <span class="visually-hidden">Loading...</span>
</div>
<div class="spinner-grow text-primary" role="status">
  <span class="visually-hidden">Loading...</span>
</div>
```
```tsx code
<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:

```html
<div class="spinner-border text-primary me-2" role="status"><span class="visually-hidden">Loading...</span></div>
<div class="spinner-border text-success me-2" role="status"><span class="visually-hidden">Loading...</span></div>
<div class="spinner-border text-danger me-2" role="status"><span class="visually-hidden">Loading...</span></div>
<div class="spinner-border spinner-border-sm text-primary me-2" role="status"><span class="visually-hidden">Loading...</span></div>
<div class="spinner-border text-primary" style="width: 3rem; height: 3rem;" role="status"><span class="visually-hidden">Loading...</span></div>
```
```tsx code
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:

```html
<div class="card" style="max-width: 24rem;">
  <div class="card-header">
    <h2 class="card-title mb-0">Latest orders</h2>
  </div>
  <div class="card-body d-flex justify-content-center py-5">
    <div class="spinner-border text-primary" role="status">
      <span class="visually-hidden">Loading orders...</span>
    </div>
  </div>
</div>
```
```tsx code title="src/components/latest-orders.tsx"
"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](/nextjs/docs/components/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.

```tsx title="src/app/(dashboard)/loading.tsx"
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](/nextjs/docs/components/buttons/#loading-state) on the Buttons page:

```html
<button type="button" class="btn btn-primary" disabled>
  <span class="spinner-border spinner-border-sm me-2" aria-hidden="true"></span>
  Saving...
</button>
```
```tsx code
<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 a `visually-hidden` text 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](/react/docs/components/spinners/): the same markup in AdminKit for React (Vite)
- [Bootstrap 5 spinners](/bootstrap/docs/components/spinners/): the spinners of the HTML version
- [Placeholders](/nextjs/docs/components/placeholders/): loading shapes for cards and text
- [General UI demo](https://nextjs-demo.adminkit.io/ui-general)

---

Canonical HTML page: https://adminkit.io/nextjs/docs/components/spinners/

Site overview for agents: https://adminkit.io/llms.txt (expanded: https://adminkit.io/llms-full.txt) · Documentation: https://adminkit.io/bootstrap/docs/ · Questions: support@adminkit.io
