Skip to main content

Loading states

Content placeholders​

When a page, panel, list, card or dialog body is waiting for content, render a placeholder that matches the final layout. Use Skeleton from @lumio/ui for blocks, circles and lines, and SkeletonText for a stack of text lines. Keep the same grid, row count, spacing and approximate dimensions as the loaded content so the page does not jump when the request finishes. A pending content read must not appear as an empty state, a blank region, a spinner or visible “Loading…” text.

Wrap a content placeholder in SkeletonRegion. It sets role="status" and aria-busy="true" and announces the translated label through screen-reader-only text. Skeleton blocks are aria-hidden, so assistive technology encounters one loading announcement rather than a list of empty shapes. Use the surface's existing loading translation; the dashboard commonly uses t("common.loading").

import { Skeleton, SkeletonRegion, SkeletonText } from "@lumio/ui";

<SkeletonRegion label={t("common.loading")} className="grid gap-4 sm:grid-cols-2">
{Array.from({ length: 4 }, (_, index) => (
<div key={index} className="rounded-lg border p-4">
<Skeleton className="mb-4 h-32 w-full" />
<SkeletonText lines={2} lineClassName="h-4" />
</div>
))}
</SkeletonRegion>

For a single control placeholder, such as the theme toggle before mount, use a control-sized Skeleton; a content region is not needed. Feed placeholders should resemble the rows and respect the view's active display options. For pagination or load more, keep already loaded content visible and place the skeletons where incoming rows will appear.

Full-height Chat, Events and Music surfaces​

When DefaultPresetGate waits for a view's stored default preset, its fallback occupies the whole surface frame, not just the feed. ChatSurfaceSkeleton and EventsSurfaceSkeleton in apps/web/src/components/surface-skeleton.tsx keep the loaded shell's geometry on the dashboard and in popouts. The caller's header actions, seasonal banner and compact scenery are real elements because they do not depend on the preset. Tabs, settings and viewer-list controls, the Chat input or Events search bar, and feed rows use placeholders in their final positions. Chat also shows the viewer-list panel and its loading rows immediately when the saved layout allows it; quick-hide and missing chat:read leave the panel out. A saved hidden header or footer has no placeholder row.

The frame needs its saved layout before the first paint. SurfaceSkeletonRoot puts surfaceLayoutScript() (apps/web/src/lib/surface-layout.ts) as its first child, following the pre-paint SeasonHead pattern. The self-contained, read-only script reads the scoped settings blob and Chat viewer-list quick-hide state, then writes data-sk-* attributes and --sk-* CSS variables on the frame. These determine header/footer visibility, font size, viewer-list side, width and docked or overlay mode, and the surface motion override. useSurfaceLayout() applies the same calculation through useSyncExternalStore and a layout measurement during client navigation. Keep the script and hook on the same layout calculation so hydration and the first client frame match the loaded shell. The <script> element exists only in the SSR HTML: PrepaintScript (apps/web/src/components/prepaint-script.tsx) renders it for the server render and the hydration render and drops it for every client render, because a script React creates in the browser never runs and React 19 reports it as a console error. Render every inline pre-paint script through it — SeasonHead does too, since a server component's markup also reaches the browser in the RSC payload of a soft navigation — and make sure the client path applies the same effect on its own.

Music uses one frame for every Spotify state. Its page keeps the heading and popout action in place; the connect bar appears only when the worker is idle or manually active. While the player starts, a player-sized skeleton occupies its slot. An unreachable-worker message appears only after a failed player read, in the player's footprint, rather than during the read. The Music popout keeps its seasonal banner and bottom bar in place; the settings cog and wake-lock indicator are present after the preset gate resolves in every authenticated Spotify state. The initial gate uses a cog-sized placeholder, and the search field's slot keeps its height when no player is shown. Its saved font size and motion override apply from the first frame.

Waits without a content shape​

Keep a spinner for a pending button or action, a refresh icon, or an OAuth or redirect hand-off. These waits do not replace a shaped content area. In the seasonal dashboard, SeasonalSpinner is available for a standalone wait of this kind; inline button and input spinners stay ordinary so controls do not change width.

Stream-rendered overlay and widget output may render nothing until its data arrives. A dashboard preview of an overlay or widget is a dashboard loading surface and uses a placeholder matching the preview frame. Widget and overlay motion remains outside the dashboard motion preference even when previewed inside it.

Motion and seasonal look​

Skeleton carries animate-pulse bg-muted. The animate-pulse class is part of the motion contract: the baseline in shared/ui/src/globals.css exempts it under Reduced and Off, preserving an opacity pulse so loading remains perceptible. Do not replace that class with a custom animation without updating the motion rule. The placeholder uses theme tokens, so it works with the seasonal look off, Full or Subtle and in both light and dark themes. Seasonal decoration stays behind the skeleton and its controls.

The Motion guide records the motion behaviour; the Seasonal look guide covers dashboard and popout scope.