Repository navigation
feat(mosaic): section skeletons with a page-wide loading wave #10029
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
maxyinger
wants to merge
21
commits into
main
Choose a base branch
from
section-skeletons
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
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 c95f4f7
feat(mosaic): move pulses onto the skeleton wave and add panel title …
maxyinger e39b086
docs(repo): document what a loading skeleton renders as bones
maxyinger fefa219
feat(mosaic): draw skeleton lines as cap-height bars on the baseline
maxyinger 82c447a
feat(mosaic): square text skeleton bars and size them in ch
maxyinger 22c0936
fix(mosaic): drop children from skeleton section parts and add a skel…
maxyinger 9fd3614
Merge remote-tracking branch 'origin/main' into section-skeletons
maxyinger 10176cb
feat(mosaic): make skeleton cards inert and let their parts inherit t…
maxyinger d4f4332
refactor(mosaic): render the active devices skeleton from the real vi…
maxyinger a84a2b1
Merge remote-tracking branch 'origin/main' into section-skeletons
maxyinger 6873fba
Merge remote-tracking branch 'origin/main' into section-skeletons
maxyinger 947512e
Merge remote-tracking branch 'origin/main' into section-skeletons
maxyinger b1a95a9
Merge remote-tracking branch 'origin/main' into section-skeletons
maxyinger 9e54ada
feat(mosaic): replace the skeleton wave with a synced horizontal shimmer
maxyinger 3d67f36
feat(mosaic): draw skeleton text from mock content with a CSS-only sh…
maxyinger a0c8577
feat(mosaic): round skeleton text bands and leave a gap between lines
maxyinger e1589a5
feat(mosaic): size skeleton text bars from mock text at cap height
maxyinger 4afa651
docs(repo): point the motion skill at the shimmer's ease-in-out keyword
maxyinger 28dbcd4
Merge remote-tracking branch 'origin/main' into section-skeletons
maxyinger 22a1f36
fix(mosaic): hide content nested in skeleton text and drop device bad…
maxyinger 73d38a4
Merge remote-tracking branch 'origin/main' into section-skeletons
maxyinger File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| --- | ||
| --- |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
|
|
||
| ## 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. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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