# Loading State

> Show that long-running work is in progress, with a label and the time elapsed.

Source: https://ai.honestui.com/docs/components/loading-state

```tsx
import { LoadingState } from "@/components/ai/loading-state"

export function LoadingStateDemo() {
  return <LoadingState label="Building the export" />
}

```

## Overview [#overview]

Loading State is one line: a small animated mark, a label that says what is in progress, and how long it has been running. Use it when work takes long enough that a person may wonder whether anything is happening.

Render it while the work runs and remove it when the work ends. Loading State does not know when your work finishes and reports no progress of its own. The elapsed time is real: it counts from `startedAt`, or from when the component mounted.

Use [Thinking State](/docs/components/thinking-state) instead when you have steps to show.

## Anatomy [#anatomy]

<Anatomy title="LoadingState">
  <AnatomyItem name="Mark" description="A 3 by 3 grid of cells that light in a pattern" />

  <AnatomyItem name="Label" description="What is in progress" />

  <AnatomyItem name="Elapsed" description="Seconds, then minutes and seconds, since the work began" />
</Anatomy>

Installing `loading-state` brings [Text Shimmer](https://www.honestui.com/docs/animated/text-shimmer) along as a registry dependency.

## Behavior [#behavior]

### Patterns [#patterns]

`pattern` is `drive`, `dots`, or `orbit`. Drive and dots sweep a wave across the grid, in square or round cells. Orbit laps the edge. The pattern is decoration and carries no meaning.

### Elapsed time [#elapsed-time]

The time updates once a second and reads as `42s`, then `1m 12s`. Pass `startedAt` when the work began before the component mounted, for example after a page reload. Set `showElapsed` to `false` to leave it out.

## Accessibility [#accessibility]

The label is a polite status, so it is announced when the component appears and when the label changes. The elapsed time is hidden from screen readers, because a status that changes every second would be read every second. The mark is decorative.

With reduced motion, the cells stay dim and the label stops shimmering. The elapsed time still updates.

## Installation [#installation]


  

  
    <CliBlock commands="[&#x22;@honestai/loading-state&#x22;]" />
  

  
    
      
        Add the components it is built from.
      

      <CliBlock commands="[&#x22;text-shimmer&#x22;]" />

      
        Copy and paste this file into your project.
      

      ### components/ai/loading-state.tsx

```tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { TextShimmer } from "@/components/animated/text-shimmer"

export type LoadingPattern = "drive" | "dots" | "orbit"

// Each cell's delay in the cycle. `null` leaves a cell unlit.
const waveDelays = Array.from({ length: 9 }, (_, index) => {
  const row = Math.floor(index / 3)
  const column = index % 3
  return (column + Math.abs(row - 1)) * 90
})

const perimeter = [0, 1, 2, 5, 8, 7, 6, 3]
const orbitDelays = Array.from({ length: 9 }, (_, index) => {
  const position = perimeter.indexOf(index)
  return position === -1 ? null : position * 110
})

const patterns: Record<
  LoadingPattern,
  { delays: (number | null)[]; durationMs: number; isRound: boolean }
> = {
  drive: { delays: waveDelays, durationMs: 650, isRound: false },
  dots: { delays: waveDelays, durationMs: 650, isRound: true },
  orbit: { delays: orbitDelays, durationMs: 950, isRound: false },
}

const gridStyles =
  "@keyframes hui-loading-cell{0%,100%{opacity:.15}40%{opacity:1}}@media(prefers-reduced-motion:reduce){.hui-loading-cell{animation:none!important}}"

function formatElapsed(milliseconds: number) {
  const seconds = Math.max(0, Math.floor(milliseconds / 1000))
  if (seconds < 60) return `${seconds}s`
  return `${Math.floor(seconds / 60)}m ${seconds % 60}s`
}

/** Seconds since `startedAt`, updated once a second. */
function useElapsed(startedAt: number | Date | undefined, isEnabled: boolean) {
  const [mountedAt] = React.useState(() => Date.now())
  const start = startedAt === undefined ? mountedAt : new Date(startedAt).getTime()
  const [now, setNow] = React.useState(() => Date.now())

  React.useEffect(() => {
    if (!isEnabled) return
    const timer = window.setInterval(() => setNow(Date.now()), 1000)
    return () => window.clearInterval(timer)
  }, [isEnabled])

  return formatElapsed(now - start)
}

export type LoadingStateProps = Omit<React.ComponentProps<"div">, "children"> & {
  /** What is in progress, such as "Building the export". */
  label: string
  pattern?: LoadingPattern
  /** Shows the time since `startedAt`. */
  showElapsed?: boolean
  /** When the work began. Defaults to when the component mounted. */
  startedAt?: number | Date
}

/**
 * A small animated mark, a label, and the time elapsed, for work that takes
 * more than a moment. Render it while the work runs and remove it when it ends.
 */
function LoadingState({
  label,
  pattern = "drive",
  showElapsed = true,
  startedAt,
  className,
  ...props
}: LoadingStateProps) {
  const elapsed = useElapsed(startedAt, showElapsed)
  const { delays, durationMs, isRound } = patterns[pattern]

  return (
    <div
      data-slot="loading-state"
      className={cn(
        "flex w-fit max-w-full items-center gap-[var(--hui-space-3)] [font-size:var(--hui-font-size-small)] [letter-spacing:var(--hui-letter-spacing-small)] [line-height:var(--hui-line-height-small)]",
        className
      )}
      {...props}
    >
      <style>{gridStyles}</style>
      <span aria-hidden="true" className="grid shrink-0 grid-cols-[repeat(3,4px)] gap-[1.5px]">
        {delays.map((delay, index) => (
          <span
            key={index}
            className={cn(
              "hui-loading-cell size-[4px] bg-[var(--hui-color-foreground-base-primary)]",
              isRound ? "rounded-full" : "rounded-[1px]"
            )}
            style={{
              opacity: delay === null ? 0.08 : 0.15,
              animation:
                delay === null
                  ? undefined
                  : `hui-loading-cell ${durationMs}ms ease-in-out ${delay}ms infinite`,
            }}
          />
        ))}
      </span>
      {/* Only the label is a status. A ticking timer would be announced every second. */}
      <span role="status" className="min-w-0">
        <TextShimmer
          duration={1.6}
          className="[font-weight:var(--hui-font-weight-medium)]"
        >
          {label}
        </TextShimmer>
      </span>
      {showElapsed && (
        <span
          aria-hidden="true"
          // The server and the browser read the clock at different moments.
          suppressHydrationWarning
          data-slot="loading-state-elapsed"
          className="shrink-0 font-mono tabular-nums text-[var(--hui-color-foreground-base-secondary)] [font-size:var(--hui-font-size-mono-mini)]"
        >
          {elapsed}
        </span>
      )}
    </div>
  )
}

export { LoadingState }

```

      
        Update the import paths to match your project setup.
      
    
  


## Usage [#usage]

```tsx
import { LoadingState } from "@/components/ai/loading-state"

{run.isPending && <LoadingState label="Building the export" startedAt={run.startedAt} />}
```

Write the label as the work, not as "Loading". "Building the export" tells the person what they are waiting for.

## Examples [#examples]

### Patterns [#patterns-1]

The three patterns, without the elapsed time.

```tsx
import { LoadingState } from "@/components/ai/loading-state"

export function LoadingStatePatternsExample() {
  return (
    <div className="flex flex-col gap-[var(--hui-space-5)]">
      <LoadingState pattern="drive" label="Reading 3 files" showElapsed={false} />
      <LoadingState pattern="dots" label="Comparing totals" showElapsed={false} />
      <LoadingState pattern="orbit" label="Waiting for the test run" showElapsed={false} />
    </div>
  )
}

```

## API reference [#api-reference]

### LoadingState [#loadingstate]

Accepts native div props beyond those listed.

| Prop          | Type                           | Default    | Description                       |
| ------------- | ------------------------------ | ---------- | --------------------------------- |
| `label`       | string                         | Required   | What is in progress.              |
| `pattern`     | `"drive" \| "dots" \| "orbit"` | `"drive"`  | The mark's animation.             |
| `showElapsed` | boolean                        | `true`     | Shows the time since `startedAt`. |
| `startedAt`   | `number \| Date`               | Mount time | When the work began.              |
