Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
1ebb298
feat(mosaic): add section skeletons with a page-wide loading wave
maxyinger Oct 1, 2026
c95f4f7
feat(mosaic): move pulses onto the skeleton wave and add panel title …
maxyinger Oct 1, 2026
e39b086
docs(repo): document what a loading skeleton renders as bones
maxyinger Oct 1, 2026
fefa219
feat(mosaic): draw skeleton lines as cap-height bars on the baseline
maxyinger Oct 2, 2026
82c447a
feat(mosaic): square text skeleton bars and size them in ch
maxyinger Oct 2, 2026
22c0936
fix(mosaic): drop children from skeleton section parts and add a skel…
maxyinger Oct 2, 2026
9fd3614
Merge remote-tracking branch 'origin/main' into section-skeletons
maxyinger Oct 2, 2026
10176cb
feat(mosaic): make skeleton cards inert and let their parts inherit t…
maxyinger Oct 2, 2026
d4f4332
refactor(mosaic): render the active devices skeleton from the real vi…
maxyinger Oct 2, 2026
a84a2b1
Merge remote-tracking branch 'origin/main' into section-skeletons
maxyinger Oct 2, 2026
6873fba
Merge remote-tracking branch 'origin/main' into section-skeletons
maxyinger Oct 2, 2026
947512e
Merge remote-tracking branch 'origin/main' into section-skeletons
maxyinger Oct 2, 2026
b1a95a9
Merge remote-tracking branch 'origin/main' into section-skeletons
maxyinger Oct 5, 2026
9e54ada
feat(mosaic): replace the skeleton wave with a synced horizontal shimmer
maxyinger Oct 6, 2026
3d67f36
feat(mosaic): draw skeleton text from mock content with a CSS-only sh…
maxyinger Oct 6, 2026
a0c8577
feat(mosaic): round skeleton text bands and leave a gap between lines
maxyinger Oct 6, 2026
e1589a5
feat(mosaic): size skeleton text bars from mock text at cap height
maxyinger Oct 6, 2026
4afa651
docs(repo): point the motion skill at the shimmer's ease-in-out keyword
maxyinger Oct 6, 2026
28dbcd4
Merge remote-tracking branch 'origin/main' into section-skeletons
maxyinger Oct 6, 2026
22a1f36
fix(mosaic): hide content nested in skeleton text and drop device bad…
maxyinger Oct 6, 2026
73d38a4
Merge remote-tracking branch 'origin/main' into section-skeletons
maxyinger Oct 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/section-skeletons.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions packages/mosaic/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Read the guide for the task you are doing. Paths below are relative to this pack
| Author a headless primitive | [Headless primitives](docs/headless.md) |
| Style a component or change the CSS build | [StyleX](docs/stylex.md) |
| Add or debug motion | [Motion](docs/motion.md) |
| Build a loading skeleton | [Skeletons](docs/skeletons.md) |
| Write a model | [Models](docs/models.md) |
| Write a controller | [Controllers](docs/controllers.md) |
| Author or debug a state machine | [Controllers](docs/controllers.md), then [Machine runtime](src/machine/README.md) |
Expand Down
12 changes: 5 additions & 7 deletions packages/mosaic/docs/motion.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Motion: entrances, exits, and pulses
# Motion: entrances, exits, and loading

Token semantics live in `packages/mosaic/src/tokens.stylex.ts`, above
`durationDefaults` / `easingDefaults` — read those comments first. This file is the
Expand Down Expand Up @@ -36,13 +36,11 @@ For entrances, opacity takes `--cl-ease-enter`: there is nothing
past `1` to overshoot into, so the pass is clamped away and only its cost — the
slower approach to full opacity — is left.

## Repeating pulses
## Loading skeletons

Use `--cl-ease-pulse` for repeating opacity fades such as loading skeletons
(`user-profile-backup-codes.styles.ts`). Its
symmetric curve slows at both ends of each fade, keeping the reversal smooth.
Keep the pulse duration on the component and disable the animation under
`prefers-reduced-motion: reduce`.
Loading placeholders shimmer: a highlight sweeps across each one over 1.6s on the
plain `ease-in-out` keyword, not a token. How to build them, size them, and tune the
shimmer is in `skeletons.md`.

## A curve has a direction — don't run the entrance curve backwards

Expand Down
121 changes: 121 additions & 0 deletions packages/mosaic/docs/skeletons.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Skeletons: loading placeholders

A skeleton stands in for content that is being fetched and is not yet on screen. It
has the exact size of that content, so the swap never shifts layout, and a highlight
sweeps across every bone.

## What to render as bones

When a surface loads as one unit (a panel, a table on first load), render the whole
thing as bones, including titles, card headings and column headers that are already
known: one loading state reads cleaner, shimmers as one surface, and swaps to content in one
moment. A unit is what the user sees appear at once, however many requests feed it.

Content that isn't being fetched keeps rendering. A section loaded by a later request
shows bones beside sections that have already loaded, and a table moving to another
page turns its rows into bones while its headers and controls stay. Whether a table's
controls are bones on first load is still open.

Actions (buttons, menus) are not drawn, but their height is held when it outgrows
the content beside it.

## Building one

Each component defines its own bones, but every bone uses the shared shimmer and the
same fill, `--cl-color-neutral-alpha-200`.

- **Render the real view with mock data.** The view takes `skeleton` and passes it
to its container (`<Section.Group skeleton={skeleton}>`). Every part inside
inherits it and becomes a bone (`skeleton={false}` opts one out), and the
container turns `inert` and `aria-hidden`. Because the skeleton is the view's own
markup, it can't drift from it: change a row and the skeleton changes with it.
While `skeleton` is set, the view skips its dialogs and confirmations.
- **The `.skeleton.tsx` is one line** next to the view, rendering it with a
`PLACEHOLDER_*` constant:
`<UserProfileActiveDevicesSectionView skeleton devices={PLACEHOLDER_DEVICES} />`
(see `user-profile-active-devices-section.skeleton.tsx`). A panel's skeleton
composes its sections' skeletons.
- **Mock data decides row count, shape, and text length.** Text bones are drawn
from the mock strings, so write them at a typical length: a placeholder that
wraps to two lines where real text takes one shifts the layout. Aim at the most
common loaded shape: a typical count (2–3), every line present, and the branch
real data usually takes (a current device, not the empty state). When the count
is known before the fetch, use it instead: a table moving between pages with a
known total shows exactly `min(pageSize, total − offset)` rows. A first load or a
new search can't know, so it uses the typical count and accepts a shift. The page
size is a maximum, not a count.
- **Announce it.** The skeleton is hidden from assistive technology, so the view
renders a `VisuallyHidden` `role='status'` message ("Loading active devices")
while `skeleton` is set.
- **Parts also take `skeleton` standalone**, as does `Panel.Title`, for a bone
outside a skeleton container.
- **What each part does:** text parts (Title, Label, Description, `Panel.Title`)
keep their mock text and wrap it in `SkeletonText`; Media renders empty as a
filled block; Actions render nothing and hold a small control's height.
- **A component with its own shape** composes the pieces in
`styles/skeleton.styles.ts` itself:
- `SkeletonText` (`utils/skeleton-text.tsx`) around mock text: one span, one
line, as wide as its text, drawing a bar with its `::before`. The span is
`visibility: hidden` and only the bar is visible, so anything nested in the
mock text (a badge, a link, an icon) keeps its width but never draws. Leave
badges out of mock rows entirely; a skeleton doesn't draw them.
- `skeletonStyles.bone`: fill and radius, for media and blocks.
- `skeletonStyles.shimmer`: the moving highlight for a block, as an `::after`
overlay. `Avatar.Fallback`, which has its own fill and circle, takes `shimmer`
alone.

## Sizing

- **Height must match exactly.** Text bones are the text's own line boxes, so text
rows match by construction. Watch anything taller than its text:
`Section.Actions skeleton` exists because a 28px `sm` menu trigger outgrew a 20px
line.
- **The bar is cap height on the baseline.** `SkeletonText` is one line of the
part's own type (`1lh` tall, so heights match), and its `::before` bar is `1cap`
tall (`0.7em` fallback), sitting on the baseline with a pill radius. It covers
the same band as the text's capitals in any font, with nothing measured, and
stacked lines keep a natural gap.
- **Width comes from the mock text.** The span is `width: fit-content` around its
transparent mock text, and the bar fills it (a `-100%` end margin keeps the bar
from taking space in the line, so it shares the text's baseline). So each bar is
exactly as long as the text it stands in for. Mock text is held to one line
(`nowrap`, clipped at the part's width), so a long placeholder can't wrap and
shift the layout; keep placeholders to one line of real content. With no mock
text, the bar falls back to `12ch`.

## The shimmer

Modeled on React Spectrum's `Skeleton`, with no JavaScript: a highlight one bone wide,
peaking in the middle, sweeps left to right across two bone widths, over 1.6s
`ease-in-out` (the CSS keyword, not a token), repeating. Off under
`prefers-reduced-motion: reduce`, leaving the plain fill.

- **Blocks** (media, the avatar) move an `::after` overlay with `transform`
(`translateX(-100%)` → `translateX(100%)`), clipped by `overflow: hidden`.
- **Text bars** are themselves a pseudo-element, so they move their own
background instead: a gradient `300%` wide (transparent, highlight, transparent
at 33% / 50% / 66%) over the fill, from `background-position: 100%` to `0%`.
Same geometry and timing as the overlay.
- **The highlight** is a wash over the fill, one value in both techniques:
background-tinted in light mode, neutral-tinted in dark.
- **No sync.** Bones that mount together sweep together; a section mounting later
runs out of step. CSS animations keep running through hydration, so a
server-rendered skeleton doesn't jump.
- The sweep is relative to each bone's width, so a wide bar's highlight moves
faster than a narrow one's.

## When to show it

Not decided by the skeleton. The wiring around a panel owns it. The planned gate is
`useSpinDelay(loading, { delay: 150, minDuration: 500 })` with the skeleton mounted
but `visibility: hidden` until the delay passes, so a fast load never flashes it and
nothing shifts when it appears.
Comment on lines +107 to +112

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm unsure about this. something to keep an eye on. happy to remove mentioning it at all if we don't feel strongly about it


## Checking one

- Add a swingset **Loading** story with a **Reload** button that fakes a load (see
`section.stories.tsx` → `Loading`).
- Measure, don't eyeball: with `agent-browser`, compare each row's and card's
`getBoundingClientRect().height` in the skeleton and loaded states. They must be
equal, except where the number of rows differs.
- Clear swingset's `.next` after any `*.styles.ts` edit.
15 changes: 0 additions & 15 deletions packages/mosaic/src/components/avatar/avatar.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,6 @@ import * as stylex from '@stylexjs/stylex';

import { colorVars, fontFamilyVars, fontWeightVars, radiusVars, space } from '../../tokens.stylex';

// Timed to match `skeleton.tsx`'s pulse, so the two generations of placeholder read as one thing.
const pulse = stylex.keyframes({
'50%': { opacity: 0.5 },
});

export const styles = stylex.create({
// root — sizes and positions its parts; fill comes from the image or fallback
base: {
Expand Down Expand Up @@ -71,16 +66,6 @@ export const styles = stylex.create({
visibility: 'hidden',
},

fallbackPending: {
animationDuration: '2s',
animationIterationCount: 'infinite',
animationName: {
default: pulse,
'@media (prefers-reduced-motion: reduce)': 'none',
},
animationTimingFunction: 'cubic-bezier(0.4, 0, 0.6, 1)',
},

icon: {
borderColor: colorVars['--cl-color-border'],
borderRadius: radiusVars['--cl-radius-full'],
Expand Down
9 changes: 5 additions & 4 deletions packages/mosaic/src/components/avatar/avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { MosaicComponentProps, MosaicElementProps } from '../../props';
import { mergeStyleProps, themeProps } from '../../props';
import { focusOutline } from '../../styles/focus-outline.styles';
import { reset } from '../../styles/reset.styles';
import { skeletonStyles } from '../../styles/skeleton.styles';
import { shapes, sizes, styles } from './avatar.styles';

type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error';
Expand Down Expand Up @@ -145,12 +146,12 @@ const AvatarFallback = React.forwardRef<HTMLSpanElement, AvatarFallbackProps>(fu
return () => clearTimeout(timer);
}, [delayMs]);

const pending = canRender && status === 'loading';

if (!canRender || status === 'loaded') {
return null;
}

const pending = status === 'loading';

return (
<span
ref={ref}
Expand All @@ -160,7 +161,7 @@ const AvatarFallback = React.forwardRef<HTMLSpanElement, AvatarFallbackProps>(fu
reset.base,
styles.fallback,
bordered && styles.overlay,
pending && styles.fallbackPending,
pending && skeletonStyles.shimmer,
xstyle,
),
rest,
Expand Down Expand Up @@ -193,7 +194,7 @@ const AvatarIcon = React.forwardRef<HTMLSpanElement, AvatarIconProps>(function M
/**
* Compound avatar. `Avatar.Root` positions and sizes the box; `Avatar.Image` renders
* once its source loads; `Avatar.Fallback` holds the space until then, as a blank
* placeholder that pulses only while an image is actually on its way; `Avatar.Icon`
* placeholder that shimmers only while an image is actually on its way; `Avatar.Icon`
* adds an optional corner affordance.
*/
export const Avatar = {
Expand Down
15 changes: 11 additions & 4 deletions packages/mosaic/src/components/panel/panel.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { inertProps } from '@clerk/shared/inert';
import * as stylex from '@stylexjs/stylex';
import React from 'react';

import { useRender } from '../../primitives/utils';
import type { MosaicComponentProps } from '../../props';
import { mergeStyleProps, themeProps } from '../../props';
import { reset } from '../../styles/reset.styles';
import { SkeletonText } from '../../utils/skeleton-text';
import { Heading, HeadingLevelProvider, useHeadingLevel } from '../heading';
import { ContentPanelContext, ProfileContext } from '../profile/profile.context';
import { styles } from './panel.styles';

export type PanelRootProps = MosaicComponentProps<'div'>;
export type PanelTitleProps = MosaicComponentProps<'div'>;
export type PanelTitleProps = MosaicComponentProps<'div'> & { skeleton?: boolean };
export type PanelSectionsProps = MosaicComponentProps<'div'>;

const Root = React.forwardRef<HTMLDivElement, PanelRootProps>(function PanelRoot({ render, xstyle, ...rest }, ref) {
Expand All @@ -24,7 +26,7 @@ const Root = React.forwardRef<HTMLDivElement, PanelRootProps>(function PanelRoot

// Inside a profile page, the profile renders the page title, and the ref reaches that instead.
const Title = React.forwardRef<HTMLDivElement, PanelTitleProps>(function PanelTitle(
{ children, render, xstyle, ...rest },
{ skeleton = false, children, render, xstyle, ...rest },
ref,
) {
const inProfilePage = React.useContext(ContentPanelContext);
Expand All @@ -38,13 +40,18 @@ const Title = React.forwardRef<HTMLDivElement, PanelTitleProps>(function PanelTi
ref: pageTitleRef ? null : ref,
enabled: !inProfilePage,
props: {
...mergeStyleProps(themeProps('panel-title'), stylex.props(reset.base, styles.title, xstyle), rest),
...mergeStyleProps(
themeProps('panel-title', { skeleton }),
stylex.props(reset.base, styles.title, xstyle),
skeleton ? { 'aria-hidden': true, ...inertProps(true) } : {},
rest,
),
children: (
<Heading
level={level}
size='2xl'
>
{children}
{skeleton ? <SkeletonText>{children}</SkeletonText> : children}
</Heading>
),
},
Expand Down
4 changes: 3 additions & 1 deletion packages/mosaic/src/components/section/section.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,6 @@ export const styles = stylex.create({
flexDirection: 'column',
flexGrow: 1,
justifyContent: 'center',
rowGap: space['0.5'],
minWidth: 0,
},
label: {
Expand Down Expand Up @@ -156,6 +155,9 @@ export const styles = stylex.create({
maxWidth: '50%',
minWidth: 0,
},
actionsSkeleton: {
height: space['7'],
},
note: {
alignItems: 'center',
color: colorVars['--cl-color-foreground-secondary'],
Expand Down
48 changes: 48 additions & 0 deletions packages/mosaic/src/components/section/section.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -419,4 +419,52 @@ describe('Section', () => {

expect(screen.getByTestId('item')).toHaveAttribute('data-wrap', '');
});

it('turns a skeleton card into inert placeholders', () => {
render(
<Section.Root>
<Section.Group
skeleton
data-testid='group'
>
<Section.Header>
<Section.Title>Account</Section.Title>
</Section.Header>
<Section.Body>
<Section.Items>
<Section.Item>
<Section.Media>media</Section.Media>
<Section.Content>
<Section.Label>Name</Section.Label>
<Section.Description>Description</Section.Description>
</Section.Content>
<Section.Actions>
<button type='button'>Edit</button>
</Section.Actions>
</Section.Item>
</Section.Items>
</Section.Body>
</Section.Group>
</Section.Root>,
);

const group = screen.getByTestId('group');
expect(group).toHaveAttribute('aria-hidden', 'true');
expect(group).toHaveAttribute('inert');
expect(group).not.toHaveAttribute('role');
expect(group.querySelector('button')).toBeNull();
expect(screen.getByText('Name')).toHaveAttribute('aria-hidden', 'true');
expect(screen.getByText('Description')).toHaveAttribute('aria-hidden', 'true');
expect(group.querySelectorAll('[data-skeleton]')).toHaveLength(5);
});

it('lets a part opt out of its card skeleton', () => {
render(
<Section.Group skeleton>
<Section.Label skeleton={false}>Name</Section.Label>
</Section.Group>,
);

expect(screen.getByText('Name')).not.toHaveAttribute('data-skeleton');
});
});
Loading
Loading