Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This names only @astryxdesign/core, but the two new playing-card templates ship as @astryxdesign/cli output, and CI lint's changeset coverage fails on that. A separate [feat] changeset naming @astryxdesign/cli would clear it (spec:AST-017 FR7).

'@astryxdesign/cli': patch
---

[feat] `createAnimator(element)` and its hook `useAnimator(ref)` animate one element with the motion Astryx's components use; the hook takes the element's ref, so a gesture hook can share the element. `animate` springs `x`, `y`, `scale`, `rotate` and `opacity` to a target on the state-change motion, `decay` coasts any of them from a release velocity, `set` jumps, `get` and `getVelocity` read, and `stop` halts; `animate` and `decay` return a promise. Reduce Motion is planned for every call. `createAnimator` is exported from the package root and `useAnimator` from `@astryxdesign/core/hooks`; the CLI's templates gain a playing-cards example of each.

@vjeux
2 changes: 2 additions & 0 deletions apps/sandbox/scripts/next-export-routes.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@
"/templates/",
"/templates/AlertDialogAsyncAction/",
"/templates/AlertDialogDeleteConfirmation/",
"/templates/AnimatorPlayingCards/",
"/templates/AnimatorPlayingCardsVanilla/",
"/templates/AppShellContentOnly/",
"/templates/AppShellMobileHookUsage/",
"/templates/AppShellShowcase/",
Expand Down
24 changes: 21 additions & 3 deletions docs/specs/AST-107-public-gesture-hooks/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,8 @@ animator2.destroy();
`{state, translation, velocity}` as a drag on the element begins, changes,
ends, or is cancelled. `axis` limits the drag to `'x'` or `'y'`. A finger,
a mouse, and a pen all drive it (`spec:AST-094` FR43). It never
hands out a recognizer object, and it recognizes no press or long press.
hands out a recognizer object, and it recognizes no press or long press. A
cancelled pan reports `velocity` {0, 0}, so nothing flings on cancel.
- **FR2 — `useDragToSnapPoints` settles an element on snap points.**
`useDragToSnapPoints(ref, {axis, snapPoints, flickThreshold?, onSettle})`
lets a drag move the element along `axis`, under
Expand Down Expand Up @@ -150,7 +151,8 @@ animator2.destroy();
curve is not offered. `animate` on a channel it does not know, any call
before `ref` attaches, and any call after `destroy` do nothing and resolve at
once. A later `animate`, `decay`, `set`, or `stop` on a channel resolves that
channel's earlier promise where it stopped.
channel's earlier promise where it stopped. A remount starts from the
element's resting values; no animator values are kept after an unmount.
- **FR4 — One hook per job.** `usePanGesture` does not do presses or long
presses, which `useLongPress` owns. `useAnimator` animates one element, and
`useAnimatedLayout` animates an area (`spec:AST-105`). A component's own
Expand All @@ -169,7 +171,10 @@ onSettle}) → {settleTo(index), destroy}`, and `createAnimator(element) →
its factory and has no behavior of its own. `destroy` removes every
listener the factory added, and importing a factory does no DOM work, so it
is safe on the server. No internal factory shares a public factory's name
(`architecture:react-component-runtime` INV13).
(`architecture:react-component-runtime` INV13). `createPanGesture`'s
`destroy()` during a drag reports `cancelled` once; `usePanGesture`'s
unmount reports nothing (no callback after unmount). `decay` on `opacity`
stops at 0 or 1.
- **FR7 — Core and Lab use the public layer.** Wherever a public gesture or
motion API covers a component's job (the factories and hooks here,
`createReorderable`, and `useAnimatedLayout`), Core and Lab components MUST
Expand Down Expand Up @@ -272,6 +277,19 @@ factory that attaches to the element itself, with a thin hook, and

Rejected: a context menu reachable only through the `<ContextMenu>` wrapper.

### DEC-4 — Edge behaviors of the public animator and pan gesture

**Reference:** `spec:AST-107/DEC-4`
**Decider:** `cixzhang`, `2026-11-02`

A cancelled pan reports zero velocity; the factory's destroy() mid-drag
reports cancelled once and the hook's unmount reports nothing; an opacity
decay is clamped to 0–1; a remount starts from resting values.

Rejected: reporting the last velocity on cancel; callbacks after unmount;
unclamped opacity; keeping values across an unmount (a new keyed element
could inherit an off-screen position while still taking keyboard focus).

## Open questions

None.
20 changes: 20 additions & 0 deletions internal/shadcn-registry/routes.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -1961,6 +1961,11 @@
"path": "examples/context-menu/bottom-sheet",
"aliases": []
},
{
"name": "example-create-animator-playing-cards-in-plain-java-script",
"path": "examples/create-animator/playing-cards-in-plain-java-script",
"aliases": []
},
{
"name": "example-date-input-clearable",
"path": "examples/date-input/clearable",
Expand Down Expand Up @@ -3636,6 +3641,11 @@
"path": "examples/typeahead/with-validation",
"aliases": []
},
{
"name": "example-use-animator-playing-cards",
"path": "examples/use-animator/playing-cards",
"aliases": []
},
{
"name": "example-use-app-shell-mobile-custom-mobile-trigger",
"path": "examples/use-app-shell-mobile/custom-mobile-trigger",
Expand Down Expand Up @@ -3776,11 +3786,21 @@
"path": "examples/visually-hidden/supplementary-context",
"aliases": []
},
{
"name": "hook-create-animator",
"path": "hooks/create-animator",
"aliases": []
},
{
"name": "hook-use-animated-layout",
"path": "hooks/use-animated-layout",
"aliases": []
},
{
"name": "hook-use-animator",
"path": "hooks/use-animator",
"aliases": []
},
{
"name": "hook-use-announce",
"path": "hooks/use-announce",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

/** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */
export const doc = {
type: 'block',
exampleFor: 'createAnimator',
name: 'createAnimator — Playing cards in plain JavaScript',
displayName: 'createAnimator — Playing cards in plain JavaScript',
description:
'The playing card from the useAnimator example, built in plain JavaScript with createAnimator and pointer events: it follows a drag, springs back on a gentle release, flies off with momentum on a quick flick, and a button discards it from the keyboard. React only mounts it.',
isReady: true,
aspectRatio: 4 / 3,
componentsUsed: [],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

'use client';

import {createAnimator} from '@astryxdesign/core';

const DECK = [
{face: 'A\u2660', name: 'Ace of spades'},
{face: 'K\u2665', name: 'King of hearts'},
{face: 'Q\u2663', name: 'Queen of clubs'},
{face: 'J\u2666', name: 'Jack of diamonds'},
];
/** A release faster than this (px/s) throws the card away. */
const THROW_SPEED = 1000;
/** The slowest throw, so a thrown card always leaves the table. */
const MIN_THROW = 1600;

/**
* A deck on a table, in plain JavaScript: no React below this line. It
* returns a function that takes it down again.
*/
function mountDeck(table: HTMLElement, discardButton: HTMLButtonElement) {
let top = 0;
const card = document.createElement('div');
card.textContent = DECK[0].face;
// An image's name is all a screen reader announces, so it names the card.
card.setAttribute('role', 'img');
card.setAttribute('aria-label', DECK[0].name);
Object.assign(card.style, {
width: '120px',
height: '170px',
display: 'grid',
placeItems: 'center',
font: 'bold 40px system-ui, sans-serif',
// The theme's surface and text, so the rank 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',
});
// Announces each discard to screen readers.
const status = document.createElement('div');
status.setAttribute('aria-live', 'polite');
Object.assign(status.style, {
position: 'absolute',
width: '1px',
height: '1px',
overflow: 'hidden',
clipPath: 'inset(50%)',
whiteSpace: 'nowrap',
});
table.append(card, status);
const animator = createAnimator(card);
// One discard at a time: a press while a card leaves deals no second card.
let isDiscarding = false;

// Announces the discard and deals the next card in place of the old one.
async function dealNext() {
status.textContent = `${DECK[top % DECK.length].name} discarded`;
top += 1;
const next = DECK[top % DECK.length];
card.textContent = next.face;
card.setAttribute('aria-label', next.name);
animator.set({x: 0, y: 0, rotate: 0, scale: 0.9, opacity: 0});
isDiscarding = false;
await animator.animate({scale: 1, opacity: 1});
}

// Starts a discard unless one runs. It also ends a drag in progress: the
// drag's next move would take the discard's motion over and drag the dealt
// card.
function startDiscard() {
if (isDiscarding) {
return false;
}
isDiscarding = true;
origin = null;
return true;
}

async function discard(vx: number, vy: number) {
if (!startDiscard()) {
return;
}
const speed = Math.hypot(vx, vy);
const boost = speed < MIN_THROW ? MIN_THROW / Math.max(speed, 1) : 1;
// The card coasts on while it fades; once it is gone, the next card is
// dealt in place, which ends the coast. A coast continues the person's
// throw, so Reduce Motion keeps it.
void animator.decay({x: speed < 1 ? MIN_THROW : vx * boost, y: vy * boost});
await animator.animate({opacity: 0});
await dealNext();
}

// The drag: follow the pointer, then read the 80 ms before the release, so
// a pointer that held still before letting go releases at rest.
let origin: {x: number; y: number} | null = null;
let samples: {t: number; x: number; y: number}[] = [];
function onDown(event: PointerEvent) {
// A leaving card takes no press. Pinning it would end its discard and deal
// the next card under the finger.
if (isDiscarding) {
return;
}
card.setPointerCapture(event.pointerId);
// Pins the card where it shows, so the drag starts there. Only the
// position stops, so a card fading in keeps fading in.
const {x, y, rotate} = animator.get();
animator.set({x, y, rotate});
origin = {x: event.clientX - x, y: event.clientY - y};
samples = [];
}
function onMove(event: PointerEvent) {
if (origin == null) {
return;
}
const x = event.clientX - origin.x;
const y = event.clientY - origin.y;
samples.push({t: event.timeStamp, x, y});
animator.set({x, y, rotate: x / 18});
}
// A cancelled pointer was not let go, so the card springs back.
function onCancel() {
if (origin == null) {
return;
}
origin = null;
void animator.animate({x: 0, y: 0, rotate: 0});
}
function onUp(event: PointerEvent) {
if (origin == null) {
return;
}
origin = null;
const recent = samples.filter(s => event.timeStamp - s.t <= 80);
const first = recent[0];
const last = recent.at(-1);
const seconds = last && first ? (last.t - first.t) / 1000 : 0;
const vx = seconds > 0 && last && first ? (last.x - first.x) / seconds : 0;
const vy = seconds > 0 && last && first ? (last.y - first.y) / seconds : 0;
if (Math.abs(vx) > THROW_SPEED) {
void discard(vx, vy);
} else {
void animator.animate(
{x: 0, y: 0, rotate: 0},
{velocity: {x: vx, y: vy}},
);
}
}
// The button moves the card off on the state-change motion: a planned
// move, which Reduce Motion turns into a fade where the card is.
async function discardWithButton() {
if (!startDiscard()) {
return;
}
await animator.animate({x: 400, opacity: 0});
await dealNext();
}
const onDiscard = () => void discardWithButton();

card.addEventListener('pointerdown', onDown);
card.addEventListener('pointermove', onMove);
card.addEventListener('pointerup', onUp);
card.addEventListener('pointercancel', onCancel);
discardButton.addEventListener('click', onDiscard);
return () => {
discardButton.removeEventListener('click', onDiscard);
animator.destroy();
card.remove();
status.remove();
};
}

/**
* Mounts the deck 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 button = wrapper.querySelector('button') as HTMLButtonElement;
return mountDeck(table, button);
}

export default function AnimatorPlayingCardsVanilla() {
return (
<div
ref={mountTable}
style={{display: 'grid', gap: 16, padding: 16, maxWidth: 360}}>
<div style={{height: 200, display: 'grid', placeItems: 'center'}} />
<button type="button">Discard the top card</button>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

/** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */
export const doc = {
type: 'block',
exampleFor: 'useAnimator',
name: 'useAnimator — Playing cards',
displayName: 'useAnimator — Playing cards',
description:
'A playing card that follows a drag: a gentle release springs it back on its release velocity, and a quick flick throws it away with momentum before the next card is dealt. A Discard button moves it off from the keyboard, fading it in place under Reduce Motion, and the discard is announced. Built only on the public useAnimator hook.',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional (docs accuracy): a keyboard user on a desktop has a fine pointer, so under Reduce Motion the Discard press removes the card at once rather than fading it. Suggest "…moves it off from the keyboard, fading it in place under Reduce Motion on a touch screen…" or drop the Reduce Motion clause.

isReady: true,
aspectRatio: 4 / 3,
componentsUsed: [
'Card',
'Button',
'Layout',
'Text',
'VisuallyHidden',
'useAnimator',
],
};
Loading
Loading