@@ -5,17 +5,18 @@ Token semantics live in `packages/mosaic/src/tokens.stylex.ts`, above
55how-to layer: the rules that decide a transition's shape, and how to check one
66rather 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
2021Named curves come from [ easing.dev] ( https://www.easing.dev ) (Lochie Axon's Easing
2122Graphs). 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
114115list out only when a slot genuinely differs, as the dialog's sheet does for its
115116slide.
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
138149children, or a snapshot of the outgoing content. See "Exiting content must be
139150frozen" 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
143297A state change on an element that is already there and stays there — background,
0 commit comments