Wasatch Realty
Spring open house — co-branded post
Reviewed by Dana Whitfield · Rulebook v3
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.
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).
Spring open house — co-branded post
Reviewed by Dana Whitfield · Rulebook v3
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.
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.
Component-local state: idle · Global observer reads: idle
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} />
}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>
}import { useGlobalLoading } from '@halo-compliance/ui'
function MyCustomChrome() {
const loading = useGlobalLoading()
return loading ? <Spinner /> : <Idle />
}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.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.{ 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.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.Every state change fires through three channels in lockstep so consumers can subscribe via whichever fits.
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.window alongside each change. Any code that wants the standard event API (devtools panels, sidecar tools, future telemetry) can window.addEventListener to it.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.
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.
setLoading(true) needs a matching setLoading(false) in the promise's .finally. The cleanup effect catches unmount; it doesn't catch unhandled promise rejection.useLoading's local isLoading for skeleton choreography; reserve global state for "the user should know something is happening somewhere."useLoading's setter (or the dev-only console pattern in the next section).To trigger or clear the chrome spin from the console (useful when working on the mark animation itself):
// 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.