AsyncView
The four-state async renderer every data screen needs: placeholder → loading → success / error. You own the async work and pass the status; AsyncView picks the view, with lens-ready defaults.
Installation
npx @glasskit-ui/cli add async-viewInstall the SDK (it provides GlassViewport, useDpad and the stylesheet), then copy these files into your project:
npm install @glasskit-ui/react// components/lib/utils.tsimport { clsx, type ClassValue } from "clsx";import { twMerge } from "tailwind-merge";export type { ClassValue };/** * Merge class names the shadcn way: clsx joins conditionals, tailwind-merge * de-dupes conflicting Tailwind utilities so a consumer's `className` override * wins (e.g. passing `px-2` beats the component's `px-6`). Lens components are * Tailwind utilities + `--gk-*` tokens, so this de-dupe matters. */export function cn(...inputs: ClassValue[]): string { return twMerge(clsx(inputs));}/** * Accessible name from a free-form `label` prop: the label itself when it's a * plain string, otherwise undefined (a ReactNode can't become an aria-label). */export function stringLabel(label: unknown): string | undefined { return typeof label === "string" ? label : undefined;}// components/glasskit/async-view.tsximport type { ReactNode } from "react";import { cn } from "../lib/utils";export type AsyncStatus = "idle" | "loading" | "success" | "error";/** * <AsyncView> — the four-state async renderer every data screen needs: * placeholder (idle) → loading → success/error. The consumer owns the * async work and passes the current `status`; AsyncView picks the view. * This is the spine's one styled-with-logic piece — the logic is the * state→view selection, nothing more. * * Sensible additive defaults: an emitted pulse for loading, a dim line * for error. Override any state via its slot. */export function AsyncView({ status, children, loading, error, placeholder, errorLabel = "Couldn’t load", className,}: { status: AsyncStatus; /** Success content. */ children?: ReactNode; loading?: ReactNode; error?: ReactNode; placeholder?: ReactNode; /** Default error message when no `error` slot is given. */ errorLabel?: ReactNode; className?: string;}) { if (status === "success") return <>{children}</>; let body: ReactNode; if (status === "loading") { body = loading ?? <Spinner />; } else if (status === "error") { body = error ?? ( <p className="t-body text-muted-foreground">{errorLabel}</p> ); } else { body = placeholder ?? null; } return ( <div className={cn( "flex flex-col items-center justify-center gap-3 text-center", className, )} role="status" aria-busy={status === "loading"} > {body} </div> );}/** The default loading indicator — three emitted dots pulsing in sequence. */export function Spinner({ label = "Loading" }: { label?: string }) { return ( <span className="gk-spinner" role="img" aria-label={label}> <span /> <span /> <span /> </span> );}Usage
<AsyncView status={status} error={<span className="t-caption text-foreground-faint">Couldn’t load</span>}> <Readout label="Heart rate" value={bpm} unit="BPM" /></AsyncView>Props
| Prop | Type | Default | Description |
|---|---|---|---|
status | "idle" | "loading" | "success" | "error" | — | Which view to render. |
children | ReactNode | — | Success content. |
loading | ReactNode | — | Override the default Spinner. |
error | ReactNode | — | Override the default error line. |
placeholder | ReactNode | — | Shown when idle. |
errorLabel | ReactNode | "Couldn’t load" | Default error message when no error slot is given. |
className | string | — | Extra classes for the centered state wrapper (idle / loading / error). |
Loading, empty, and error
AsyncView is the spine for data screens. Give it status and the three slots:
<AsyncView
status={status}
placeholder={
<EmptyState title="No workouts" hint="Start one on your phone." />
}
error={<ErrorState title="No signal" onRetry={refetch} />}
>
{data}
</AsyncView>- EmptyState is for when nothing failed and there is just no content yet. It reads quiet, with an optional invite action.
- ErrorState is for when something failed and the wearer can retry. No red: the lens has one accent, so the words carry the failure.
- The default
loadingslot is the pulsing Spinner; override it for skeleton-style screens.
Progress
Emitted progress in two shapes: a continuous linear bar (a native <progress>, so the fill needs no inline style; also covers countdowns) and discrete step-of-N dots for wizards.
EmptyState
The nothing-here screen: optional glyph + title + hint + one action. The quiet sibling of ErrorState: nothing failed, there's just no content yet. Pairs with AsyncView's placeholder slot.