diff --git a/.changeset/public-drag-to-snap-points.md b/.changeset/public-drag-to-snap-points.md
new file mode 100644
index 0000000000000..f4b470e4573d3
--- /dev/null
+++ b/.changeset/public-drag-to-snap-points.md
@@ -0,0 +1,8 @@
+---
+'@astryxdesign/core': patch
+'@astryxdesign/cli': patch
+---
+
+[feat] `createDragToSnapPoints` and its hook `useDragToSnapPoints` let a drag move an element along one axis and settle it on snap points (px offsets from its resting position) at the release: the nearest one, the next one in its direction after a flick faster than `flickThreshold` (default 1008 px/s), or the one it started from when the release moves back. The settle continues the release velocity, `settleTo(index)` settles without a drag, and `onSettle(index)` reports where it landed. `createDragToSnapPoints` is exported from the package root and `useDragToSnapPoints` from `@astryxdesign/core/hooks`; the CLI's templates gain a playing-card example of each.
+
+@vjeux
diff --git a/apps/sandbox/scripts/next-export-routes.json b/apps/sandbox/scripts/next-export-routes.json
index e9a26f36719e0..519ffa8599431 100644
--- a/apps/sandbox/scripts/next-export-routes.json
+++ b/apps/sandbox/scripts/next-export-routes.json
@@ -294,6 +294,8 @@
"/templates/DividerShowcase/",
"/templates/DividerVariants/",
"/templates/DividerVertical/",
+ "/templates/DragToSnapPointsCard/",
+ "/templates/DragToSnapPointsCardVanilla/",
"/templates/DrawerForm/",
"/templates/DrawerHeaderShowcase/",
"/templates/DrawerHeaderSubtitleEndContent/",
diff --git a/internal/shadcn-registry/routes.lock.json b/internal/shadcn-registry/routes.lock.json
index 60a7420693a98..beea5af7e88a2 100644
--- a/internal/shadcn-registry/routes.lock.json
+++ b/internal/shadcn-registry/routes.lock.json
@@ -1976,6 +1976,11 @@
"path": "examples/create-animator/playing-cards-in-plain-java-script",
"aliases": []
},
+ {
+ "name": "example-create-drag-to-snap-points-playing-card-in-plain-java-script",
+ "path": "examples/create-drag-to-snap-points/playing-card-in-plain-java-script",
+ "aliases": []
+ },
{
"name": "example-date-input-clearable",
"path": "examples/date-input/clearable",
@@ -3711,6 +3716,11 @@
"path": "examples/use-container-reveal/reveal-on-hover-row-actions",
"aliases": []
},
+ {
+ "name": "example-use-drag-to-snap-points-playing-card-on-three-places",
+ "path": "examples/use-drag-to-snap-points/playing-card-on-three-places",
+ "aliases": []
+ },
{
"name": "example-use-focus-trap-quick-edit-panel",
"path": "examples/use-focus-trap/quick-edit-panel",
@@ -3946,6 +3956,11 @@
"path": "hooks/create-animator",
"aliases": []
},
+ {
+ "name": "hook-create-drag-to-snap-points",
+ "path": "hooks/create-drag-to-snap-points",
+ "aliases": []
+ },
{
"name": "hook-create-pan-gesture",
"path": "hooks/create-pan-gesture",
@@ -4011,6 +4026,11 @@
"path": "hooks/use-dev-warning",
"aliases": []
},
+ {
+ "name": "hook-use-drag-to-snap-points",
+ "path": "hooks/use-drag-to-snap-points",
+ "aliases": []
+ },
{
"name": "hook-use-entry-animation",
"path": "hooks/use-entry-animation",
diff --git a/packages/cli/assets/templates/blocks/components/createDragToSnapPoints/DragToSnapPointsCardVanilla.doc.mjs b/packages/cli/assets/templates/blocks/components/createDragToSnapPoints/DragToSnapPointsCardVanilla.doc.mjs
new file mode 100644
index 0000000000000..cb6b6445eaaf8
--- /dev/null
+++ b/packages/cli/assets/templates/blocks/components/createDragToSnapPoints/DragToSnapPointsCardVanilla.doc.mjs
@@ -0,0 +1,14 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */
+export const doc = {
+ type: 'block',
+ exampleFor: 'createDragToSnapPoints',
+ name: 'createDragToSnapPoints — Playing card in plain JavaScript',
+ displayName: 'createDragToSnapPoints — Playing card in plain JavaScript',
+ description:
+ 'The three-place playing card from the useDragToSnapPoints example, built in plain JavaScript with createDragToSnapPoints: a sideways drag settles it on the nearest place or the next after a flick, buttons settle it from the keyboard, and a live region announces the place. React only mounts it.',
+ isReady: true,
+ aspectRatio: 4 / 3,
+ componentsUsed: [],
+};
diff --git a/packages/cli/assets/templates/blocks/components/createDragToSnapPoints/DragToSnapPointsCardVanilla.tsx b/packages/cli/assets/templates/blocks/components/createDragToSnapPoints/DragToSnapPointsCardVanilla.tsx
new file mode 100644
index 0000000000000..947b7f484d16d
--- /dev/null
+++ b/packages/cli/assets/templates/blocks/components/createDragToSnapPoints/DragToSnapPointsCardVanilla.tsx
@@ -0,0 +1,110 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+'use client';
+
+import {createDragToSnapPoints} from '@astryxdesign/core';
+
+const PLACES = ['Hand', 'Table', 'Discard pile'];
+
+/**
+ * A card that snaps between three places, in plain JavaScript: no React
+ * below this line. It returns a function that takes it down again.
+ */
+function mountCard(table: HTMLElement, buttons: HTMLElement) {
+ const card = document.createElement('div');
+ card.textContent = 'Q\u2665';
+ Object.assign(card.style, {
+ width: '96px',
+ height: '136px',
+ display: 'grid',
+ placeItems: 'center',
+ font: 'bold 36px system-ui, sans-serif',
+ // The theme's surface and text, so the card reads in light and dark.
+ borderRadius: 'var(--radius-container)',
+ backgroundColor: 'var(--color-background-surface)',
+ color: 'var(--color-text-primary)',
+ boxShadow: '0 2px 8px var(--color-shadow)',
+ touchAction: 'none',
+ cursor: 'grab',
+ userSelect: 'none',
+ });
+ table.append(card);
+ const status = document.createElement('div');
+ status.setAttribute('aria-live', 'polite');
+ Object.assign(status.style, {position: 'absolute', clip: 'rect(0 0 0 0)'});
+ table.append(status);
+
+ // The places run in reading order, so leftward in a right-to-left layout.
+ // There the snap points are negative, so they sort the other way round.
+ const steps = [0, 110, 220];
+ const isRtl = getComputedStyle(table).direction === 'rtl';
+ const placeAt = (index: number) => (isRtl ? steps.length - 1 - index : index);
+ const snap = createDragToSnapPoints(card, {
+ axis: 'x',
+ snapPoints: isRtl ? steps.map(step => -step) : steps,
+ onSettle: index => {
+ status.textContent = `The card is on the ${PLACES[placeAt(index)].toLowerCase()}.`;
+ },
+ });
+ const controls = PLACES.map((name, index) => {
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.textContent = name;
+ // The Astryx reset removes a native button's own look, so these take the
+ // theme's secondary button look.
+ Object.assign(button.style, {
+ height: 'var(--size-element-md)',
+ paddingInline: 'var(--spacing-3)',
+ border: 'none',
+ borderRadius: 'var(--radius-element)',
+ backgroundColor: 'var(--color-neutral)',
+ color: 'var(--color-text-primary)',
+ font: 'inherit',
+ fontSize: 'var(--text-label-size)',
+ fontWeight: 'var(--font-weight-medium)',
+ cursor: 'pointer',
+ });
+ button.addEventListener('click', () => snap.settleTo(placeAt(index)));
+ buttons.append(button);
+ return button;
+ });
+ return () => {
+ snap.destroy();
+ for (const button of controls) {
+ button.remove();
+ }
+ card.remove();
+ status.remove();
+ };
+}
+
+/**
+ * Mounts the card when the wrapper attaches, and takes it down when it
+ * detaches: its cleanup is the callback ref's.
+ */
+function mountTable(wrapper: HTMLDivElement | null) {
+ if (wrapper == null) {
+ return undefined;
+ }
+ const table = wrapper.firstElementChild as HTMLElement;
+ const buttons = wrapper.lastElementChild as HTMLElement;
+ return mountCard(table, buttons);
+}
+
+export default function DragToSnapPointsCardVanilla() {
+ return (
+
+ );
+}
diff --git a/packages/cli/assets/templates/blocks/components/useDragToSnapPoints/DragToSnapPointsCard.doc.mjs b/packages/cli/assets/templates/blocks/components/useDragToSnapPoints/DragToSnapPointsCard.doc.mjs
new file mode 100644
index 0000000000000..62261d34f369a
--- /dev/null
+++ b/packages/cli/assets/templates/blocks/components/useDragToSnapPoints/DragToSnapPointsCard.doc.mjs
@@ -0,0 +1,21 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */
+export const doc = {
+ type: 'block',
+ exampleFor: 'useDragToSnapPoints',
+ name: 'useDragToSnapPoints — Playing card on three places',
+ displayName: 'useDragToSnapPoints — Playing card on three places',
+ description:
+ 'A playing card that a sideways drag moves between three places: a slow release settles on the nearest, a quick flick carries to the next, and a release moving back returns where it started. Buttons settle it from the keyboard, and the new place is announced. Built only on the public useDragToSnapPoints hook.',
+ isReady: true,
+ aspectRatio: 4 / 3,
+ componentsUsed: [
+ 'Card',
+ 'Button',
+ 'Layout',
+ 'Text',
+ 'VisuallyHidden',
+ 'useDragToSnapPoints',
+ ],
+};
diff --git a/packages/cli/assets/templates/blocks/components/useDragToSnapPoints/DragToSnapPointsCard.tsx b/packages/cli/assets/templates/blocks/components/useDragToSnapPoints/DragToSnapPointsCard.tsx
new file mode 100644
index 0000000000000..e78ef4921ce3a
--- /dev/null
+++ b/packages/cli/assets/templates/blocks/components/useDragToSnapPoints/DragToSnapPointsCard.tsx
@@ -0,0 +1,74 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+'use client';
+
+import {useLayoutEffect, useRef, useState} from 'react';
+import {Button} from '@astryxdesign/core/Button';
+import {Card} from '@astryxdesign/core/Card';
+import {useDragToSnapPoints} from '@astryxdesign/core/hooks';
+import {HStack, VStack} from '@astryxdesign/core/Layout';
+import {Text} from '@astryxdesign/core/Text';
+import {VisuallyHidden} from '@astryxdesign/core/VisuallyHidden';
+
+const PLACES = ['Hand', 'Table', 'Discard pile'];
+/** How far along the row each place sits, in px from where the card rests. */
+const STEPS = [0, 110, 220];
+
+export default function DragToSnapPointsCard() {
+ const ref = useRef(null);
+ const [place, setPlace] = useState(0);
+ // The places run in reading order, so leftward in a right-to-left layout.
+ // There the snap points are negative, so they sort the other way round.
+ // The direction is the one the card renders in.
+ const [isRtl, setIsRtl] = useState(false);
+ // Reads the direction the browser computed for the card once it is laid
+ // out.
+ useLayoutEffect(() => {
+ if (ref.current != null) {
+ setIsRtl(getComputedStyle(ref.current).direction === 'rtl');
+ }
+ }, []);
+ const placeAt = (index: number) => (isRtl ? STEPS.length - 1 - index : index);
+ const {settleTo} = useDragToSnapPoints(ref, {
+ axis: 'x',
+ snapPoints: isRtl ? STEPS.map(step => -step) : STEPS,
+ onSettle: index => setPlace(placeAt(index)),
+ });
+
+ return (
+
+
+ Drag the card sideways and let go: it settles on the nearest place, or
+ the next one after a quick flick.
+
+
+
+
+
+ Q
+
+ {'\u2665'}
+
+
+
+
+ {PLACES.map((name, index) => (
+
+
+ {`The card is on the ${PLACES[place].toLowerCase()}.`}
+
+
+ );
+}
diff --git a/packages/cli/foundation/doc-compiler/inputs.test.mjs b/packages/cli/foundation/doc-compiler/inputs.test.mjs
index 56e328dc9960f..3983c29be346e 100644
--- a/packages/cli/foundation/doc-compiler/inputs.test.mjs
+++ b/packages/cli/foundation/doc-compiler/inputs.test.mjs
@@ -98,6 +98,7 @@ describe('collectDocInputs over what this repo ships', () => {
'Resizable',
'SyntaxTheme',
'createAnimator',
+ 'createDragToSnapPoints',
'createPanGesture',
]);
});
diff --git a/packages/core/api-report.md b/packages/core/api-report.md
index 103f34dedce93..8a0dd7137af94 100644
--- a/packages/core/api-report.md
+++ b/packages/core/api-report.md
@@ -2892,6 +2892,7 @@ hook useCommandPaletteContext: () => CommandPaletteContextValue | null
hook useContainerReveal: (options?: UseContainerRevealOptions) => UseContainerRevealReturn
hook useDevWarning: (component: string, message: string, condition?: boolean) => void
hook useDirection: () => 'ltr' | 'rtl'
+hook useDragToSnapPoints: (ref: RefObject, options: DragToSnapPointsOptions) => UseDragToSnapPointsResult
hook useDropdownMenuContext: () => DropdownMenuContextValue | null
hook useEntryAnimation: (preset?: EntryAnimationPreset) => StyleXStyles | null
hook useFocusTrap: (options: UseFocusTrapOptions) => UseFocusTrapReturn
@@ -2994,6 +2995,7 @@ function composeEventHandlers: (...handlers: (((event:
function container: ({…}: ContainerOptions) => readonly [Readonly<{ readonly boxSizing: stylex.StyleXClassNameFor<"boxSizing", "border-box">; readonly paddingInlineStart: stylex.StyleXClassNameFor<"paddingIn … #72b9b91f
function createAnimator: (element: HTMLElement) => Animator
function createCodeLanguage: ({…}: CreateCodeLanguageOptions) => CodeLanguage
+function createDragToSnapPoints: (element: HTMLElement, options: DragToSnapPointsOptions) => DragToSnapPoints
function createISOTimeString: (value: string) => ISOTimeString | null
function createIncrementalState: () => IncrementalState
function createPanGesture: (element: Element, options: PanGestureOptions) => PanGesture
@@ -4212,6 +4214,14 @@ interface DividerVariantMap
strong: true
subtle: true
type DomainTokenName = SyntaxTokenName | DataTokenName
+interface DragToSnapPoints
+ destroy: () => void
+ settleTo: (index: number) => void
+interface DragToSnapPointsOptions
+ axis: 'x' | 'y'
+ flickThreshold?: number
+ onSettle: (index: number) => void
+ snapPoints: ReadonlyArray
interface DrawerHeaderProps extends BaseProps (props of DrawerHeader)
interface DrawerProps extends BaseProps (props of Drawer)
type DropdownMenuButtonProps = Omit
@@ -5915,6 +5925,8 @@ interface UseContainerRevealOptions
interface UseContainerRevealReturn
getContainerProps: (options?: ContainerRevealOptions) => { className?: string; style?: CSSProperties }
getContentRevealProps: (options?: ContentRevealOptions) => { className?: string; style?: CSSProperties }
+interface UseDragToSnapPointsResult
+ settleTo: (index: number) => void
interface UseFocusTrapOptions
isActive: boolean
onEscape?: () => void
@@ -9096,6 +9108,8 @@ AnnouncePoliteness (.)
ClickableContainerResult (.)
ContainerRevealOptions (.)
ContentRevealOptions (.)
+DragToSnapPoints (.)
+DragToSnapPointsOptions (.)
EntryAnimationPreset (.)
Hotkey (.)
INTERACTIVE_SELECTORS (.)
@@ -9130,6 +9144,7 @@ UseClipboardOptions (.)
UseClipboardReturn (.)
UseContainerRevealOptions (.)
UseContainerRevealReturn (.)
+UseDragToSnapPointsResult (.)
UseFocusTrapOptions (.)
UseFocusTrapReturn (.)
UseGridFocusOptions (.)
@@ -9168,6 +9183,7 @@ useClickableContainer (.)
useClipboard (.)
useContainerReveal (.)
useDevWarning (.)
+useDragToSnapPoints (.)
useEntryAnimation (.)
useFocusTrap (.)
useGridFocus (.)
diff --git a/packages/core/src/dragToSnapPoints/__tests__/createDragToSnapPoints.interaction.browser.spec.ts b/packages/core/src/dragToSnapPoints/__tests__/createDragToSnapPoints.interaction.browser.spec.ts
new file mode 100644
index 0000000000000..046076e8fa812
--- /dev/null
+++ b/packages/core/src/dragToSnapPoints/__tests__/createDragToSnapPoints.interaction.browser.spec.ts
@@ -0,0 +1,147 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file createDragToSnapPoints.interaction.browser.spec.ts
+ * @input The public snap-point drag, bundled with esbuild onto a blank page.
+ * Real touches come through the interaction harness's touch driver, in
+ * Chromium and WebKit on the phone profile.
+ * @output Proof of `spec:AST-107` FR2 in a real engine. A slow drag settles
+ * on the nearest snap point, and a quick flick carries to the next one. A
+ * release that moves back returns to the snap point the drag started from.
+ * `settleTo` from a key settles without a drag. Each reports `onSettle`
+ * once, and the element rests on the snap point.
+ * @position Browser proof for dragToSnapPoints/createDragToSnapPoints.ts, run
+ * by `pnpm test:interaction`
+ */
+
+import {fileURLToPath} from 'node:url';
+import {touchDriver, type TouchDriver} from '@astryxdesign/interaction-harness';
+import {expect, test, type Page} from '@astryxdesign/a11y-spec/playwright';
+import {build} from 'esbuild';
+
+let script = '';
+
+test.beforeAll(async () => {
+ const result = await build({
+ entryPoints: [fileURLToPath(new URL('./snapBundle.ts', import.meta.url))],
+ bundle: true,
+ write: false,
+ format: 'iife',
+ globalName: 'PublicSnap',
+ target: 'es2022',
+ });
+ script = result.outputFiles[0].text;
+});
+
+const POINTS = [0, 240, 480];
+
+// A real navigation: touch emulation exposes the Touch Events API to
+// documents it creates.
+async function open(page: Page): Promise {
+ const html = `
+
+
+
+`;
+ await page.route('http://snap.test/', async route =>
+ route.fulfill({contentType: 'text/html', body: html}),
+ );
+ await page.goto('http://snap.test/');
+ return touchDriver(page);
+}
+
+/** A finger from (x, y) moving by `dy` over `steps` steps of `ms`, then lifting. */
+function dragScript(
+ x: number,
+ y: number,
+ moves: ReadonlyArray,
+ ms: number,
+) {
+ const steps = [{t: 0, fingers: [{x, y, id: 1}]}];
+ moves.forEach((dy, i) => {
+ steps.push({t: (i + 1) * ms, fingers: [{x, y: y + dy, id: 1}]});
+ });
+ steps.push({t: (moves.length + 1) * ms, fingers: []});
+ return steps;
+}
+
+/**
+ * The settle indices, read once a settle has fired and the panel has stopped
+ * moving. A second settle while it moved shows as a second index.
+ */
+async function settled(page: Page): Promise {
+ await page.waitForFunction(
+ () => (window as unknown as {__settled: number[]}).__settled.length > 0,
+ );
+ return page.evaluate(async () => {
+ const panel = document.getElementById('panel') as HTMLElement;
+ let last = Number.NaN;
+ for (;;) {
+ await new Promise(resolve => {
+ requestAnimationFrame(() => resolve());
+ });
+ const top = panel.getBoundingClientRect().top;
+ if (top === last) {
+ return (window as unknown as {__settled: number[]}).__settled;
+ }
+ last = top;
+ }
+ });
+}
+
+async function panelTop(page: Page): Promise {
+ return page.evaluate(
+ () =>
+ (document.getElementById('panel') as HTMLElement).getBoundingClientRect()
+ .top,
+ );
+}
+
+const linear = (to: number, count: number) =>
+ Array.from({length: count}, (_, i) => (to * (i + 1)) / count);
+
+test('a slow drag settles on the nearest snap point', async ({page}) => {
+ const driver = await open(page);
+ // 300 px over about a second: slow, nearer 240 than 480.
+ await driver.play(dragScript(120, 100, linear(300, 30), 33));
+ expect(await settled(page)).toEqual([1]);
+ expect(await panelTop(page)).toBeCloseTo(40 + 240, 0);
+});
+
+test('a quick flick carries to the next snap point', async ({page}) => {
+ const driver = await open(page);
+ // 110 px in about 80 ms: past 1008 px/s, yet nearer 0 than 240.
+ await driver.play(dragScript(120, 100, linear(110, 5), 16));
+ expect(await settled(page)).toEqual([1]);
+ expect(await panelTop(page)).toBeCloseTo(40 + 240, 0);
+});
+
+test('a release moving back returns to the snap point the drag started from', async ({
+ page,
+}) => {
+ const driver = await open(page);
+ // Down 300 px, then back up 40 px over the last frames before the lift.
+ const moves = [...linear(300, 20), 290, 280, 270, 260];
+ await driver.play(dragScript(120, 100, moves, 33));
+ expect(await settled(page)).toEqual([0]);
+ expect(await panelTop(page)).toBeCloseTo(40, 0);
+});
+
+test('settleTo settles without a drag, the keyboard path', async ({page}) => {
+ await open(page);
+ await page.evaluate(() =>
+ (window as unknown as {snap: {settleTo(i: number): void}}).snap.settleTo(2),
+ );
+ expect(await settled(page)).toEqual([2]);
+ expect(await panelTop(page)).toBeCloseTo(40 + 480, 0);
+});
diff --git a/packages/core/src/dragToSnapPoints/__tests__/snapBundle.ts b/packages/core/src/dragToSnapPoints/__tests__/snapBundle.ts
new file mode 100644
index 0000000000000..181521cda438f
--- /dev/null
+++ b/packages/core/src/dragToSnapPoints/__tests__/snapBundle.ts
@@ -0,0 +1,11 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file snapBundle.ts
+ * @input The public snap-point drag
+ * @output Re-exports it as one entry point
+ * @position The esbuild entry createDragToSnapPoints.interaction.browser.spec.ts
+ * bundles into its page (global `PublicSnap`)
+ */
+
+export {createDragToSnapPoints} from '../createDragToSnapPoints';
diff --git a/packages/core/src/dragToSnapPoints/createDragToSnapPoints.doc.mjs b/packages/core/src/dragToSnapPoints/createDragToSnapPoints.doc.mjs
new file mode 100644
index 0000000000000..169fc21ea92a1
--- /dev/null
+++ b/packages/core/src/dragToSnapPoints/createDragToSnapPoints.doc.mjs
@@ -0,0 +1,139 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/** @type {import('@astryxdesign/cli/authoring').HookDoc} */
+export const docs = {
+ name: 'createDragToSnapPoints',
+ displayName: 'createDragToSnapPoints',
+ // A factory documented as a hook: listed with the utilities, beside its
+ // hook, not as a tile in the components gallery.
+ group: 'Utilities',
+ keywords: [
+ 'drag',
+ 'snap',
+ 'snap points',
+ 'detents',
+ 'drawer',
+ 'flick',
+ 'swipe',
+ 'gesture',
+ 'vanilla',
+ 'factory',
+ ],
+ params: [
+ {
+ name: 'element',
+ type: 'HTMLElement',
+ description:
+ 'The element a drag moves. It moves along `axis` only, by translation, so lay it out at its resting position as usual.',
+ required: true,
+ },
+ {
+ name: 'options.axis',
+ type: "'x' | 'y'",
+ description: 'The axis the element moves along.',
+ required: true,
+ },
+ {
+ name: 'options.snapPoints',
+ type: 'number[]',
+ description:
+ "Offsets in px along `axis` from the element's resting position, such as `[0, 240, 480]`, sorted before use (`onSettle` and `settleTo` count in that order); an empty list makes the drag do nothing. Unlike BottomSheet's `snapPoints`, they are not fractions of the viewport.",
+ required: true,
+ },
+ {
+ name: 'options.flickThreshold',
+ type: 'number',
+ description:
+ "A release faster than this, in px/s, carries to the next snap point in its direction. The default is the flick speed measured for UIKit's sheet.",
+ default: '1008',
+ required: false,
+ },
+ {
+ name: 'options.onSettle',
+ type: '(index: number) => void',
+ description:
+ "Called with the snap point's index once the element settles on it, after a drag or `settleTo`. A settle that a new drag interrupts calls nothing.",
+ required: true,
+ },
+ ],
+ returns: [
+ {
+ name: 'settleTo',
+ type: '(index: number) => void',
+ description:
+ 'Settles the element on the snap point at `index` without a drag, then calls `onSettle`. Wire it to keys and buttons: it is the keyboard path. An index outside the snap points does nothing and warns in development.',
+ },
+ {
+ name: 'destroy',
+ type: '() => void',
+ description:
+ 'Removes every listener and stops the element where it is. Call it when you remove the element.',
+ },
+ ],
+ usage: {
+ description:
+ "Lets a drag move an element along one axis and settles it on snap points when the finger lifts, the way an iOS sheet settles on its detents: on the nearest snap point to where it is, on the next one in its direction after a quick flick, and back where it started when the finger was moving back. The settle continues the release's velocity. For a bottom sheet, use BottomSheet's `snapPoints`; for a scrolling row, Carousel's `hasSnap`. Use the `useDragToSnapPoints` hook in React.",
+ bestPractices: [
+ {
+ guidance: true,
+ description:
+ 'Offer the same positions without a drag: buttons or keys that call `settleTo`.',
+ },
+ {
+ guidance: true,
+ description:
+ 'Keep the snap points few and far apart, so a release clearly picks one.',
+ },
+ {
+ guidance: false,
+ description:
+ 'Use it for a sheet or a carousel: their own props already settle the way people expect.',
+ },
+ ],
+ accessibility: [
+ {
+ name: 'Keyboard and assistive technology',
+ category: 'Keyboard',
+ description:
+ 'A drag is one way in, never the only one. Give each snap point a control a keyboard and a screen reader can reach, such as Expand and Collapse buttons, and call `settleTo` from it. When a position changes what the element shows, announce the change.',
+ },
+ {
+ name: 'Reduce Motion',
+ category: 'Content',
+ criterion: '2.3.3 Animation from Interactions',
+ description:
+ 'When the person asks for reduced motion, the settle after a touch drag becomes a short cross-fade into the snap point, as does any settle on a device whose primary pointer is coarse, such as a tablet; after a mouse or pen drag elsewhere it lands at once. `settleTo` fades on such a device and lands at once with a fine primary pointer. While the finger is down, the element still follows it.',
+ },
+ ],
+ },
+ relatedComponents: ['BottomSheet', 'Carousel'],
+ relatedHooks: ['useDragToSnapPoints', 'createPanGesture', 'createAnimator'],
+ importPath: '@astryxdesign/core',
+ category: 'interaction',
+};
+
+/** @type {import('@astryxdesign/cli/authoring').HookTranslationDoc} */
+export const docsDense = {
+ description:
+ 'A drag that settles an element on snap points (px offsets) at the release, without React.',
+ paramDescriptions: {
+ element: 'The element a drag moves.',
+ 'options.axis': "'x' or 'y'.",
+ 'options.snapPoints': 'Ascending px offsets from the resting position.',
+ 'options.flickThreshold':
+ 'px/s past which a release carries one snap point; 1008.',
+ 'options.onSettle': '(index) once settled.',
+ },
+ returnDescriptions: {
+ settleTo: '(index) settles without a drag: the keyboard path.',
+ destroy: '() removes every listener.',
+ },
+ usage: {
+ description:
+ 'Nearest snap point, the next on a flick, the start when moving back; the settle keeps the release velocity. Sheets and carousels: use their props.',
+ bestPractices: [
+ {guidance: true, description: 'Wire settleTo to buttons and keys.'},
+ {guidance: false, description: 'Rebuild a sheet or carousel with it.'},
+ ],
+ },
+};
diff --git a/packages/core/src/dragToSnapPoints/createDragToSnapPoints.finePointer.test.ts b/packages/core/src/dragToSnapPoints/createDragToSnapPoints.finePointer.test.ts
new file mode 100644
index 0000000000000..1e99f79630738
--- /dev/null
+++ b/packages/core/src/dragToSnapPoints/createDragToSnapPoints.finePointer.test.ts
@@ -0,0 +1,115 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file createDragToSnapPoints.finePointer.test.ts
+ * @input vitest (jsdom, fake clock) and a recording stand-in for
+ * Element.animate. Mouse and pen pointer events drive the factory through
+ * the document's gesture host, where the primary pointer is fine.
+ * @output Checks `spec:AST-094` FR43 for the snap-point drag. With a fine
+ * primary pointer, a mouse or pen drag moves the element and settles it on
+ * a snap point, as a finger's does.
+ * @position Testing; validates createDragToSnapPoints.ts through the host. Its
+ * own file, so the document's host is created where the pointer is fine.
+ */
+
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
+import {createDragToSnapPoints} from './createDragToSnapPoints';
+
+const POINTS = [0, 240, 480];
+
+beforeEach(() => {
+ vi.useFakeTimers({
+ toFake: [
+ 'setTimeout',
+ 'clearTimeout',
+ 'performance',
+ 'requestAnimationFrame',
+ 'cancelAnimationFrame',
+ ],
+ });
+ HTMLElement.prototype.animate = function () {
+ let start: number | null = null;
+ const animation = {
+ get startTime() {
+ return start;
+ },
+ set startTime(value: number | null) {
+ start = value;
+ },
+ playState: 'running',
+ ready: new Promise(() => {}),
+ finish() {},
+ cancel() {},
+ };
+ requestAnimationFrame(() => {
+ start ??= performance.now();
+ });
+ return animation as unknown as Animation;
+ };
+});
+
+afterEach(() => {
+ Reflect.deleteProperty(HTMLElement.prototype, 'animate');
+ vi.useRealTimers();
+ document.body.replaceChildren();
+});
+
+// The document's host outlives a test, so event times only move forward.
+let clock = 1_000_000;
+
+/** A `pointerType` drag down by `dy` in `steps` steps of `ms`, then a release. */
+function dragY(
+ target: Element,
+ pointerType: 'mouse' | 'pen',
+ dy: number,
+ steps = 4,
+ ms = 16,
+): void {
+ clock += 10_000;
+ const start = clock;
+ const send = (type: string, y: number, time: number, buttons: number) => {
+ const event = new PointerEvent(type, {
+ bubbles: true,
+ cancelable: true,
+ composed: true,
+ pointerId: 1,
+ pointerType,
+ isPrimary: true,
+ clientX: 10,
+ clientY: y,
+ button: 0,
+ buttons,
+ });
+ Object.defineProperty(event, 'timeStamp', {value: time});
+ target.dispatchEvent(event);
+ };
+ send('pointerdown', 10, start, 1);
+ for (let step = 1; step <= steps; step++) {
+ send('pointermove', 10 + (dy * step) / steps, start + step * ms, 1);
+ }
+ send('pointerup', 10 + dy, start + steps * ms + 4, 0);
+}
+
+describe('where the primary pointer is fine (spec:AST-094 FR43)', () => {
+ it('a mouse or pen drag moves the element and settles it on the nearest snap point', async () => {
+ expect(window.matchMedia?.('(pointer: coarse)').matches ?? false).toBe(
+ false,
+ );
+ for (const pointerType of ['mouse', 'pen'] as const) {
+ const element = document.createElement('div');
+ document.body.append(element);
+ const onSettle = vi.fn();
+ const snap = createDragToSnapPoints(element, {
+ axis: 'y',
+ snapPoints: POINTS,
+ onSettle,
+ });
+ // 300 px over a second: slow, nearer 240 than 480.
+ dragY(element, pointerType, 300, 20, 50);
+ await vi.advanceTimersByTimeAsync(3000);
+ expect(onSettle, pointerType).toHaveBeenCalledTimes(1);
+ expect(onSettle, pointerType).toHaveBeenCalledWith(1);
+ snap.destroy();
+ }
+ });
+});
diff --git a/packages/core/src/dragToSnapPoints/createDragToSnapPoints.reducedMotion.test.ts b/packages/core/src/dragToSnapPoints/createDragToSnapPoints.reducedMotion.test.ts
new file mode 100644
index 0000000000000..8d8bcb32ee88b
--- /dev/null
+++ b/packages/core/src/dragToSnapPoints/createDragToSnapPoints.reducedMotion.test.ts
@@ -0,0 +1,172 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file createDragToSnapPoints.reducedMotion.test.ts
+ * @input vitest (jsdom, fake clock) and a recording stand-in for
+ * Element.animate. A stand-in for the public pan hands its events to the
+ * test, with Reduce Motion on and a fine primary pointer.
+ * @output Checks `design:reduced-motion` DR2 and DR3 for the snap-point
+ * drag's release. A touch drag's release cross-fades into the snap point.
+ * A mouse or pen drag's release lands at once, and so does `settleTo` on a
+ * fine pointer.
+ * @position Testing; validates createDragToSnapPoints.ts's release plan
+ */
+
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
+import type {
+ PanGestureEvent,
+ PanGestureOptions,
+} from '../gestures/createPanGesture';
+
+let onPan: ((event: PanGestureEvent) => void) | null = null;
+
+vi.mock('../gestures/createPanGesture', () => ({
+ createPanGesture: (_element: Element, options: PanGestureOptions) => {
+ onPan = options.onPan;
+ return {destroy: () => {}};
+ },
+}));
+
+const {createDragToSnapPoints} = await import('./createDragToSnapPoints');
+
+interface Recorded {
+ keyframes: Keyframe[];
+}
+
+const recorded: Recorded[] = [];
+const originalMatchMedia = window.matchMedia;
+
+beforeEach(() => {
+ vi.useFakeTimers({
+ toFake: [
+ 'setTimeout',
+ 'clearTimeout',
+ 'performance',
+ 'requestAnimationFrame',
+ 'cancelAnimationFrame',
+ ],
+ });
+ // Reduce Motion on, with a fine primary pointer.
+ window.matchMedia = (query: string) =>
+ ({
+ matches: query.includes('prefers-reduced-motion: reduce'),
+ media: query,
+ onchange: null,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ addListener: () => {},
+ removeListener: () => {},
+ dispatchEvent: () => false,
+ }) as MediaQueryList;
+ HTMLElement.prototype.animate = function (keyframes: Keyframe[]) {
+ let start: number | null = null;
+ const animation = {
+ get startTime() {
+ return start;
+ },
+ set startTime(value: number | null) {
+ start = value;
+ },
+ playState: 'running',
+ ready: new Promise(() => {}),
+ finish() {},
+ cancel() {},
+ };
+ requestAnimationFrame(() => {
+ start ??= performance.now();
+ });
+ recorded.push({keyframes: Array.isArray(keyframes) ? keyframes : []});
+ return animation as unknown as Animation;
+ };
+});
+
+afterEach(() => {
+ window.matchMedia = originalMatchMedia;
+ Reflect.deleteProperty(HTMLElement.prototype, 'animate');
+ vi.useRealTimers();
+ recorded.length = 0;
+ onPan = null;
+ document.body.replaceChildren();
+});
+
+function drawer() {
+ const element = document.createElement('div');
+ document.body.append(element);
+ const onSettle = vi.fn();
+ const snap = createDragToSnapPoints(element, {
+ axis: 'y',
+ snapPoints: [0, 240, 480],
+ onSettle,
+ });
+ return {element, onSettle, snap};
+}
+
+/**
+ * A press by `pointerType` on `element`, then a drag down by `dy` released
+ * at `vy` px/s. The pan reports values only, so the factory reads the
+ * pointer from the press.
+ */
+function release(
+ element: HTMLElement,
+ dy: number,
+ vy: number,
+ pointerType: 'touch' | 'mouse' | 'pen',
+): void {
+ element.dispatchEvent(
+ new PointerEvent('pointerdown', {bubbles: true, pointerType}),
+ );
+ const at = (state: PanGestureEvent['state'], y: number, v: number) =>
+ onPan?.({
+ state,
+ translation: {x: 0, y},
+ velocity: {x: 0, y: v},
+ });
+ at('began', 0, 0);
+ at('changed', dy, 0);
+ at('ended', dy, vy);
+}
+
+const fades = () =>
+ recorded.some(({keyframes}) =>
+ keyframes.some(frame => frame.opacity !== undefined),
+ );
+
+/** Whether a recorded animation moves the element (FR2: no travel). */
+const travels = () =>
+ recorded.some(({keyframes}) => {
+ const values = keyframes
+ .map(frame => frame.translate)
+ .filter(value => value !== undefined);
+ return new Set(values).size > 1;
+ });
+
+describe('the release under Reduce Motion on a fine pointer (DR2, DR3)', () => {
+ it('a touch drag\u2019s release cross-fades into the snap point', async () => {
+ const {element, onSettle} = drawer();
+ release(element, 300, 0, 'touch');
+ expect(fades()).toBe(true);
+ // It goes to its snap point without travel.
+ expect(travels()).toBe(false);
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(onSettle).toHaveBeenCalledWith(1);
+ });
+
+ it.each(['mouse', 'pen'] as const)(
+ 'a %s drag\u2019s release lands at once',
+ async pointerType => {
+ const {element, onSettle} = drawer();
+ release(element, 300, 0, pointerType);
+ expect(fades()).toBe(false);
+ await vi.advanceTimersByTimeAsync(0);
+ expect(onSettle).toHaveBeenCalledWith(1);
+ },
+ );
+
+ it('settleTo lands at once', async () => {
+ const {onSettle, snap} = drawer();
+ snap.settleTo(2);
+ expect(fades()).toBe(false);
+ await vi.advanceTimersByTimeAsync(0);
+ expect(onSettle).toHaveBeenCalledWith(2);
+ });
+});
diff --git a/packages/core/src/dragToSnapPoints/createDragToSnapPoints.server.test.ts b/packages/core/src/dragToSnapPoints/createDragToSnapPoints.server.test.ts
new file mode 100644
index 0000000000000..8c873deb0da9e
--- /dev/null
+++ b/packages/core/src/dragToSnapPoints/createDragToSnapPoints.server.test.ts
@@ -0,0 +1,49 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file createDragToSnapPoints.server.test.ts
+ * @input esbuild, a fresh Node process with no window or document
+ * @output Checks `spec:AST-107` FR6: importing the snap-point factory and
+ * its hook does no DOM work, so both are safe on the server
+ * @position Testing; validates createDragToSnapPoints.ts and
+ * hooks/useDragToSnapPoints.ts
+ */
+
+import {execFileSync} from 'node:child_process';
+import path from 'node:path';
+import {describe, expect, it} from 'vitest';
+
+/** Bundles `entry` and imports it in a fresh Node process; prints its export names. */
+function importOnServer(entry: string): string[] {
+ const out = execFileSync(
+ process.execPath,
+ [
+ '--input-type=module',
+ '-e',
+ [
+ "import {build} from 'esbuild';",
+ `const result = await build({entryPoints: [${JSON.stringify(entry)}], bundle: true, write: false, format: 'esm', platform: 'node', logLevel: 'silent'});`,
+ "const source = Buffer.from(result.outputFiles[0].text).toString('base64');",
+ "if (typeof window !== 'undefined' || typeof document !== 'undefined') throw new Error('not a server');",
+ "const mod = await import('data:text/javascript;base64,' + source);",
+ 'process.stdout.write(JSON.stringify(Object.keys(mod).sort()));',
+ ].join('\n'),
+ ],
+ {cwd: __dirname, encoding: 'utf8'},
+ );
+ return JSON.parse(out) as string[];
+}
+
+describe('the snap-point drag on the server', () => {
+ it('the factory imports with no window or document', () => {
+ expect(
+ importOnServer(path.join(__dirname, 'createDragToSnapPoints.ts')),
+ ).toContain('createDragToSnapPoints');
+ });
+
+ it('the hook imports with no window or document', () => {
+ expect(
+ importOnServer(path.join(__dirname, '../hooks/useDragToSnapPoints.ts')),
+ ).toContain('useDragToSnapPoints');
+ });
+});
diff --git a/packages/core/src/dragToSnapPoints/createDragToSnapPoints.test.ts b/packages/core/src/dragToSnapPoints/createDragToSnapPoints.test.ts
new file mode 100644
index 0000000000000..0b211c3866b60
--- /dev/null
+++ b/packages/core/src/dragToSnapPoints/createDragToSnapPoints.test.ts
@@ -0,0 +1,517 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file createDragToSnapPoints.test.ts
+ * @input vitest (jsdom, fake clock), the release rule and a recording
+ * stand-in for Element.animate. Touches drive the factory through the
+ * document's gesture host.
+ * @output Checks `spec:AST-107` FR2 and FR6 for the factory. A slow release
+ * settles on the nearest snap point. A flick past `flickThreshold`, or the
+ * default 1008 px/s, settles on the next one. A release that moves back
+ * settles on the starting one, however far the drag went. Also covered:
+ * `settleTo` from a key, `onSettle` only after a settle that arrives, bad
+ * snap points refused and no listener left after `destroy`.
+ * @position Testing; validates createDragToSnapPoints.ts
+ */
+
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
+import {coarsePrimaryPointer} from '../gestures/__tests__/harness';
+import type * as AnimatorModule from '../motion/animator';
+import {resolveSnapPoint} from '../motion/resolveSnapPoint';
+import {springs} from '../motion/uikit/springs';
+import {
+ createDragToSnapPoints,
+ DEFAULT_FLICK_THRESHOLD,
+} from './createDragToSnapPoints';
+
+// Every animate call the factory's animator makes, as the factory made it.
+const animates = vi.hoisted(
+ () =>
+ [] as {target: unknown; options: {spring?: unknown; velocity?: unknown}}[],
+);
+vi.mock('../motion/animator', async importOriginal => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ createMotionEngine: (
+ ...args: Parameters
+ ) => {
+ const animator = actual.createMotionEngine(...args);
+ const animate = animator.animate.bind(animator);
+ animator.animate = (target, options, ...rest) => {
+ animates.push({target, options: {...options}});
+ return animate(target, options, ...rest);
+ };
+ return animator;
+ },
+ };
+});
+
+const POINTS = [0, 240, 480];
+
+// The factory settles where Core's one release policy says, called with
+// the drag's start: these are FR2's and FR37's cases through that call.
+describe('the release policy the factory calls (FR2, AST-094 FR37)', () => {
+ const at = (offset: number, velocity: number, startIndex = 0) =>
+ resolveSnapPoint({
+ points: POINTS,
+ position: offset,
+ velocity,
+ flickThreshold: DEFAULT_FLICK_THRESHOLD,
+ start: POINTS[startIndex],
+ }).index;
+
+ it('a slow release settles on the nearest snap point', () => {
+ expect(at(100, 300)).toBe(0);
+ expect(at(130, 300)).toBe(1);
+ expect(at(400, 0, 1)).toBe(2);
+ });
+
+ it('a release past the default flick speed carries to the next snap point in its direction', () => {
+ expect(DEFAULT_FLICK_THRESHOLD).toBe(1008);
+ expect(at(30, 1009)).toBe(1);
+ expect(at(30, 1007)).toBe(0);
+ expect(at(260, -1500, 2)).toBe(1);
+ });
+
+ it('flickThreshold changes the speed that carries', () => {
+ expect(
+ resolveSnapPoint({
+ points: POINTS,
+ position: 30,
+ velocity: 600,
+ flickThreshold: 500,
+ start: 0,
+ }).index,
+ ).toBe(1);
+ });
+
+ it('a release moving back toward the start returns to the snap point it started from, however far it went', () => {
+ expect(at(200, -50)).toBe(0);
+ expect(at(470, -3000)).toBe(0);
+ expect(at(10, 2000, 2)).toBe(2);
+ });
+
+ it('a flick past the last snap point stays on the last', () => {
+ expect(at(500, 2000, 1)).toBe(2);
+ expect(at(-20, -2000, 1)).toBe(0);
+ });
+});
+
+interface Recorded {
+ startTime: number | null;
+ cancelled: boolean;
+ readonly playState: string;
+ ready: Promise;
+ finish(): void;
+ cancel(): void;
+}
+
+let restorePointer: () => void = () => {};
+const recorded: Recorded[] = [];
+
+beforeEach(() => {
+ vi.useFakeTimers({
+ toFake: [
+ 'setTimeout',
+ 'clearTimeout',
+ 'performance',
+ 'requestAnimationFrame',
+ 'cancelAnimationFrame',
+ ],
+ });
+ restorePointer = coarsePrimaryPointer();
+ HTMLElement.prototype.animate = function () {
+ let start: number | null = null;
+ const animation: Recorded = {
+ get startTime() {
+ return start;
+ },
+ set startTime(value) {
+ start = value;
+ },
+ cancelled: false,
+ get playState() {
+ return this.cancelled ? 'idle' : 'running';
+ },
+ ready: new Promise(() => {}),
+ finish() {},
+ cancel() {
+ this.cancelled = true;
+ },
+ };
+ requestAnimationFrame(() => {
+ if (start == null) {
+ start = performance.now();
+ }
+ });
+ recorded.push(animation);
+ return animation as unknown as Animation;
+ };
+});
+
+afterEach(() => {
+ restorePointer();
+ Reflect.deleteProperty(HTMLElement.prototype, 'animate');
+ vi.useRealTimers();
+ document.body.replaceChildren();
+});
+
+function touch(
+ type: string,
+ target: EventTarget,
+ contact: {x: number; y: number},
+ time: number,
+ isLift = false,
+): void {
+ const event = new Event(type, {bubbles: true, cancelable: true});
+ const entry = {identifier: 1, clientX: contact.x, clientY: contact.y, target};
+ Object.defineProperty(event, 'changedTouches', {value: [entry]});
+ Object.defineProperty(event, 'touches', {value: isLift ? [] : [entry]});
+ Object.defineProperty(event, 'timeStamp', {value: time});
+ target.dispatchEvent(event);
+}
+
+// The document's host outlives a test, so event times only move forward.
+let clock = 1_000_000;
+
+/** A finger dragging down by `dy` in `steps` steps of `ms`, then lifting. */
+function dragY(target: Element, dy: number, steps = 4, ms = 16): void {
+ clock += 10_000;
+ const start = clock;
+ touch('touchstart', target, {x: 10, y: 10}, start);
+ for (let step = 1; step <= steps; step++) {
+ touch(
+ 'touchmove',
+ target,
+ {x: 10, y: 10 + (dy * step) / steps},
+ start + step * ms,
+ );
+ }
+ touch('touchend', target, {x: 10, y: 10 + dy}, start + steps * ms + 4, true);
+}
+
+function sheet(): HTMLElement {
+ const element = document.createElement('div');
+ document.body.append(element);
+ return element;
+}
+
+describe('createDragToSnapPoints', () => {
+ it('a slow drag settles on the nearest snap point and reports it once settled', async () => {
+ const element = sheet();
+ const onSettle = vi.fn();
+ const snap = createDragToSnapPoints(element, {
+ axis: 'y',
+ snapPoints: POINTS,
+ onSettle,
+ });
+ // 300 px over a second: slow, nearer 240 than 480.
+ dragY(element, 300, 20, 50);
+ expect(onSettle).not.toHaveBeenCalled();
+ await vi.advanceTimersByTimeAsync(3000);
+ expect(onSettle).toHaveBeenCalledTimes(1);
+ expect(onSettle).toHaveBeenCalledWith(1);
+ snap.destroy();
+ });
+
+ it('a quick flick carries to the next snap point', async () => {
+ const element = sheet();
+ const onSettle = vi.fn();
+ const snap = createDragToSnapPoints(element, {
+ axis: 'y',
+ snapPoints: POINTS,
+ onSettle,
+ });
+ // 100 px in 64 ms: about 1500 px/s, past 1008, yet nearer 0 than 240.
+ dragY(element, 100);
+ await vi.advanceTimersByTimeAsync(3000);
+ expect(onSettle).toHaveBeenCalledWith(1);
+ snap.destroy();
+ });
+
+ it('flickThreshold sets the speed that carries a drag to the next snap point', async () => {
+ // The same quick, short drag down from 0, released under the default
+ // flick speed and past 600 px/s.
+ const settle = async (flickThreshold?: number) => {
+ const element = sheet();
+ const onSettle = vi.fn();
+ const snap = createDragToSnapPoints(element, {
+ axis: 'y',
+ snapPoints: POINTS,
+ ...(flickThreshold == null ? {} : {flickThreshold}),
+ onSettle,
+ });
+ animates.length = 0;
+ dragY(element, 30, 3, 12);
+ const release = (animates[0]?.options.velocity as {y: number}).y;
+ await vi.advanceTimersByTimeAsync(3000);
+ snap.destroy();
+ element.remove();
+ return {release, settled: onSettle.mock.calls.at(-1)?.[0] as unknown};
+ };
+ const byDefault = await settle();
+ const at600 = await settle(600);
+ expect(byDefault.release).toBeGreaterThan(600);
+ expect(byDefault.release).toBeLessThan(DEFAULT_FLICK_THRESHOLD);
+ expect(at600.release).toBeCloseTo(byDefault.release, 6);
+ // Nearer 0 and under 1008 px/s: back to 0. Past 600: on to 240.
+ expect(byDefault.settled).toBe(0);
+ expect(at600.settled).toBe(1);
+ });
+
+ it('the settle starts at the release velocity, a commit and a spring-back alike (spec:AST-094 FR38)', async () => {
+ const element = sheet();
+ const snap = createDragToSnapPoints(element, {
+ axis: 'y',
+ snapPoints: POINTS,
+ onSettle: () => {},
+ });
+ // A flick down commits to the next snap point.
+ animates.length = 0;
+ dragY(element, 100);
+ expect(animates).toHaveLength(1);
+ const commit = animates[0];
+ expect(commit.target).toEqual({y: 240});
+ expect(commit.options.spring).toBe(springs.settle);
+ const committed = (commit.options.velocity as {y: number}).y;
+ expect(committed).toBeGreaterThan(DEFAULT_FLICK_THRESHOLD);
+ await vi.advanceTimersByTimeAsync(3000);
+ // From 240, down 120 px and back up 60 quickly: the spring-back to 240
+ // starts at that upward speed.
+ animates.length = 0;
+ clock += 10_000;
+ const start = clock;
+ touch('touchstart', element, {x: 10, y: 10}, start);
+ for (let step = 1; step <= 6; step++) {
+ touch(
+ 'touchmove',
+ element,
+ {x: 10, y: 10 + 20 * step},
+ start + step * 50,
+ );
+ }
+ touch('touchmove', element, {x: 10, y: 90}, start + 316);
+ touch('touchmove', element, {x: 10, y: 70}, start + 332);
+ touch('touchend', element, {x: 10, y: 70}, start + 336, true);
+ expect(animates).toHaveLength(1);
+ const back = animates[0];
+ expect(back.target).toEqual({y: 240});
+ expect(back.options.spring).toBe(springs.settle);
+ expect((back.options.velocity as {y: number}).y).toBeLessThan(0);
+ snap.destroy();
+ });
+
+ it('settleTo settles without a drag, the keyboard path', async () => {
+ const element = sheet();
+ const onSettle = vi.fn();
+ const snap = createDragToSnapPoints(element, {
+ axis: 'y',
+ snapPoints: POINTS,
+ onSettle,
+ });
+ snap.settleTo(2);
+ await vi.advanceTimersByTimeAsync(3000);
+ expect(onSettle).toHaveBeenCalledWith(2);
+ snap.destroy();
+ });
+
+ it('a settle interrupted by another reports only the last', async () => {
+ const element = sheet();
+ const onSettle = vi.fn();
+ const snap = createDragToSnapPoints(element, {
+ axis: 'y',
+ snapPoints: POINTS,
+ onSettle,
+ });
+ snap.settleTo(2);
+ await vi.advanceTimersByTimeAsync(50);
+ snap.settleTo(1);
+ await vi.advanceTimersByTimeAsync(3000);
+ expect(onSettle.mock.calls).toEqual([[1]]);
+ snap.destroy();
+ });
+
+ it('sorts the snap points before use', async () => {
+ const element = sheet();
+ const onSettle = vi.fn();
+ const snap = createDragToSnapPoints(element, {
+ axis: 'y',
+ snapPoints: [480, 0, 240],
+ onSettle,
+ });
+ // 300 px over about a second: slow, nearer 240, the second snap point.
+ dragY(element, 300, 20, 50);
+ await vi.advanceTimersByTimeAsync(3000);
+ expect(onSettle).toHaveBeenCalledWith(1);
+ snap.destroy();
+ });
+
+ it('an empty list does nothing and warns in development', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const element = sheet();
+ const onSettle = vi.fn();
+ const snap = createDragToSnapPoints(element, {
+ axis: 'y',
+ snapPoints: [],
+ onSettle,
+ });
+ expect(warn).toHaveBeenCalledTimes(1);
+ dragY(element, 300, 20, 50);
+ snap.settleTo(0);
+ await vi.advanceTimersByTimeAsync(3000);
+ expect(onSettle).not.toHaveBeenCalled();
+ expect(element.style.translate).toBe('');
+ snap.destroy();
+ warn.mockRestore();
+ });
+
+ it('settleTo with an index outside the snap points does nothing and warns in development', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const element = sheet();
+ const onSettle = vi.fn();
+ const snap = createDragToSnapPoints(element, {
+ axis: 'y',
+ snapPoints: POINTS,
+ onSettle,
+ });
+ snap.settleTo(9);
+ snap.settleTo(-1);
+ await vi.advanceTimersByTimeAsync(3000);
+ expect(onSettle).not.toHaveBeenCalled();
+ expect(warn).toHaveBeenCalledTimes(2);
+ snap.destroy();
+ warn.mockRestore();
+ });
+
+ it('a drag created again starts where its predecessor left the element (spec:AST-107 FR2)', async () => {
+ const element = sheet();
+ const first = createDragToSnapPoints(element, {
+ axis: 'y',
+ snapPoints: POINTS,
+ onSettle: () => {},
+ });
+ first.settleTo(2);
+ await vi.advanceTimersByTimeAsync(3000);
+ first.destroy();
+ // Created again with new snap points, since the handle has no update. A
+ // slow 30 px drag moves on from 480, so it settles back on 480, not 0.
+ const onSettle = vi.fn();
+ const again = createDragToSnapPoints(element, {
+ axis: 'y',
+ snapPoints: [0, 240, 480, 720],
+ onSettle,
+ });
+ animates.length = 0;
+ dragY(element, 30, 10, 60);
+ expect(animates.at(-1)?.target).toEqual({y: 480});
+ await vi.advanceTimersByTimeAsync(3000);
+ expect(onSettle).toHaveBeenCalledWith(2);
+ again.destroy();
+ });
+
+ it('destroyed during a settle, the element stays where it was, not where the settle began (spec:AST-107 FR6)', async () => {
+ const element = sheet();
+ const snap = createDragToSnapPoints(element, {
+ axis: 'y',
+ snapPoints: POINTS,
+ onSettle: () => {},
+ });
+ snap.settleTo(2);
+ await vi.advanceTimersByTimeAsync(150);
+ snap.destroy();
+ const [, y = '0'] = element.style.translate.split(' ');
+ const at = Number.parseFloat(y);
+ // Partway to 480, held there: neither back at 0 nor jumped to 480.
+ expect(at).toBeGreaterThan(0);
+ expect(at).toBeLessThan(480);
+ });
+
+ it('destroyed mid-drag, the finger moves nothing and nothing reports (spec:AST-107 FR6)', async () => {
+ const element = sheet();
+ const onSettle = vi.fn();
+ const snap = createDragToSnapPoints(element, {
+ axis: 'y',
+ snapPoints: POINTS,
+ onSettle,
+ });
+ clock += 10_000;
+ const start = clock;
+ touch('touchstart', element, {x: 10, y: 10}, start);
+ for (let step = 1; step <= 4; step++) {
+ touch(
+ 'touchmove',
+ element,
+ {x: 10, y: 10 + step * 20},
+ start + step * 16,
+ );
+ }
+ const during = element.style.translate;
+ snap.destroy();
+ for (let step = 5; step <= 8; step++) {
+ touch(
+ 'touchmove',
+ element,
+ {x: 10, y: 10 + step * 20},
+ start + step * 16,
+ );
+ }
+ touch('touchend', element, {x: 10, y: 170}, start + 8 * 16 + 4, true);
+ await vi.advanceTimersByTimeAsync(3000);
+ expect(element.style.translate).toBe(during);
+ expect(onSettle).not.toHaveBeenCalled();
+ });
+
+ it('destroy removes every listener it added (spec:AST-107 FR6)', () => {
+ const added = new Map();
+ const key = (target: EventTarget, type: string, capture: boolean) =>
+ `${target === document ? 'document' : target === window ? 'window' : 'element'}:${type}:${capture}`;
+ const captureOf = (options?: boolean | AddEventListenerOptions) =>
+ typeof options === 'boolean' ? options : (options?.capture ?? false);
+ const add = vi.spyOn(EventTarget.prototype, 'addEventListener');
+ const remove = vi.spyOn(EventTarget.prototype, 'removeEventListener');
+ const element = sheet();
+ const snap = createDragToSnapPoints(element, {
+ axis: 'y',
+ snapPoints: POINTS,
+ onSettle: () => {},
+ });
+ for (const call of add.mock.calls) {
+ const k = key(
+ add.mock.contexts.at(add.mock.calls.indexOf(call)) as EventTarget,
+ call[0],
+ captureOf(call[2]),
+ );
+ added.set(k, (added.get(k) ?? 0) + 1);
+ }
+ expect(added.size).toBeGreaterThan(0);
+ snap.destroy();
+ for (const [index, call] of remove.mock.calls.entries()) {
+ const k = key(
+ remove.mock.contexts[index] as EventTarget,
+ call[0],
+ captureOf(call[2]),
+ );
+ added.set(k, (added.get(k) ?? 0) - 1);
+ }
+ expect([...added].filter(([, count]) => count !== 0)).toEqual([]);
+ add.mockRestore();
+ remove.mockRestore();
+ });
+
+ it('after destroy no drag moves the element and nothing reports', async () => {
+ const element = sheet();
+ const onSettle = vi.fn();
+ const snap = createDragToSnapPoints(element, {
+ axis: 'y',
+ snapPoints: POINTS,
+ onSettle,
+ });
+ snap.destroy();
+ dragY(element, 300, 20, 50);
+ snap.settleTo(2);
+ await vi.advanceTimersByTimeAsync(3000);
+ expect(onSettle).not.toHaveBeenCalled();
+ expect(element.style.translate).toBe('');
+ });
+});
diff --git a/packages/core/src/dragToSnapPoints/createDragToSnapPoints.ts b/packages/core/src/dragToSnapPoints/createDragToSnapPoints.ts
new file mode 100644
index 0000000000000..28fc457e03f8f
--- /dev/null
+++ b/packages/core/src/dragToSnapPoints/createDragToSnapPoints.ts
@@ -0,0 +1,264 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+'use client';
+
+/**
+ * @file createDragToSnapPoints.ts
+ * @input An element; the public pan (gestures/createPanGesture.ts) and the
+ * motion animator with the `springs.settle` role
+ * @output The public snap-point drag: `createDragToSnapPoints`, its options
+ * and handle
+ * @position Public entry point, re-exported from the package root, and
+ * wrapped by `useDragToSnapPoints`. Importing it does no DOM work.
+ *
+ * SYNC: When modified, update:
+ * - /packages/core/src/dragToSnapPoints/createDragToSnapPoints.test.ts
+ * - /packages/core/src/dragToSnapPoints/createDragToSnapPoints.doc.mjs
+ * - /packages/core/src/hooks/useDragToSnapPoints.ts
+ */
+
+import {
+ createPanGesture,
+ type PanGestureEvent,
+} from '../gestures/createPanGesture';
+import {createMotionEngine} from '../motion/animator';
+import {resolveSnapPoint} from '../motion/resolveSnapPoint';
+import {devWarn} from '../utils/devWarning';
+import {registerSpringsTable, springs} from '../motion/uikit/springs';
+
+/**
+ * The release speed, in px/s, above which a drag carries to the next snap
+ * point: the flick speed measured for UIKit's sheet on iOS 26.2.
+ */
+export const DEFAULT_FLICK_THRESHOLD = 1008;
+
+/** Options of `createDragToSnapPoints`. */
+export interface DragToSnapPointsOptions {
+ /** The axis the element moves along. */
+ axis: 'x' | 'y';
+ /**
+ * Offsets in px along `axis` from the element's resting position, sorted
+ * before use (`onSettle` and `settleTo` count in that order). Not viewport
+ * fractions, unlike BottomSheet's `snapPoints`. An empty list makes the
+ * drag do nothing and warns in development.
+ */
+ snapPoints: ReadonlyArray;
+ /**
+ * A release faster than this, in px/s, carries to the next snap point in
+ * its direction. Default 1008.
+ */
+ flickThreshold?: number;
+ /** Called with the snap point's index once the element settles on it. */
+ onSettle: (index: number) => void;
+}
+
+/** What `createDragToSnapPoints` returns. */
+export interface DragToSnapPoints {
+ /**
+ * Settles the element on the snap point at `index` without a drag (the
+ * keyboard path), then calls `onSettle`. An index outside the snap points
+ * does nothing and warns in development.
+ */
+ settleTo(index: number): void;
+ /** Removes every listener and stops the element where it is. */
+ destroy(): void;
+}
+
+/**
+ * Where `element` is drawn now, read from the inline `translate` an animator
+ * left on it. A drag destroyed and created again leaves it where it stopped.
+ * An axis it does not set reads 0.
+ */
+function drawnAt(element: HTMLElement): {x: number; y: number} {
+ const [x = 0, y = 0] = element.style
+ .getPropertyValue('translate')
+ .trim()
+ .split(/\s+/)
+ .map(part => Number.parseFloat(part))
+ .map(value => (Number.isFinite(value) ? value : 0));
+ return {x, y};
+}
+
+/** Drops a point that is not a finite number, with a dev warning. */
+function sortedSnapPoints(snapPoints: ReadonlyArray): number[] {
+ const usable = snapPoints.filter(point => Number.isFinite(point));
+ if (usable.length < snapPoints.length) {
+ devWarn(
+ 'createDragToSnapPoints',
+ 'snapPoints must be numbers; the others are ignored.',
+ );
+ }
+ return usable.sort((a, b) => a - b);
+}
+
+/**
+ * Lets a drag move `element` along `axis` and settles it on `snapPoints` at
+ * the release, then calls `onSettle(index)`.
+ *
+ * The release settles on the nearest snap point. A release faster than
+ * `flickThreshold` carries to the next one in its direction. A release that
+ * moves back toward the drag's start returns to the snap point it started
+ * from. The settle springs from the release velocity. Under Reduce Motion,
+ * the release cross-fades after a touch drag or on a coarse primary pointer,
+ * and lands at once elsewhere. `settleTo(index)` settles without a drag, for
+ * the keyboard.
+ *
+ * @example
+ * ```
+ * const snap = createDragToSnapPoints(drawer, {
+ * axis: 'y',
+ * snapPoints: [0, 240, 480],
+ * onSettle: index => console.log('settled on', index),
+ * });
+ * button.addEventListener('click', () => snap.settleTo(0));
+ * // Later:
+ * snap.destroy();
+ * ```
+ */
+export function createDragToSnapPoints(
+ element: HTMLElement,
+ options: DragToSnapPointsOptions,
+): DragToSnapPoints {
+ const {axis, onSettle} = options;
+ const flickThreshold = options.flickThreshold ?? DEFAULT_FLICK_THRESHOLD;
+ const points = sortedSnapPoints(options.snapPoints);
+ if (points.length === 0) {
+ devWarn(
+ 'createDragToSnapPoints',
+ 'snapPoints is empty, so the drag does nothing.',
+ );
+ return {settleTo() {}, destroy() {}};
+ }
+ // It starts where the element is drawn, so a drag created again to
+ // change its options moves on from there instead of jumping.
+ const drawn = drawnAt(element);
+ const animator = createMotionEngine(element, {initial: drawn});
+ let targetIndex = resolveSnapPoint({
+ points,
+ position: drawn[axis],
+ velocity: 0,
+ flickThreshold,
+ }).index;
+ // Each settle and each drag takes a turn; only the latest settle reports.
+ let turn = 0;
+ let drag: {
+ startOffset: number;
+ startIndex: number;
+ pointerType: string;
+ } | null = null;
+ let destroyed = false;
+ // The pointer type of the press a drag starts from. A touch drag's release
+ // cross-fades under Reduce Motion, as does any release on a coarse primary
+ // pointer. The public pan reports values only, so the press itself is read.
+ // A drag with no pointer event before it, such as bare touches, is a touch.
+ let pressPointerType = 'touch';
+ const onPointerDown = (event: PointerEvent) => {
+ if (drag == null) {
+ pressPointerType = event.pointerType;
+ }
+ };
+ element.addEventListener('pointerdown', onPointerDown, {
+ capture: true,
+ passive: true,
+ });
+
+ const settle = (
+ target: number,
+ velocity: number,
+ touchRelease: boolean,
+ ): void => {
+ registerSpringsTable();
+ targetIndex = target;
+ const mine = ++turn;
+ const handle = animator.animate(
+ {[axis]: points[target]},
+ {
+ spring: springs.settle,
+ ...(velocity !== 0 ? {velocity: {[axis]: velocity}} : {}),
+ },
+ {touchRelease},
+ );
+ void handle.finished.then(arrived => {
+ if (arrived && mine === turn && !destroyed) {
+ onSettle(targetIndex);
+ }
+ });
+ };
+
+ const pan = createPanGesture(element, {
+ axis,
+ onPan: ({state, translation, velocity}: PanGestureEvent) => {
+ if (destroyed) {
+ return;
+ }
+ if (state === 'began') {
+ // Catch a settle in flight where it is on screen.
+ turn += 1;
+ animator.stop();
+ drag = {
+ startOffset: animator.get(axis),
+ startIndex: targetIndex,
+ pointerType: pressPointerType,
+ };
+ }
+ if (drag == null) {
+ return;
+ }
+ const offset = drag.startOffset + translation[axis];
+ if (state === 'began' || state === 'changed') {
+ animator.set({[axis]: offset});
+ return;
+ }
+ const {startIndex, startOffset, pointerType} = drag;
+ drag = null;
+ const touchRelease = pointerType === 'touch';
+ if (state === 'cancelled') {
+ settle(startIndex, 0, touchRelease);
+ return;
+ }
+ const release = velocity[axis];
+ // Core's one release policy. The drag's start lets a release that moves
+ // back return to the snap point it started from.
+ const {index: target} = resolveSnapPoint({
+ points,
+ position: offset,
+ velocity: release,
+ flickThreshold,
+ start: startOffset,
+ });
+ settle(target, release, touchRelease);
+ },
+ });
+
+ const snap: DragToSnapPoints = {
+ settleTo(target) {
+ if (destroyed) {
+ return;
+ }
+ if (!Number.isInteger(target) || target < 0 || target >= points.length) {
+ devWarn(
+ 'createDragToSnapPoints',
+ `settleTo(${target}) is outside the ${points.length} snap points, so it does nothing.`,
+ );
+ return;
+ }
+ drag = null;
+ settle(target, 0, false);
+ },
+ destroy() {
+ if (destroyed) {
+ return;
+ }
+ destroyed = true;
+ element.removeEventListener('pointerdown', onPointerDown, {
+ capture: true,
+ });
+ pan.destroy();
+ // Held where it is: disposing cancels a running settle, which would
+ // otherwise leave the element where that settle began.
+ animator.stop();
+ animator.dispose();
+ },
+ };
+ return snap;
+}
diff --git a/packages/core/src/dragToSnapPoints/publicSurface.test.ts b/packages/core/src/dragToSnapPoints/publicSurface.test.ts
new file mode 100644
index 0000000000000..366dae53e5dfc
--- /dev/null
+++ b/packages/core/src/dragToSnapPoints/publicSurface.test.ts
@@ -0,0 +1,34 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file publicSurface.test.ts
+ * @input Core's package.json, the package root's source, this directory
+ * @output Checks `spec:AST-107` FR2 and FR6 for the snap-point drag's entry.
+ * `createDragToSnapPoints` ships from the package root only, as
+ * `createAnimator` does, with no subpath of its own. Its hook ships from
+ * the hooks barrel.
+ * @position Public API test; enforced by Vitest
+ */
+
+import fs from 'node:fs';
+import path from 'node:path';
+import {describe, expect, it} from 'vitest';
+
+const SRC = path.join(__dirname, '..');
+
+describe('the snap-point drag\u2019s entry', () => {
+ it('createDragToSnapPoints ships from the package root only: no `./dragToSnapPoints` subpath', () => {
+ const pkg = JSON.parse(
+ fs.readFileSync(path.join(SRC, '..', 'package.json'), 'utf8'),
+ ) as {exports: Record};
+ expect(Object.keys(pkg.exports)).not.toContain('./dragToSnapPoints');
+ expect(fs.existsSync(path.join(__dirname, 'index.ts'))).toBe(false);
+ const root = fs.readFileSync(path.join(SRC, 'index.ts'), 'utf8');
+ expect(root).toContain("from './dragToSnapPoints/createDragToSnapPoints';");
+ });
+
+ it('the hooks barrel hands out useDragToSnapPoints', () => {
+ const hooks = fs.readFileSync(path.join(SRC, 'hooks', 'index.ts'), 'utf8');
+ expect(hooks).toMatch(/useDragToSnapPoints/);
+ });
+});
diff --git a/packages/core/src/dragToSnapPoints/snapTemplates.test.tsx b/packages/core/src/dragToSnapPoints/snapTemplates.test.tsx
new file mode 100644
index 0000000000000..d7eb5bc354d5d
--- /dev/null
+++ b/packages/core/src/dragToSnapPoints/snapTemplates.test.tsx
@@ -0,0 +1,176 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file snapTemplates.test.tsx
+ * @input The CLI's two playing-card examples of the snap-point drag: the
+ * `useDragToSnapPoints` one and the plain-JavaScript `createDragToSnapPoints`
+ * one. Each renders left to right and right to left, with a recording
+ * stand-in for Element.animate.
+ * @output Tests `design:bidirectionality` DR13 for the examples. The places
+ * run in reading order. So a card sent to the next place moves toward the
+ * inline end, rightward or leftward, and its place has the same name. The
+ * plain-JavaScript example's buttons take the theme's button look.
+ * @position The examples ship to products through the CLI, so their direction
+ * handling is checked here, beside the drag they demonstrate.
+ */
+
+import type {ComponentType, ReactNode} from 'react';
+import {act, fireEvent, render, screen} from '@testing-library/react';
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
+import {InternationalizationProvider} from '../i18n';
+
+// The examples live in the CLI's templates, outside Core's type-checked
+// sources, so they load at run time; the CLI type-checks them itself.
+const TEMPLATES = '../../../cli/assets/templates/blocks/components';
+async function loadExample(file: string): Promise {
+ const module = (await import(/* @vite-ignore */ `${TEMPLATES}/${file}`)) as {
+ default: ComponentType;
+ };
+ return module.default;
+}
+const DragToSnapPointsCard = await loadExample(
+ 'useDragToSnapPoints/DragToSnapPointsCard',
+);
+const DragToSnapPointsCardVanilla = await loadExample(
+ 'createDragToSnapPoints/DragToSnapPointsCardVanilla',
+);
+
+beforeEach(() => {
+ vi.useFakeTimers({
+ toFake: [
+ 'setTimeout',
+ 'clearTimeout',
+ 'performance',
+ 'requestAnimationFrame',
+ 'cancelAnimationFrame',
+ ],
+ });
+ HTMLElement.prototype.animate = function () {
+ let start: number | null = null;
+ const animation = {
+ get startTime() {
+ return start;
+ },
+ set startTime(value: number | null) {
+ start = value;
+ },
+ playState: 'running',
+ ready: new Promise(() => {}),
+ finish() {},
+ cancel() {},
+ };
+ requestAnimationFrame(() => {
+ start ??= performance.now();
+ });
+ return animation as unknown as Animation;
+ };
+ // jsdom computes no inherited direction; a browser takes an element's from
+ // the nearest dir attribute, which this reads the same way.
+ const computed = window.getComputedStyle.bind(window);
+ vi.spyOn(window, 'getComputedStyle').mockImplementation((element, pseudo) => {
+ const style = computed(element, pseudo);
+ const dir = element.closest('[dir]')?.getAttribute('dir') ?? 'ltr';
+ Object.defineProperty(style, 'direction', {value: dir, configurable: true});
+ return style;
+ });
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ Reflect.deleteProperty(HTMLElement.prototype, 'animate');
+ vi.useRealTimers();
+});
+
+function inDirection(dir: 'ltr' | 'rtl', example: ReactNode) {
+ return (
+
+
+ {example}
+
+
+ );
+}
+
+/** Sends the card to `place` with its button; returns where it settled, in px. */
+async function sendTo(place: string, card: () => HTMLElement): Promise {
+ fireEvent.click(screen.getByRole('button', {name: place}));
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(3000);
+ });
+ const [x = '0'] = (card().style.translate ?? '').split(' ');
+ // At rest on its starting place the card carries no translate at all.
+ const offset = Number.parseFloat(x);
+ return Number.isFinite(offset) ? offset : 0;
+}
+
+describe('the snap-point examples follow the reading direction (design:bidirectionality DR13)', () => {
+ const examples: [string, ComponentType, () => HTMLElement][] = [
+ [
+ 'useDragToSnapPoints',
+ DragToSnapPointsCard,
+ () => screen.getByLabelText(/^Queen of hearts/),
+ ],
+ [
+ 'createDragToSnapPoints',
+ DragToSnapPointsCardVanilla,
+ () => screen.getByText('Q\u2665'),
+ ],
+ ];
+ for (const [name, Example, card] of examples) {
+ it(`${name}: the next place is rightward left to right, leftward right to left, and named the same`, async () => {
+ for (const [dir, sign] of [
+ ['ltr', 1],
+ ['rtl', -1],
+ ] as const) {
+ const {unmount} = render(inDirection(dir, ));
+ expect(await sendTo('Table', card), dir).toBe(sign * 110);
+ expect(await sendTo('Discard pile', card), dir).toBe(sign * 220);
+ expect(
+ screen.getByText('The card is on the discard pile.'),
+ ).toBeTruthy();
+ expect(await sendTo('Hand', card), dir).toBe(0);
+ unmount();
+ }
+ });
+
+ it(`${name}: without an internationalization provider, the places follow the direction the card renders in (architecture:internationalization INV11)`, async () => {
+ const {unmount} = render(
+
+
+
,
+ );
+ expect(await sendTo('Table', card)).toBe(-110);
+ unmount();
+ });
+ }
+});
+
+describe('the plain-JavaScript example', () => {
+ it('takes its card and buttons down when it unmounts', () => {
+ const {unmount} = render();
+ const card = screen.getByText('Q\u2665');
+ const [button] = screen.getAllByRole('button');
+ unmount();
+ // The unmount drops the whole tree, so only the cleanup detaches these
+ // nodes from their own parents.
+ expect(card.parentElement).toBeNull();
+ expect(button.parentElement).toBeNull();
+ });
+
+ it('colours its card from the theme\u2019s surface and text tokens, so it reads in a dark theme', () => {
+ render();
+ const {style} = screen.getByText('Q\u2665');
+ expect(style.backgroundColor).toBe('var(--color-background-surface)');
+ expect(style.color).toBe('var(--color-text-primary)');
+ });
+
+ it('gives its place buttons the theme\u2019s secondary button look, which the reset takes from a native button', () => {
+ render();
+ for (const {style} of screen.getAllByRole('button')) {
+ expect(style.backgroundColor).toBe('var(--color-neutral)');
+ expect(style.color).toBe('var(--color-text-primary)');
+ expect(style.borderRadius).toBe('var(--radius-element)');
+ expect(style.height).toBe('var(--size-element-md)');
+ }
+ });
+});
diff --git a/packages/core/src/hooks/index.ts b/packages/core/src/hooks/index.ts
index 56636015cb466..c10e8c3664d6d 100644
--- a/packages/core/src/hooks/index.ts
+++ b/packages/core/src/hooks/index.ts
@@ -135,6 +135,13 @@ export type {
export {useAnimator} from './useAnimator';
+export {useDragToSnapPoints} from './useDragToSnapPoints';
+export type {UseDragToSnapPointsResult} from './useDragToSnapPoints';
+export type {
+ DragToSnapPoints,
+ DragToSnapPointsOptions,
+} from '../dragToSnapPoints/createDragToSnapPoints';
+
export {useLongPress} from './useLongPress';
export type {UseLongPressOptions, UseLongPressHandlers} from './useLongPress';
diff --git a/packages/core/src/hooks/useDragToSnapPoints.doc.mjs b/packages/core/src/hooks/useDragToSnapPoints.doc.mjs
new file mode 100644
index 0000000000000..30679ef006269
--- /dev/null
+++ b/packages/core/src/hooks/useDragToSnapPoints.doc.mjs
@@ -0,0 +1,127 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/** @type {import('@astryxdesign/cli/authoring').HookDoc} */
+export const docs = {
+ name: 'useDragToSnapPoints',
+ displayName: 'useDragToSnapPoints',
+ keywords: [
+ 'drag',
+ 'snap',
+ 'snap points',
+ 'detents',
+ 'drawer',
+ 'flick',
+ 'swipe',
+ 'gesture',
+ ],
+ params: [
+ {
+ name: 'ref',
+ type: 'RefObject',
+ description: 'A ref to the element a drag moves, along `axis` only.',
+ required: true,
+ },
+ {
+ name: 'options.axis',
+ type: "'x' | 'y'",
+ description:
+ 'The axis the element moves along. A new one creates the drag again, starting where the element is.',
+ required: true,
+ },
+ {
+ name: 'options.snapPoints',
+ type: 'number[]',
+ description:
+ "Offsets in px along `axis` from the element's resting position, such as `[0, 240, 480]`, sorted before use (`onSettle` and `settleTo` count in that order); an empty list makes the drag do nothing. Unlike BottomSheet's `snapPoints`, they are not fractions of the viewport. New ones create the drag again and nothing more: the element stays where it is, a settle in flight included, and the next drag or `settleTo` starts there.",
+ required: true,
+ },
+ {
+ name: 'options.flickThreshold',
+ type: 'number',
+ description:
+ "A release faster than this, in px/s, carries to the next snap point in its direction. The default is the flick speed measured for UIKit's sheet. A new one creates the drag again, as new snap points do.",
+ default: '1008',
+ required: false,
+ },
+ {
+ name: 'options.onSettle',
+ type: '(index: number) => void',
+ description:
+ "Called with the snap point's index once the element settles on it. The latest function is the one called, so it may be new on every render.",
+ required: true,
+ },
+ ],
+ returns: [
+ {
+ name: 'settleTo',
+ type: '(index: number) => void',
+ description:
+ 'Settles the element on the snap point at `index` without a drag: the keyboard path. The same function on every render.',
+ },
+ ],
+ usage: {
+ description:
+ "Lets a drag move an element along one axis and settles it on snap points when the finger lifts, the way an iOS sheet settles on its detents: on the nearest snap point to where it is, on the next one in its direction after a quick flick, and back where it started when the finger was moving back. The settle continues the release's velocity. For a bottom sheet, use BottomSheet's `snapPoints`; for a scrolling row, Carousel's `hasSnap`. Outside React, use `createDragToSnapPoints`.",
+ bestPractices: [
+ {
+ guidance: true,
+ description:
+ 'Offer the same positions without a drag: buttons or keys that call `settleTo`.',
+ },
+ {
+ guidance: true,
+ description:
+ 'Keep the settled index in state from `onSettle`, and render what the position means from it.',
+ },
+ {
+ guidance: false,
+ description:
+ 'Use it for a sheet or a carousel: their own props already settle the way people expect.',
+ },
+ ],
+ accessibility: [
+ {
+ name: 'Keyboard and assistive technology',
+ category: 'Keyboard',
+ description:
+ 'A drag is one way in, never the only one. Give each snap point a control a keyboard and a screen reader can reach, such as Expand and Collapse buttons, and call `settleTo` from it. When a position changes what the element shows, announce the change.',
+ },
+ {
+ name: 'Reduce Motion',
+ category: 'Content',
+ criterion: '2.3.3 Animation from Interactions',
+ description:
+ 'When the person asks for reduced motion, the settle after a touch drag becomes a short cross-fade into the snap point, as does any settle on a device whose primary pointer is coarse, such as a tablet; after a mouse or pen drag elsewhere it lands at once. `settleTo` fades on such a device and lands at once with a fine primary pointer. While the finger is down, the element still follows it.',
+ },
+ ],
+ },
+ relatedComponents: ['BottomSheet', 'Carousel'],
+ relatedHooks: ['usePanGesture', 'useAnimator'],
+ importPath: '@astryxdesign/core/hooks',
+ category: 'interaction',
+};
+
+/** @type {import('@astryxdesign/cli/authoring').HookTranslationDoc} */
+export const docsDense = {
+ description:
+ 'A drag that settles the element at ref on snap points (px offsets): {settleTo}.',
+ paramDescriptions: {
+ ref: 'Ref to the dragged element.',
+ 'options.axis': "'x' or 'y'.",
+ 'options.snapPoints': 'Ascending px offsets from the resting position.',
+ 'options.flickThreshold':
+ 'px/s past which a release carries one snap point; 1008.',
+ 'options.onSettle': '(index) once settled; latest one is called.',
+ },
+ returnDescriptions: {
+ settleTo: '(index) settles without a drag; stable.',
+ },
+ usage: {
+ description:
+ 'Nearest snap point, the next on a flick, the start when moving back; the settle keeps the release velocity. Sheets and carousels: use their props.',
+ bestPractices: [
+ {guidance: true, description: 'Wire settleTo to buttons and keys.'},
+ {guidance: false, description: 'Rebuild a sheet or carousel with it.'},
+ ],
+ },
+};
diff --git a/packages/core/src/hooks/useDragToSnapPoints.test.tsx b/packages/core/src/hooks/useDragToSnapPoints.test.tsx
new file mode 100644
index 0000000000000..443bc49ed70e6
--- /dev/null
+++ b/packages/core/src/hooks/useDragToSnapPoints.test.tsx
@@ -0,0 +1,419 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file useDragToSnapPoints.test.tsx
+ * @input vitest (jsdom, fake clock), React Testing Library, the hook
+ * @output Checks `spec:AST-107` FR2 and FR6 for the hook. `settleTo` from a
+ * key settles and calls the latest `onSettle`, and `settleTo` is stable.
+ * A render that React throws away leaves the committed `onSettle` in place.
+ * An unmount stops reporting, and an empty list stays empty. The hook
+ * module holds no behavior of its own.
+ * @position Testing; validates useDragToSnapPoints.ts
+ */
+
+import fs from 'node:fs';
+import path from 'node:path';
+import {act, render} from '@testing-library/react';
+import {Suspense, startTransition, use, useRef} from 'react';
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
+import {coarsePrimaryPointer} from '../gestures/__tests__/harness';
+import {useDragToSnapPoints} from './useDragToSnapPoints';
+
+beforeEach(() => {
+ vi.useFakeTimers({
+ toFake: [
+ 'setTimeout',
+ 'clearTimeout',
+ 'performance',
+ 'requestAnimationFrame',
+ 'cancelAnimationFrame',
+ ],
+ });
+ HTMLElement.prototype.animate = function (
+ keyframes: Keyframe[] | PropertyIndexedKeyframes | null,
+ ) {
+ played.push(keyframes);
+ let start: number | null = null;
+ const animation = {
+ get startTime() {
+ return start;
+ },
+ set startTime(value: number | null) {
+ start = value;
+ },
+ playState: 'running',
+ ready: new Promise(() => {}),
+ finish() {},
+ cancel() {},
+ };
+ requestAnimationFrame(() => {
+ start ??= performance.now();
+ });
+ return animation as unknown as Animation;
+ };
+});
+
+afterEach(() => {
+ Reflect.deleteProperty(HTMLElement.prototype, 'animate');
+ vi.useRealTimers();
+});
+
+const POINTS = [0, 240, 480];
+
+function touch(
+ type: string,
+ target: EventTarget,
+ contact: {x: number; y: number},
+ time: number,
+ isLift = false,
+): void {
+ const event = new Event(type, {bubbles: true, cancelable: true});
+ const entry = {identifier: 1, clientX: contact.x, clientY: contact.y, target};
+ Object.defineProperty(event, 'changedTouches', {value: [entry]});
+ Object.defineProperty(event, 'touches', {value: isLift ? [] : [entry]});
+ Object.defineProperty(event, 'timeStamp', {value: time});
+ target.dispatchEvent(event);
+}
+
+// The document's host outlives a test, so event times only move forward.
+let clock = 2_000_000;
+
+/** The keyframes of every animation the element played, in order. */
+const played: (Keyframe[] | PropertyIndexedKeyframes | null)[] = [];
+
+function Drawer({
+ onSettle,
+ settlers,
+ snapPoints = POINTS,
+}: {
+ onSettle: (index: number) => void;
+ settlers: Set<(index: number) => void>;
+ snapPoints?: ReadonlyArray;
+}) {
+ const ref = useRef(null);
+ const {settleTo} = useDragToSnapPoints(ref, {
+ axis: 'y',
+ snapPoints,
+ onSettle,
+ });
+ settlers.add(settleTo);
+ return ;
+}
+
+describe('useDragToSnapPoints', () => {
+ it('settleTo settles from a key and calls the latest onSettle; settleTo is stable', async () => {
+ const settlers = new Set<(index: number) => void>();
+ const first = vi.fn();
+ const latest = vi.fn();
+ const {rerender} = render();
+ rerender();
+ expect(settlers.size).toBe(1);
+ const [settleTo] = settlers;
+ act(() => settleTo(2));
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(3000);
+ });
+ expect(first).not.toHaveBeenCalled();
+ expect(latest).toHaveBeenCalledWith(2);
+ });
+
+ it('a render that React throws away leaves the committed onSettle in place', async () => {
+ const settlers = new Set<(index: number) => void>();
+ const committed = vi.fn();
+ const discarded = vi.fn();
+ const never = new Promise(() => {});
+ function Pending({
+ onSettle,
+ isPending,
+ }: {
+ onSettle: (index: number) => void;
+ isPending: boolean;
+ }) {
+ const ref = useRef(null);
+ const {settleTo} = useDragToSnapPoints(ref, {
+ axis: 'y',
+ snapPoints: POINTS,
+ onSettle,
+ });
+ settlers.add(settleTo);
+ if (isPending) {
+ use(never);
+ }
+ return ;
+ }
+ const {rerender} = render(
+
+
+ ,
+ );
+ // A transition that suspends keeps the committed screen, and React throws
+ // its render away.
+ act(() => {
+ startTransition(() => {
+ rerender(
+
+
+ ,
+ );
+ });
+ });
+ const [settleTo] = settlers;
+ act(() => settleTo(2));
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(3000);
+ });
+ expect(discarded).not.toHaveBeenCalled();
+ expect(committed).toHaveBeenCalledWith(2);
+ });
+
+ it('after an unmount nothing reports', async () => {
+ const settlers = new Set<(index: number) => void>();
+ const onSettle = vi.fn();
+ const {unmount} = render(
+ ,
+ );
+ const [settleTo] = settlers;
+ act(() => settleTo(1));
+ unmount();
+ await vi.advanceTimersByTimeAsync(3000);
+ expect(onSettle).not.toHaveBeenCalled();
+ expect(() => settleTo(2)).not.toThrow();
+ });
+
+ it('an empty list stays empty: settleTo does nothing', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const settlers = new Set<(index: number) => void>();
+ const onSettle = vi.fn();
+ render();
+ const [settleTo] = settlers;
+ act(() => settleTo(0));
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(3000);
+ });
+ expect(onSettle).not.toHaveBeenCalled();
+ expect(warn).toHaveBeenCalled();
+ warn.mockRestore();
+ });
+
+ it('drives an element mounted after the hook first ran, and follows the one that replaces it (INV6)', async () => {
+ const settlers = new Set<(index: number) => void>();
+ const onSettle = vi.fn();
+ function Late({shown, version}: {shown: boolean; version: number}) {
+ const ref = useRef(null);
+ const {settleTo} = useDragToSnapPoints(ref, {
+ axis: 'y',
+ snapPoints: POINTS,
+ onSettle,
+ });
+ settlers.add(settleTo);
+ return shown ? : null;
+ }
+ const {rerender, getByTestId} = render();
+ rerender();
+ const [settleTo] = settlers;
+ act(() => settleTo(1));
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(3000);
+ });
+ expect(onSettle).toHaveBeenLastCalledWith(1);
+ // A new node behind the ref: the drag moves to it.
+ rerender();
+ const replaced = getByTestId('late');
+ act(() => settleTo(2));
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(3000);
+ });
+ expect(onSettle).toHaveBeenLastCalledWith(2);
+ expect(replaced.style.translate).toContain('480px');
+ });
+
+ it('an option changed mid-use leaves the element where it is, and the next settle starts there', async () => {
+ const settlers = new Set<(index: number) => void>();
+ const onSettle = vi.fn();
+ function Sheet({flickThreshold}: {flickThreshold: number}) {
+ const ref = useRef(null);
+ const {settleTo} = useDragToSnapPoints(ref, {
+ axis: 'y',
+ snapPoints: POINTS,
+ flickThreshold,
+ onSettle,
+ });
+ settlers.add(settleTo);
+ return ;
+ }
+ const {rerender, getByTestId} = render();
+ const element = getByTestId('sheet');
+ const [settleTo] = settlers;
+ act(() => settleTo(1));
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(3000);
+ });
+ const settled = element.style.translate;
+ expect(settled).toContain('240px');
+ rerender();
+ expect(element.style.translate).toBe(settled);
+ // The next settle moves on from 240, not from the resting position.
+ played.length = 0;
+ act(() => settleTo(2));
+ expect(JSON.stringify(played[0])).toContain('240px');
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(3000);
+ });
+ expect(onSettle).toHaveBeenLastCalledWith(2);
+ });
+
+ it('an option changed during a settle stops it where it is, and the next settle starts there', async () => {
+ const settlers = new Set<(index: number) => void>();
+ const onSettle = vi.fn();
+ function Sheet({flickThreshold}: {flickThreshold: number}) {
+ const ref = useRef(null);
+ const {settleTo} = useDragToSnapPoints(ref, {
+ axis: 'y',
+ snapPoints: POINTS,
+ flickThreshold,
+ onSettle,
+ });
+ settlers.add(settleTo);
+ return ;
+ }
+ const {rerender} = render();
+ const [settleTo] = settlers;
+ act(() => settleTo(2));
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(150);
+ });
+ played.length = 0;
+ rerender();
+ // The drag is created again with the new option and nothing more: the
+ // settle stops where the element is, and nothing reports.
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(3000);
+ });
+ expect(played).toHaveLength(0);
+ expect(onSettle).not.toHaveBeenCalled();
+ // The next settle starts from there: never from 0, never a jump.
+ const [next] = [...settlers].slice(-1);
+ act(() => next(2));
+ const frames = played[0] as Keyframe[];
+ const yOf = (frame: Keyframe | undefined) =>
+ Number.parseFloat(String(frame?.translate ?? '').split(' ')[1] ?? '');
+ expect(yOf(frames[0])).toBeGreaterThan(0);
+ expect(yOf(frames[0])).toBeLessThan(480);
+ expect(yOf(frames.at(-1))).toBeCloseTo(480, 0);
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(3000);
+ });
+ expect(onSettle).toHaveBeenLastCalledWith(2);
+ });
+
+ it('new snap points leave a resting element where it is, and the next settle starts there', async () => {
+ const settlers = new Set<(index: number) => void>();
+ const onSettle = vi.fn();
+ function Sheet({snapPoints}: {snapPoints: number[]}) {
+ const ref = useRef(null);
+ const {settleTo} = useDragToSnapPoints(ref, {
+ axis: 'y',
+ snapPoints,
+ onSettle,
+ });
+ settlers.add(settleTo);
+ return ;
+ }
+ const {rerender} = render();
+ const [settleTo] = settlers;
+ act(() => settleTo(1));
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(3000);
+ });
+ // 240 is still a snap point, now the third: nothing moves.
+ played.length = 0;
+ rerender();
+ expect(played).toHaveLength(0);
+ // 240 is gone: still nothing moves, and nothing reports.
+ onSettle.mockClear();
+ rerender();
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(3000);
+ });
+ expect(played).toHaveLength(0);
+ expect(onSettle).not.toHaveBeenCalled();
+ // The next settle, to 300, starts from 240.
+ act(() => settleTo(1));
+ expect(JSON.stringify(played[0])).toContain('240px');
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(3000);
+ });
+ expect(onSettle).toHaveBeenLastCalledWith(1);
+ });
+
+ it('an option changed during a release\u2019s settle leaves it where it is: the hook only creates the drag again', async () => {
+ // A flick from 260 toward 480, stopped about 330 by a new option. Creating
+ // the drag again is all the hook does (spec:AST-107 FR6), so nothing aims
+ // it at 240 or reports a point it never reached.
+ const restore = coarsePrimaryPointer();
+ try {
+ const settlers = new Set<(index: number) => void>();
+ const onSettle = vi.fn();
+ function Sheet({flickThreshold}: {flickThreshold: number}) {
+ const ref = useRef(null);
+ const {settleTo} = useDragToSnapPoints(ref, {
+ axis: 'y',
+ snapPoints: POINTS,
+ flickThreshold,
+ onSettle,
+ });
+ settlers.add(settleTo);
+ return ;
+ }
+ const {rerender, container} = render();
+ const element = container.firstElementChild as HTMLElement;
+ const [settleTo] = settlers;
+ act(() => settleTo(1));
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(3000);
+ });
+ onSettle.mockClear();
+ // From 240: 20 px down slowly, to 260, then 30 px in 20 ms (1500 px/s),
+ // a flick toward 480.
+ clock += 10_000;
+ const start = clock;
+ act(() => {
+ touch('touchstart', element, {x: 10, y: 10}, start);
+ touch('touchmove', element, {x: 10, y: 20}, start + 200);
+ touch('touchmove', element, {x: 10, y: 30}, start + 400);
+ touch('touchmove', element, {x: 10, y: 45}, start + 410);
+ touch('touchmove', element, {x: 10, y: 60}, start + 420);
+ touch('touchend', element, {x: 10, y: 60}, start + 424, true);
+ });
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(40);
+ });
+ played.length = 0;
+ rerender();
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(3000);
+ });
+ expect(played).toHaveLength(0);
+ expect(onSettle).not.toHaveBeenCalled();
+ } finally {
+ restore();
+ }
+ });
+
+ it('delegates entirely to the factory: the hook module holds no behavior of its own', () => {
+ const source = fs.readFileSync(
+ path.join(__dirname, 'useDragToSnapPoints.ts'),
+ 'utf8',
+ );
+ const imports = [...source.matchAll(/from '([^']+)'/g)].map(m => m[1]);
+ expect([...new Set(imports)].sort()).toEqual([
+ '../dragToSnapPoints/createDragToSnapPoints',
+ './useAttachment',
+ './useIsomorphicLayoutEffect',
+ 'react',
+ ]);
+ expect(source).toContain('createDragToSnapPoints(element');
+ expect(source).toContain('useAttachment(');
+ expect(source).not.toMatch(/\b(motion|gestures)\//);
+ });
+});
diff --git a/packages/core/src/hooks/useDragToSnapPoints.ts b/packages/core/src/hooks/useDragToSnapPoints.ts
new file mode 100644
index 0000000000000..62ae75c7d4c11
--- /dev/null
+++ b/packages/core/src/hooks/useDragToSnapPoints.ts
@@ -0,0 +1,96 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+'use client';
+
+/**
+ * @file useDragToSnapPoints.ts
+ * @input React, the public snap-point drag factory
+ * (dragToSnapPoints/createDragToSnapPoints.ts)
+ * @output Exports `useDragToSnapPoints`: a drag that settles the element
+ * `ref` points at on snap points
+ * @position Public hook, exported from `@astryxdesign/core/hooks`. A thin
+ * wrapper over `createDragToSnapPoints` with no behavior of its own.
+ *
+ * SYNC: When modified, update:
+ * - /packages/core/src/hooks/useDragToSnapPoints.test.tsx
+ * - /packages/core/src/hooks/useDragToSnapPoints.doc.mjs
+ */
+
+import {useRef, useState} from 'react';
+import type {RefObject} from 'react';
+import {
+ createDragToSnapPoints,
+ type DragToSnapPoints,
+ type DragToSnapPointsOptions,
+} from '../dragToSnapPoints/createDragToSnapPoints';
+import {useAttachment} from './useAttachment';
+import {useIsomorphicLayoutEffect} from './useIsomorphicLayoutEffect';
+
+export interface UseDragToSnapPointsResult {
+ /**
+ * Settles the element on the snap point at `index` without a drag: the
+ * keyboard path. The same function on every render.
+ */
+ readonly settleTo: (index: number) => void;
+}
+
+/**
+ * Lets a drag move the element along `axis` and settles it on `snapPoints`
+ * at the release.
+ *
+ * The drag follows the element behind `ref`, also a node that replaces it,
+ * and it ends when the element unmounts. An option change creates the drag
+ * again, and the element stays where it is, a settle in flight included.
+ * The latest `onSettle` is the one called, so it may be a new function on
+ * every render. `settleTo` is the same function on every render.
+ *
+ * @example
+ * ```
+ * const ref = useRef(null);
+ * const {settleTo} = useDragToSnapPoints(ref, {
+ * axis: 'y',
+ * snapPoints: [0, 240, 480],
+ * onSettle: setIndex,
+ * });
+ * ```
+ */
+export function useDragToSnapPoints(
+ ref: RefObject,
+ options: DragToSnapPointsOptions,
+): UseDragToSnapPointsResult {
+ const {axis, snapPoints, flickThreshold} = options;
+ const onSettleRef = useRef(options.onSettle);
+ // Effect: syncs the drag, which runs outside React, with the latest
+ // committed onSettle, never one from a render that React throws away.
+ useIsomorphicLayoutEffect(() => {
+ onSettleRef.current = options.onSettle;
+ });
+ const snapRef = useRef(null);
+ // The snap points as a value, so a new array with the same points keeps
+ // the drag (an empty list stays empty).
+ const points = JSON.stringify(snapPoints);
+ // A new axis, snap points or flick speed creates the drag again.
+ useAttachment(
+ ref,
+ element => {
+ const snap = createDragToSnapPoints(element, {
+ axis,
+ snapPoints: JSON.parse(points) as number[],
+ flickThreshold,
+ onSettle: index => onSettleRef.current(index),
+ });
+ snapRef.current = snap;
+ return () => {
+ snap.destroy();
+ if (snapRef.current === snap) {
+ snapRef.current = null;
+ }
+ };
+ },
+ `${axis} ${points} ${flickThreshold ?? ''}`,
+ );
+ const [result] = useState(() => ({
+ settleTo: index => snapRef.current?.settleTo(index),
+ }));
+ return result;
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 3ed448fa5cbfa..0453e52c27a82 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -186,6 +186,14 @@ export {
type AnimatorValues,
} from './animator/createAnimator';
+// Settling an element on snap points: from the package root only, since one
+// API has one entry. Its hook ships from './hooks'.
+export {
+ createDragToSnapPoints,
+ type DragToSnapPoints,
+ type DragToSnapPointsOptions,
+} from './dragToSnapPoints/createDragToSnapPoints';
+
// Hooks
export * from './hooks';
diff --git a/scripts/touch-support-bundle.test.mjs b/scripts/touch-support-bundle.test.mjs
index ff97b8737f33d..e75b5fe77dbc1 100644
--- a/scripts/touch-support-bundle.test.mjs
+++ b/scripts/touch-support-bundle.test.mjs
@@ -237,6 +237,8 @@ const GESTURE_USERS = new Set([
'Toast',
// The public pan, which the root's createPanGesture shares.
'hooks: usePanGesture',
+ // The snap-point drag, which moves an element on that pan.
+ 'hooks: useDragToSnapPoints',
]);
/** Public imports that carry no gesture code, whatever the list above says. */