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. In /popout/chat and /popout/events the window bar (PopoutWindowBar) belongs to the header: the popout passes it as windowBar to both the skeleton and the shell, the skeleton draws it inside its frame root so the pre-paint data-sk-header=off hides it before the first paint, and the shell draws it only while Show Header is on - no bar appears and vanishes.

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, the Events density (data-sk-density), 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.

All module skeletons — Chat, Events, Music, OBS and the viewer list — come from one kit in apps/web/src/components/surface-skeleton.tsx, used alike by the dashboard page, the popout and the Cockpit:

  • Shape from shared measures. A skeleton row takes its height, padding and gaps from the same constants as the loaded row (apps/web/src/lib/surface-metrics.ts for the chat message row, the events timeline row, the compact music player and the /dashboard/music page player, chatterRowMetrics for the viewer list), in em of the module's font size. ChatFeedRowsSkeleton follows the chat density as well, and the events skeletons follow the Events density (eventRowMetrics, eventDensityTextScale).
  • Two tones. Primary text (names, titles, amounts) uses SK_TONE_PRIMARY, a step stronger than the default bg-muted of secondary text.
  • A calm wave. Rows carry SK_WAVE_ROW and skWave(index): the same animate-pulse opacity pulse, each row 100 ms after the one above. The index folds every SK_WAVE_PERIOD (20) rows — one 2 s pulse — so a long skeleton keeps the same wave without its lower rows waiting seconds for their first pulse. Nothing moves, so the wave stays visible under Reduced and Off.
  • Fill the visible height, anchored like the content. The chat feed's first-load skeleton (ChatFeedRowsSkeleton without rows) is anchored at the bottom, like the feed that scrolls to its newest message, and renders as many rows as CHAT_ROW fits into SK_FILL_PX at the smallest font; the box clips the rest at the top. Its parent gives it the feed's height (h-full). The "load older" placeholder keeps a fixed rows={3} at the top. The viewer-list rows also run down the whole panel instead of stopping after a few.
  • The viewer-list head keeps its height. While the list loads, its "updated" line holds a skeleton line in the line's own box (ViewerListHeadSubSkeleton, headSub × CHATTER_HEAD_SUB_LEADING). The platform filter depends on the supported platforms the first chatter read returns, so the page cannot know it in advance. Every loaded viewer list (module, popout, chat panel) remembers per browser whether it showed the filter (lumio:chat-viewer-list-filter:<userId>:<accountId>, written by useRememberViewerListFilter), and every loading form renders the filter's box (ViewerListFilterSkeleton) hidden unless its row carries data-cl-filter="on": from the remembered value, or — only without one — from the guess viewerListFilterLikely (two or more chat platforms among the channels; Spotify never counts). The mark is written outside React, by an inline pre-paint script in the SSR HTML and by useViewerListFilterHint for client renders, so hydration never mismatches. The chat loading frame draws the same head.
  • Events in timeline form. EventTimelineRowSkeleton stands in for one timeline row (EventRow, FollowWaveRow): round avatar with the platform badge, sentence line, on some rows a chip line or a speech bubble, the time on the right — all from eventRowMetrics(density) in surface-metrics.ts, the same measures and text scale EventRow, FollowWaveRow and EventsTimelineList read for the stored eventDensity (full = EVENT_ROW). EventsSurfaceSkeleton renders the list in both densities and shows the one data-sk-density names, so a compact feed loads without a jump. EventsTimelineRowsSkeleton adds a time-group heading and serves the feed's first load and, with grouped={false}, the "load more" rows at the bottom. It never draws a highlight card: nobody knows in advance which event becomes one. The Dashboard home's ActivitySkeleton uses the same row at the compact 12 px size. EventsSurfaceSkeleton keeps the real Events title and the real Filter button at the summary bar's end, and like EventsShell draws the head's first row only when it has content (not in a Cockpit tile, under the popout window bar only with a slot). The summary bar's category pills are EventsSummaryPillSkeletons in both loading states - in EventsSurfaceSkeleton and in the live EventsSummaryBar while the feed's first page loads (loading): five Skeleton blocks in the loaded pill's box (padding, border, icon slot, the invisible real label from 34rem @container/events width, a typical value), so the pills do not pop in. The Shared Chat chip and the Filter button sit in a pinned end group outside the scrolling pills, so their place never depends on how wide the loaded numbers are.
  • Embedded form. ChatSurfaceSkeleton and EventsSurfaceSkeleton take embedded for a host that draws the module chrome: no cog, no header slot, no seasonal decoration.
  • OBS: one skeleton for the one module. ObsModuleSkeleton draws the OBS module's status row, stream / recording pills and scene grid with the module's measures (OBS_MODULE); the fixed "Scenes" label is real. /dashboard/obs, /popout/obs and the Cockpit tile use it; the page's old generic card is gone.
  • Music: one skeleton per player layout. MusicPlayerSkeleton is the compact player's skeleton (the music popout and the Cockpit music tile). MusicPageSkeleton is the /dashboard/music page's: its two-column layout (now-playing card, queue and tabs grid) in the page player's measures (MUSIC_PAGE). MusicPageTrackRowsSkeleton serves its queue and the search results.

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 window bar, seasonal banner and bottom bar in place; the window bar's settings cog and wake-lock indicator stay real controls in every authenticated Spotify state, also while the preset gate holds. 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.

Cockpit tiles​

Every tile of /dashboard/cockpit loads on its own: its module shows its own first-data skeleton, no tile waits for another, and there is no DefaultPresetGate around the modules. CockpitModuleSlot (components/dynamic/cockpit-module-slot.tsx) only watches the tile: when the content replaces the skeleton it fades in over it in that same frame (.lm-cockpit-reveal, opacity only, motion Full only — at Reduced and Off the swap is instant). The slot sees the busy → ready switch with a MutationObserver (before the browser paints the commit), puts the class straight on its DOM node and removes it when the fade ends; it never fades content that is already on screen, and a module that has shown content once in the page session never fades again (preset or tab switch, reconnect, Try again); a skeleton still showing after 20 s, or a crash, turns into the reason and Try again, which remounts only that module. A module that cannot load at all (feature off — not_released with its date — or a missing permission) shows the reason and the way at once, never a skeleton: the missing right and who grants it, the cheapest plan that includes it ("From Pro", read by the page from plans { features }) with See plans, or who switches it back on. The tile, the "Not in layout" chips and + Module take these texts from one hook (components/dynamic/cockpit-lock-text.tsx). A tile whose module is locked, still loading after 20 s or crashed always keeps the slim tile head (name, ↗, and while editing grip, Enlarge and ×) — also a chat, whose own tab strip is the head only while it is loaded.

A live surface's yellow "Connection lost – reconnecting…" strip is not a loading state: during the first connect the skeleton already says "loading". Chat, events and music (page, popout and Cockpit tile) therefore gate it through useConnectionLost(connected) (hooks/use-connection-lost.ts), which keeps it hidden for the first 6 s after mount and then follows the connection. A server-rejected subscribe (connectionError / wsErrorCode, ZAF-900) is not gated and shows at once.

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