Skip to content

Commit 2b2d642

Browse files
authored
feat(mosaic): animate card banners open and closed (#10003)
1 parent dee649c commit 2b2d642

30 files changed

Lines changed: 580 additions & 175 deletions

‎.changeset/mosaic-card-banner.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

‎.claude/skills/mosaic/SKILL.md‎

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -62,18 +62,18 @@ test. Copy from it.
6262

6363
## Which reference to read
6464

65-
| You are… | Read |
66-
| ---------------------------------------------------------------------- | ------------------------------------------------------ |
67-
| Building on / authoring a headless primitive (`src/primitives/`) | `references/headless.md` |
68-
| Styling a component (tokens, `stylex.create`, `themeProps`, CSS build) | `references/stylex.md` |
69-
| Building an enter/exit transition, or any motion that reads as wrong | `references/motion.md` |
70-
| Writing the model (the Clerk adapter, `status`, permissions) | `references/models.md` |
71-
| Writing the controller (local state, pending, action wrapping) | `references/controllers.md` |
72-
| Authoring or debugging a state machine, or wiring one to React | `references/machines.md` → in-tree `machine/README.md` |
73-
| Writing the view (rendering plain props) | `references/views.md` |
74-
| Testing a feature (feature tests, unit tests) | `references/testing.md` |
75-
| Migrating a legacy component into Mosaic (the end-to-end workflow) | `references/migration.md` |
76-
| Running the parity audit that guards a migration | `references/parity-audit.md` |
65+
| You are… | Read |
66+
| ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------ |
67+
| Building on / authoring a headless primitive (`src/primitives/`) | `references/headless.md` |
68+
| Styling a component (tokens, `stylex.create`, `themeProps`, CSS build) | `references/stylex.md` |
69+
| Building an enter/exit transition, a row that expands and collapses, or any motion that reads as wrong | `references/motion.md` |
70+
| Writing the model (the Clerk adapter, `status`, permissions) | `references/models.md` |
71+
| Writing the controller (local state, pending, action wrapping) | `references/controllers.md` |
72+
| Authoring or debugging a state machine, or wiring one to React | `references/machines.md` → in-tree `machine/README.md` |
73+
| Writing the view (rendering plain props) | `references/views.md` |
74+
| Testing a feature (feature tests, unit tests) | `references/testing.md` |
75+
| Migrating a legacy component into Mosaic (the end-to-end workflow) | `references/migration.md` |
76+
| Running the parity audit that guards a migration | `references/parity-audit.md` |
7777

7878
The migration workflow (`migration.md`) ties the flow references together: it
7979
treats the legacy component as the spec and drives you through the model,

‎.claude/skills/mosaic/references/motion.md‎

Lines changed: 165 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,18 @@ Token semantics live in `packages/mosaic/src/tokens.stylex.ts`, above
55
how-to layer: the rules that decide a transition's shape, and how to check one
66
rather than eyeball it.
77

8-
| Token | Value | For |
9-
| ----------------------- | --------------------------------------- | -------------------------------- |
10-
| `--cl-duration-instant` | `0s` | hover and press arrival |
11-
| `--cl-duration-fast` | `0.1s` | exits |
12-
| `--cl-duration-base` | `0.15s` | entrances, hover exit |
13-
| `--cl-duration-slow` | `0.25s` | larger surfaces |
14-
| `--cl-duration-slower` | `0.35s` | — |
15-
| `--cl-ease-default` | `cubic-bezier(0.175, 0.885, 0.32, 1.1)` | things ARRIVING (Swift Out) |
16-
| `--cl-ease-enter` | `cubic-bezier(0, 0, 0.2, 1)` | arrivals that must not overshoot |
17-
| `--cl-ease-exit` | `cubic-bezier(0.55, 0.085, 0.68, 0.53)` | things LEAVING (In Quad) |
18-
| `--cl-ease-pulse` | `cubic-bezier(0.4, 0, 0.6, 1)` | repeating opacity pulses |
8+
| Token | Value | For |
9+
| ----------------------- | --------------------------------------- | ---------------------------------- |
10+
| `--cl-duration-instant` | `0s` | hover and press arrival |
11+
| `--cl-duration-fast` | `0.1s` | exits |
12+
| `--cl-duration-base` | `0.15s` | entrances, hover exit |
13+
| `--cl-duration-slow` | `0.25s` | larger surfaces |
14+
| `--cl-duration-slower` | `0.35s` | — |
15+
| `--cl-ease-default` | `cubic-bezier(0.175, 0.885, 0.32, 1.1)` | things ARRIVING (Swift Out) |
16+
| `--cl-ease-enter` | `cubic-bezier(0, 0, 0.2, 1)` | arrivals that must not overshoot |
17+
| `--cl-ease-exit` | `cubic-bezier(0.55, 0.085, 0.68, 0.53)` | things LEAVING (In Quad) |
18+
| `--cl-ease-in-out` | `cubic-bezier(0.645, 0.045, 0.355, 1)` | rest-to-rest layout (In Out Cubic) |
19+
| `--cl-ease-pulse` | `cubic-bezier(0.4, 0, 0.6, 1)` | repeating opacity pulses |
1920

2021
Named curves come from [easing.dev](https://www.easing.dev) (Lochie Axon's Easing
2122
Graphs). Take one from there rather than inventing a bezier, so the catalog stays
@@ -114,6 +115,16 @@ exit collapses to one value because both properties want the same curve; write t
114115
list out only when a slot genuinely differs, as the dialog's sheet does for its
115116
slide.
116117

118+
### A layout settle is not an exit
119+
120+
`--cl-ease-exit` is for content that is _leaving_: it accelerates away and the abrupt
121+
stop at the end is never seen, because the surface has faded to nothing by then. A
122+
row that collapses behind that content is still there at the end, so the same curve
123+
stops it dead against the content below. That is what `--cl-ease-in-out` is for: the
124+
row starts from rest and returns to rest, matching the content's ease-in at the top
125+
of the curve and easing into the landing at the bottom. See "Expanding and
126+
collapsing a row".
127+
117128
## Asymmetry, in three places
118129

119130
**Duration.** Exits are shorter than entrances — an arrival earns a moment to
@@ -138,6 +149,149 @@ animate out, hold its last frame for the length of the exit: `Freeze` around the
138149
children, or a snapshot of the outgoing content. See "Exiting content must be
139150
frozen" in `headless.md` for which to use and the two ways to get it wrong.
140151

152+
## Expanding and collapsing a row
153+
154+
The reference is `Card.Banner` (`card.styles.ts`, `banner.collapse` / `clip` /
155+
`surface`). It is the recipe to adopt wherever a row opens and closes around content —
156+
`Field.Message`, the `Collapsible` panel, swingset's `CodeFooter` — in follow-ups.
157+
158+
### The content's enter/exit leads; the row is a byproduct
159+
160+
Played together, a row growing around a banner reads as the row: a cell squashing its
161+
content, with a hard clip line moving across it. Two rules take that apart, and they
162+
guarantee different things:
163+
164+
1. **No hard clip edge is ever visible.** The moving edge is the content's own edge
165+
(it is anchored to it, see Geometry), so nothing is cut there; the stationary edge
166+
is faded (see the mask). Clipping under the header is not a goal in itself — it is
167+
what falls out of wanting a soft edge for the whole motion and nothing visible at
168+
rest.
169+
2. **The row's fast motion happens while the content is invisible.** That is what
170+
decides prominence: what the eye attaches to is the content arriving and
171+
leaving, and the row is just the space it takes. Break it and the collapse gets
172+
ahead of the content's exit, and one motion reads as two.
173+
174+
Rule 2 is met by a different mechanism in each direction, measured on a 54px banner:
175+
176+
- **Enter: delay the content.** The row opens on `--cl-ease-enter` at `slow` and is
177+
~85% open at 100ms; the surface waits one `fast` before its own entrance, so it
178+
arrives into a strip that has all but stopped. The first 100ms is the row moving
179+
alone, and that is fine: the strip is empty, so nothing is being clipped and it
180+
reads as space being made. The content is anchored to the moving edge (below), so
181+
what it does once visible is slide the last few pixels down into place rather
182+
than be revealed by a line crossing it.
183+
- **Exit: let the curve stagger it.** The surface leaves at once at `fast`; the row
184+
collapses on `--cl-ease-in-out` at `slow` with no delay. The in-out's slow start
185+
holds the row within ~7px of rest for that 100ms, so the content has faded to
186+
nothing by the time the row is moving fast. No delay is needed, and a delayed
187+
collapse reads as two events.
188+
189+
Both totals land at ~250ms; the asymmetry lives inside the sequence.
190+
191+
### Curves
192+
193+
- **Row opening: `--cl-ease-enter`.** It answers input, so it departs at once like
194+
everything else that responds to a pointer; `--cl-ease-default` would carry the
195+
height past target and read as a bounce in the layout.
196+
- **Row closing: `--cl-ease-in-out`.** Not `--cl-ease-exit`, per "A layout settle is
197+
not an exit" above. Not `--cl-ease-enter` either, tempting as one curve for both
198+
directions is: its fast start moves the row 16px in the first frame while the
199+
surface is still at 93% opacity, so the collapse gets ahead of the content's
200+
ease-in exit and the two read as separate animations with the layout in front —
201+
rule 2 inverted. The in-out's slow start is what keeps the row still until the
202+
surface has gone.
203+
- **Surface: the popover recipe.** Opacity at `fast` on `--cl-ease-enter`, scale at
204+
`base` on `--cl-ease-default`, both at `fast` on `--cl-ease-exit` going out. Scale
205+
from `0.96` about the **top** edge: the row grows downward from the header, and
206+
with the content anchored to the moving edge a top origin pulls the surface's
207+
bottom edge a pixel inside the clip rather than onto it. (Center was not tried;
208+
top read right.)
209+
210+
### Durations
211+
212+
The row takes `slow` in both directions. That is not the exception to "exits are
213+
shorter" it looks like: the departure is the surface's `fast` fade, and the row's
214+
collapse is a layout settle that follows it.
215+
216+
These values were tuned on a ~54px row and are **soft guidance**. A row of a very
217+
different height — a one-line message, a whole section — may want a token either
218+
way, and a reviewer stepping it is not violating anything. Check it per frame either
219+
way; the dead-frame test still applies to a short collapse.
220+
221+
### Geometry
222+
223+
```text
224+
wrapper display: grid; grid-template-rows: 0fr ↔ 1fr; carries the transition attrs and the mask
225+
clip grid-row: 1 / span 2; display: grid; align-content: end; min-height: 0; overflow: clip; NO padding
226+
content spacing as its own margins; the top one is the fade's length (one constant for both)
227+
```
228+
229+
- **Anchor the content to the moving edge** (`align-content: end`). Anchored to the
230+
top, the content is revealed by the clip line crossing it, and the bottom border is
231+
the last thing to appear — a hard edge at exactly the moment the row is crawling to
232+
a stop. Anchored to the bottom, the content's own bottom edge _is_ the moving edge,
233+
so nothing is ever cut there, and the clipping moves to the top, under the header,
234+
where content sliding out from under something reads as natural and where there
235+
is room for a fade (below).
236+
237+
- **The clip layer spans into the implicit second row.** A fractional `fr` track is
238+
sized twice: the wrapper's height resolves to `f × content`, then the track
239+
re-resolves against that now-definite height and comes out at `f² × content`. An
240+
item stretched to the track alone therefore lags the wrapper's edge — by 13px at
241+
the midpoint of a 54px row — leaving empty space between the clipped content and
242+
whatever follows, and putting any fade on the wrapper in the wrong place. Spanning
243+
the implicit row hands the item the wrapper's full height. Measured in Chromium
244+
and WebKit: `0.62fr` of 54px gives wrapper 33.5 / item 20.8 without the span,
245+
33.5 / 33.5 with it.
246+
247+
- Padding on the clip layer is the `0fr` track's minimum and holds it open, which is
248+
why the content's spacing is margins one level down.
249+
- The wrapper cannot sit in a grid parent with a gap: a grid track floors at zero
250+
no matter how negative an item's margin, so a closed row still costs one gap.
251+
Give it a flex or gapless parent (`Card.Banner` sits between the header and the
252+
content grid for exactly this reason). Measured in Chromium and WebKit.
253+
- Measure the height (`Field.Message`'s ResizeObserver) only when the content
254+
changes size _while open_; `0fr ↔ 1fr` covers open/closed on its own.
255+
256+
### The fade on the clipped edge
257+
258+
A mask on the wrapper, not an element: it needs no background color, works on any
259+
surface a consumer themes, and tracks the box for free because the wrapper's box _is_
260+
the animating track (given the span above — without it the clip edge is somewhere
261+
else).
262+
263+
```ts
264+
const INSET = space['4']; // the surface's top margin AND the fade's length
265+
maskImage: `linear-gradient(to bottom, transparent, black ${INSET})`,
266+
```
267+
268+
That is the whole mask, and it is static. The rule — **the fade exists whenever the
269+
clip edge is crossing content, and is never visible at rest** — is met by geometry
270+
rather than animation: with the content anchored to the moving edge, the only edge
271+
that ever cuts it is the top one, and at rest the top `INSET` of the box is the
272+
content's own top margin, so the ramp ends exactly on the content's top border and
273+
never touches it while the row is still. **The fade's length and the content's top
274+
margin are one constant** — drift between them either eats the border at rest or
275+
leaves a hard strip under the header.
276+
277+
A fade on the _moving_ edge instead (content anchored to the top, a bottom gradient
278+
slid in and out on `mask-size`) was built first and can be made to work, but it has
279+
to animate the fade away at exactly the moment the row is crawling to a stop, and
280+
every timing of that read as an artifact. Anchor the content instead; that variant
281+
is not a recipe.
282+
283+
A hard line at the moving edge is not a fade problem. Before touching the mask,
284+
check the clip layer's height against the wrapper's per frame: if they differ, the
285+
item is sized to the re-resolved track and nothing about the fade can be right (see
286+
the span rule above).
287+
288+
### Reduced motion
289+
290+
The row takes `transition-property: none` and snaps; the mask is static and needs
291+
nothing. The surface keeps its fade and pins the scale, per "Reduced motion" below,
292+
and its entrance delay goes to `instant` as well: it exists to wait for the row, and
293+
a row that has snapped open leaves nothing to wait for.
294+
141295
## Color and state changes (hover, press)
142296

143297
A state change on an element that is already there and stays there — background,
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"files": [
33
{ "path": "./dist/index.js", "maxSize": "125KB" },
4-
{ "path": "./dist/styles.css", "maxSize": "14KB" }
4+
{ "path": "./dist/styles.css", "maxSize": "15KB" }
55
]
66
}

‎packages/mosaic/src/blocks/confirmation/confirmation.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ describe('Confirmation with a handle', () => {
203203
});
204204

205205
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
206-
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
206+
expect(screen.getByRole('alert').textContent).toBe('');
207207
});
208208

209209
it('closes through the handle', async () => {

‎packages/mosaic/src/blocks/confirmation/confirmation.tsx‎

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type { ReactNode } from 'react';
22

3-
import { Banner } from '../../components/banner';
43
import { Button, SubmitButton } from '../../components/button';
54
import { Card } from '../../components/card';
65
import type { DialogFocusTarget, DialogHandle, DialogTriggerProps } from '../../components/dialog';
@@ -47,16 +46,12 @@ function ConfirmationCard({
4746
<Card.Title>{title}</Card.Title>
4847
<Card.Description>{description}</Card.Description>
4948
</Card.Header>
50-
{errorMessage ? (
51-
<Card.Content>
52-
<Banner.Root
53-
role='alert'
54-
color='negative'
55-
>
56-
<Banner.Label>{errorMessage}</Banner.Label>
57-
</Banner.Root>
58-
</Card.Content>
59-
) : null}
49+
<Card.Banner
50+
role='alert'
51+
color='negative'
52+
>
53+
{errorMessage}
54+
</Card.Banner>
6055
<Card.Footer>
6156
<Dialog.Close
6257
render={

0 commit comments

Comments
 (0)