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.tsfor the chat message row, the events timeline row, the compact music player and the/dashboard/musicpage player,chatterRowMetricsfor the viewer list), inemof the module's font size.ChatFeedRowsSkeletonfollows 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 defaultbg-mutedof secondary text. - A calm wave. Rows carry
SK_WAVE_ROWandskWave(index): the sameanimate-pulseopacity pulse, each row 100 ms after the one above. The index folds everySK_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 (
ChatFeedRowsSkeletonwithoutrows) is anchored at the bottom, like the feed that scrolls to its newest message, and renders as many rows asCHAT_ROWfits intoSK_FILL_PXat 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 fixedrows={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 byuseRememberViewerListFilter), and every loading form renders the filter's box (ViewerListFilterSkeleton) hidden unless its row carriesdata-cl-filter="on": from the remembered value, or — only without one — from the guessviewerListFilterLikely(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 byuseViewerListFilterHintfor client renders, so hydration never mismatches. The chat loading frame draws the same head. - Events in timeline form.
EventTimelineRowSkeletonstands 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 fromeventRowMetrics(density)insurface-metrics.ts, the same measures and text scaleEventRow,FollowWaveRowandEventsTimelineListread for the storedeventDensity(full=EVENT_ROW).EventsSurfaceSkeletonrenders the list in both densities and shows the onedata-sk-densitynames, so a compact feed loads without a jump.EventsTimelineRowsSkeletonadds a time-group heading and serves the feed's first load and, withgrouped={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'sActivitySkeletonuses the same row at the compact 12 px size.EventsSurfaceSkeletonkeeps the real Events title and the real Filter button at the summary bar's end, and likeEventsShelldraws 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 areEventsSummaryPillSkeletonsin both loading states - inEventsSurfaceSkeletonand in the liveEventsSummaryBarwhile the feed's first page loads (loading): fiveSkeletonblocks in the loaded pill's box (padding, border, icon slot, the invisible real label from 34rem@container/eventswidth, 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.
ChatSurfaceSkeletonandEventsSurfaceSkeletontakeembeddedfor a host that draws the module chrome: no cog, no header slot, no seasonal decoration. - OBS: one skeleton for the one module.
ObsModuleSkeletondraws 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/obsand the Cockpit tile use it; the page's old generic card is gone. - Music: one skeleton per player layout.
MusicPlayerSkeletonis the compact player's skeleton (the music popout and the Cockpit music tile).MusicPageSkeletonis the/dashboard/musicpage's: its two-column layout (now-playing card, queue and tabs grid) in the page player's measures (MUSIC_PAGE).MusicPageTrackRowsSkeletonserves 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.