Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
5b0abfb
feat(core): useAnimatedLayout, one module that glides the layout shif…
cixzhang Oct 26, 2026
b476220
feat(core): useAnimatedLayout({containerRef}) returns a handle that C…
cixzhang Oct 26, 2026
52c08dc
fix(core): the layout glide corrects only a real ancestor scale, not …
cixzhang Oct 26, 2026
7cfe5e0
chore(storybook): the Animated layout story's tags row is narrow, so …
cixzhang Oct 26, 2026
e2afc61
Merge 8ba53b45a4 (main, AST-105 current) into the animated layout draft
cixzhang Oct 26, 2026
dc023f3
wip(core): animated layout follows AST-105 (springs.settle, Reduce Mo…
cixzhang Oct 26, 2026
b60f171
feat(core): useAnimatedLayout follows AST-105
cixzhang Oct 26, 2026
ac58ce3
chore(storybook): the story's internal-reporting demos say they are s…
cixzhang Oct 26, 2026
427f850
chore(docsite): the ShadCN route lock lists the useAnimatedLayout hoo…
cixzhang Oct 26, 2026
1bccf04
fix(core): the animated layout reads springs.settle from the internal…
cixzhang Oct 26, 2026
b894315
test(core): the animated-layout browser proofs settle between changes…
cixzhang Oct 26, 2026
ea01d0c
fix(core): animated layout measures every grouped change, drops refus…
cixzhang Oct 26, 2026
186526e
fix(core): a retargeted glide keeps its motion and adds the new move
cixzhang Oct 26, 2026
5bcb797
docs(architecture): INV12 plays the glide through the shared rectangl…
cixzhang Oct 26, 2026
7bad709
docs(architecture): rewrap INV12
cixzhang Oct 26, 2026
3a5f9ae
refactor(core): useAnimatedLayout lands alone; Collapsible and TreeLi…
cixzhang Oct 26, 2026
7e13143
feat(core): Collapsible and TreeList adopt useAnimatedLayout
cixzhang Oct 26, 2026
b3826b7
feat(core): the changed element's exit, held out of flow on the same …
cixzhang Oct 26, 2026
f8c2db7
Merge the module's exit (feat/animated-layout) into the adoption
cixzhang Oct 26, 2026
044b865
feat(core): a Collapsible's or TreeList row's close plays the content…
cixzhang Oct 26, 2026
52863fc
test(core): the exiting group's rows leave the tree's keyboard order
cixzhang Oct 26, 2026
1c44863
chore(changeset): Collapsible and TreeList adopt animatedLayout
cixzhang Oct 26, 2026
a09ce74
fix(core): the internal module alone; a displaced changed element mov…
cixzhang Oct 26, 2026
980ff13
Merge feat/animated-layout into the adoption; the public hook and the…
cixzhang Oct 26, 2026
2f7cedb
fix(core): the exit frees exactly its space and is inert; elements th…
cixzhang Oct 26, 2026
f97811a
Merge feat/animated-layout (an exact, inert exit; app-moved followers…
cixzhang Oct 26, 2026
52841d7
fix(core): the exit measures the content after it, holds only a measu…
cixzhang Oct 26, 2026
86a458b
Merge feat/animated-layout (the exit measures the content after it an…
cixzhang Oct 26, 2026
bc32d66
fix(core): nested changes compose in any order; the exit finds the re…
cixzhang Oct 26, 2026
ac812d7
Merge feat/animated-layout (nested changes in any order; the exit's n…
cixzhang Oct 26, 2026
e4b19b0
fix(core): the exit's correction follows a scaled ancestor, and its m…
cixzhang Oct 26, 2026
731af79
Merge feat/animated-layout (scale-corrected exit; measured out of flo…
cixzhang Oct 26, 2026
c08d59b
fix(core): Collapsible and TreeList report every change, whatever cau…
cixzhang Oct 26, 2026
7c96972
test(core): the adopters' browser spec serves Storybook through a11y-…
cixzhang Oct 26, 2026
7ca7a04
fix(core): a TreeList row collapsing with an expanded child moves wha…
cixzhang Oct 26, 2026
5695f69
docs(core): Collapsible FR10 and TreeList FR8 cite AST-108 FR4a for t…
cixzhang Oct 26, 2026
8338da9
Merge main (#8482 landed as 7a530cc) into the adopters
cixzhang Oct 27, 2026
007f414
docs(spec): Collapsible FR10 and TreeList FR8 cite AST-105 for the sh…
cixzhang Oct 27, 2026
36c6fd8
Merge main: TreeList's animatedLayout row is FR9 beside main's FR8; a…
cixzhang Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/animated-layout-adopters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[feature] `useAnimatedLayout({containerRef})` returns a handle that Collapsible and TreeList take as their new `animatedLayout` prop (`spec:AST-105`). When one of them opens or closes, the content after it inside that container glides to its new place instead of jumping, the revealed content reveals, closing content conceals before it hides, and the chevron turns, all on one shared motion, under every pointer, a mouse included. Fixed and stuck sticky elements stay put, the glide never scrolls the container, and under Reduce Motion the change cross-fades on touch and happens at once with a mouse. A single-mode CollapsibleGroup glides every item it changes. Without the prop, nothing changes.

@cixzhang
269 changes: 269 additions & 0 deletions apps/storybook/stories/AnimatedLayout.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

import {useRef, type ReactNode} from 'react';
import type {Meta, StoryObj} from '@storybook/react';
import {ScrollableArea} from '@astryxdesign/core/ScrollableArea';
import {TreeList} from '@astryxdesign/core/TreeList';
import type {TreeListItemData} from '@astryxdesign/core/TreeList';
import {Collapsible} from '@astryxdesign/core/Collapsible';
import {useAnimatedLayout} from '@astryxdesign/core/hooks';

const meta: Meta = {
title: 'Core/Animated layout',
parameters: {layout: 'padded'},
};

export default meta;

type Story = StoryObj;

const card = {
padding: 12,
margin: '0 0 8px',
borderRadius: 12,
border: '1px solid var(--color-border)',
background: 'var(--color-background-surface)',
color: 'var(--color-text-primary)',
fontFamily: 'var(--font-family-body)',
fontSize: 14,
} as const;

const plain = {
...card,
borderStyle: 'dashed',
background: 'var(--color-background-muted)',
} as const;

const label = {
margin: '0 0 6px',
fontSize: 12,
fontWeight: 600,
color: 'var(--color-text-secondary)',
fontFamily: 'var(--font-family-body)',
} as const;

const TREE: TreeListItemData[] = [
{
id: 'photos',
label: 'Photos',
children: [
{id: 'beach', label: 'Beach.jpg'},
{
id: 'trips',
label: 'Trips',
children: [
{id: 'snow', label: 'Snow.jpg'},
{id: 'lake', label: 'Lake.jpg'},
],
},
{id: 'party', label: 'Party.jpg'},
],
},
{
id: 'docs',
label: 'Documents',
children: [
{id: 'cv', label: 'Resume.pdf'},
{id: 'tax', label: 'Taxes.pdf'},
],
},
{id: 'notes', label: 'Notes.txt'},
];

function Section({title, children}: {title: string; children: ReactNode}) {
return (
<section style={{margin: '0 0 12px'}}>
<p style={label}>{title}</p>
{children}
</section>
);
}

/**
* The sticky header and the absolute badge stay put; the `display: contents`
* wrapper's children move on their own.
*/
function Demo() {
const containerRef = useRef<HTMLDivElement>(null);
const animatedLayout = useAnimatedLayout({containerRef});
return (
<div style={{maxWidth: 520}}>
<ScrollableArea
ref={containerRef}
height={560}
label="Animated layout demo">
<div
style={{
position: 'sticky',
top: 0,
zIndex: 2,
padding: '10px 12px',
fontWeight: 600,
background: 'var(--color-background-surface)',
borderBottom: '1px solid var(--color-border)',
fontFamily: 'var(--font-family-body)',
}}>
Sticky header (stays put)
</div>
<div style={{padding: 12}}>
<Section title="A nested tree (TreeList adopts the module)">
<div style={card}>
<TreeList
aria-label="Files"
items={TREE}
animatedLayout={animatedLayout}
/>
</div>
</Section>
<Section title="A Collapsible (adopts the module)">
<div style={card}>
<Collapsible
trigger="Shipping details"
defaultIsOpen={false}
animatedLayout={animatedLayout}>
<p style={{margin: '4px 0'}}>Ships in 2 business days.</p>
<p style={{margin: '4px 0'}}>Free returns for 30 days.</p>
<p style={{margin: '4px 0'}}>Tracking arrives by email.</p>
</Collapsible>
</div>
</Section>
<div style={{...plain, position: 'relative'}}>
A plain app div with an absolute badge (the badge rides with it)
<span
style={{
position: 'absolute',
top: -8,
insetInlineEnd: -8,
padding: '2px 8px',
borderRadius: 9999,
background: 'var(--color-background-inverted)',
color: 'var(--color-background-body)',
fontSize: 12,
}}>
New
</span>
</div>
<div style={{display: 'contents'}}>
<div style={plain}>Inside a display: contents wrapper (1)</div>
<div style={plain}>Inside a display: contents wrapper (2)</div>
</div>
{Array.from({length: 8}, (_, i) => (
<div key={i} style={plain}>
Plain app content after the changes ({i + 1})
</div>
))}
</div>
</ScrollableArea>
</div>
);
}

/**
* The ScrollableArea is the container: `useAnimatedLayout({containerRef})`
* returns a handle, and each component that changes size takes it.
*/
export const AnimatedLayout: Story = {
name: 'Animated layout',
render: () => <Demo />,
};

function PanelDemo() {
const containerRef = useRef<HTMLDivElement>(null);
const animatedLayout = useAnimatedLayout({containerRef});
return (
<div
ref={containerRef}
style={{
maxWidth: 520,
padding: 12,
border: '2px dashed var(--color-border-emphasized)',
borderRadius: 16,
}}>
<div style={card}>
<Collapsible
trigger="Advanced settings"
defaultIsOpen={false}
animatedLayout={animatedLayout}>
<p style={{margin: '4px 0'}}>Cache size, retries and timeouts.</p>
<p style={{margin: '4px 0'}}>Changes apply on the next sync.</p>
</Collapsible>
</div>
<div style={plain}>Content after, in an app panel</div>
<div style={plain}>More content</div>
</div>
);
}

/** An app's own panel as the container. */
export const AppPanel: Story = {
name: 'App panel',
render: () => <PanelDemo />,
};

const COMPARE_TREE: TreeListItemData[] = [
{
id: 'src',
label: 'src',
children: [
{id: 'app', label: 'App.tsx'},
{id: 'index', label: 'index.ts'},
{id: 'styles', label: 'styles.css'},
],
},
{id: 'readme', label: 'README.md'},
];

function CompareColumn({
title,
isAnimated,
}: {
title: string;
isAnimated: boolean;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const handle = useAnimatedLayout({containerRef});
const animatedLayout = isAnimated ? handle : undefined;
return (
<div style={{flex: '1 1 0', minWidth: 0}}>
<div style={{fontWeight: 600, marginBottom: 8}}>{title}</div>
<div
ref={containerRef}
data-compare={isAnimated ? 'after' : 'before'}
style={{
padding: 12,
border: '2px dashed var(--color-border-emphasized)',
borderRadius: 16,
}}>
<div style={card}>
<Collapsible
trigger="Advanced settings"
defaultIsOpen={false}
animatedLayout={animatedLayout}>
<p style={{margin: '4px 0'}}>Cache size, retries and timeouts.</p>
<p style={{margin: '4px 0'}}>Changes apply on the next sync.</p>
</Collapsible>
</div>
<div style={plain}>Content after the Collapsible</div>
<div style={card}>
<TreeList items={COMPARE_TREE} animatedLayout={animatedLayout} />
</div>
<div style={plain}>Content after the tree</div>
<div style={plain}>More content</div>
</div>
</div>
);
}

/**
* The same Collapsible and TreeList without a handle (content after them
* jumps) and with one (it glides), side by side, for any pointer.
*/
export const BeforeAndAfter: Story = {
name: 'Before and after',
render: () => (
<div style={{display: 'flex', gap: 24, maxWidth: 880}}>
<CompareColumn title="Without animatedLayout" isAnimated={false} />
<CompareColumn title="With animatedLayout" isAnimated />
</div>
),
};
5 changes: 5 additions & 0 deletions internal/shadcn-registry/routes.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -3744,6 +3744,11 @@
"path": "examples/visually-hidden/supplementary-context",
"aliases": []
},
{
"name": "hook-use-animated-layout",
"path": "hooks/use-animated-layout",
"aliases": []
},
{
"name": "hook-use-announce",
"path": "hooks/use-announce",
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/Collapsible/Collapsible.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ export const docs = {
description:
'Identifier used for group coordination. When set inside a CollapsibleGroup, the group owns open state and its onChange is the notification callback.',
},
{
name: 'animatedLayout',
type: 'AnimatedLayout',
description:
'The handle from useAnimatedLayout({containerRef}). With it, opening and closing glides the content after this Collapsible, up to that container, on one shared motion role instead of jumping, and the revealed content and the chevron move on the same motion. Without it, the content after it takes its place at once.',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

An owner-set isOpen lands at once even with the handle (animatedLayoutAdopters.test.tsx:221 asserts it), so "opening and closing glides" overstates what a controlled caller gets.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

An owner-set isOpen lands at once even with the handle (animatedLayoutAdopters.test.tsx:225 asserts it), so "opening and closing glides" overstates what a controlled caller gets; the same wording is in TreeList's docs, both prop JSDocs and the changeset.

},
{
name: 'ref',
type: 'React.Ref<HTMLDivElement>',
Expand Down
Loading
Loading