# Loading State

> Pixel-grid loader with a shimmering label and elapsed timer.

- **Slug:** `loading-state`
- **Kind:** component · **Category:** Feedback
- **Source:** ported from [Beautiful UI](https://github.com/slev12397/beautiful-ui/blob/main/components/primitives/LoadingState.tsx) (MIT)
- **Docs page:** https://viewership.ai/ui/components/loading-state
- **All files in one bundle:** https://viewership.ai/ui/components/loading-state.bundle.txt
- **Structured JSON:** https://viewership.ai/ui/components/loading-state.json

## Install

1. Import the foundation tokens once (download: https://viewership.ai/ui/raw/foundation.css.txt):

```astro
---
// src/layouts/Layout.astro
import "../styles/foundation.css";
---
```

2. Copy this file into `src/components/viewership/loading-state/`:

   - `LoadingState.astro` (raw: https://viewership.ai/ui/raw/loading-state/LoadingState.astro.txt)

npm packages: none.

## Usage

```astro
---
import LoadingState from "../components/viewership/loading-state/LoadingState.astro";
---

<LoadingState />
```

## Example

The gallery demo, verbatim. Use the import path from Usage instead of `./`.

```astro
---
import LoadingState from "./LoadingState.astro";
import Button from "../button/Button.astro";

interface Props {
  variant?: string;
}
const { variant = "Drive" } = Astro.props;
---

{variant === "Drive" && <LoadingState variant="drive" label="Drafting report 214" />}
{variant === "Dots" && <LoadingState variant="dots" label="Pulling GA4 data" />}
{variant === "Orbit" && <LoadingState variant="orbit" label="Syncing citation runs" />}
{variant === "Inline" && (
  <div class="inline-demo">
    <Button variant="secondary" aria-busy="true">
      <LoadingState inline announce={false} elapsed={false} label="Saving draft" />
    </Button>
    <p class="sentence">
      The weekly report is ready for review. <LoadingState inline variant="dots" label="Checking brand voice" />
    </p>
  </div>
)}

<style>
  .inline-demo {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 16px;
  }
  .sentence {
    margin: 0;
    max-width: 22rem;
    font-size: 13px;
    line-height: 1.6;
    color: var(--vs-ink-2);
    text-align: center;
  }
</style>
```

## Variants

Demo variants: `Drive`, `Dots`, `Orbit`, `Inline`.

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"drive" \| "dots" \| "orbit"` | `"drive"` | Grid pattern. |
| `label` | `string` | `"Working"` | Status text, shown and announced. |
| `elapsed` | `boolean` | `true` | Show the elapsed timer (needs JS). |
| `startedAt` | `number` |  | Epoch ms the work started. Defaults to page load. |
| `inline` | `boolean` | `false` | Render as an inline <span> inside text or a button. |
| `shimmer` | `boolean` | `true` | Shimmer the label. |
| `autoplay` | `boolean` | `true` | Animate the grid. Pauses offscreen. |
| `announce` | `boolean` | `true` | Expose as role="status". Turn off inside a control that already reports busy. |

## Source

### `src/components/viewership/loading-state/LoadingState.astro`

```astro
---
/**
 * LoadingState: pixel-grid loader for long-running work.
 *
 * A 3×3 grid pulses in a wavefront beside a shimmering label and a live
 * elapsed timer.
 *
 *   drive  square cells, chevron wavefront moving right
 *   dots   the same wavefront on circular cells
 *   orbit  a comet lapping the grid perimeter
 *
 * `inline` renders a <span> that inherits the surrounding type.
 *
 * Ported from Beautiful UI `components/primitives/LoadingState.tsx`
 * (MIT, © 2026 Shane Levine).
 *
 * @example
 * <LoadingState label="Drafting report 214" />
 * <LoadingState variant="orbit" label="Syncing orders" startedAt={job.startedAt} />
 * <button aria-busy="true"><LoadingState inline announce={false} label="Saving" elapsed={false} /></button>
 */
import type { HTMLAttributes } from "astro/types";

type Variant = "drive" | "dots" | "orbit";

type Props = HTMLAttributes<"div"> & {
  variant?: Variant;
  /** Visible status text; also what screen readers announce. */
  label?: string;
  /** Show the live elapsed timer. */
  elapsed?: boolean;
  /** Epoch ms the work started; the timer counts from here. Defaults to page load. */
  startedAt?: number;
  /** Render inline (a <span>) so it can sit inside text or a button. */
  inline?: boolean;
  /** Shimmer the label. */
  shimmer?: boolean;
  /** Animate the grid. When false the grid rests in its dim state. */
  autoplay?: boolean;
  /** Expose as a polite live region (role="status"). Turn off inside an already-busy control. */
  announce?: boolean;
};

const {
  variant = "drive",
  label = "Working",
  elapsed = true,
  startedAt,
  inline = false,
  shimmer = true,
  autoplay = true,
  announce = true,
  class: className,
  ...rest
} = Astro.props;

/* chevron wavefront: column plus distance from the middle row, 90ms per step */
const chevron: (number | null)[] = Array.from({ length: 9 }, (_, i) => {
  const r = Math.floor(i / 3);
  const c = i % 3;
  return (c + Math.abs(r - 1)) * 90;
});

/* comet: clockwise around the perimeter, the centre cell stays off */
const ORBIT_ORDER = [0, 1, 2, 5, 8, 7, 6, 3];
const orbit: (number | null)[] = Array.from({ length: 9 }, (_, i) => {
  const k = ORBIT_ORDER.indexOf(i);
  return k === -1 ? null : k * 110;
});

const PATTERNS: Record<Variant, { delays: (number | null)[]; dur: number; round: boolean }> = {
  drive: { delays: chevron, dur: 650, round: false },
  dots: { delays: chevron, dur: 650, round: true },
  orbit: { delays: orbit, dur: 950, round: false },
};
const { delays, dur, round } = PATTERNS[variant] ?? PATTERNS.drive;
const Tag = inline ? "span" : "div";
---

<Tag
  class:list={["vs-loading-state", inline && "vs-loading-state--inline", round && "is-round", className]}
  role={announce ? "status" : undefined}
  data-autoplay={autoplay ? "" : undefined}
  data-started-at={startedAt}
  style={`--vs-ls-dur: ${dur}ms`}
  {...rest}
>
  <span class="ls-grid" aria-hidden="true">
    {delays.map((delay) =>
      delay === null ? <span class="cell is-off" /> : <span class="cell" style={`--vs-ls-delay: ${delay}ms`} />,
    )}
  </span>
  <span class:list={["ls-label", shimmer && "is-shimmer"]}>{label}</span>
  {elapsed && (
    <span class="elapsed" aria-hidden="true" data-elapsed>0.0s</span>
  )}
</Tag>

<style>
  .vs-loading-state {
    display: flex;
    width: fit-content;
    align-items: center;
    gap: 10px;
  }
  .vs-loading-state--inline {
    display: inline-flex;
    gap: 0.5em;
    vertical-align: middle;
  }

  .ls-grid {
    display: grid;
    flex-shrink: 0;
    grid-template-columns: repeat(3, 4px);
    gap: 1.5px;
  }
  .cell {
    width: 4px;
    height: 4px;
    border-radius: 1px;
    background: var(--vs-ink);
    opacity: 0.15;
  }
  .is-round .cell {
    border-radius: 50%;
  }
  .cell.is-off {
    opacity: 0.07;
  }
  [data-autoplay] .cell:not(.is-off) {
    animation: vs-pixel-on var(--vs-ls-dur) ease-in-out var(--vs-ls-delay) infinite;
  }

  .ls-label {
    font-size: 13px;
    font-weight: 500;
    color: var(--vs-ink-2);
    white-space: nowrap;
  }
  .vs-loading-state--inline .ls-label {
    font-size: inherit;
    font-weight: inherit;
  }
  .ls-label.is-shimmer {
    color: transparent;
    background-image: linear-gradient(90deg, var(--vs-ink-3) 35%, var(--vs-ink) 50%, var(--vs-ink-3) 65%);
    background-size: 200% 100%;
    -webkit-background-clip: text;
    background-clip: text;
    animation: vs-shimmer-text 1.4s linear infinite;
  }

  .elapsed {
    font-family: var(--vs-font-sans);
    font-size: 12px;
    color: var(--vs-ink-3);
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
  }
  .vs-loading-state--inline .elapsed {
    font-size: 0.92em;
  }

  /* offscreen: freeze the grid and shimmer where they are */
  [data-paused] .cell,
  [data-paused] .ls-label {
    animation-play-state: paused;
  }

  /* the timer needs script; without it, show only the loader and label */
  @media (scripting: none) {
    .elapsed {
      display: none;
    }
  }

  @media (prefers-reduced-motion: reduce) {
    [data-autoplay] .cell:not(.is-off) {
      animation: none;
    }
    .ls-label.is-shimmer {
      animation: none;
      background: none;
      color: var(--vs-ink-2);
    }
  }
  @media (forced-colors: active) {
    .cell {
      background: CanvasText;
    }
    .ls-label.is-shimmer {
      background: none;
      color: CanvasText;
    }
  }
</style>

<script>
  const format = (ms: number) => {
    const total = Math.max(0, Math.floor(ms / 100)) / 10;
    if (total < 60) return `${total.toFixed(1)}s`;
    return `${Math.floor(total / 60)}m ${(total % 60).toFixed(1)}s`;
  };

  function init(root: HTMLElement) {
    const out = root.querySelector<HTMLElement>("[data-elapsed]");
    const started = Number(root.dataset.startedAt) || Date.now();
    let interval: number | undefined;

    const render = () => {
      if (!root.isConnected) return teardown();
      if (out) out.textContent = format(Date.now() - started);
    };
    const start = () => {
      root.removeAttribute("data-paused");
      render();
      if (out && interval === undefined) interval = window.setInterval(render, 100);
    };
    const stop = () => {
      root.setAttribute("data-paused", "");
      window.clearInterval(interval);
      interval = undefined;
    };

    /* the timer measures wall-clock time, so pausing offscreen only skips repaints */
    const io = new IntersectionObserver(([entry]) => (entry?.isIntersecting ? start() : stop()));
    io.observe(root);
    render();

    function teardown() {
      stop();
      io.disconnect();
    }
    document.addEventListener("astro:before-swap", teardown, { once: true });
  }

  const boot = () =>
    document.querySelectorAll<HTMLElement>(".vs-loading-state:not([data-vs-ready])").forEach((root) => {
      root.dataset.vsReady = "";
      init(root);
    });
  boot();
  document.addEventListener("astro:page-load", boot);
</script>
```
