@@ -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
408491Gate the ** moving property** , not the duration — the signal is about vestibular
0 commit comments