Skip to content

feat(core): useAnimatedLayout, and Collapsible and TreeList glide the content after them (spec:AST-105) - #8568

Merged
cixzhang merged 39 commits into
mainfrom
feat/animated-layout-adopters
Oct 27, 2026
Merged

cixzhang merged 39 commits into
mainfrom
feat/animated-layout-adopters

Conversation

@cixzhang

@cixzhang cixzhang commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

User need

When a section opens or closes inside a scroll container or app panel, the content after it jumps to its new place, and closing content vanishes at once. People lose their place in a list, with a mouse and on a phone alike. Collapsible and TreeList are the components whose size changes most often.

Problem and solution fit

  • The public useAnimatedLayout({containerRef}) hook returns one opaque handle per area; Collapsible and TreeList take it as a new animatedLayout prop, and report each open and close through it. The hook ships here, with the components that take it, so it is never public without a use.
  • Opening: the content after the component glides down while the revealed content reveals.
  • Closing: the closing content stays where it is, giving up its space, and conceals before it hides, while the content after it glides up.
  • The chevron turns on the same motion.
  • A single-mode CollapsibleGroup measures every item a toggle changes, so the items between glide too.

Current specification

  • spec:AST-105 (current) FR1–FR5: one handle per area; a component without one keeps its behavior; the content after a change glides; the changed element animates entering and exiting on the one shared motion role; every pointer, with fine-pointer evidence on each adopting pull request.
  • component:Collapsible FR10 and component:TreeList FR8 (added here) record the prop.

Public delta and caller burden

  • New hook useAnimatedLayout({containerRef}) from @astryxdesign/core/hooks, returning AnimatedLayout, an opaque handle with no public members. Its documentation carries spec:AST-105 FR7's text.
  • New optional animatedLayout?: AnimatedLayout on Collapsible and TreeList. Without it, both render and behave exactly as before.
  • ScrollableArea does not register itself: an app passes the ref it already gives ScrollableArea as containerRef.
  • The caller decides one thing: which container the glide stays inside, by passing that container's ref to useAnimatedLayout.
const scrollRef = useRef(null);
const animatedLayout = useAnimatedLayout({containerRef: scrollRef});
<ScrollableArea ref={scrollRef} label="Project">
  <Collapsible trigger="Advanced settings" animatedLayout={animatedLayout}>…</Collapsible>
  <TreeList items={items} animatedLayout={animatedLayout} />
</ScrollableArea>;

Design and alternatives

  • Measuring: every change is measured before it lands, whatever caused it: a click, the keyboard, an owner-set isOpen, a group's controlled value, or isExpanded changed in items (spec:AST-105 FR1). The measurement runs in the commit itself, before React changes the DOM, through a small internal LayoutChangeSnapshot (getSnapshotBeforeUpdate). It runs only for a render that commits and only once under StrictMode, and render stays pure. In a single-mode group, each item measures its own change, so the items between glide too.
  • The exit: a measured close keeps the content (or the row's child group) rendered while the module keeps it in place, inert, giving up exactly its space, and conceals it. It hides (or the group unmounts) when the exit ends. Opening again mid-exit ends it. Rows of an exiting group leave the tree's keyboard order at once.
  • The chevron: it is the trigger's Icon (its icon theming target). With a handle, its CSS transition takes the shared spring.

Evidence

Desktop (mouse, fine pointer), real Chrome 156, from the Storybook story Core/Animated layout › Before and after. Animations were slowed 10× to capture frames at the times shown. Each image shows Open then Close; each has a row without the handle and a row with it.

Collapsible: without the handle, the content below jumps at 0 ms either way. With it, the content after glides while the section reveals, and on close the section conceals while the content after glides up.

Collapsible open and close, five frames each, without and with animatedLayout

TreeList: without the handle, README.md and the content below jump at 0 ms either way. With it, they glide while the child rows reveal, and on collapse the child rows conceal while README.md glides up.

TreeList expand and collapse, five frames each, without and with animatedLayout

Scope

  • One capability is added.
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Consumer docs, focused tests, and migration evidence ship with the feature.
  • Public text and artifacts contain no internal Meta context.

Testing

  • animatedLayout.test.ts adds the chevron's linear() transition (with a handle, cleared without one, immediate under Reduce Motion).
  • animatedLayoutAdopters.interaction.browser.spec.ts (real Chromium, against the built Core/Animated layout story through a ScrollableArea ref; 2 of 2 pass, three repeats): Collapsible opening and closing, and a TreeList row expanding and collapsing. The content after each glides, the closing content exits inert until it hides, and the area's scroll doesn't move.
  • animatedLayoutAdopters.test.tsx (13 tests):
    • Collapsible and TreeList with and without a handle;
    • a single-mode group gliding the items between;
    • an owner-set isOpen, a group's controlled value, and isExpanded changed in items all glide;
    • one measurement under StrictMode, and none for a change the owner refuses;
    • a closing Collapsible's content kept rendered, inert and giving up its space until its exit ends, then hidden;
    • a collapsing TreeList row's group kept, inert and out of the keyboard order, until its exit ends, then unmounted;
    • a close without a handle landing at once;
    • a stable, member-free handle.
  • The module's own jsdom, boundary and real-browser tests landed with chore(core): the internal module for size-change glides (spec:AST-105, INV12) #8482.
  • pnpm -F @astryxdesign/core typecheck, eslint, pnpm check:repo, pnpm check:knowledge. Motion, Collapsible, TreeList and hooks tests: 1185 pass.

Writing rubric results

Records touched: component:Collapsible (Public concepts row, FR10, Verification row) and component:TreeList (Public concepts row, FR8, Verification row).

  • R1 narration: none; the rows state what the prop does in the present tense.
  • R2 research: none.
  • R3 evidence: the Basis cells cite spec:AST-105 FR1 and FR2; the Verification cells name the test files.
  • R4 rejected alternatives: none added.
  • R5 change references: none.
  • R6 open questions: none added.

…t around a size change

When something inside a registered area changes size, the content after it
glides from its old place to its new one with a compositor-only translate
instead of jumping (architecture:react-component-runtime INV12, proposed
in #8463).

- motion/animatedLayout.ts: one batched measurement before and after the
  change; a bounded parentElement/nextElementSibling walk up to the area
  (no TreeWalker), descending only through display: contents; absolute and
  fixed elements skipped; only elements whose rectangle changed and that
  are visible move; scale-corrected; Web Animations translate with
  composite: 'add', feature-tested per call with fallbacks; offscreen
  elements land silently; Reduce Motion cross-fades with no travel; every
  pointer.
- useAnimatedLayout(ref) (public) registers an area; reportLayoutChange
  stays internal. ScrollableArea registers its viewport itself.
- Collapsible and TreeList adopt it: the shown content plays its own
  entrance, and the chevron turns, on the same placeholder layout-change
  role as the glide.
- Storybook: Core/Animated layout, with a nested tree, a Collapsible, a
  field error, load more, wrapping tags, plain divs, a display: contents
  wrapper, a sticky header and an absolute badge.

Draft for trying it out; tests, docs and a changeset follow.
…ollapsible and TreeList take as animatedLayout

The API shape the owner set: the hook takes {containerRef} and returns an
opaque handle; Collapsible and TreeList accept an animatedLayout prop and
report their size changes through it, with no context registration.
Without the prop, a component keeps today's behavior, its chevron
included. ScrollableArea no longer registers itself: an app passes the
ScrollableArea's ref as containerRef. The story uses exactly this shape.
Adds the changeset.
…subpixel rounding

A real-Chromium check showed unscaled followers moving by 79.92 px for an
80 px shift: subpixel rounding made rect.width / offsetWidth read a hair
off 1. A scale within 1% of 1 is now treated as none.
…tion by pointer, visible-part rule for the changed element, top-layer and hidden skipped, chevron on the spring)
- One shared motion role: the glide, the changed element's entrance and the
  disclosure chevron all play springs.settle, resolved through the internal
  motion-role registry (a theme's --motion-springs-settle reaches all three);
  the glide's keyframes are sampled from the spring and played linearly.
- Reduce Motion by pointer (design:reduced-motion DR1-DR3): a cross-fade
  under a coarse primary pointer, an immediate change otherwise.
- The changed element outside the area's visible part lands without
  animation; hidden and top-layer elements are skipped.
- Collapsible and TreeList take the chevron from their Icon's icon target
  and set its transition before the change commits; no handle leaves every
  inline value untouched.
- Tests: the walk, motion and handle in jsdom; Collapsible and TreeList with
  and without a handle; one real-browser case per AST-105 Coverage row, plus
  FR2-FR5.
- Docs: the useAnimatedLayout hook doc (FR7 text), the animatedLayout prop in
  the Collapsible and TreeList docs and records, and a Before and after story.
… registry only

Motion roles are internal (spec:AST-094 DEC-11): the glide, the changed
element and the chevron share the registry's one value, and a scope's
--motion-springs-settle changes nothing. The tests assert that instead of
an override.
… and compare scrolling with and without a handle

Run in real Chromium (all 20 cases, three repeats): the page helper's
microtask wait is plain JavaScript; the out-of-view and nested-area cases
wait for the first change's glide to finish before the second; and the FR3
scroll case compares the area's scroll frame by frame with and without a
handle, since the browser's own scroll anchoring moves it either way.
…ed ones and retargets on the compositor

- A single-mode CollapsibleGroup measures every item a toggle changes (the
  one that closes as well as the one that opens), so the items between
  glide with the content after the group. Items holding a handle register
  their measurement with the group.
- A measurement no commit claims by the next frame (a controlled owner
  refused the toggle) is dropped, so it never moves unchanged rows later.
  A change nothing measured (an owner set isOpen directly) lands at once,
  entrance included.
- The follower glide is one replace-composited translate, which the
  compositor runs (architecture:react-component-runtime INV12). An element
  already gliding is retargeted: its glide stops and the new one starts from
  where it is on screen, so it keeps moving without a jump.
- INV12's source boundary check (no layout property animated, the walk only
  through parentElement / nextElementSibling, nothing public, only the hook
  and the adopters use it) and a real-browser case on a throttled phone
  over a 10-level by 50-sibling tree with two changes in one frame.
spec:AST-105's Coverage row: an element already moving keeps its running
motion and adds the new move. The new glide now samples the sum of the
running glide's remaining curve and the new move into one replace-composited
translate, so the element keeps its speed through the change instead of
restarting from rest. Every animation of a change starts on the document
timeline's current time, so a replaced glide moves in the very frame it is
replaced and the changed element and its followers share one start. Tests:
the carried speed (jsdom) and, in a real browser, no jump and no lost speed
across the change.
…e morph once Core has it, its own compositor translate until then
…st adopt it in their own pull request

The module, the hook, their tests and docs, and the INV12 record line stay
here. Collapsible's and TreeList's adoption (their animatedLayout prop,
docs and records), the CollapsibleGroup measurement, the adopter tests and
the Storybook story move to a stacked pull request, so each lands and
reverts on its own. The changeset now describes the hook alone.
Restores the adoption from the module pull request onto it: the
animatedLayout prop on Collapsible and TreeList (docs and records), the
CollapsibleGroup measuring every item a toggle changes, the adopter tests,
and the Storybook story. The story keeps only adopting components and plain
following content; its demos that drove the internal reporting functions
directly are gone.
…motion

spec:AST-105 FR4: the changed element plays its own animation when it exits
too. holdLayoutExit keeps a closing element on screen but out of flow
(absolutely positioned where it is), so the content after it glides up at
once, and conceals it upward from its bottom edge on springs.settle (a fade
under Reduce Motion with a coarse pointer, nothing with a fine one); the
component hides it when the exit finishes, and releaseLayoutExit puts it
back, including when it opens again mid-exit. The throttled-tree browser
case now reads its animations in the same task, so a slow round trip can't
outlast the glide.
…'s exit

spec:AST-105 FR4: the changed element animates when it exits too. A measured
close keeps the Collapsible's content (or the TreeList row's child group)
rendered, inert and hidden from assistive technology, while holdLayoutExit
holds it out of flow and conceals it on springs.settle; the rows after it
glide up at once, and the content hides (or the group unmounts) when the
exit ends. Opening again mid-exit ends it. Rows of an exiting group leave
the tree's keyboard order at once. An unmeasured close, no handle, or an
exit that lands at once hides it in the same commit.
@vercel

vercel Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 27, 2026 9:25pm UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 27, 2026, 9:57 PM UTC

@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Previews ready: Storybook and Sandbox were built with this PR's docsite. CI evidence will be added when available.

View Storybook for this PR · View Sandbox for this PR

…es with its row; the exit stays in flow

- This pull request is now the internal module only: the public hook, its
  docs and the chevron's transition move to the adoption pull request, so
  nothing public appears before a component can take the handle.
- A changed element that an earlier change in the same frame displaced moves
  with its row, by its total change (AST-105 FR3), while it plays its own
  entrance or exit; an element not rendered before the change has no old
  place and does not move.
- The exit keeps the element in flow where it is and gives up its space
  with a negative end margin as large as its box, so it scrolls and clips
  with its container and keeps its width (no offset-parent positioning).
- Browser proofs: the sticky row uses a stuck header after the change, the
  flex row a wrapped item and a row-direction neighbor whose rectangle does
  not change; new cases for the displaced changed element and the exit in a
  scrolled area.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval is waiting on an engineering owner's spec decision, and one small fix is needed whatever that decision is. The proposed component:Collapsible FR10 and component:TreeList FR8 add a clause that spec:AST-105 does not state, that with a handle the disclosure chevron turns on the shared 735 ms spring instead of the theme's turn; an engineering owner records approval of this exact head by commenting /approve-spec 8338da941dd1b8187ac6f6b4ff3c590025824e49 on this pull request (the author may do so as an engineering owner), or asks for the clause to be dropped. Either way, please drop the AST-108 FR4a citation from FR10 and FR8, or cite it as spec:AST-108 FR4a once #8613 is on main; everything else matches AST-105, and the note about being stacked on #8482 can go now that it has merged.

[Automated review]

@cixzhang

Copy link
Copy Markdown
Contributor Author

/approve-spec 8338da9

…ared motion

The adopter rows cited AST-108 FR4a, which is not on main yet. AST-105
FR1-FR4 already decides the shared motion they name.
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Oct 27, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval is waiting on an engineering owner's spec decision, and one fix is needed whatever that decision is. With an animatedLayout handle, the proposed component:Collapsible FR10 and the new TreeList row make the disclosure chevron turn on the shared 735 ms spring instead of the theme's turn, which spec:AST-105 does not state; the /approve-spec recorded on the previous head no longer applies, so an engineering owner approves the clause by commenting /approve-spec <full SHA> on the head that will merge (/approve-spec 007f4142037845183ec25c1b800337799e6c702d for this one) or asks for it to be dropped, and the author may do so as an engineering owner. Separately, main now gives component:TreeList FR8 to an expanding group's own motion (#8691), so this branch conflicts in TreeList.spec.md and CI has not run on it: please merge main, renumber the animatedLayout row (with its Verification row and the browser spec's FR8 references), and reword "Without one, expanding and collapsing are unchanged" so it agrees with the new FR8.

[Automated review]

| FR5 | `TreeListItemData` MUST admit `className`, `style`, and `xstyle` as one per-item styling seam. Every supplied value MUST compose on the same Item row box; an implementation MUST NOT treat current support for only one mechanism as authority to exclude the other two. | Owner decision; `architecture:public-component-api` INV5–INV6; `spec:AST-002/DEC-1` | Settled intent; implementation may be staged |
| FR6 | The Item row box is the logical element that paints the row, owns its hover state, and contains Start content, Item label and description, and End content. The styling seam MUST NOT move to the semantic `<li>` or another element without a compatibility decision. | Owner decision and the caller-owned per-row interaction boundary | Settled intent |
| FR7 | A click on an Item row outside its Item label performs the row's action: an Item with `onClick` runs it; otherwise an Item with children toggles; otherwise an Item whose only action is its link follows that link as Enter does, with modified and middle clicks following the shared enlarged-surface rule in `family:navigation-destinations`. The Chevron and controls in Start content and End content keep their own activation, and a disabled Item does nothing. | Owner decision; `family:navigation-destinations` | Settled intent; implementation may be staged |
| FR8 | `TreeList` MUST accept `animatedLayout?: AnimatedLayout`. With a handle, expanding and collapsing a row report the row's size change through it, so the content after the row glides inside the handle's container, and the shown rows and the row's Chevron move on the same motion (`spec:AST-105` FR1–FR4). Without one, expanding and collapsing are unchanged. The prop MUST NOT change tree semantics or keyboard behavior. | `spec:AST-105` FR1, FR2 | Current behavior |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

main now gives FR8 in this record to an expanding group's own motion (#8691), so this row conflicts with it and the branch cannot merge as is. After merging main, give this row its own number (FR9) or fold it into main's FR8 handle clause, update the Verification row and the FR8 references in animatedLayoutAdopters.interaction.browser.spec.ts, and reword "Without one, expanding and collapsing are unchanged", which contradicts the new FR8's no-handle slide under a coarse pointer.

…dopters cite AST-108 FR4a

main gave component:TreeList FR8 to an expanding group's own motion
(#8691). The animatedLayout row becomes FR9 (its Verification row and the
interaction spec's references too), and 'without one' now defers to FR8.
AST-108 is on main, so Collapsible FR10 and TreeList FR9 cite its FR4a
(coordinated animations share one clock) for the chevron moving on the
same motion as the glide.
@cixzhang

Copy link
Copy Markdown
Contributor Author

/approve-spec 36c6fd8

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Oct 27, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval is recommended: the new useAnimatedLayout hook, the animatedLayout prop and the glide match spec:AST-105, the chevron's shared spring matches spec:AST-108 FR4a, and the new Collapsible FR10 and TreeList FR9 rows carry an engineering owner's approval for this head. The adopters' browser spec passes, and without a handle the existing Collapsible and TreeList stories render exactly as before. Some CI jobs (unit, Storybook build and interaction proofs) had not reported yet, so let them finish before merging; the description still calls the TreeList row FR8 and the browser spec two tests, which you may want to refresh.

[Automated review]

@cixzhang
cixzhang merged commit f8e0c92 into main Oct 27, 2026
68 checks passed
cixzhang added a commit that referenced this pull request Oct 27, 2026
# Conflicts:
#	docs/specs/AST-105-animated-layout/spec.md
cixzhang added a commit that referenced this pull request Oct 27, 2026
# Conflicts:
#	apps/storybook/stories/AnimatedLayout.stories.tsx
cixzhang added a commit that referenced this pull request Oct 27, 2026
cixzhang added a commit that referenced this pull request Oct 27, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
… the TreeList expansion change

TreeList.tsx and TreeListItem.tsx take meta#8568's code as merged. The touch
slide re-applies on top: the tree renders a row's children through a
function the row calls while it shows them, the row plays its own slide
only while the tree holds no animated-layout handle (with one, the handle
plays the group's motion under every pointer, component:TreeList FR8), the
chevron's coarse-pointer transition gives way to the spring only without a
handle, and a collapsed group still leaving, by either exit, carries
data-tree-exiting and inert, so one keyboard selector skips it.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
Main brings meta#8752, meta#8568 (useAnimatedLayout) and meta#8604 (INV11). The only
conflict, internal/shadcn-registry/routes.lock.json, is regenerated with
UPDATE_SHADCN_ROUTE_LOCK=1 node apps/docsite/scripts/generate-data.mjs:
main's lock plus this pull request's four routes.
@github-actions
github-actions Bot deleted the feat/animated-layout-adopters branch October 28, 2026 06:17
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 28, 2026
…List

Main's meta#8568 (useAnimatedLayout and the TreeList glide) conflicts with the
tree expansion change this PR carries through meta#8106, in TreeList.tsx and
TreeListItem.tsx. Resolved as meta#8088's own merge of that main resolves the
same conflict (6801c11, on the same file versions): the row renders its
children through renderChildren, plays its touch slide only without an
animated-layout handle (useTreeExpansionMotion's isEnabled), and a group
collapsing under a handle is marked as exiting and inert. Nothing of this
PR's own changes.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 28, 2026
…t and the route lock

- TreeList.tsx and TreeListItem.tsx: main's meta#8568 (useAnimatedLayout and
  the TreeList glide) against the tree expansion change carried through
  meta#8106. Resolved as meta#8088's own merge of that main resolves the same
  conflict (6801c11): the row renders its children through
  renderChildren, plays its touch slide only without an animated-layout
  handle (useTreeExpansionMotion's isEnabled), and a group collapsing
  under a handle is marked as exiting and inert; this branch's revert
  (dividers stay drawn on press, no Core selection hold) stays applied.
- internal/shadcn-registry/routes.lock.json: regenerated by the docsite's
  generate-data.mjs (UPDATE_SHADCN_ROUTE_LOCK=1); it lists both
  hook-create-reorderable and main's hook-use-animated-layout.

This branch was successfully deployed

1 active deployment
Preview — 36c6fd87 Deployed Oct 27, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant