Skip to content
Merged
Show file tree
Hide file tree
Changes from 63 commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
c784084
spec(AST-094): touch presentations feel native by default
cixzhang Oct 24, 2026
0c35c43
chore(core): the UIKit constants table for touch presentations
vjeux Oct 24, 2026
17c181b
chore(core): UIKit's spring, decay, rubber-band and projection math
vjeux Oct 24, 2026
0901218
chore(core): a compositor animator for touch presentations
vjeux Oct 24, 2026
6bd2851
chore(core): the constants table names only what exists, and dates ev…
vjeux Oct 24, 2026
76be493
fix(core): paging past the last page lands on a real page
vjeux Oct 24, 2026
51aa3c8
fix(core): under Reduce Motion, a move keeps a running fade's target
vjeux Oct 24, 2026
7da6b3c
fix(core): an identity at rest leaves the element
vjeux Oct 24, 2026
4b3de64
Merge main into the constants table (lab typechecks with its tests)
vjeux Oct 25, 2026
530315f
Merge #8053 (main merged in) into the motion math
vjeux Oct 25, 2026
63aef34
Merge #8054 (main merged in) into the animator
vjeux Oct 25, 2026
e7df427
perf(core): the gesture table's sources move to JSDoc, out of the bundle
vjeux Oct 25, 2026
439dcc3
docs(spec): the draft AST-094 copy leaves this stack
vjeux Oct 25, 2026
0bf13da
Merge main (the deploy typecheck gate test reads the step as written)…
vjeux Oct 25, 2026
fb5b3ae
perf(core): the coast math and the scroll table leave the animator
vjeux Oct 25, 2026
bbbcb60
refactor(core): the platform presets are named for their use
vjeux Oct 25, 2026
7c5f71c
Merge main (67dc45f66d3) into #8054
cixzhang Oct 25, 2026
03f66c3
fix(core): the UIKit table keeps only what a touch presentation plays…
cixzhang Oct 25, 2026
1133bf9
Merge remote-tracking branch 'origin/main' into m8053
cixzhang Oct 25, 2026
eded944
style(core): MEASUREMENTS.md table widths after the dropped rows
cixzhang Oct 25, 2026
f9d0900
feat(core): the press release's curve is a press value
vjeux Oct 25, 2026
bbae49a
Merge main (cdd75aec) into the compositor animator
cixzhang Oct 25, 2026
9db9315
docs(core): the velocity weight says a rested finger releases at 0
vjeux Oct 25, 2026
946a6d0
fix(motion): the animator follows design:reduced-motion DR2-DR4
cixzhang Oct 25, 2026
5d3cab6
Merge #8053 (the table keeps only what a touch presentation plays; th…
vjeux Oct 25, 2026
daed39c
Merge #8054 (#8053's table, the press release's curve) into the animator
vjeux Oct 25, 2026
7e36bcd
fix(core): the table's notes point at what exists, and the press entr…
cixzhang Oct 25, 2026
c1fa660
Merge #8053's notes and press pins into the press release's curve and…
vjeux Oct 25, 2026
59a748f
Merge #8053 (the table's notes point at what exists; the press entrie…
vjeux Oct 25, 2026
129eebf
Merge #8054 (#8053's notes and press pins) into the animator
vjeux Oct 25, 2026
8dbc144
Merge #8054 (main with #8043's interaction proofs) into the animator
vjeux Oct 25, 2026
6ae3390
Merge main (#8043's interaction proofs) into the UIKit constants table
vjeux Oct 25, 2026
51b3ecb
Merge #8053 (main with #8043's interaction proofs) into the motion math
vjeux Oct 25, 2026
61f575f
perf(core): the animator reads the Reduce Motion fade, not the whole …
vjeux Oct 25, 2026
1f751c9
docs(core): M-S2 feeds scroll.decelerationRate, where the coast readi…
cixzhang Oct 25, 2026
96f8240
Merge #8053 (M-S2 feeds scroll.decelerationRate) into the press relea…
vjeux Oct 25, 2026
1ae9200
Merge #8053 (M-S2 feeds scroll.decelerationRate) into the motion math
vjeux Oct 25, 2026
615cf0a
Merge #8054 (M-S2 feeds scroll.decelerationRate) into the animator
vjeux Oct 25, 2026
16b597f
fix(motion): no call skips the touch cross-fade, and a mouse move lea…
cixzhang Oct 25, 2026
96bc965
Merge #8055's fix (no call skips the touch cross-fade; a mouse move l…
vjeux Oct 25, 2026
7234d44
Merge main (99ad3f6d906) into the motion math
cixzhang Oct 26, 2026
b232014
fix(motion): a touch dip borrows the element's own opacity and hands …
cixzhang Oct 26, 2026
2071394
Merge #8054 (7234d440: main with the landed constants table) into the…
cixzhang Oct 26, 2026
d463e85
fix(motion): an interrupted Reduce Motion fade finishes, a borrowed o…
cixzhang Oct 26, 2026
d3a2e41
fix(core): toSpring's clamp, FR6 for the rubber band's inverse, the p…
cixzhang Oct 26, 2026
4c6f769
fix(motion): under Reduce Motion, travel lands only while the element…
cixzhang Oct 26, 2026
fb9c3dc
perf(core): the keyboard notifications' duration has its own export
vjeux Oct 26, 2026
07e1428
Merge #8054 (toSpring's clamp, FR6 for the rubber band's inverse, the…
vjeux Oct 26, 2026
3dca43f
Merge main (16770e30070) into the motion math
cixzhang Oct 26, 2026
7221fca
Merge #8054 (3dca43f6: main with the per-group UIKit table) into the …
cixzhang Oct 26, 2026
ba87b89
test(motion): the UIKit table test reads values bound from an exporte…
cixzhang Oct 26, 2026
2f67199
Merge main (b58ca635, the motion math landed) into the animator
cixzhang Oct 26, 2026
0ceb667
Merge main (b58ca635: the landed motion math and gesture recognizer c…
vjeux Oct 26, 2026
73d10a6
fix(motion): an overlapping call's fade re-times an earlier call's pe…
cixzhang Oct 26, 2026
db11472
Merge vjeux's #8055 head (0ceb6675, main) into the overlap fix
cixzhang Oct 26, 2026
9824ce0
Revert "perf(core): the keyboard notifications' duration has its own …
vjeux Oct 26, 2026
47a6c65
fix(motion): a call that does not own opacity keeps a running borrowe…
cixzhang Oct 26, 2026
82ceea1
fix(motion): a borrowed dip that ended while detached hands the opaci…
cixzhang Oct 26, 2026
df1fba5
fix(motion): an unmount, a new element or dispose() hands a borrowed …
cixzhang Oct 26, 2026
bbec914
fix(motion): a new element taking over mid-dip keeps the dip, so the …
cixzhang Oct 26, 2026
5e41033
fix(motion): a running dip survives every attach, and an opacity the …
cixzhang Oct 26, 2026
d019124
Merge main (3ab7f32d: #8494's scroll-to-top note, #8488) into the ani…
cixzhang Oct 26, 2026
4874db4
docs(motion): the scroll-to-top note keeps main's wording: a tap-star…
cixzhang Oct 26, 2026
1e1b7bd
fix(motion): a hidden element lends no borrow, and a stop that finish…
cixzhang Oct 26, 2026
89614c7
Merge main (2af4d305: #8257, the motion roles internal) into the anim…
cixzhang Oct 26, 2026
15c71e1
fix(motion): a release past an edge springs back as a Reduce Motion c…
cixzhang Oct 26, 2026
effe685
docs(motion): decay's docs and the changeset say a transition curve c…
cixzhang Oct 26, 2026
7ad0455
fix(motion): a held identity follows a node replacement, a mixed rele…
cixzhang Oct 26, 2026
8fc7f2e
docs(motion): the animator's SYNC list and test header name only file…
cixzhang Oct 26, 2026
File filter

Filter by extension

Filter by extension

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

[chore] Internal: a compositor animator for touch presentations. It plays UIKit's springs, and scroll coasts (built by a separate coast module, so a surface that never coasts does not carry them; momentum is the only curve `decay` plays) as Web Animations on translate, rotate, scale and opacity, so they keep running while JavaScript is busy, and reads values and velocities back from the curves for interruption, retargeting and Reduce Motion cross-fades. Not exported from the package entry point; no component uses it yet.

@vjeux
74 changes: 70 additions & 4 deletions packages/core/src/motion/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
Internal motion foundation for the touch presentations of core components: the
UIKit constants table (every spring, duration, deceleration rate, rubber-band
constant, curve and gesture threshold a touch presentation uses, each with its
source) and the spring, decay, rubber-band and projection math UIKit uses.
Nothing here is exported from the package entry point. Core's motion and
gesture modules read the table by path; components take touch motion from the
theme's motion roles (`spec:AST-094` FR4), not from this table.
source), the spring, decay, rubber-band and projection math UIKit uses, and a
compositor animator that plays it. Nothing here is exported from the package
entry point. Core's motion and gesture modules read the table by path;
components take touch motion from the theme's motion roles (`spec:AST-094`
FR4), not from this table.

## Files

Expand All @@ -21,13 +22,78 @@ theme's motion roles (`spec:AST-094` FR4), not from this table.
| `MEASUREMENTS.md` | The UIKit reference captures each unconfirmed value still needs. |
| `spring.ts` | Closed-form damped spring, every Apple parameterization, settling duration, a spring chasing a moving target. |
| `decay.ts` | UIScrollView's momentum coast. |
| `coast.ts` | A release's momentum curves for `animator.decay`: the coast, the edge spring, a release past an edge. |
| `rubberBand.ts` | UIScrollView's resistance past an edge, and its inverse. |
| `projection.ts` | Release targets: the rest point nearest the projected coast; paging. |
| `bezier.ts` | Cubic-bézier timing functions (Core Animation's named curves). |
| `animator.ts` | Compositor animator: springs and momentum curves compiled to Web Animations, read back analytically. |

Component-specific values (a sheet's detents, a switch's geometry, a context
menu's timeline) live in a domain file per component, `uikit/<component>.ts`,
under the same rules.

## Animator

```
import {createAnimator} from '../motion/animator';
import {springs} from '../motion/uikit';

const animator = createAnimator(sheet);

// A drag writes one style per frame and never renders:
animator.set({y: offset});

// The finger lifts: spring to the detent with the release velocity (px/s).
const handle = animator.animate({y: detent}, {spring: springs.sheetDetent, velocity: {y: 1200}});

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 example plays the table's springs.sheetDetent directly, which spec:AST-094 FR4 rules out for a component ("A component MUST read touch motion through these roles and MUST NOT play a literal") and which line 9 of this README also says; passing a theme motion role here, as #8067's useAnimator example does, keeps the two consistent.


// A finger lands mid-flight: where is the sheet right now, how fast?
const y = animator.get('y', event.timeStamp);
const v = animator.getVelocity('y', event.timeStamp);
animator.stop(['y']); // freezes at the value on screen

// A throw on a scroll-like surface, with an edge (coast.ts builds the curves):
animator.decay(coast(animator, {x: velocityX}, {bounds: {x: {min: -maxScroll, max: 0}}}));

await handle.finished; // true if it ran to the end, false if interrupted
```

- Drives `x`, `y`, `rotate`, `scaleX`, `scaleY` (`scale` sets both) and
`opacity`, through the individual CSS properties `translate`, `rotate`,
`scale` and `opacity`. The element's `transform` stays the component's.
- `get` / `getVelocity` evaluate the curves; the DOM is never read.
- `animate` always starts from the presentation value with the presentation
velocity unless a velocity is handed in, so retargeting is continuous
(`handle.retarget(to)` keeps the call's spring).
- Everything one call starts shares one start time; `{at}` puts several
animators on one clock.
- Reduced Motion (`prefers-reduced-motion: reduce`, `design:reduced-motion`
DR2–DR4): on a coarse primary pointer every `animate` cross-fades within
200 ms. An entrance lands and fades in, an exit fades out then moves, any
other move dips (fade out, move, fade in), and an opacity change fades, over
`timing.reducedMotionCrossFadeMs` capped at 200 ms. On any other pointer every
change completes at once. No call keeps travel or a spring or skips the
touch cross-fade. A `decay` keeps moving: momentum after a throw is
direct movement.

### How it stays on the compositor

Each curve is baked into keyframes, sampled adaptively until linear
interpolation is within 0.05 px, and played with `easing: linear` and an
explicit `startTime`. The element's inline style already holds the end values,
and the keyframes hold the end state for a few seconds past the curve's end;
the main thread drops the animation as soon as it runs after that end. So the
frame an animation ends needs no main thread and shows no flicker, even when
the main thread is blocked as the curve ends (WebKit's compositor otherwise
falls back to the last value its main thread committed, mid-flight).

Keyframes rather than one `linear()` easing: measured in Chromium, both are
composited and, for the same samples, equally precise; but WebKit's Core
Animation path does not accelerate an effect whose easing is `linear()` with
points (`KeyframeEffect::canBeAccelerated`) unless its newer threaded
animations are on, so on older iOS Safari a `linear()` spring would run on the
main thread. Linear segments between keyframes map directly onto Core
Animation keyframe animations.

## Math

`spring.ts` solves m·x″ + c·x′ + k·x = 0 in closed form in all three regimes:
Expand Down
Loading