Loading state

Three moves cover every wait: Skeleton holds the shape while content loads, Spinner marks an action in flight, and one call to setLoading(true) spins the Halo mark at the top of the page — this tab and every other one. No prop drilling, no React Context: the bridge is just a global counter mirrored to localStorage.

BetaLoading state is Beta — the component set is settled (Skeleton · Spinner · useLoading · useGlobalLoading); the prop surfaces may still move.

Skeleton — live

A skeleton stands in for content that will replace it with the same visual shape — a list, a card, a table. The pulse is an opacity breath on the brand loading rhythm, not a shimmer sweep. Skeletons are decoration: each plate renders aria-hidden; the loading region owns the announcement via aria-busy (the demo card sets it — inspect it mid-load).

idle

Wasatch Realty

Co-marketing partner · Facebook · Utah

Spring open house — co-branded post

Reviewed by Dana Whitfield · Rulebook v3

Watch the card swap to skeletons in the exact shape of what's coming, then snap back when the fetch lands. It loads through useLoading, so the Halo mark up top spins right along with it — one state, two surfaces.

Spinner — live

A spinner marks an action the user just initiated — a save, a submission, an export. Tone matches the surface's metal: silver in the default register, gold on primary surfaces. Every spinner is a role="status" live region; the label defaults to a visually-hidden "Loading…" and can be printed beside the glyph.

sm · md · lg gold · labeled
Loading…Loading…Loading…Saving changes…
These are the real spinners, turning right now — three sizes, gold for primary surfaces, and a printed label when you want one. Reduced motion freezes the arc; the label still tells the story.

Chrome broadcast — live

Hit the trigger and you broadcast a real load. The Halo mark in the top-left of this page (the design site's actual AppBar) spins for 3 seconds, and the component's own state updates right alongside it — both readouts are below.

idle

Component-local state: idle · Global observer reads: idle

Keep an eye on the Halo mark in the top-left — it spins while the load runs. That mark is this site's real chrome (you're inside the SiteLayout right now), so you're watching the actual broadcast, not a staged demo.

Usage

Leaf component (content load → Skeleton)tsx
import { useEffect } from 'react'
import { Skeleton, useLoading } from '@halo-compliance/ui'

function VendorList() {
  const { isLoading, setLoading } = useLoading()

  useEffect(() => {
    setLoading(true)
    fetchVendors()
      .then(setVendors)
      .finally(() => setLoading(false))
  }, [])

  if (isLoading) {
    return (
      <>
        <Skeleton />
        <Skeleton />
        <Skeleton width="60%" />
      </>
    )
  }
  return <List items={vendors} />
}
Action wait (Spinner)tsx
import { Spinner } from '@halo-compliance/ui'

function ExportAction({ exporting, onExport }) {
  return exporting
    ? <Spinner size="sm" showLabel label={t('exporting')} />
    : <Button onClick={onExport}>{t('export')}</Button>
}
Chrome observer (already wired in BrandSlot — for reference)tsx
import { useGlobalLoading } from '@halo-compliance/ui'

function MyCustomChrome() {
  const loading = useGlobalLoading()
  return loading ? <Spinner /> : <Idle />
}

API

<Skeleton />
Placeholder plate. shape picks the form ('line' | 'rect' | 'circle'); width and height take any CSS length — defaults match a text line. Renders aria-hidden (skeletons are decoration); the loading region owns the announcement via aria-busy.
<Spinner />
Action-wait indicator. size (sm · md · lg), tone (silver · gold — match the surface's metal), label names what's happening, showLabel prints it beside the glyph. Renders a role="status" live region with a translated default label.
useLoading()
Returns { isLoading, setLoading }. Local React state for the component's own rendering, plus a side effect that mutates the global counter when setLoading is called. Cleanup decrements automatically on unmount so the chrome doesn't get stuck spinning if the component unmounts mid-load.
useGlobalLoading()
Returns boolean. True when any component anywhere has called setLoading(true) and not yet matched it with setLoading(false). Also true when another tab is in a loading state (cross-tab via localStorage). Use in chrome — the mark, a progress bar, the tab favicon.

Three transports

Every state change fires through three channels in lockstep so consumers can subscribe via whichever fits.

In-memory subscribers
A Set of callbacks inside the hook module. Fastest path; drives the chrome's same-tab reactivity. Subscribers are managed by useGlobalLoading internally — you don't subscribe directly.
localStorage['halo:loading']
Mirror of the counter as a string. Survives navigation, gives cross-tab visibility, and shows up in devtools for debugging.
CustomEvent('halo:loading')
Fires on window alongside each change. Any code that wants the standard event API (devtools panels, sidecar tools, future telemetry) can window.addEventListener to it.

Cross-tab

Open this page in two tabs. Trigger the demo in one — the mark spins in both. The localStorage write fires a storage event in every other tab on the same origin; useGlobalLoading listens for that key. Cross-tab is read-only — each tab's hook only mutates its own count; the chrome shows "yes" if either the local count or the stored value is positive.

Reduced motion

The mark's spin is a CSS animation. Users with prefers-reduced-motion: reduce (OS-level), or who've picked Reduced in the Preferences corner (the button in the bottom-left), get no spin — the global motion override turns it off. You still get loading feedback in the component itself (skeletons, "loading…" labels); the chrome just sits still.

Anti-patterns

  • Don't forget to clear. Every setLoading(true) needs a matching setLoading(false) in the promise's .finally. The cleanup effect catches unmount; it doesn't catch unhandled promise rejection.
  • Don't gate page-level routing on this. The mark spin is an ambient indicator, not a status reporter. Use useLoading's local isLoading for skeleton choreography; reserve global state for "the user should know something is happening somewhere."
  • Don't write to localStorage directly to fake it. The custom event fires in lockstep with localStorage; setting one without the other gets the chrome out of sync. Use useLoading's setter (or the dev-only console pattern in the next section).
  • Don't spin for sub-100ms operations. A spinner that appears and disappears in a frame is visual noise. For fast paths, skip the broadcast.
  • Don't pair a skeleton with a spinner on the same surface. Skeleton answers "content is coming"; spinner answers "your action is processing." One surface gets one answer — the distinction is firm and learnable.

Debugging from devtools

To trigger or clear the chrome spin from the console (useful when working on the mark animation itself):

Browser devtools consolejs
// Start the spin
localStorage.setItem('halo:loading', '1')
window.dispatchEvent(new CustomEvent('halo:loading'))

// Stop the spin
localStorage.setItem('halo:loading', '0')
window.dispatchEvent(new CustomEvent('halo:loading'))

This bypasses the React state path, so the chrome's local in-memory counter doesn't change — but the cross-tab listener path catches it. Useful for isolation testing the mark animation.

  • /app-bar#brand-slot — the mark variant consumes useGlobalLoading and spins when true.
  • /data-table — DataTable's loading prop renders skeleton rows (the same plate, by the row).
  • /preferences — the Reduced motion preference kills the spin.
  • /error-handling — companion cross-cutting concern (boundaries; same chrome-observes-leaf-state pattern).