Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
73 commits
Select commit Hold shift + click to select a range
777076f
test(Resizable): a LayoutPanel's pane slides closed and open
vjeux Nov 4, 2026
265fa15
feat(Resizable): a LayoutPanel's pane slides closed and open under ev…
vjeux Nov 4, 2026
a33ce9d
docs(LayoutPanel): a panel given the hook's own value slides closed a…
vjeux Nov 4, 2026
980c4e6
test(Resizable): a start and an end LayoutPanel slide with their hand…
vjeux Nov 4, 2026
2b4978a
docs(Layout): component:Resizable and component:LayoutPanel no longer…
vjeux Nov 4, 2026
dd2ccb0
test(Resizable): a press on the handle where it shows during a slide …
vjeux Nov 6, 2026
30b820a
fix(Resizable): the handle keeps its grab zone above the gliding content
vjeux Nov 6, 2026
553b8c1
test(Resizable): a pull past a limit rubber-bands and springs back, a…
vjeux Nov 6, 2026
a087d03
feat(Resizable): a pull past a limit rubber-bands and springs back, a…
vjeux Nov 6, 2026
6ae0738
docs(Resizable): FR8 records the rubber band at the limits and the cl…
vjeux Nov 6, 2026
b4d0155
test(Resizable): the separator's value keeps to its bounds while the …
vjeux Nov 6, 2026
a740700
fix(Resizable): the separator's value keeps to its bounds while the s…
vjeux Nov 6, 2026
44d42e5
test(Resizable): a spring-back past a limit lays out no frame
vjeux Nov 6, 2026
4157c28
fix(Resizable): the spring-back past a limit plays on the layout glide
vjeux Nov 6, 2026
34d2014
docs(Resizable): record the iOS mid-slide press gap
vjeux Nov 6, 2026
f0e46f5
docs(Resizable): drop the iOS mid-slide press gap
vjeux Nov 6, 2026
0dffc20
test(Resizable): a release slide plays the settle spring at the theme…
vjeux Nov 6, 2026
7bcf990
fix(Resizable): a release slide plays the settle spring at the theme'…
vjeux Nov 6, 2026
5ebf6f6
test(Resizable): a panel at the hook's size alone stops at a limit
vjeux Nov 6, 2026
d1e8334
fix(Resizable): only a pane a LayoutPanel renders rubber-bands
vjeux Nov 6, 2026
25eb0f4
refactor(Resizable): the release velocity reaches the region off Resi…
vjeux Nov 6, 2026
b009883
docs(Resizable): FR8 scopes the rubber band to a LayoutPanel's pane
vjeux Nov 6, 2026
63d8ce1
docs(motion): split long header and JSDoc sentences
vjeux Nov 6, 2026
cd5393d
test(Resizable): a pull past a limit moves the edge at most 8 px with…
vjeux Nov 6, 2026
b9bd820
fix(Resizable): a pull past a limit is drawn, at most 8 px, with no r…
vjeux Nov 6, 2026
7eccbd3
docs(Resizable): FR8 caps the rubber band at about 8 px; only the lif…
vjeux Nov 6, 2026
1d8205f
test(Resizable): slide captures wait for the commit by frames
vjeux Nov 6, 2026
fa3bfea
refactor(Resizable): split the rubber band and the lift close out
vjeux Nov 6, 2026
9e404dd
test(Resizable): a pull past a limit rubber-bands and springs back, a…
vjeux Nov 6, 2026
c74034c
feat(Resizable): a pull past a limit rubber-bands and springs back, a…
vjeux Nov 6, 2026
85543e5
docs(Resizable): FR8 records the rubber band at the limits and the cl…
vjeux Nov 6, 2026
c3613b1
test(Resizable): the separator's value keeps to its bounds while the …
vjeux Nov 6, 2026
cbdf599
fix(Resizable): the separator's value keeps to its bounds while the s…
vjeux Nov 6, 2026
790d0d6
test(Resizable): a spring-back past a limit lays out no frame
vjeux Nov 6, 2026
4b85aae
fix(Resizable): the spring-back past a limit plays on the layout glide
vjeux Nov 6, 2026
a7131fd
test(Resizable): a release slide plays the settle spring at the theme…
vjeux Nov 6, 2026
90f6083
fix(Resizable): a release slide plays the settle spring at the theme'…
vjeux Nov 6, 2026
8b8f90f
test(Resizable): a panel at the hook's size alone stops at a limit
vjeux Nov 6, 2026
aaa068f
fix(Resizable): only a pane a LayoutPanel renders rubber-bands
vjeux Nov 6, 2026
3edfa72
refactor(Resizable): the release velocity reaches the region off Resi…
vjeux Nov 6, 2026
d307281
docs(Resizable): FR8 scopes the rubber band to a LayoutPanel's pane
vjeux Nov 6, 2026
a1ba6f4
test(Resizable): a pull past a limit moves the edge at most 8 px with…
vjeux Nov 6, 2026
8c9b846
fix(Resizable): a pull past a limit is drawn, at most 8 px, with no r…
vjeux Nov 6, 2026
63bd040
docs(Resizable): FR8 caps the rubber band at about 8 px; only the lif…
vjeux Nov 6, 2026
55f77c2
test(Resizable): a re-grab catches the edge where it shows
vjeux Nov 6, 2026
786c00c
fix(Resizable): a re-grab catches the edge where it shows
vjeux Nov 6, 2026
b60f303
test(Resizable): a catch between snaps follows the finger to one
vjeux Nov 6, 2026
1e33e3a
fix(Resizable): a catch between snaps follows the finger to one
vjeux Nov 6, 2026
f74228c
docs(Resizable): FR8 says how far a catch follows the finger 1:1
vjeux Nov 6, 2026
389a525
test(Resizable): a mid-close catch draws the pane where it shows
vjeux Nov 6, 2026
c21d03c
fix(Resizable): a catch draws the pane at the size it reopens at
vjeux Nov 6, 2026
bf234da
test(Resizable): past a limit the handle alone moves, over the content
vjeux Nov 6, 2026
f3e6154
fix(Resizable): the handle alone draws a pull past a limit
vjeux Nov 6, 2026
fe596c2
docs(Resizable): FR8 records the handle-only overshoot
vjeux Nov 6, 2026
f78b423
docs(Resizable): FR8 says the band is every pointer's, not a key's
vjeux Nov 6, 2026
a6e2f77
test(Resizable): a re-grab mid-close holds the pane where it shows
vjeux Nov 6, 2026
1b58c8d
fix(Resizable): a re-grab holds a closing pane, then opens it
vjeux Nov 6, 2026
aa8f936
test(Resizable): a closing lift never moves the handle back open
vjeux Nov 6, 2026
499c821
fix(Resizable): a closing lift slides the handle from where it shows
vjeux Nov 6, 2026
7f283a7
docs(LayoutPanel): the slide changeset names the lift
vjeux Nov 6, 2026
91b8c3b
docs(animated-layout): a gesture's release settles on its spring
vjeux Nov 6, 2026
449d58e
docs(touch-presentations): AST-105's release glide settles on a spring
vjeux Nov 7, 2026
7db03bc
test(touch-support): Resizable reads the release velocity
vjeux Nov 7, 2026
94cdeb8
docs(Resizable): Prettier pads the spec's three tables
vjeux Nov 7, 2026
1ca5eda
test(Resizable): NOT FOR LANDING: snap and drag-to-open try-its
vjeux Nov 6, 2026
c0747dd
test(Resizable): NOT FOR LANDING: card 81e, the grip tab bands
vjeux Nov 6, 2026
91d6dec
test(Resizable): NOT FOR LANDING: try-its clear their switch
vjeux Nov 6, 2026
78117f7
Merge commit '12579501166dd59ec8a122c01e48a326fd125482' into HEAD
vjeux Nov 7, 2026
c4a095b
Merge commit '78117f723049a945ab3b14b95b1ea8ee1eccb60c' into HEAD
vjeux Nov 7, 2026
28a2045
Merge commit 'c4a095b24820d14a5e6474e4a64d55ae25bb03c6' into HEAD
vjeux Nov 7, 2026
662ec2c
Merge commit 'a19d45a5dd9295fd3da378e4280cbe2815928133' into HEAD
vjeux Nov 7, 2026
20b42fa
Merge commit '662ec2cea7616b85f7b21a15d6014a515e3f9fb0' into HEAD
vjeux Nov 7, 2026
ab7d4e0
Merge commit '20b42fab1ab2d1d1e0cd274cde511d068ebac979' into HEAD
vjeux Nov 7, 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/layout-panel-slides-on-collapse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[feat] A `LayoutPanel` that takes a `useResizable` region in its `resizable` prop slides closed and open from the edge facing its `ResizeHandle` when the region collapses or opens, under every pointer, with the content beside it moving on the same motion: through `collapse()`, `expand()`, Enter, a double-click, the grow arrow, a controlled `isCollapsed`, or a lift past the collapse threshold. A drag that reopens the panel follows the pointer, and Reduce Motion is honored. A hand-built `ResizableProps` object does not slide. No prop or option is added.

@vjeux
7 changes: 7 additions & 0 deletions .changeset/resizable-limits-rubber-band.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[feat] A `ResizeHandle` that any pointer pulls past the maximum, or under the minimum, of a region whose pane a `LayoutPanel` renders now moves the handle with resistance, at most about 8 px past the limit and over the content beside it, while the panes keep their sizes, and the handle springs back to the limit after the lift. A panel sized by the region's `size` alone stops at the limit. A collapsible region no longer collapses mid-drag: only the lift collapses it, when the pointer is past `collapsedSize`, or released fast enough to come to rest past it, and a `LayoutPanel`'s pane slides shut on a spring from the release velocity. Any other lift under the minimum returns to the minimum. A pointer that grabs the handle again catches it where it shows. Arrow keys still stop at the limit. Reduce Motion lands both at once.

@vjeux
346 changes: 345 additions & 1 deletion apps/storybook/stories/Resizable.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

import {useRef, useState} from 'react';
import {useLayoutEffect, useRef, useState} from 'react';
import type {Meta, StoryObj} from '@storybook/react';
import * as stylex from '@stylexjs/stylex';
import {colorVars, radiusVars} from '@astryxdesign/core/theme/tokens.stylex';
Expand All @@ -27,6 +27,8 @@ const ps = stylex.create({
overflow: 'hidden',
},
sz: {opacity: 0.6, fontSize: 12, fontFamily: 'monospace'},
// NOT FOR LANDING: two splits fit one phone screen.
compact: {height: 300},
});

const meta: Meta<typeof ResizeHandle> = {
Expand Down Expand Up @@ -840,3 +842,345 @@ export const CollapsingScrollPanelFixture: Story = {
tags: ['!autodocs', 'no-visual'],
render: () => <CollapsingScrollPanel />,
};

/**
* A LayoutPanel that takes a `useResizable` region slides closed and open
* from the edge facing its ResizeHandle. The content beside it moves on
* the same motion. "Close panel" collapses it from inside, so focus moves to
* the handle and the collapsed panel is inert. Enter on the handle, a
* double-click or "Open panel" opens it again.
*/
function SlidingPanelSplit({
side,
lineCount = 0,
snaps,
isInitiallyCollapsed = false,
maxSize = 320,
band,
isCompact = false,
}: {
side: 'start' | 'end';
lineCount?: number;
snaps?: number[];
isInitiallyCollapsed?: boolean;
maxSize?: number;
band?: 'handle' | 'grip';
isCompact?: boolean;
}) {
const resizable = useResizable({
defaultSize: 200,
minSize: 120,
maxSize,
collapsible: true,
snaps,
defaultIsCollapsed: isInitiallyCollapsed,
});
const pane = (
<LayoutPanel resizable={resizable} data-testid="sliding-panel">
<Stack gap={2}>
<Heading level={4}>Panel</Heading>
<Button label="Close panel" onClick={resizable.collapse} />
{Array.from({length: lineCount}, (_, index) => (
<Text key={index}>Line {index + 1}</Text>
))}
</Stack>
</LayoutPanel>
);
const handle = (
<ResizeHandle
resizable={resizable}
isReversed={side === 'end'}
hasDivider
label="Resize panel"
/>
);
return (
<div
{...stylex.props(ps.shell, isCompact && ps.compact)}
data-try-it-band={band}>
<Layout
height="fill"
start={
side === 'start' ? (
<>
{pane}
{handle}
</>
) : undefined
}
content={
<LayoutContent data-testid="sliding-content">
<Stack gap={2}>
<Text>The content glides with the panel's edge.</Text>
<Button label="Open panel" onClick={resizable.expand} />
<Button
label="Toggle panel"
onClick={
resizable.isCollapsed ? resizable.expand : resizable.collapse
}
/>
</Stack>
</LayoutContent>
}
end={
side === 'end' ? (
<>
{handle}
{pane}
</>
) : undefined
}
/>
</div>
);
}

export const SlidingStartPanel: Story = {
render: () => <SlidingPanelSplit side="start" />,
};

export const SlidingEndPanel: Story = {
render: () => <SlidingPanelSplit side="end" />,
};

/**
* NOT FOR LANDING: try-it stories. `closeLine` draws an accent line where
* the edge will land. `snapOnLift`
* lets a drag move freely between snap points and settles on the nearest at
* the lift. `expandFeedback` shows a drag out of a collapsed panel: a peek
* of its edge, or the line at the size it opens to.
*/
interface TryItArgs {
closeLine: boolean;
snapOnLift: boolean;
expandFeedback: 'none' | 'peek' | 'line';
}

const TRY_IT: TryItArgs = {
closeLine: true,
snapOnLift: false,
expandFeedback: 'none',
};

const TRY_IT_CONTROLS = {
expandFeedback: {control: 'inline-radio', options: ['none', 'peek', 'line']},
} as const;

/**
* NOT FOR LANDING: sets the page-global try-it switch while a try-it story
* shows, and clears it when the story goes, so no other story reads it.
*/
function useTryItSwitch(choices: object): void {
const key = JSON.stringify(choices);
useLayoutEffect(() => {
const page = globalThis as {__astryxResizeTryIt?: object};
page.__astryxResizeTryIt = JSON.parse(key) as object;
return () => {
delete page.__astryxResizeTryIt;
};
}, [key]);
}

function TryItPanel({
closeLine,
snapOnLift,
expandFeedback,
snaps,
isInitiallyCollapsed,
}: TryItArgs & {snaps?: number[]; isInitiallyCollapsed?: boolean}) {
useTryItSwitch({
closeLine,
snapOnLift,
expandFeedback: expandFeedback === 'none' ? undefined : expandFeedback,
});
return (
<SlidingPanelSplit
side="start"
snaps={snaps}
isInitiallyCollapsed={isInitiallyCollapsed}
/>
);
}

/** NOT FOR LANDING: on a phone, 55% of the viewport, up to 320 px. */
function phoneMax(): number {
return Math.min(320, Math.round(window.innerWidth * 0.55));
}

/**
* NOT FOR LANDING: the slide as it ships, on a phone. The maximum is 55% of
* the viewport, up to 320 px, so the content beside the panel keeps room to
* read past the limit. `band` shows card 81d's handle band or card 81e's
* grip tab band.
*/
export const TryItSlideOnAPhone: StoryObj<{band: 'handle' | 'grip'}> = {
name: 'Try it: the slide on a phone',
args: {band: 'handle'},
argTypes: {band: {control: 'inline-radio', options: ['handle', 'grip']}},
render: ({band}) => (
<SlidingPanelSplit side="start" maxSize={phoneMax()} band={band} />
),
};

/**
* NOT FOR LANDING: card 81d beside card 81e on a phone. Above, the handle
* bands past a limit; below, the grip tab alone bands while the handle line
* stays put. Re-grab and the close on the lift are the same in both.
*/
export const TryItHandleBesideGripOnAPhone: Story = {
name: 'Try it: 81d handle band beside 81e grip tab band, on a phone',
render: () => {
return (
<Stack gap={3}>
<Text>81d: the handle bands</Text>
<SlidingPanelSplit side="start" maxSize={phoneMax()} isCompact />
<Text>81e: the grip tab bands</Text>
<SlidingPanelSplit
side="start"
maxSize={phoneMax()}
band="grip"
isCompact
/>
</Stack>
);
},
};

export const TryItSnapPoints: StoryObj<typeof TryItPanel> = {
name: 'Try it: snap points 120, 200, 280, settled on lift',
args: {...TRY_IT, snapOnLift: true},
argTypes: TRY_IT_CONTROLS,
render: args => <TryItPanel {...args} snaps={[120, 200, 280]} />,
};

export const TryItDragToOpenPeek: StoryObj<typeof TryItPanel> = {
name: 'Try it: drag to open, with a peek',
args: {...TRY_IT, expandFeedback: 'peek'},
argTypes: TRY_IT_CONTROLS,
render: args => <TryItPanel {...args} isInitiallyCollapsed />,
};

export const TryItDragToOpenLine: StoryObj<typeof TryItPanel> = {
name: 'Try it: drag to open, with the line at the open size',
args: {...TRY_IT, expandFeedback: 'line'},
argTypes: TRY_IT_CONTROLS,
render: args => <TryItPanel {...args} isInitiallyCollapsed />,
};

/** A sliding start panel with more lines than fit, for a scrolled slide. */
export const SlidingScrolledPanelFixture: Story = {
tags: ['!autodocs', 'no-visual'],
render: () => <SlidingPanelSplit side="start" lineCount={40} />,
};

/**
* A start LayoutPanel whose collapse state its parent owns: `isCollapsed`
* with `onCollapseChange`, and a toggle that flips the parent's state. The
* panel slides on each controlled change too.
*/
function ControlledSlidingPanel() {
const [isCollapsed, setIsCollapsed] = useState(false);
const resizable = useResizable({
defaultSize: 200,
minSize: 120,
maxSize: 320,
collapsible: true,
isCollapsed,
onCollapseChange: setIsCollapsed,
});
return (
<div {...stylex.props(ps.shell)}>
<Layout
height="fill"
start={
<>
<LayoutPanel resizable={resizable} data-testid="sliding-panel">
<Stack gap={2}>
<Heading level={4}>Panel</Heading>
<Button label="Close panel" onClick={resizable.collapse} />
</Stack>
</LayoutPanel>
<ResizeHandle
resizable={resizable}
hasDivider
label="Resize panel"
/>
</>
}
content={
<LayoutContent data-testid="sliding-content">
<Stack gap={2}>
<Text>The parent owns whether the panel is collapsed.</Text>
<Button
label="Toggle panel"
onClick={() => {
setIsCollapsed(!isCollapsed);
}}
/>
</Stack>
</LayoutContent>
}
/>
</div>
);
}

export const SlidingControlledPanel: Story = {
render: () => <ControlledSlidingPanel />,
};

/**
* Browser fixtures for two start panels. One takes the hook's own `props`
* and slides. The other takes a hand-built `ResizableProps` object, with the same
* fields in an object the hook did not return, and collapses without a slide.
* Both move focus inside them to the handle and go inert while collapsed.
*/
function PropsPanelSplit({isHandBuilt}: {isHandBuilt: boolean}) {
const resizable = useResizable({
defaultSize: 200,
minSize: 120,
maxSize: 320,
collapsible: true,
});
const panelValue = isHandBuilt ? {...resizable.props} : resizable.props;
return (
<div {...stylex.props(ps.shell)}>
<Layout
height="fill"
start={
<>
<LayoutPanel resizable={panelValue} data-testid="sliding-panel">
<Stack gap={2}>
<Heading level={4}>Panel</Heading>
<Button label="Close panel" onClick={resizable.collapse} />
</Stack>
</LayoutPanel>
<ResizeHandle
resizable={resizable}
hasDivider
label="Resize panel"
/>
</>
}
content={
<LayoutContent data-testid="sliding-content">
<Stack gap={2}>
<Text>The content glides with the panel's edge.</Text>
<Button label="Open panel" onClick={resizable.expand} />
</Stack>
</LayoutContent>
}
/>
</div>
);
}

export const SlidingPanelWithHookProps: Story = {
tags: ['!autodocs', 'no-visual'],
render: () => <PropsPanelSplit isHandBuilt={false} />,
};

export const PanelWithHandBuiltProps: Story = {
tags: ['!autodocs', 'no-visual'],
render: () => <PropsPanelSplit isHandBuilt />,
};
2 changes: 1 addition & 1 deletion docs/specs/AST-094-touch-presentations/motion-roles.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

# Motion roles

The owner's goal: "The main goals are consistent timings and well-coordinated pieces." The rules serve it: springs for a component whose main interaction is a gesture, including the moves a tap starts inside it; curve tokens for a component whose main interaction is a tap, tap-to-expand included; the nearest-token mapping for a measured duration; one coordinated scale for springs and curves through `tempo`, each spring paired with a timing tier (`spec:AST-094` FR45); and no per-component exceptions and no platform-native option. The owner's guidance: "The guidance im thinking is: timing should be coordinated between spring and motion curve tokens, then use spring for gestures where velocity matters, use motion curves for taps presses and other non gesture motion." A component whose main interaction is a gesture plays springs for its motion, including moves a tap starts inside it (a tap on a sheet detent or a close button settles on the same spring), so its pieces stay coordinated. A component whose main interaction is a tap plays the theme's curve tokens (duration and easing, scaled by `tempo`), never a spring, since a tap carries no velocity; no character or option plays a platform-measured curve for it (owner decision, 2026-11-04). `spec:AST-094` FR45 lists which components each covers. Every motion role a registered table lists (`spec:AST-094` FR4, DEC-5): its kind, its default as the table measures it, and the record that names it. A role FR4 lists is named by FR4; any other role is one a component's own record measures, named by the current component records that carry its id. In a component whose main interaction is a tap, a role plays only for motion a gesture drives; in a gesture component, its roles also settle the moves a tap, a key or the program starts (`spec:AST-094` FR45). In a component whose main interaction is a tap, motion that a tap, a click, a hold, a key or the program starts plays the theme's duration and easing tokens, and an exit it starts plays the theme's exit duration and easing (`spec:AST-094` FR7), and a role measured for that motion stays as the native reference. Two approved motions keep their springs until their own records change: the animator's `animate` (`spec:AST-107` FR3) and `useDragToSnapPoints`'s `settleTo` (`spec:AST-107`). AST-105's glide, swap and edge reveal are tap-started layout motion and play the theme's curve tokens (`spec:AST-105` FR4). Reduce Motion substitutions and continuous busy loops (such as ProgressBar's indeterminate pulse, `component:ProgressBar` FR6) keep their named roles; they are neither gesture- nor interaction-started motion. Measured input timings, such as gesture thresholds, hold durations and flick speeds, are not motion roles.
The owner's goal: "The main goals are consistent timings and well-coordinated pieces." The rules serve it: springs for a component whose main interaction is a gesture, including the moves a tap starts inside it; curve tokens for a component whose main interaction is a tap, tap-to-expand included; the nearest-token mapping for a measured duration; one coordinated scale for springs and curves through `tempo`, each spring paired with a timing tier (`spec:AST-094` FR45); and no per-component exceptions and no platform-native option. The owner's guidance: "The guidance im thinking is: timing should be coordinated between spring and motion curve tokens, then use spring for gestures where velocity matters, use motion curves for taps presses and other non gesture motion." A component whose main interaction is a gesture plays springs for its motion, including moves a tap starts inside it (a tap on a sheet detent or a close button settles on the same spring), so its pieces stay coordinated. A component whose main interaction is a tap plays the theme's curve tokens (duration and easing, scaled by `tempo`), never a spring, since a tap carries no velocity; no character or option plays a platform-measured curve for it (owner decision, 2026-11-04). `spec:AST-094` FR45 lists which components each covers. Every motion role a registered table lists (`spec:AST-094` FR4, DEC-5): its kind, its default as the table measures it, and the record that names it. A role FR4 lists is named by FR4; any other role is one a component's own record measures, named by the current component records that carry its id. In a component whose main interaction is a tap, a role plays only for motion a gesture drives; in a gesture component, its roles also settle the moves a tap, a key or the program starts (`spec:AST-094` FR45). In a component whose main interaction is a tap, motion that a tap, a click, a hold, a key or the program starts plays the theme's duration and easing tokens, and an exit it starts plays the theme's exit duration and easing (`spec:AST-094` FR7), and a role measured for that motion stays as the native reference. Two approved motions keep their springs until their own records change: the animator's `animate` (`spec:AST-107` FR3) and `useDragToSnapPoints`'s `settleTo` (`spec:AST-107`). AST-105's glide, swap and edge reveal are tap-started layout motion and play the theme's curve tokens, except a gesture's release, which settles on its spring (`spec:AST-105` FR4). Reduce Motion substitutions and continuous busy loops (such as ProgressBar's indeterminate pulse, `component:ProgressBar` FR6) keep their named roles; they are neither gesture- nor interaction-started motion. Measured input timings, such as gesture thresholds, hold durations and flick speeds, are not motion roles.

| Role | Kind | Default | Named by |
| ------------------------------- | -------- | --------------------------------------------------------------- | ----------------------- |
Expand Down
Loading