Skip to content

Commit 4eb30c7

Browse files
maxyingerclaude
andcommitted
docs(mosaic): add list reorder and loading wave notes to the motion skill
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent b474e13 commit 4eb30c7

1 file changed

Lines changed: 83 additions & 0 deletions

File tree

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

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,89 @@ Three limits on the rule:
403403
accept the spread; a component whose height genuinely varies by multiples wants
404404
a scale per size variant, not one constant.
405405

406+
## Reordering a list: symmetric collapse and expand
407+
408+
A row that changes position is not slid with a transform. It leaves as one element
409+
and arrives as another: `useReorderKeys` re-keys a moved item, `usePresenceList`
410+
keeps the outgoing element where it was until its exit finishes, and each row is a
411+
grid slot whose single track transitions `1fr ↔ 0fr` (the three-layer structure from
412+
"Expanding and collapsing a row"). Adding and removing a row is the same transition,
413+
so the list has one motion vocabulary. Rows that keep their order keep their elements
414+
and are simply pushed.
415+
416+
**The height invariant.** A reorder plays a collapse and an expand at once, and the
417+
container must not notice. That holds only if the two tracks are mirror images every
418+
frame: same duration, same curve, no delay on either. `--cl-duration-slower` on
419+
In-Out Cubic both ways. The direction asymmetry that is right for a single entrance
420+
or exit ("Asymmetry, in three places") is wrong here, because the sum of the two
421+
tracks is what the user sees as the card's height. Measured on five rows: 359px on
422+
every frame with matched timing; an 8px dip when the outgoing row lost its action
423+
menu in the same commit, so keep every child of an outgoing copy mounted, inert. Any
424+
per-direction feel has to come from the content, which does not affect height.
425+
426+
**Anchor the element the change is about.** The cheapest reorder moves the fewest
427+
rows (the longest common subsequence of the old and new order), which for "set as
428+
primary" moves the primary itself: it collapses at the bottom while a copy expands at
429+
the top. Anchoring it instead (`useReorderKeys(items, byId, isPrimary)`) keeps its
430+
element and takes the subsequence on each side of it, so the primary rides up on the
431+
collapsing tracks above it while the rows it passes swap sides. The row the user
432+
acted on is the one that visibly travels. Use the plain subsequence for everything
433+
else; a removal never moves the anchor, so it falls through to that.
434+
435+
**Start both halves in the same style pass.** `useTransition` releases an entrance's
436+
`data-starting-style` from a passive effect one animation frame after commit, while
437+
an exit's `data-ending-style` is set at commit. Driven from those attributes, the
438+
collapse starts a frame before the expand and the sum dips by a few pixels at the
439+
curve's steepest point, more when the frame after a heavy commit runs late. Give the
440+
entering track its `0fr` through `@starting-style` instead (StyleX 0.19 compiles the
441+
key), and do not spread the hook's inline `transition: none` onto that element, or
442+
the starting style has nothing to transition. Both tracks then resolve in the same
443+
style pass with no scheduling in JS. Apply the `@starting-style` variant only to rows
444+
mounted after the list's first render, or the whole list expands from nothing on
445+
load.
446+
447+
**Content timing under a symmetric in-out track.** The rule from the banner work
448+
still decides prominence: the row moves fast only while the content is invisible.
449+
With one shared In-Out Cubic, that means the content enter waits `--cl-duration-slow`
450+
(the track is 91% open at 250ms of 350) and then fades at `base` on `--cl-ease-enter`
451+
with a `scale(0.98 → 1)` on `--cl-ease-default` from the leading edge; the content
452+
exit starts at once and lands at `fast` on `--cl-ease-exit`, gone before the track has
453+
moved 10%. Delays live on the content only.
454+
455+
**Borders belong on the row, not on what fades.** Opacity on the row element fades
456+
its border with the content, so the separator arrives late on enter. Put the fade
457+
and scale on the row's children (a marker on the row, `stylex.when.ancestor` on the
458+
children) and leave the row with its border. On enter the border is there from the
459+
first frame; on exit its color fades to transparent over `fast`, linear, with the
460+
content. Never step a border in with a delayed `0s` transition; a line that appears
461+
in one frame reads as a snap however it is timed.
462+
463+
**Reduced motion is a cut, in one commit.** Zero every transition and hold every
464+
value at rest, and also hide an exiting row as soon as it carries `data-closed`
465+
(`display: none` under the media query). `useTransition` unmounts it a frame later,
466+
and without that rule the incoming row mounts one commit before the outgoing row is
467+
unmounted and the list shows both for a frame: the new primary's badge "flashes in"
468+
before the old row leaves. This is CSS only; no media query needs to be read in JS.
469+
470+
## A loading wave for pending rows
471+
472+
For a row-level pending state with no spinner, such as a set-primary request: mark
473+
the row busy at once, and once the request outlasts `useSpinDelay`'s 150ms, pulse
474+
every row's content in a wave and hold the reorder until the pulse has shown for its
475+
400ms minimum. The wave is one keyframe animation staggered by row index through a
476+
custom property (`animation-delay: calc(var(--_cl-contact-index) * 80ms)`):
477+
478+
```ts
479+
const pulse = stylex.keyframes({ '0%': { opacity: 1 }, '20%': { opacity: 0.4 }, '40%': { opacity: 1 }, '100%': { opacity: 1 } });
480+
animationDuration: '1.4s',
481+
animationTimingFunction: 'cubic-bezier(0.645, 0.045, 0.355, 1)',
482+
```
483+
484+
The dip takes the first 40% of the cycle and the rest is a hold at full opacity,
485+
so the wave passes and settles rather than throbbing. The per-segment curve is
486+
In-Out Cubic, not `--cl-ease-pulse`: the gentler pulse curve read as linear on text.
487+
Off under reduced motion.
488+
406489
## Reduced motion
407490

408491
Gate the **moving property**, not the duration — the signal is about vestibular

0 commit comments

Comments
 (0)