From 628ef6fc2c423b47d2fd59a3cfff1240c54c09f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 02:56:35 +0000 Subject: [PATCH 01/12] enhance(website): Physical spring motion, starting with the Store drawer Adds website/src/components/motion: a small motion system built on FLIP and damped springs that runs on the compositor. - spring.ts: closed-form springs described by duration and bounce; samples keyframes and CSS linear() easings - tokens.ts / css.ts: named springs (snappy, smooth), injected as CSS custom properties by a docusaurus plugin, instant under reduced motion - glide.ts: translate-only Web Animations that keep their velocity when a new target interrupts them - MotionGroup / useLayoutMotion / Reveal: measure before and after a commit, glide members to their new place, slide presences in and out along their flex container's main axis The playground's Store now opens and closes as a drawer: the toggle and panel move as one, the panel's contents render a frame after it starts moving, and a click mid-flight reverses it with its momentum. In row layout the result stays rendered (inert) under the drawer instead of display: none. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Fs5XNeMpXHVoVCow69FnHB --- .circleci/config.yml | 7 +- .claude/rules/ci-config.md | 2 +- .cursor/rules/ci-config.mdc | 2 +- jest.config.js | 16 +- website/docusaurus.config.ts | 8 + website/src/components/Playground/README.md | 5 +- .../components/Playground/preview/Preview.tsx | 26 ++- .../Playground/preview/StoreInspector.tsx | 16 +- .../components/Playground/styles.module.css | 23 +- website/src/components/motion/MotionGroup.tsx | 208 ++++++++++++++++++ website/src/components/motion/README.md | 92 ++++++++ website/src/components/motion/Reveal.tsx | 35 +++ .../motion/__tests__/MotionGroup.test.tsx | 99 +++++++++ .../components/motion/__tests__/css.test.ts | 14 ++ .../motion/__tests__/spring.test.ts | 68 ++++++ website/src/components/motion/css.ts | 24 ++ website/src/components/motion/glide.ts | 72 ++++++ website/src/components/motion/index.ts | 6 + website/src/components/motion/spring.ts | 86 ++++++++ website/src/components/motion/tokens.ts | 12 + 20 files changed, 790 insertions(+), 31 deletions(-) create mode 100644 website/src/components/motion/MotionGroup.tsx create mode 100644 website/src/components/motion/README.md create mode 100644 website/src/components/motion/Reveal.tsx create mode 100644 website/src/components/motion/__tests__/MotionGroup.test.tsx create mode 100644 website/src/components/motion/__tests__/css.test.ts create mode 100644 website/src/components/motion/__tests__/spring.test.ts create mode 100644 website/src/components/motion/css.ts create mode 100644 website/src/components/motion/glide.ts create mode 100644 website/src/components/motion/index.ts create mode 100644 website/src/components/motion/spring.ts create mode 100644 website/src/components/motion/tokens.ts diff --git a/.circleci/config.yml b/.circleci/config.yml index 3454ad5b7134..83822bea28c8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -103,9 +103,9 @@ jobs: ESMODULE_RELEVANT=false fi # Docs/website/tooling-only changes can't affect lint, typecheck or - # unit tests (Playground has unit tests, so it stays relevant). + # unit tests (Playground and motion have unit tests, so they stay relevant). if ! grep -Evq "^(${DOCS_ONLY})" /tmp/ci-changed-files \ - && ! grep -q '^website/src/components/Playground/' /tmp/ci-changed-files; then + && ! grep -Eq '^website/src/components/(Playground|motion)/' /tmp/ci-changed-files; then echo "Only docs/website/tooling paths changed; test jobs will halt." TESTS_RELEVANT=false fi @@ -184,8 +184,9 @@ jobs: - project/node_modules - project/packages - project/scripts - # Playground unit tests (transformCode, codeModel); rest of website omitted + # Playground and motion unit tests; rest of website omitted - project/website/src/components/Playground + - project/website/src/components/motion - project/.yarnrc.yml - project/babel.config.js - project/eslint.config.mjs diff --git a/.claude/rules/ci-config.md b/.claude/rules/ci-config.md index 7509cb372c7c..7fa713e54957 100644 --- a/.claude/rules/ci-config.md +++ b/.claude/rules/ci-config.md @@ -13,7 +13,7 @@ paths: - Jest `--maxWorkers` is pinned per job to the `resource_class` vCPU count (large = 4, medium = 2) because docker containers report the host's CPUs via `os.cpus()`. Exception: the ReactNative `unit_tests` run is deliberately uncapped — its suites are fake-timer-wait dominated and capping workers flakes 5s test timeouts. - Jobs halt via the `halt-unless-relevant-change` command based on flags computed once in `setup` (`.ci-esmodule-relevant`, `.ci-tests-relevant`) and transported via `save_cache`/`restore_cache` (keyed on `CIRCLE_SHA1`) so jobs can halt before paying `attach_workspace`. Missing/unreadable flags fail open (jobs run). On the default branch both flags are always true (a push may carry several commits). The diff uses `--no-renames` so moving a file out of a relevant dir still counts. - `esmodule` (validate-esmodule-browser-build, esmodule-types*): a denylist, so new paths fail open. False only when every changed path is provably outside the esmodule jobs' inputs: the shared `DOCS_ONLY` paths (also the `tests` denylist) plus `.vscode/`, `plans/`, root `__tests__/` (excluded by every `tsconfig.compile.json`), `eslint.config.mjs`, `jest.config.js`, `examples/*.md`, and examples the jobs never build (`benchmark`, `benchmark-react`, `coin-app`, `nextjs`, `normalizr-github`, `normalizr-redux`, `test-bundlesize`, `vue-todo-app`). Only add a path if no esmodule job (or the `setup` builds feeding them) reads it. - - `tests` (lint, typecheck, unit_tests, node_matrix): false only when every changed path is docs/website/tooling (`website/`, `docs/`, `.changeset/`, `.cursor/`, `.agents/`, `.claude/`, `.github/`, root `*.md`), except `website/src/components/Playground/` (has unit tests). When both flags are false, `setup` halts before install. + - `tests` (lint, typecheck, unit_tests, node_matrix): false only when every changed path is docs/website/tooling (`website/`, `docs/`, `.changeset/`, `.cursor/`, `.agents/`, `.claude/`, `.github/`, root `*.md`), except `website/src/components/Playground/` and `website/src/components/motion/` (have unit tests). When both flags are false, `setup` halts before install. - Legacy TS types (`ci:build:legacy-types`, consumed by `esmodule-types`): - Built inside `setup` (`ci:build:setup:esmodule`) only when the esmodule flag is set; there is no separate job, to keep a job hop off the critical path. - CI builds the endpoint, normalizr and rest legacy outputs, all for TS >= 4.0 (the minimum supported TS, and the oldest in the `esmodule-types` matrix). `use-enhanced-reducer` still ships a `ts3.4` build in release builds (`build:types`). diff --git a/.cursor/rules/ci-config.mdc b/.cursor/rules/ci-config.mdc index fdf8974e6368..76cc5bd39dc9 100644 --- a/.cursor/rules/ci-config.mdc +++ b/.cursor/rules/ci-config.mdc @@ -11,7 +11,7 @@ alwaysApply: false - Jest `--maxWorkers` is pinned per job to the `resource_class` vCPU count (large = 4, medium = 2) because docker containers report the host's CPUs via `os.cpus()`. Exception: the ReactNative `unit_tests` run is deliberately uncapped — its suites are fake-timer-wait dominated and capping workers flakes 5s test timeouts. - Jobs halt via the `halt-unless-relevant-change` command based on flags computed once in `setup` (`.ci-esmodule-relevant`, `.ci-tests-relevant`) and transported via `save_cache`/`restore_cache` (keyed on `CIRCLE_SHA1`) so jobs can halt before paying `attach_workspace`. Missing/unreadable flags fail open (jobs run). On the default branch both flags are always true (a push may carry several commits). The diff uses `--no-renames` so moving a file out of a relevant dir still counts. - `esmodule` (validate-esmodule-browser-build, esmodule-types*): a denylist, so new paths fail open. False only when every changed path is provably outside the esmodule jobs' inputs: the shared `DOCS_ONLY` paths (also the `tests` denylist) plus `.vscode/`, `plans/`, root `__tests__/` (excluded by every `tsconfig.compile.json`), `eslint.config.mjs`, `jest.config.js`, `examples/*.md`, and examples the jobs never build (`benchmark`, `benchmark-react`, `coin-app`, `nextjs`, `normalizr-github`, `normalizr-redux`, `test-bundlesize`, `vue-todo-app`). Only add a path if no esmodule job (or the `setup` builds feeding them) reads it. - - `tests` (lint, typecheck, unit_tests, node_matrix): false only when every changed path is docs/website/tooling (`website/`, `docs/`, `.changeset/`, `.cursor/`, `.agents/`, `.claude/`, `.github/`, root `*.md`), except `website/src/components/Playground/` (has unit tests). When both flags are false, `setup` halts before install. + - `tests` (lint, typecheck, unit_tests, node_matrix): false only when every changed path is docs/website/tooling (`website/`, `docs/`, `.changeset/`, `.cursor/`, `.agents/`, `.claude/`, `.github/`, root `*.md`), except `website/src/components/Playground/` and `website/src/components/motion/` (have unit tests). When both flags are false, `setup` halts before install. - Legacy TS types (`ci:build:legacy-types`, consumed by `esmodule-types`): - Built inside `setup` (`ci:build:setup:esmodule`) only when the esmodule flag is set; there is no separate job, to keep a job hop off the critical path. - CI builds the endpoint, normalizr and rest legacy outputs, all for TS >= 4.0 (the minimum supported TS, and the oldest in the `esmodule-types` matrix). `use-enhanced-reducer` still ships a `ts3.4` build in release builds (`build:types`). diff --git a/jest.config.js b/jest.config.js index 1dab51d128b7..04402ea41909 100644 --- a/jest.config.js +++ b/jest.config.js @@ -57,17 +57,17 @@ const packages = [ 'test', ]; -// CircleCI persist_to_workspace omits most of website/; only include this root -// when the tree is present (full checkout / when CI persists Playground). -const playgroundRoot = path.join( - __dirname, +// CircleCI persist_to_workspace omits most of website/; only include these +// roots when the tree is present (full checkout / when CI persists them). +const websiteRoots = [ 'website/src/components/Playground', -); + 'website/src/components/motion', +]; const reactDomRoots = [ ...packages.map(pkgName => `/packages/${pkgName}/src`), - ...(fs.existsSync(playgroundRoot) ? - ['/website/src/components/Playground'] - : []), + ...websiteRoots + .filter(root => fs.existsSync(path.join(__dirname, root))) + .map(root => `/${root}`), ]; const projects = [ diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index c2502d003fcc..7c423d98f011 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -7,6 +7,7 @@ import path from 'path'; import { themes } from 'prism-react-renderer'; import gqlRedirects from './gqlRedirects'; +import { motionCss } from './src/components/motion/css'; import versions from './versions.json'; // Keep Monaco CDN preload hashes in sync with the installed monaco-editor package. @@ -299,6 +300,13 @@ const config: Config = { ], ], plugins: [ + // global motion styles (spring tokens, ): website/src/components/motion/css.ts + () => ({ + name: 'motion-css', + injectHtmlTags: () => ({ + headTags: [{ tagName: 'style', innerHTML: motionCss() }], + }), + }), [ '@docusaurus/plugin-content-docs', { diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md index 1d3b129aee22..0b1f17ac92c8 100644 --- a/website/src/components/Playground/README.md +++ b/website/src/components/Playground/README.md @@ -125,7 +125,10 @@ DesignSystem/ components injected into preview scope - Each playground gets its own `DataProvider` store (`MockResolver` serves `fixtures`); `memo(Preview)` keeps it from re-rendering on code edits. - Store inspector open state persists per `groupId` via tab storage and - avoids scroll jumps; in `row` layout it replaces the result while open. + avoids scroll jumps; in `row` layout it covers the result while open (the + result stays rendered underneath, `inert`). It opens and closes as a + drawer (`../motion`: the toggle glides, the panel `Reveal`s); the panel's + contents render a frame after it starts moving (`useDeferredValue`). - `renderCount` wraps the live result in a `` and shows its commit count in the preview header (written to the DOM, so counting adds no commits). `website/profiling-plugin.js` replaces `react-dom/client` with React's diff --git a/website/src/components/Playground/preview/Preview.tsx b/website/src/components/Playground/preview/Preview.tsx index e81d49e320bb..b78d76d38516 100644 --- a/website/src/components/Playground/preview/Preview.tsx +++ b/website/src/components/Playground/preview/Preview.tsx @@ -15,6 +15,7 @@ import React, { type ProfilerOnRenderCallback, } from 'react'; +import { MotionGroup } from '../../motion'; import Boundary from '../Boundary'; import StoreInspector from './StoreInspector'; import { useTabStorage } from '../../../utils/tabStorage'; @@ -55,7 +56,7 @@ function Preview({ [], ); - const hiddenResult = row && selectedValue === 'y'; + const coveredResult = row && selectedValue === 'y'; return ( ({ silenceMissing={true} getInitialInterceptorData={getInitialInterceptorData} > -
- - - -
- + +
+ + + +
+ +
); diff --git a/website/src/components/Playground/preview/StoreInspector.tsx b/website/src/components/Playground/preview/StoreInspector.tsx index 81d861af8e89..0b05d9ab697e 100644 --- a/website/src/components/Playground/preview/StoreInspector.tsx +++ b/website/src/components/Playground/preview/StoreInspector.tsx @@ -1,7 +1,8 @@ import { StateContext } from '@data-client/react'; import clsx from 'clsx'; -import React, { useContext, memo, useMemo } from 'react'; +import React, { useContext, useDeferredValue, memo, useMemo } from 'react'; +import { Reveal, useLayoutMotion } from '../../motion'; import styles from '../styles.module.css'; import Tree from './Tree'; @@ -13,12 +14,16 @@ function StoreInspector({ toggle: React.MouseEventHandler; }) { const isSelected = selectedValue === 'y'; + // the empty drawer starts moving at once; the tree renders a frame later + const showTree = useDeferredValue(isSelected); return ( <> - {isSelected ? - - : null} + + {showTree ? + + : null} + ); } @@ -32,8 +37,9 @@ export function StoreToggle({ onClick?: React.MouseEventHandler; open?: boolean; }) { + const ref = useLayoutMotion(); return ( -
+
Store .playgroundHeader, } .arrow { - transition: all 200ms ease 0s; + transition: transform var(--motion-snappy); transform-origin: 45% 50% 0px; position: relative; display: inline-block; @@ -200,6 +200,9 @@ div.playgroundTextEdit > .playgroundHeader, .playgroundResult { display: flex; height: 100%; + /* frame for the Store drawer as it slides in and out */ + position: relative; + isolation: isolate; } .debugToggle { @@ -217,6 +220,24 @@ div.playgroundTextEdit > .playgroundHeader, flex: 0 0 auto; } +/* The Store drawer (painted before its contents render) */ +.storePanel { + flex: 4 1 40%; + min-width: 0; + background: var(--monoco-code-background); +} +[data-theme='dark'] .storePanel { + background: var(--ifm-pre-background); +} + +/* Row layout with the Store open: the result stays underneath, so the Store + slides over real content instead of an empty frame */ +.covered { + position: absolute; + inset: 0; + z-index: -1; +} + .debugToggle:hover { background-color: var(--pg-tab-hover-bg); color: var(--pg-tab-hover); diff --git a/website/src/components/motion/MotionGroup.tsx b/website/src/components/motion/MotionGroup.tsx new file mode 100644 index 000000000000..00d5378f3608 --- /dev/null +++ b/website/src/components/motion/MotionGroup.tsx @@ -0,0 +1,208 @@ +import React, { + Component, + createContext, + useCallback, + useContext, + useLayoutEffect, + useRef, + type RefCallback, + type RefObject, +} from 'react'; + +import { glide, velocityOf, stop, ORIGIN, type Point } from './glide'; +import type { Spring } from './spring'; +import { springs } from './tokens'; + +/** How a member enters and leaves; absent for members that only move */ +interface Presence { + /** Leaving: slides out, then `onExited` should unmount it */ + exiting: boolean; + onExited: () => void; +} +type Members = Map>; + +interface Snapshot { + /** Position on screen (including any glide in flight), parent-relative */ + at: Point; + velocity: Point; + /** Layout box (no transforms), to pin a presence where it was if it exits */ + box?: Box; +} +interface Box { + left: number; + top: number; + width: number; + height: number; +} + +interface Move { + el: HTMLElement; + presence?: Presence; + from: Point; + to: Point; + velocity?: Point; +} + +const GroupContext = createContext(null); + +interface Props { + /** Layout only animates when this changes (e.g. the open state) */ + layoutDependency: unknown; + spring?: Spring; + children: React.ReactNode; +} + +/** + * Animates layout changes React commits inside it: members glide from where + * they were on screen to their new place (FLIP, translate only, so content + * never distorts), and ``s slide in and out along their container's + * flow. One spring drives every member, so things that move together read as + * one physical object. A change mid-flight keeps each member's momentum. + * + * Measures before React touches the DOM, so it must re-render with the + * change it animates: put it where that state lives, and pass that state as + * `layoutDependency`. + */ +export default class MotionGroup extends Component { + private members: Members = new Map(); + + getSnapshotBeforeUpdate(prev: Props): Map | null { + if (Object.is(prev.layoutDependency, this.props.layoutDependency)) + return null; + const snapshots = new Map(); + // reduced motion: nothing to measure, everything lands in place + if (prefersReducedMotion()) return snapshots; + for (const [el, presence] of this.members) { + snapshots.set(el, { + at: parentRelative(el), + velocity: velocityOf(el), + box: + presence.current ? + { + left: el.offsetLeft, + top: el.offsetTop, + width: el.offsetWidth, + height: el.offsetHeight, + } + : undefined, + }); + } + return snapshots; + } + + componentDidUpdate( + _props: unknown, + _state: unknown, + snapshots: Map | null, + ) { + if (!snapshots) return; + if (prefersReducedMotion()) { + for (const { current } of this.members.values()) + if (current?.exiting) current.onExited(); + return; + } + const { spring = springs.smooth } = this.props; + // settle the final layout before measuring anything + for (const [el, { box }] of snapshots) { + stop(el); + if (box && this.members.get(el)?.current?.exiting) pin(el, box); + else unpin(el); + } + // measure everything, then start animations (one style recalc) + const moves: Move[] = []; + for (const [el, { current: presence }] of this.members) { + const before = snapshots.get(el); + if (!before) { + // just mounted: a presence arrives from the end of its container's flow + if (presence) moves.push({ el, from: exitOffset(el), to: ORIGIN }); + continue; + } + const at = parentRelative(el); + moves.push({ + el, + presence, + from: { x: before.at.x - at.x, y: before.at.y - at.y }, + to: presence?.exiting ? exitOffset(el) : ORIGIN, + velocity: before.velocity, + }); + } + for (const { el, presence, ...path } of moves) { + const animation = glide(el, spring, path); + if (presence?.exiting) { + if (animation) animation.onfinish = presence.onExited; + else presence.onExited(); + } + } + } + + render() { + return ( + {this.props.children} + ); + } +} + +/** Ref joining an element to the nearest `` */ +export function useMember(presence?: Presence): RefCallback { + const members = useContext(GroupContext); + const latest = useRef(presence); + // runs before the group's componentDidUpdate in the same commit + useLayoutEffect(() => { + latest.current = presence; + }); + return useCallback( + (el: HTMLElement | null) => { + if (!el || !members) return; + members.set(el, latest); + return () => { + members.delete(el); + }; + }, + [members], + ); +} + +function parentRelative(el: HTMLElement): Point { + const rect = el.getBoundingClientRect(); + const parent = el.parentElement?.getBoundingClientRect() ?? rect; + return { x: rect.left - parent.left, y: rect.top - parent.top }; +} + +/** Just past the end of the parent's main axis */ +function exitOffset(el: HTMLElement): Point { + const parent = el.parentElement; + if (!parent) return ORIGIN; + return getComputedStyle(parent).flexDirection.startsWith('column') ? + { x: 0, y: Math.max(parent.clientHeight - el.offsetTop, el.offsetHeight) } + : { x: Math.max(parent.clientWidth - el.offsetLeft, el.offsetWidth), y: 0 }; +} + +const unpinned = new WeakMap(); +/** Takes an exiting element out of flow, so siblings take its space at once */ +function pin(el: HTMLElement, { left, top, width, height }: Box) { + if (!unpinned.has(el)) unpinned.set(el, el.style.cssText); + Object.assign(el.style, { + position: 'absolute', + boxSizing: 'border-box', + left: `${left}px`, + top: `${top}px`, + width: `${width}px`, + height: `${height}px`, + }); +} +function unpin(el: HTMLElement) { + const cssText = unpinned.get(el); + if (cssText === undefined) return; + el.style.cssText = cssText; + unpinned.delete(el); +} + +// same as @docusaurus/theme-common's, but safe without matchMedia (jsdom) +function prefersReducedMotion() { + return !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; +} + +/** Ref for an element that glides to its new place instead of jumping */ +export function useLayoutMotion() { + return useMember(); +} diff --git a/website/src/components/motion/README.md b/website/src/components/motion/README.md new file mode 100644 index 000000000000..48f2256d5725 --- /dev/null +++ b/website/src/components/motion/README.md @@ -0,0 +1,92 @@ +# Motion + +Physical, interruptible animation for the website. Things move like objects +with mass: they take time to get going, settle without a hard stop, and a +change of mind mid-flight turns them around with their momentum instead of +restarting. + +## Using it + +Pick motion by what moves, never by milliseconds: + +| Token | For | +| ---------------- | ------------------------------------------- | +| `springs.snappy` | small, light things: arrows, chips, toggles | +| `springs.smooth` | panels and drawers that carry content | + +**CSS transitions** (state changes styled by a class, like a rotating arrow): + +```css +.arrow { + transition: transform var(--motion-snappy); +} +``` + +**Layout changes** (something opens, so things move): + +```tsx +import { MotionGroup, Reveal, useLayoutMotion } from '../motion'; + +function Drawer({ open }: { open: boolean }) { + return ( + // where the state lives; layoutDependency says which change to animate + + + + {/* slides in from the end of its flex container, out the same way */} + + + + + ); +} + +function Handle() { + // inside the group: glides to its new spot instead of jumping + const ref = useLayoutMotion(); + return
; +} +``` + +`Reveal` follows the flex direction, so a panel that slides in sideways on +desktop rises from the bottom when a container query stacks it. It slides +over its siblings, and its contents fill it: clip it with `overflow: hidden` +on an ancestor, and make its parent `position: relative` (exits are pinned +there while they leave). + +Reduced motion (`prefers-reduced-motion: reduce`) lands everything in place +instantly, in CSS and JS alike. + +## Why it is built this way + +- **FLIP, translate only.** React commits the final layout at once; each + member is measured before and after, then animated from where it was to + where it is with `translate`. Content is never scaled, so text never + distorts, and layout is never animated, so no frame re-runs layout. +- **Compositor-driven.** Animations are Web Animations with keyframes sampled + from the spring, so the browser runs them off the main thread. Opening the + Store mounts an expensive tree in the same moment; a JS (rAF) animation + would stutter exactly then, this one does not. +- **Springs, not easing curves.** `spring.ts` solves a damped harmonic + oscillator in closed form from a perceptual `duration` and `bounce`. + Knowing the exact position and velocity at any instant is what lets an + interruption continue smoothly. +- **Not React ``** (React 19.3): it animates snapshots, blocks + input while running, can't hand velocity to a reversal, and only runs for + transition updates (the Store's open state is a synchronous store). +- **Not Motion (framer-motion) `layout`**: it computes layout animations on + the main thread every frame, and adds tens of KB. + +## Files + +``` +spring.ts spring physics, keyframe sampling, CSS linear() easing +tokens.ts the named springs +css.ts global CSS (springs as custom properties, Reveal's box), + injected into every page by a plugin in docusaurus.config.ts +glide.ts runs and retargets one element's translate (Web Animations) +MotionGroup.tsx measures before/after a commit; drives members; useLayoutMotion +Reveal.tsx presence: mount, slide in, slide out, unmount +``` + +Tests: `yarn test --selectProjects ReactDOM --testPathPatterns website/src/components/motion` diff --git a/website/src/components/motion/Reveal.tsx b/website/src/components/motion/Reveal.tsx new file mode 100644 index 000000000000..04eee8ed2b96 --- /dev/null +++ b/website/src/components/motion/Reveal.tsx @@ -0,0 +1,35 @@ +import clsx from 'clsx'; +import React, { useRef, useState } from 'react'; + +import { useMember } from './MotionGroup'; + +/** + * Shows `children` while `show`, sliding in from and back out past the end of + * its flex container (so it follows the layout: sideways in a row, up from + * the bottom when stacked). Stays mounted until its exit finishes, so + * reopening mid-exit just turns it around. Must be inside a ``. + * + * It slides over its siblings; clip it with `overflow: hidden` on an ancestor. + */ +export default function Reveal({ + show, + className, + children, +}: { + show: boolean; + /** Layout of the sliding box: it is the flex item; its children fill it */ + className?: string; + children: React.ReactNode; +}) { + const [mounted, setMounted] = useState(show); + if (show && !mounted) setMounted(true); + // keep showing what it had while it slides out + const shown = useRef(children); + if (show) shown.current = children; + const ref = useMember({ exiting: !show, onExited: () => setMounted(false) }); + return mounted ? +
+ {shown.current} +
+ : null; +} diff --git a/website/src/components/motion/__tests__/MotionGroup.test.tsx b/website/src/components/motion/__tests__/MotionGroup.test.tsx new file mode 100644 index 000000000000..414b230dba00 --- /dev/null +++ b/website/src/components/motion/__tests__/MotionGroup.test.tsx @@ -0,0 +1,99 @@ +/// + +import { act, render, screen } from '@testing-library/react'; +import React from 'react'; + +import { MotionGroup, Reveal, useLayoutMotion } from '..'; + +// jsdom has no layout or Web Animations: give every box a size and record +// the animations started +const PARENT_WIDTH = 300; +const animations: { + el: Element; + keyframes: Keyframe[]; + animation: Partial; +}[] = []; +beforeEach(() => { + animations.length = 0; + jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(100); + jest + .spyOn(HTMLElement.prototype, 'clientWidth', 'get') + .mockReturnValue(PARENT_WIDTH); + HTMLElement.prototype.animate = function (keyframes: any) { + const animation: Partial = { + playState: 'running', + currentTime: 0, + cancel: jest.fn(), + onfinish: null, + }; + animations.push({ el: this, keyframes, animation }); + return animation as Animation; + }; +}); +afterEach(() => { + jest.restoreAllMocks(); + delete (HTMLElement.prototype as any).animate; +}); + +function Handle() { + return
; +} +function Drawer({ open, label = 'panel' }: { open: boolean; label?: string }) { + return ( + + + {label} + + ); +} + +it('does not animate what is there on first render', () => { + render(); + expect(screen.getByText('panel')).toBeTruthy(); + expect(animations).toEqual([]); +}); + +it('slides a revealed element in from the end of its container', () => { + const { rerender } = render(); + rerender(); + const panel = screen.getByText('panel'); + const enter = animations.find(({ el }) => el === panel); + expect(enter?.keyframes[0].translate).toBe(`${PARENT_WIDTH}px 0px`); + expect(enter?.keyframes.at(-1)?.translate).toBe('0px 0px'); +}); + +it('keeps an exiting element, with its last content, until it slides out', () => { + const { rerender } = render(); + rerender(); + const exit = animations.find(({ el }) => el.textContent === 'first'); + expect(exit?.keyframes.at(-1)?.translate).toBe(`${PARENT_WIDTH}px 0px`); + expect((exit?.el as HTMLElement).style.position).toBe('absolute'); + + act(() => (exit?.animation.onfinish as any)()); + expect(screen.queryByText('first')).toBeNull(); +}); + +it('turns around mid-exit instead of remounting', () => { + const { rerender } = render(); + rerender(); + const panel = screen.getByText('panel'); + rerender(); + expect(screen.getByText('panel')).toBe(panel); + expect(panel.style.position).toBe(''); +}); + +it('only measures when layoutDependency changes', () => { + const rect = jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect'); + const { rerender } = render(); + rerender(); + expect(rect).not.toHaveBeenCalled(); +}); + +it('lands in place when the user prefers reduced motion', () => { + window.matchMedia = jest.fn().mockReturnValue({ matches: true }); + const { rerender } = render(); + rerender(); + expect(animations).toEqual([]); + expect(screen.queryByText('panel')).toBeNull(); + delete (window as any).matchMedia; +}); diff --git a/website/src/components/motion/__tests__/css.test.ts b/website/src/components/motion/__tests__/css.test.ts new file mode 100644 index 000000000000..39cb3d34e288 --- /dev/null +++ b/website/src/components/motion/__tests__/css.test.ts @@ -0,0 +1,14 @@ +import { motionCss } from '../css'; +import { springEasing } from '../spring'; +import { springs } from '../tokens'; + +it('declares every spring, instant under reduced motion', () => { + const css = motionCss(); + for (const [name, spring] of Object.entries(springs)) { + const { duration, easing } = springEasing(spring); + expect(css).toContain(`--motion-${name}: ${duration}ms ${easing};`); + expect(css).toMatch( + new RegExp(`prefers-reduced-motion: reduce.*--motion-${name}: 0s;`), + ); + } +}); diff --git a/website/src/components/motion/__tests__/spring.test.ts b/website/src/components/motion/__tests__/spring.test.ts new file mode 100644 index 000000000000..6060446a8f73 --- /dev/null +++ b/website/src/components/motion/__tests__/spring.test.ts @@ -0,0 +1,68 @@ +import { sampleSpring, springAt, springEasing, SAMPLE_RATE } from '../spring'; + +const smooth = { duration: 0.4, bounce: 0.15 }; +const critical = { duration: 0.4, bounce: 0 }; + +describe('springAt', () => { + it.each([smooth, critical])('starts where it is told (%o)', spring => { + expect(springAt(spring, { offset: 100, velocity: -50 }, 0)).toEqual({ + offset: 100, + velocity: -50, + }); + }); + + it.each([smooth, critical])( + 'velocity is the derivative of offset (%o)', + spring => { + const start = { offset: 80, velocity: 300 }; + const h = 1e-6; + for (const t of [0.05, 0.13, 0.3]) { + const numeric = + (springAt(spring, start, t + h).offset - + springAt(spring, start, t - h).offset) / + (2 * h); + expect(springAt(spring, start, t).velocity).toBeCloseTo(numeric, 3); + } + }, + ); + + it('critically damped never overshoots from rest', () => { + const samples = sampleSpring(critical, { offset: 1, velocity: 0 }, 1e-4); + expect(Math.min(...samples)).toBeGreaterThanOrEqual(0); + }); + + it('bounce overshoots', () => { + const samples = sampleSpring(smooth, { offset: 1, velocity: 0 }, 1e-4); + expect(Math.min(...samples)).toBeLessThan(0); + }); + + it('carries momentum: a moving start keeps going before returning', () => { + const { offset } = springAt(smooth, { offset: 0, velocity: 500 }, 0.05); + expect(offset).toBeGreaterThan(0); + }); +}); + +describe('sampleSpring', () => { + it('ends exactly at the target after roughly the visual duration', () => { + const samples = sampleSpring(smooth, { offset: 300, velocity: 0 }, 0.25); + expect(samples[0]).toBe(300); + expect(samples.at(-1)).toBe(0); + const seconds = (samples.length - 1) / SAMPLE_RATE; + expect(seconds).toBeGreaterThan(smooth.duration); + expect(seconds).toBeLessThan(smooth.duration * 2); + }); + + it('is a single frame when already at rest', () => { + expect(sampleSpring(smooth, { offset: 0, velocity: 0 }, 0.25)).toEqual([ + 0, 0, + ]); + }); +}); + +describe('springEasing', () => { + it('is a CSS linear() from 0 to 1', () => { + const { easing, duration } = springEasing(smooth); + expect(easing).toMatch(/^linear\(0, .*, 1\)$/); + expect(duration).toBeGreaterThan(400); + }); +}); diff --git a/website/src/components/motion/css.ts b/website/src/components/motion/css.ts new file mode 100644 index 000000000000..5dc67660ab82 --- /dev/null +++ b/website/src/components/motion/css.ts @@ -0,0 +1,24 @@ +import { springEasing } from './spring'; +import { springs } from './tokens'; + +/** + * Global motion styles, injected into every page's by the + * `motion-css` plugin in docusaurus.config.ts: + * - each spring as a CSS custom property (`transition: rotate var(--motion-snappy)`), + * instant under reduced motion + * - ``'s box, which its contents fill + */ +export function motionCss() { + const names = Object.keys(springs) as (keyof typeof springs)[]; + const vars = names.map(name => { + const { duration, easing } = springEasing(springs[name]); + return `--motion-${name}: ${duration}ms ${easing};`; + }); + const instant = names.map(name => `--motion-${name}: 0s;`); + return [ + `:root { ${vars.join(' ')} }`, + `@media (prefers-reduced-motion: reduce) { :root { ${instant.join(' ')} } }`, + '.motion-reveal { display: flex; }', + '.motion-reveal > * { flex: 1 1 auto; min-width: 0; }', + ].join('\n'); +} diff --git a/website/src/components/motion/glide.ts b/website/src/components/motion/glide.ts new file mode 100644 index 000000000000..9cf2c2b6d8a6 --- /dev/null +++ b/website/src/components/motion/glide.ts @@ -0,0 +1,72 @@ +import { + sampleSpring, + springAt, + SAMPLE_RATE, + type Spring, + type SpringState, +} from './spring'; + +export interface Point { + x: number; + y: number; +} +export const ORIGIN: Point = { x: 0, y: 0 }; + +interface Glide { + animation: Animation; + spring: Spring; + /** Where each axis started, relative to the target */ + start: [x: SpringState, y: SpringState]; +} +const glides = new WeakMap(); + +/** + * Springs `el`'s CSS `translate` from `from` to `to` (px) on the compositor. + * Keyframes are sampled from the spring per axis, so a glide that interrupts + * another keeps its momentum (`velocity`, px/s) in both directions. + */ +export function glide( + el: HTMLElement, + spring: Spring, + { from, to, velocity = ORIGIN }: { from: Point; to: Point; velocity?: Point }, +): Animation | undefined { + stop(el); + // no Web Animations (e.g. jsdom): land in place + if (typeof el.animate !== 'function') return; + if (from.x === to.x && from.y === to.y && !velocity.x && !velocity.y) return; + const start = (['x', 'y'] as const).map(axis => ({ + offset: from[axis] - to[axis], + velocity: velocity[axis], + })) as Glide['start']; + const [xs, ys] = start.map(axis => sampleSpring(spring, axis, PRECISION)); + const frames = Math.max(xs.length, ys.length); + const keyframes = Array.from({ length: frames }, (_, i) => ({ + translate: `${to.x + (xs[i] ?? 0)}px ${to.y + (ys[i] ?? 0)}px`, + })); + const animation = el.animate(keyframes, { + duration: ((frames - 1) / SAMPLE_RATE) * 1000, + // hold an off-target end (exits) until the caller removes the element + fill: to.x || to.y ? 'forwards' : 'none', + }); + glides.set(el, { animation, spring, start }); + return animation; +} + +/** How fast `el`'s glide is moving it right now (px/s) */ +export function velocityOf(el: Element): Point { + const current = glides.get(el); + if (!current || current.animation.playState === 'finished') return ORIGIN; + const { animation, spring, start } = current; + const t = Number(animation.currentTime ?? 0) / 1000; + const [x, y] = start.map(axis => springAt(spring, axis, t).velocity); + return { x, y }; +} + +/** Ends `el`'s glide, snapping it back to its layout position */ +export function stop(el: Element) { + glides.get(el)?.animation.cancel(); + glides.delete(el); +} + +/** Sub-pixel: settles once the remaining motion is invisible */ +const PRECISION = 0.25; diff --git a/website/src/components/motion/index.ts b/website/src/components/motion/index.ts new file mode 100644 index 000000000000..2b4b728085a6 --- /dev/null +++ b/website/src/components/motion/index.ts @@ -0,0 +1,6 @@ +/** Physical, compositor-driven motion. See README.md */ +export { default as MotionGroup, useLayoutMotion } from './MotionGroup'; +export { default as Reveal } from './Reveal'; + +export { springs } from './tokens'; +export type { Spring } from './spring'; diff --git a/website/src/components/motion/spring.ts b/website/src/components/motion/spring.ts new file mode 100644 index 000000000000..953c759ff13a --- /dev/null +++ b/website/src/components/motion/spring.ts @@ -0,0 +1,86 @@ +/** + * Damped harmonic oscillator (mass 1) described the way people perceive it: + * how long the motion takes and how much it bounces. Same model as SwiftUI's + * and Motion's `visualDuration`/`bounce` springs. + */ +export interface Spring { + /** Seconds to (visually) reach the target, ignoring the bounce tail */ + readonly duration: number; + /** 0 = no overshoot (critically damped), towards 1 = springier */ + readonly bounce: number; +} + +/** Offset from the target and velocity (units per second) */ +export interface SpringState { + offset: number; + velocity: number; +} + +/** Position and velocity `t` seconds after starting at `offset` with `velocity` */ +export function springAt( + { duration, bounce }: Spring, + { offset, velocity }: SpringState, + t: number, +): SpringState { + const omega = (2 * Math.PI) / duration; + const zeta = 1 - bounce; + const decay = Math.exp(-zeta * omega * t); + if (zeta >= 1) { + const b = velocity + omega * offset; + return { + offset: decay * (offset + b * t), + velocity: decay * (b - omega * (offset + b * t)), + }; + } + const omegaD = omega * Math.sqrt(1 - zeta * zeta); + const b = (velocity + zeta * omega * offset) / omegaD; + const cos = Math.cos(omegaD * t); + const sin = Math.sin(omegaD * t); + return { + offset: decay * (offset * cos + b * sin), + velocity: + decay * + ((b * omegaD - zeta * omega * offset) * cos - + (offset * omegaD + zeta * omega * b) * sin), + }; +} + +/** Frame rate keyframes and `linear()` easings are sampled at */ +export const SAMPLE_RATE = 60; +const MAX_SECONDS = 3; + +/** + * Offsets sampled at SAMPLE_RATE until the spring rests within `precision` + * of the target; the last sample is exactly 0. + */ +export function sampleSpring( + spring: Spring, + start: SpringState, + precision: number, +): number[] { + const samples = [start.offset]; + for (let frame = 1; frame < MAX_SECONDS * SAMPLE_RATE; frame++) { + const { offset, velocity } = springAt(spring, start, frame / SAMPLE_RATE); + if ( + Math.abs(offset) < precision && + Math.abs(velocity) < precision * SAMPLE_RATE + ) + break; + samples.push(offset); + } + samples.push(0); + return samples; +} + +/** CSS `linear()` easing that plays `spring` from rest, and its duration */ +export function springEasing(spring: Spring) { + const samples = sampleSpring(spring, { offset: 1, velocity: 0 }, 0.001); + return { + easing: `linear(${samples.map(offset => round(1 - offset)).join(', ')})`, + duration: Math.round(((samples.length - 1) / SAMPLE_RATE) * 1000), + }; +} + +function round(n: number) { + return Math.round(n * 1e4) / 1e4; +} diff --git a/website/src/components/motion/tokens.ts b/website/src/components/motion/tokens.ts new file mode 100644 index 000000000000..f04e4a88fe42 --- /dev/null +++ b/website/src/components/motion/tokens.ts @@ -0,0 +1,12 @@ +import type { Spring } from './spring'; + +/** + * The site's motion vocabulary. Pick by what moves, not by milliseconds; the + * same names are CSS custom properties (`var(--motion-snappy)`), see css.ts. + */ +export const springs = { + /** Small, light things: arrows, chips, toggles */ + snappy: { duration: 0.25, bounce: 0.15 }, + /** Panels and drawers that carry content; no overshoot past their edge */ + smooth: { duration: 0.4, bounce: 0 }, +} as const satisfies Record; From 768b6a84ff94170f6e9a2f04ad494036411f069d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 03:01:14 +0000 Subject: [PATCH 02/12] fix(website): Unmount a closed Reveal that no MotionGroup slides out A Reveal left mounted after `show` turned false when it had no MotionGroup around it, or when the group's layoutDependency didn't change in the same commit. It now leaves on its own when no exit animation is running. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Fs5XNeMpXHVoVCow69FnHB --- website/src/components/motion/Reveal.tsx | 17 +++++++- .../motion/__tests__/MotionGroup.test.tsx | 40 +++++++++++++++++-- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/website/src/components/motion/Reveal.tsx b/website/src/components/motion/Reveal.tsx index 04eee8ed2b96..8b399c72e328 100644 --- a/website/src/components/motion/Reveal.tsx +++ b/website/src/components/motion/Reveal.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx'; -import React, { useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useMember } from './MotionGroup'; @@ -26,7 +26,20 @@ export default function Reveal({ // keep showing what it had while it slides out const shown = useRef(children); if (show) shown.current = children; - const ref = useMember({ exiting: !show, onExited: () => setMounted(false) }); + const memberRef = useMember({ + exiting: !show, + onExited: () => setMounted(false), + }); + const el = useRef(null); + const ref = (node: HTMLDivElement | null) => { + el.current = node; + return memberRef(node); + }; + // no group slid it out (none around it, or its layoutDependency didn't + // change with `show`): leave now instead of staying stuck on screen + useEffect(() => { + if (!show && !el.current?.getAnimations?.().length) setMounted(false); + }, [show]); return mounted ?
{shown.current} diff --git a/website/src/components/motion/__tests__/MotionGroup.test.tsx b/website/src/components/motion/__tests__/MotionGroup.test.tsx index 414b230dba00..f841af2701d0 100644 --- a/website/src/components/motion/__tests__/MotionGroup.test.tsx +++ b/website/src/components/motion/__tests__/MotionGroup.test.tsx @@ -11,8 +11,11 @@ const PARENT_WIDTH = 300; const animations: { el: Element; keyframes: Keyframe[]; - animation: Partial; + animation: FakeAnimation; }[] = []; +type FakeAnimation = Partial> & { + playState: AnimationPlayState; +}; beforeEach(() => { animations.length = 0; jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(100); @@ -20,19 +23,29 @@ beforeEach(() => { .spyOn(HTMLElement.prototype, 'clientWidth', 'get') .mockReturnValue(PARENT_WIDTH); HTMLElement.prototype.animate = function (keyframes: any) { - const animation: Partial = { + const animation: FakeAnimation = { playState: 'running', currentTime: 0, - cancel: jest.fn(), + cancel: jest.fn(() => { + animation.playState = 'idle'; + }), onfinish: null, }; animations.push({ el: this, keyframes, animation }); - return animation as Animation; + return animation as unknown as Animation; + }; + HTMLElement.prototype.getAnimations = function () { + return animations + .filter( + ({ el, animation }) => el === this && animation.playState === 'running', + ) + .map(({ animation }) => animation as unknown as Animation); }; }); afterEach(() => { jest.restoreAllMocks(); delete (HTMLElement.prototype as any).animate; + delete (HTMLElement.prototype as any).getAnimations; }); function Handle() { @@ -97,3 +110,22 @@ it('lands in place when the user prefers reduced motion', () => { expect(screen.queryByText('panel')).toBeNull(); delete (window as any).matchMedia; }); + +it('leaves on its own when no group slides it out', () => { + const { rerender } = render(alone); + rerender(alone); + expect(screen.queryByText('alone')).toBeNull(); +}); + +it('leaves on its own when the group ignores the change', () => { + function Mismatched({ show }: { show: boolean }) { + return ( + + panel + + ); + } + const { rerender } = render(); + rerender(); + expect(screen.queryByText('panel')).toBeNull(); +}); From 13d5b77aba8eb6f124f389d24af6300240abb9f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 03:03:43 +0000 Subject: [PATCH 03/12] test(website): Cover motion's momentum, column, no-WAAPI and pinned paths Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Fs5XNeMpXHVoVCow69FnHB --- website/src/components/motion/MotionGroup.tsx | 10 ++- .../motion/__tests__/MotionGroup.test.tsx | 70 ++++++++++++++++++- 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/website/src/components/motion/MotionGroup.tsx b/website/src/components/motion/MotionGroup.tsx index 00d5378f3608..9738280346c6 100644 --- a/website/src/components/motion/MotionGroup.tsx +++ b/website/src/components/motion/MotionGroup.tsx @@ -162,16 +162,20 @@ export function useMember(presence?: Presence): RefCallback { ); } +// members are mounted while the group measures them, so they have a parent +function parentOf(el: HTMLElement) { + return el.parentElement as HTMLElement; +} + function parentRelative(el: HTMLElement): Point { const rect = el.getBoundingClientRect(); - const parent = el.parentElement?.getBoundingClientRect() ?? rect; + const parent = parentOf(el).getBoundingClientRect(); return { x: rect.left - parent.left, y: rect.top - parent.top }; } /** Just past the end of the parent's main axis */ function exitOffset(el: HTMLElement): Point { - const parent = el.parentElement; - if (!parent) return ORIGIN; + const parent = parentOf(el); return getComputedStyle(parent).flexDirection.startsWith('column') ? { x: 0, y: Math.max(parent.clientHeight - el.offsetTop, el.offsetHeight) } : { x: Math.max(parent.clientWidth - el.offsetLeft, el.offsetWidth), y: 0 }; diff --git a/website/src/components/motion/__tests__/MotionGroup.test.tsx b/website/src/components/motion/__tests__/MotionGroup.test.tsx index f841af2701d0..efc2a29e6ae0 100644 --- a/website/src/components/motion/__tests__/MotionGroup.test.tsx +++ b/website/src/components/motion/__tests__/MotionGroup.test.tsx @@ -25,7 +25,7 @@ beforeEach(() => { HTMLElement.prototype.animate = function (keyframes: any) { const animation: FakeAnimation = { playState: 'running', - currentTime: 0, + currentTime: null, cancel: jest.fn(() => { animation.playState = 'idle'; }), @@ -129,3 +129,71 @@ it('leaves on its own when the group ignores the change', () => { rerender(); expect(screen.queryByText('panel')).toBeNull(); }); + +it('reverses with the momentum it had mid-flight', () => { + const { rerender } = render(); + rerender(); + const panel = screen.getByText('panel'); + const enter = animations.find(({ el }) => el === panel); + // 100ms into sliding in (moving towards the start) + (enter as any).animation.currentTime = 100; + rerender(); + const exit = animations.at(-1); + expect(exit?.el).toBe(panel); + const x = (i: number) => parseFloat(exit?.keyframes[i].translate as string); + // keeps moving the way it was going before turning around + expect(x(1)).toBeLessThan(x(0)); + expect(x(exit!.keyframes.length - 1)).toBe(PARENT_WIDTH); +}); + +it('slides along a column container vertically', () => { + jest.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(200); + function Column({ open }: { open: boolean }) { + return ( +
+ +
+ ); + } + const { rerender, container } = render(); + (container.firstChild as HTMLElement).style.display = 'flex'; + rerender(); + const panel = screen.getByText('panel'); + const enter = animations.find(({ el }) => el === panel); + expect(enter?.keyframes[0].translate).toBe('0px 200px'); +}); + +it('lands in place without Web Animations', () => { + delete (HTMLElement.prototype as any).animate; + const { rerender } = render(); + rerender(); + expect(screen.queryByText('panel')).toBeNull(); +}); + +it('does not slide in members that only move', () => { + function Late({ open }: { open: boolean }) { + return ( + {open && } + ); + } + const { rerender } = render(); + rerender(); + expect(animations).toEqual([]); +}); + +it('keeps an exiting element pinned through further changes', () => { + function Steps({ step }: { step: number }) { + return ( + + panel + + ); + } + const { rerender } = render(); + rerender(); + rerender(); + const panel = screen.getByText('panel'); + expect(panel.style.position).toBe('absolute'); + rerender(); + expect(panel.style.position).toBe(''); +}); From 4ef010eb2d26d7ad782f8cd8507ce611a0551b35 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 03:04:55 +0000 Subject: [PATCH 04/12] enhance(website): Reveal checks only its own glide; stable ref - Unmount check looks at the group's glide, not every animation on the element, so a caller's CSS transition can't keep a closed Reveal mounted - Memoize the merged ref so the group membership isn't re-added each render - Docstring: without a MotionGroup, Reveal mounts and unmounts without motion Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Fs5XNeMpXHVoVCow69FnHB --- website/src/components/motion/Reveal.tsx | 19 ++++++++++++------- .../motion/__tests__/MotionGroup.test.tsx | 8 -------- website/src/components/motion/glide.ts | 5 +++++ 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/website/src/components/motion/Reveal.tsx b/website/src/components/motion/Reveal.tsx index 8b399c72e328..b587aa81804c 100644 --- a/website/src/components/motion/Reveal.tsx +++ b/website/src/components/motion/Reveal.tsx @@ -1,13 +1,15 @@ import clsx from 'clsx'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { isGliding } from './glide'; import { useMember } from './MotionGroup'; /** * Shows `children` while `show`, sliding in from and back out past the end of * its flex container (so it follows the layout: sideways in a row, up from * the bottom when stacked). Stays mounted until its exit finishes, so - * reopening mid-exit just turns it around. Must be inside a ``. + * reopening mid-exit just turns it around. The motion comes from the nearest + * ``; without one it just mounts and unmounts. * * It slides over its siblings; clip it with `overflow: hidden` on an ancestor. */ @@ -31,14 +33,17 @@ export default function Reveal({ onExited: () => setMounted(false), }); const el = useRef(null); - const ref = (node: HTMLDivElement | null) => { - el.current = node; - return memberRef(node); - }; + const ref = useCallback( + (node: HTMLDivElement | null) => { + el.current = node; + return memberRef(node); + }, + [memberRef], + ); // no group slid it out (none around it, or its layoutDependency didn't // change with `show`): leave now instead of staying stuck on screen useEffect(() => { - if (!show && !el.current?.getAnimations?.().length) setMounted(false); + if (!show && !(el.current && isGliding(el.current))) setMounted(false); }, [show]); return mounted ?
diff --git a/website/src/components/motion/__tests__/MotionGroup.test.tsx b/website/src/components/motion/__tests__/MotionGroup.test.tsx index efc2a29e6ae0..391f44ebfbc6 100644 --- a/website/src/components/motion/__tests__/MotionGroup.test.tsx +++ b/website/src/components/motion/__tests__/MotionGroup.test.tsx @@ -34,18 +34,10 @@ beforeEach(() => { animations.push({ el: this, keyframes, animation }); return animation as unknown as Animation; }; - HTMLElement.prototype.getAnimations = function () { - return animations - .filter( - ({ el, animation }) => el === this && animation.playState === 'running', - ) - .map(({ animation }) => animation as unknown as Animation); - }; }); afterEach(() => { jest.restoreAllMocks(); delete (HTMLElement.prototype as any).animate; - delete (HTMLElement.prototype as any).getAnimations; }); function Handle() { diff --git a/website/src/components/motion/glide.ts b/website/src/components/motion/glide.ts index 9cf2c2b6d8a6..3686820cc429 100644 --- a/website/src/components/motion/glide.ts +++ b/website/src/components/motion/glide.ts @@ -62,6 +62,11 @@ export function velocityOf(el: Element): Point { return { x, y }; } +/** Whether a glide is moving `el` right now */ +export function isGliding(el: Element) { + return glides.get(el)?.animation.playState === 'running'; +} + /** Ends `el`'s glide, snapping it back to its layout position */ export function stop(el: Element) { glides.get(el)?.animation.cancel(); From 98488fde5089543376206ff95df6fe8e1816df4f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 03:06:59 +0000 Subject: [PATCH 05/12] fix(website): Reveal no longer imports clsx, missing from CI's trimmed install Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Fs5XNeMpXHVoVCow69FnHB --- website/src/components/motion/Reveal.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/website/src/components/motion/Reveal.tsx b/website/src/components/motion/Reveal.tsx index b587aa81804c..03c936216d8c 100644 --- a/website/src/components/motion/Reveal.tsx +++ b/website/src/components/motion/Reveal.tsx @@ -1,4 +1,3 @@ -import clsx from 'clsx'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { isGliding } from './glide'; @@ -46,7 +45,10 @@ export default function Reveal({ if (!show && !(el.current && isGliding(el.current))) setMounted(false); }, [show]); return mounted ? -
+
{shown.current}
: null; From 4ff4863d89fee99f4322ba346e37ea46049f913b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 03:13:29 +0000 Subject: [PATCH 06/12] fix(website): Opaque Store panel in dark mode so the covered result can't show through Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Fs5XNeMpXHVoVCow69FnHB --- website/src/components/Playground/styles.module.css | 5 ++++- .../src/components/motion/__tests__/MotionGroup.test.tsx | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 6ba5bee24fa7..9cb6bb3258f8 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -227,7 +227,10 @@ div.playgroundTextEdit > .playgroundHeader, background: var(--monoco-code-background); } [data-theme='dark'] .storePanel { - background: var(--ifm-pre-background); + /* the dark code tint is translucent: lay it over the container's color so + the result covered underneath never shows through */ + background: + linear-gradient(var(--ifm-pre-background) 0 0), var(--ifm-background-color); } /* Row layout with the Store open: the result stays underneath, so the Store diff --git a/website/src/components/motion/__tests__/MotionGroup.test.tsx b/website/src/components/motion/__tests__/MotionGroup.test.tsx index 391f44ebfbc6..afa4d8cacc37 100644 --- a/website/src/components/motion/__tests__/MotionGroup.test.tsx +++ b/website/src/components/motion/__tests__/MotionGroup.test.tsx @@ -189,3 +189,12 @@ it('keeps an exiting element pinned through further changes', () => { rerender(); expect(panel.style.position).toBe(''); }); + +it('keeps its own class alongside the one it is given', () => { + render( + + styled + , + ); + expect(screen.getByText('styled').className).toBe('motion-reveal panel'); +}); From fa082570cd36672ba311b12eba8352682875fa3c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 03:33:06 +0000 Subject: [PATCH 07/12] enhance(website): Simplify motion snapshots; Store panel tinted once in dark mode Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Fs5XNeMpXHVoVCow69FnHB --- .../components/Playground/styles.module.css | 7 +++---- website/src/components/motion/MotionGroup.tsx | 21 ++++++++----------- website/src/components/motion/css.ts | 8 +++---- 3 files changed, 16 insertions(+), 20 deletions(-) diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 9cb6bb3258f8..7729870708ac 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -227,10 +227,9 @@ div.playgroundTextEdit > .playgroundHeader, background: var(--monoco-code-background); } [data-theme='dark'] .storePanel { - /* the dark code tint is translucent: lay it over the container's color so - the result covered underneath never shows through */ - background: - linear-gradient(var(--ifm-pre-background) 0 0), var(--ifm-background-color); + /* opaque, so the covered result never shows through; the tree inside + adds the translucent code tint on top */ + background: var(--ifm-background-color); } /* Row layout with the Store open: the result stays underneath, so the Store diff --git a/website/src/components/motion/MotionGroup.tsx b/website/src/components/motion/MotionGroup.tsx index 9738280346c6..e621779faf57 100644 --- a/website/src/components/motion/MotionGroup.tsx +++ b/website/src/components/motion/MotionGroup.tsx @@ -26,7 +26,7 @@ interface Snapshot { at: Point; velocity: Point; /** Layout box (no transforms), to pin a presence where it was if it exits */ - box?: Box; + box: Box; } interface Box { left: number; @@ -72,19 +72,16 @@ export default class MotionGroup extends Component { const snapshots = new Map(); // reduced motion: nothing to measure, everything lands in place if (prefersReducedMotion()) return snapshots; - for (const [el, presence] of this.members) { + for (const el of this.members.keys()) { snapshots.set(el, { at: parentRelative(el), velocity: velocityOf(el), - box: - presence.current ? - { - left: el.offsetLeft, - top: el.offsetTop, - width: el.offsetWidth, - height: el.offsetHeight, - } - : undefined, + box: { + left: el.offsetLeft, + top: el.offsetTop, + width: el.offsetWidth, + height: el.offsetHeight, + }, }); } return snapshots; @@ -105,7 +102,7 @@ export default class MotionGroup extends Component { // settle the final layout before measuring anything for (const [el, { box }] of snapshots) { stop(el); - if (box && this.members.get(el)?.current?.exiting) pin(el, box); + if (this.members.get(el)?.current?.exiting) pin(el, box); else unpin(el); } // measure everything, then start animations (one style recalc) diff --git a/website/src/components/motion/css.ts b/website/src/components/motion/css.ts index 5dc67660ab82..159ecd188ad6 100644 --- a/website/src/components/motion/css.ts +++ b/website/src/components/motion/css.ts @@ -9,12 +9,12 @@ import { springs } from './tokens'; * - ``'s box, which its contents fill */ export function motionCss() { - const names = Object.keys(springs) as (keyof typeof springs)[]; - const vars = names.map(name => { - const { duration, easing } = springEasing(springs[name]); + const tokens = Object.entries(springs); + const vars = tokens.map(([name, spring]) => { + const { duration, easing } = springEasing(spring); return `--motion-${name}: ${duration}ms ${easing};`; }); - const instant = names.map(name => `--motion-${name}: 0s;`); + const instant = tokens.map(([name]) => `--motion-${name}: 0s;`); return [ `:root { ${vars.join(' ')} }`, `@media (prefers-reduced-motion: reduce) { :root { ${instant.join(' ')} } }`, From eee76b2af750e247aa45a9d676c50b33068c6e33 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 03:58:57 +0000 Subject: [PATCH 08/12] fix(website): Reveal exits toward a reversed or RTL flex end; reduced motion stops glides in flight Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Fs5XNeMpXHVoVCow69FnHB --- website/src/components/motion/MotionGroup.tsx | 19 ++++++++---- .../motion/__tests__/MotionGroup.test.tsx | 29 +++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/website/src/components/motion/MotionGroup.tsx b/website/src/components/motion/MotionGroup.tsx index e621779faf57..24480604bba4 100644 --- a/website/src/components/motion/MotionGroup.tsx +++ b/website/src/components/motion/MotionGroup.tsx @@ -94,8 +94,10 @@ export default class MotionGroup extends Component { ) { if (!snapshots) return; if (prefersReducedMotion()) { - for (const { current } of this.members.values()) + for (const [el, { current }] of this.members) { + stop(el); if (current?.exiting) current.onExited(); + } return; } const { spring = springs.smooth } = this.props; @@ -170,12 +172,19 @@ function parentRelative(el: HTMLElement): Point { return { x: rect.left - parent.left, y: rect.top - parent.top }; } -/** Just past the end of the parent's main axis */ +/** Just past the end of the parent's main axis (reversed and RTL aware) */ function exitOffset(el: HTMLElement): Point { const parent = parentOf(el); - return getComputedStyle(parent).flexDirection.startsWith('column') ? - { x: 0, y: Math.max(parent.clientHeight - el.offsetTop, el.offsetHeight) } - : { x: Math.max(parent.clientWidth - el.offsetLeft, el.offsetWidth), y: 0 }; + const { flexDirection, direction } = getComputedStyle(parent); + const column = flexDirection.startsWith('column'); + const towardStart = + flexDirection.endsWith('reverse') !== (!column && direction === 'rtl'); + const [pos, size, extent] = + column ? + [el.offsetTop, el.offsetHeight, parent.clientHeight] + : [el.offsetLeft, el.offsetWidth, parent.clientWidth]; + const distance = towardStart ? -(pos + size) : Math.max(extent - pos, size); + return column ? { x: 0, y: distance } : { x: distance, y: 0 }; } const unpinned = new WeakMap(); diff --git a/website/src/components/motion/__tests__/MotionGroup.test.tsx b/website/src/components/motion/__tests__/MotionGroup.test.tsx index afa4d8cacc37..0861f042f0b5 100644 --- a/website/src/components/motion/__tests__/MotionGroup.test.tsx +++ b/website/src/components/motion/__tests__/MotionGroup.test.tsx @@ -198,3 +198,32 @@ it('keeps its own class alongside the one it is given', () => { ); expect(screen.getByText('styled').className).toBe('motion-reveal panel'); }); + +it.each([ + ['a reversed row', { flexDirection: 'row-reverse' }], + ['a right-to-left row', { direction: 'rtl' }], +] as const)('slides toward the start in %s', (_, style) => { + function Reversed({ open }: { open: boolean }) { + return ( +
+ +
+ ); + } + const { rerender } = render(); + rerender(); + const panel = screen.getByText('panel'); + const enter = animations.find(({ el }) => el === panel); + // offsetLeft is 0 in jsdom, so it starts one width (100px) past the start + expect(enter?.keyframes[0].translate).toBe('-100px 0px'); +}); + +it('stops glides in flight when reduced motion turns on', () => { + const { rerender } = render(); + rerender(); + const enter = animations.find(({ el }) => el === screen.getByText('panel')); + window.matchMedia = jest.fn().mockReturnValue({ matches: true }); + rerender(); + expect(enter?.animation.cancel).toHaveBeenCalled(); + delete (window as any).matchMedia; +}); From f3c1fc0de38b012b6e2eae1c52c1009f35658cca Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 04:04:20 +0000 Subject: [PATCH 09/12] fix(website): A closed Reveal leaves once any glide moving it settles Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Fs5XNeMpXHVoVCow69FnHB --- website/src/components/motion/Reveal.tsx | 20 ++++++-- .../motion/__tests__/MotionGroup.test.tsx | 47 +++++++++++++++++++ website/src/components/motion/glide.ts | 7 +-- 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/website/src/components/motion/Reveal.tsx b/website/src/components/motion/Reveal.tsx index 03c936216d8c..89967a18323f 100644 --- a/website/src/components/motion/Reveal.tsx +++ b/website/src/components/motion/Reveal.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { isGliding } from './glide'; +import { activeGlide } from './glide'; import { useMember } from './MotionGroup'; /** @@ -39,10 +39,22 @@ export default function Reveal({ }, [memberRef], ); - // no group slid it out (none around it, or its layoutDependency didn't - // change with `show`): leave now instead of staying stuck on screen + // Leave on its own when no group slides it out (none around it, or its + // layoutDependency didn't change with `show`): now if it is still, or once + // whatever glide is moving it settles, so it never stays stuck on screen useEffect(() => { - if (!show && !(el.current && isGliding(el.current))) setMounted(false); + if (show) return; + let reopened = false; + const leave = () => reopened || setMounted(false); + const moving = el.current && activeGlide(el.current); + if (moving) + moving.finished.then(leave, () => { + // cancelled: the group took over and moves it next + }); + else leave(); + return () => { + reopened = true; + }; }, [show]); return mounted ?
{ .spyOn(HTMLElement.prototype, 'clientWidth', 'get') .mockReturnValue(PARENT_WIDTH); HTMLElement.prototype.animate = function (keyframes: any) { + let settle!: (finished: boolean) => void; const animation: FakeAnimation = { playState: 'running', currentTime: null, cancel: jest.fn(() => { animation.playState = 'idle'; + settle(false); }), onfinish: null, + finished: new Promise((resolve, reject) => { + settle = finished => + finished ? resolve(animation as Animation) : reject(new Error()); + }), + finish: () => { + animation.playState = 'finished'; + settle(true); + }, }; + // like a browser's, a cancelled glide's rejection is fine to ignore + animation.finished?.catch(() => undefined); animations.push({ el: this, keyframes, animation }); return animation as unknown as Animation; }; @@ -227,3 +239,38 @@ it('stops glides in flight when reduced motion turns on', () => { expect(enter?.animation.cancel).toHaveBeenCalled(); delete (window as any).matchMedia; }); + +it('leaves once its entrance settles when the group ignores the close', async () => { + // the group only animates opening; closing doesn't change layoutDependency + function OpensOnly({ open, opened }: { open: boolean; opened: number }) { + return ( + + panel + + ); + } + const { rerender } = render(); + rerender(); + const enter = animations.find(({ el }) => el === screen.getByText('panel')); + rerender(); + expect(screen.getByText('panel')).toBeTruthy(); + await act(async () => enter?.animation.finish?.()); + expect(screen.queryByText('panel')).toBeNull(); +}); + +it('stays when reopened before a settling glide finishes', async () => { + function OpensOnly({ open, opened }: { open: boolean; opened: number }) { + return ( + + panel + + ); + } + const { rerender } = render(); + rerender(); + const enter = animations.find(({ el }) => el === screen.getByText('panel')); + rerender(); + rerender(); + await act(async () => enter?.animation.finish?.()); + expect(screen.getByText('panel')).toBeTruthy(); +}); diff --git a/website/src/components/motion/glide.ts b/website/src/components/motion/glide.ts index 3686820cc429..37c91a90bda3 100644 --- a/website/src/components/motion/glide.ts +++ b/website/src/components/motion/glide.ts @@ -62,9 +62,10 @@ export function velocityOf(el: Element): Point { return { x, y }; } -/** Whether a glide is moving `el` right now */ -export function isGliding(el: Element) { - return glides.get(el)?.animation.playState === 'running'; +/** The glide moving `el` right now, if any */ +export function activeGlide(el: Element): Animation | undefined { + const animation = glides.get(el)?.animation; + return animation?.playState === 'running' ? animation : undefined; } /** Ends `el`'s glide, snapping it back to its layout position */ From 9e668ab9e9e4219a9c894916388450c385e0d295 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 04:17:13 +0000 Subject: [PATCH 10/12] enhance(website): Reveal alone unmounts itself once at rest; long springs settle before ending Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Fs5XNeMpXHVoVCow69FnHB --- website/src/components/motion/MotionGroup.tsx | 18 +---- website/src/components/motion/Reveal.tsx | 22 ++---- .../motion/__tests__/MotionGroup.test.tsx | 70 ++++++++----------- .../motion/__tests__/spring.test.ts | 6 ++ website/src/components/motion/glide.ts | 13 +++- website/src/components/motion/spring.ts | 6 +- 6 files changed, 60 insertions(+), 75 deletions(-) diff --git a/website/src/components/motion/MotionGroup.tsx b/website/src/components/motion/MotionGroup.tsx index 24480604bba4..601a1bca3d02 100644 --- a/website/src/components/motion/MotionGroup.tsx +++ b/website/src/components/motion/MotionGroup.tsx @@ -15,9 +15,8 @@ import { springs } from './tokens'; /** How a member enters and leaves; absent for members that only move */ interface Presence { - /** Leaving: slides out, then `onExited` should unmount it */ + /** Leaving: slides out past the end of its container; its owner unmounts it */ exiting: boolean; - onExited: () => void; } type Members = Map>; @@ -37,7 +36,6 @@ interface Box { interface Move { el: HTMLElement; - presence?: Presence; from: Point; to: Point; velocity?: Point; @@ -94,10 +92,7 @@ export default class MotionGroup extends Component { ) { if (!snapshots) return; if (prefersReducedMotion()) { - for (const [el, { current }] of this.members) { - stop(el); - if (current?.exiting) current.onExited(); - } + for (const el of this.members.keys()) stop(el); return; } const { spring = springs.smooth } = this.props; @@ -119,19 +114,12 @@ export default class MotionGroup extends Component { const at = parentRelative(el); moves.push({ el, - presence, from: { x: before.at.x - at.x, y: before.at.y - at.y }, to: presence?.exiting ? exitOffset(el) : ORIGIN, velocity: before.velocity, }); } - for (const { el, presence, ...path } of moves) { - const animation = glide(el, spring, path); - if (presence?.exiting) { - if (animation) animation.onfinish = presence.onExited; - else presence.onExited(); - } - } + for (const { el, ...path } of moves) glide(el, spring, path); } render() { diff --git a/website/src/components/motion/Reveal.tsx b/website/src/components/motion/Reveal.tsx index 89967a18323f..7569579260fe 100644 --- a/website/src/components/motion/Reveal.tsx +++ b/website/src/components/motion/Reveal.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { activeGlide } from './glide'; +import { settled } from './glide'; import { useMember } from './MotionGroup'; /** @@ -27,10 +27,7 @@ export default function Reveal({ // keep showing what it had while it slides out const shown = useRef(children); if (show) shown.current = children; - const memberRef = useMember({ - exiting: !show, - onExited: () => setMounted(false), - }); + const memberRef = useMember({ exiting: !show }); const el = useRef(null); const ref = useCallback( (node: HTMLDivElement | null) => { @@ -39,19 +36,12 @@ export default function Reveal({ }, [memberRef], ); - // Leave on its own when no group slides it out (none around it, or its - // layoutDependency didn't change with `show`): now if it is still, or once - // whatever glide is moving it settles, so it never stays stuck on screen + // leaves once it comes to rest: after the group slides it out, or at once + // if nothing moves it (no group, or one that ignored this change) useEffect(() => { - if (show) return; + if (show || !el.current) return; let reopened = false; - const leave = () => reopened || setMounted(false); - const moving = el.current && activeGlide(el.current); - if (moving) - moving.finished.then(leave, () => { - // cancelled: the group took over and moves it next - }); - else leave(); + settled(el.current).then(() => reopened || setMounted(false)); return () => { reopened = true; }; diff --git a/website/src/components/motion/__tests__/MotionGroup.test.tsx b/website/src/components/motion/__tests__/MotionGroup.test.tsx index 5aa6b32b1e5b..47fe113719db 100644 --- a/website/src/components/motion/__tests__/MotionGroup.test.tsx +++ b/website/src/components/motion/__tests__/MotionGroup.test.tsx @@ -24,37 +24,49 @@ beforeEach(() => { .mockReturnValue(PARENT_WIDTH); HTMLElement.prototype.animate = function (keyframes: any) { let settle!: (finished: boolean) => void; + const finished = new Promise((resolve, reject) => { + settle = done => + done ? resolve(animation as Animation) : reject(new Error()); + }); + // like a browser's, a cancelled glide's rejection is fine to ignore + finished.catch(() => undefined); const animation: FakeAnimation = { playState: 'running', currentTime: null, + finished, cancel: jest.fn(() => { animation.playState = 'idle'; settle(false); }), - onfinish: null, - finished: new Promise((resolve, reject) => { - settle = finished => - finished ? resolve(animation as Animation) : reject(new Error()); - }), finish: () => { animation.playState = 'finished'; settle(true); }, }; - // like a browser's, a cancelled glide's rejection is fine to ignore - animation.finished?.catch(() => undefined); animations.push({ el: this, keyframes, animation }); return animation as unknown as Animation; }; }); afterEach(() => { jest.restoreAllMocks(); + delete (window as any).matchMedia; delete (HTMLElement.prototype as any).animate; }); function Handle() { return
; } +/** Reveals leave once at rest, a microtask after the commit */ +const settledReveals = () => act(() => Promise.resolve()); + +// a group that animates opening, but closing doesn't change layoutDependency +function OpensOnly({ open, opened }: { open: boolean; opened: number }) { + return ( + + panel + + ); +} function Drawer({ open, label = 'panel' }: { open: boolean; label?: string }) { return ( @@ -79,14 +91,14 @@ it('slides a revealed element in from the end of its container', () => { expect(enter?.keyframes.at(-1)?.translate).toBe('0px 0px'); }); -it('keeps an exiting element, with its last content, until it slides out', () => { +it('keeps an exiting element, with its last content, until it slides out', async () => { const { rerender } = render(); rerender(); const exit = animations.find(({ el }) => el.textContent === 'first'); expect(exit?.keyframes.at(-1)?.translate).toBe(`${PARENT_WIDTH}px 0px`); expect((exit?.el as HTMLElement).style.position).toBe('absolute'); - act(() => (exit?.animation.onfinish as any)()); + await act(async () => exit?.animation.finish?.()); expect(screen.queryByText('first')).toBeNull(); }); @@ -106,31 +118,26 @@ it('only measures when layoutDependency changes', () => { expect(rect).not.toHaveBeenCalled(); }); -it('lands in place when the user prefers reduced motion', () => { +it('lands in place when the user prefers reduced motion', async () => { window.matchMedia = jest.fn().mockReturnValue({ matches: true }); const { rerender } = render(); rerender(); expect(animations).toEqual([]); + await settledReveals(); expect(screen.queryByText('panel')).toBeNull(); - delete (window as any).matchMedia; }); -it('leaves on its own when no group slides it out', () => { +it('leaves on its own when no group slides it out', async () => { const { rerender } = render(alone); rerender(alone); + await settledReveals(); expect(screen.queryByText('alone')).toBeNull(); }); -it('leaves on its own when the group ignores the change', () => { - function Mismatched({ show }: { show: boolean }) { - return ( - - panel - - ); - } - const { rerender } = render(); - rerender(); +it('leaves on its own when the group ignores the change', async () => { + const { rerender } = render(); + rerender(); + await settledReveals(); expect(screen.queryByText('panel')).toBeNull(); }); @@ -167,10 +174,11 @@ it('slides along a column container vertically', () => { expect(enter?.keyframes[0].translate).toBe('0px 200px'); }); -it('lands in place without Web Animations', () => { +it('lands in place without Web Animations', async () => { delete (HTMLElement.prototype as any).animate; const { rerender } = render(); rerender(); + await settledReveals(); expect(screen.queryByText('panel')).toBeNull(); }); @@ -237,18 +245,9 @@ it('stops glides in flight when reduced motion turns on', () => { window.matchMedia = jest.fn().mockReturnValue({ matches: true }); rerender(); expect(enter?.animation.cancel).toHaveBeenCalled(); - delete (window as any).matchMedia; }); it('leaves once its entrance settles when the group ignores the close', async () => { - // the group only animates opening; closing doesn't change layoutDependency - function OpensOnly({ open, opened }: { open: boolean; opened: number }) { - return ( - - panel - - ); - } const { rerender } = render(); rerender(); const enter = animations.find(({ el }) => el === screen.getByText('panel')); @@ -259,13 +258,6 @@ it('leaves once its entrance settles when the group ignores the close', async () }); it('stays when reopened before a settling glide finishes', async () => { - function OpensOnly({ open, opened }: { open: boolean; opened: number }) { - return ( - - panel - - ); - } const { rerender } = render(); rerender(); const enter = animations.find(({ el }) => el === screen.getByText('panel')); diff --git a/website/src/components/motion/__tests__/spring.test.ts b/website/src/components/motion/__tests__/spring.test.ts index 6060446a8f73..c1163945da59 100644 --- a/website/src/components/motion/__tests__/spring.test.ts +++ b/website/src/components/motion/__tests__/spring.test.ts @@ -52,6 +52,12 @@ describe('sampleSpring', () => { expect(seconds).toBeLessThan(smooth.duration * 2); }); + it('lets a long spring settle instead of snapping to the end', () => { + const slow = { duration: 10, bounce: 0 }; + const samples = sampleSpring(slow, { offset: 300, velocity: 0 }, 0.25); + expect(Math.abs(samples.at(-2)!)).toBeLessThan(0.5); + }); + it('is a single frame when already at rest', () => { expect(sampleSpring(smooth, { offset: 0, velocity: 0 }, 0.25)).toEqual([ 0, 0, diff --git a/website/src/components/motion/glide.ts b/website/src/components/motion/glide.ts index 37c91a90bda3..fa43b0287982 100644 --- a/website/src/components/motion/glide.ts +++ b/website/src/components/motion/glide.ts @@ -62,10 +62,17 @@ export function velocityOf(el: Element): Point { return { x, y }; } -/** The glide moving `el` right now, if any */ -export function activeGlide(el: Element): Animation | undefined { +/** + * Resolves once nothing moves `el`: now if it is still, else when its glide + * finishes. A glide cancelled for another (a retarget) hands over to it. + */ +export function settled(el: Element): Promise { const animation = glides.get(el)?.animation; - return animation?.playState === 'running' ? animation : undefined; + if (animation?.playState !== 'running') return Promise.resolve(); + return animation.finished.then( + () => undefined, + () => settled(el), + ); } /** Ends `el`'s glide, snapping it back to its layout position */ diff --git a/website/src/components/motion/spring.ts b/website/src/components/motion/spring.ts index 953c759ff13a..2ce286b0f3ed 100644 --- a/website/src/components/motion/spring.ts +++ b/website/src/components/motion/spring.ts @@ -47,7 +47,8 @@ export function springAt( /** Frame rate keyframes and `linear()` easings are sampled at */ export const SAMPLE_RATE = 60; -const MAX_SECONDS = 3; +/** Safety cap for springs that barely settle (bounce near 1), in durations */ +const MAX_DURATIONS = 12; /** * Offsets sampled at SAMPLE_RATE until the spring rests within `precision` @@ -59,7 +60,8 @@ export function sampleSpring( precision: number, ): number[] { const samples = [start.offset]; - for (let frame = 1; frame < MAX_SECONDS * SAMPLE_RATE; frame++) { + const maxFrames = MAX_DURATIONS * spring.duration * SAMPLE_RATE; + for (let frame = 1; frame < maxFrames; frame++) { const { offset, velocity } = springAt(spring, start, frame / SAMPLE_RATE); if ( Math.abs(offset) < precision && From b117c891df8a8ea6300ace423c42f1c3ec59d708 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 04:23:44 +0000 Subject: [PATCH 11/12] fix(website): A Reveal reopened under reduced motion rejoins the layout Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Fs5XNeMpXHVoVCow69FnHB --- website/src/components/motion/MotionGroup.tsx | 6 +++++- .../components/motion/__tests__/MotionGroup.test.tsx | 10 ++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/website/src/components/motion/MotionGroup.tsx b/website/src/components/motion/MotionGroup.tsx index 601a1bca3d02..a72c53527d84 100644 --- a/website/src/components/motion/MotionGroup.tsx +++ b/website/src/components/motion/MotionGroup.tsx @@ -92,7 +92,11 @@ export default class MotionGroup extends Component { ) { if (!snapshots) return; if (prefersReducedMotion()) { - for (const el of this.members.keys()) stop(el); + // land in place; a reopened exit rejoins the layout + for (const [el, { current }] of this.members) { + stop(el); + if (!current?.exiting) unpin(el); + } return; } const { spring = springs.smooth } = this.props; diff --git a/website/src/components/motion/__tests__/MotionGroup.test.tsx b/website/src/components/motion/__tests__/MotionGroup.test.tsx index 47fe113719db..7953431b1a19 100644 --- a/website/src/components/motion/__tests__/MotionGroup.test.tsx +++ b/website/src/components/motion/__tests__/MotionGroup.test.tsx @@ -266,3 +266,13 @@ it('stays when reopened before a settling glide finishes', async () => { await act(async () => enter?.animation.finish?.()); expect(screen.getByText('panel')).toBeTruthy(); }); + +it('rejoins the layout when reopened mid-exit under reduced motion', () => { + const { rerender } = render(); + rerender(); + const panel = screen.getByText('panel'); + expect(panel.style.position).toBe('absolute'); + window.matchMedia = jest.fn().mockReturnValue({ matches: true }); + rerender(); + expect(panel.style.position).toBe(''); +}); From 0e3eb1b4e8d3397164cc77943331964aaabf8d27 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 04:30:20 +0000 Subject: [PATCH 12/12] fix(website): A Reveal nothing will slide out leaves before it paints Under reduced motion, without Web Animations, or outside a MotionGroup, a closed Reveal now unmounts in the layout phase instead of after a passive effect, so it never paints a frame still in flow. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Fs5XNeMpXHVoVCow69FnHB --- website/src/components/motion/MotionGroup.tsx | 13 ++++++++ website/src/components/motion/Reveal.tsx | 25 ++++++++++----- .../motion/__tests__/MotionGroup.test.tsx | 31 +++++++++++++++---- 3 files changed, 55 insertions(+), 14 deletions(-) diff --git a/website/src/components/motion/MotionGroup.tsx b/website/src/components/motion/MotionGroup.tsx index a72c53527d84..adbc33b75a57 100644 --- a/website/src/components/motion/MotionGroup.tsx +++ b/website/src/components/motion/MotionGroup.tsx @@ -153,6 +153,19 @@ export function useMember(presence?: Presence): RefCallback { ); } +/** + * Whether the nearest `` would glide `el` when the layout + * changes; if not, changes land at once + */ +export function useWillGlide(): (el: HTMLElement) => boolean { + const members = useContext(GroupContext); + return useCallback( + el => + !!members && typeof el.animate === 'function' && !prefersReducedMotion(), + [members], + ); +} + // members are mounted while the group measures them, so they have a parent function parentOf(el: HTMLElement) { return el.parentElement as HTMLElement; diff --git a/website/src/components/motion/Reveal.tsx b/website/src/components/motion/Reveal.tsx index 7569579260fe..4d0d0a5df70c 100644 --- a/website/src/components/motion/Reveal.tsx +++ b/website/src/components/motion/Reveal.tsx @@ -1,7 +1,7 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'; import { settled } from './glide'; -import { useMember } from './MotionGroup'; +import { useMember, useWillGlide } from './MotionGroup'; /** * Shows `children` while `show`, sliding in from and back out past the end of @@ -28,6 +28,7 @@ export default function Reveal({ const shown = useRef(children); if (show) shown.current = children; const memberRef = useMember({ exiting: !show }); + const willGlide = useWillGlide(); const el = useRef(null); const ref = useCallback( (node: HTMLDivElement | null) => { @@ -36,16 +37,24 @@ export default function Reveal({ }, [memberRef], ); - // leaves once it comes to rest: after the group slides it out, or at once - // if nothing moves it (no group, or one that ignored this change) - useEffect(() => { - if (show || !el.current) return; + // leaves once it comes to rest: at once (before it paints in flow) if + // nothing will slide it out, else after the group's glide settles + useLayoutEffect(() => { + const node = el.current; + if (show || !node) return; + if (!willGlide(node)) { + setMounted(false); + return; + } let reopened = false; - settled(el.current).then(() => reopened || setMounted(false)); + // the group starts its glide later in this commit + queueMicrotask(() => + settled(node).then(() => reopened || setMounted(false)), + ); return () => { reopened = true; }; - }, [show]); + }, [show, willGlide]); return mounted ?
el.textContent === 'first'); expect(exit?.keyframes.at(-1)?.translate).toBe(`${PARENT_WIDTH}px 0px`); expect((exit?.el as HTMLElement).style.position).toBe('absolute'); + await settledReveals(); + expect(screen.getByText('first')).toBeTruthy(); await act(async () => exit?.animation.finish?.()); expect(screen.queryByText('first')).toBeNull(); @@ -118,19 +120,17 @@ it('only measures when layoutDependency changes', () => { expect(rect).not.toHaveBeenCalled(); }); -it('lands in place when the user prefers reduced motion', async () => { +it('lands in place when the user prefers reduced motion', () => { window.matchMedia = jest.fn().mockReturnValue({ matches: true }); const { rerender } = render(); rerender(); expect(animations).toEqual([]); - await settledReveals(); expect(screen.queryByText('panel')).toBeNull(); }); -it('leaves on its own when no group slides it out', async () => { +it('leaves at once when no group slides it out', () => { const { rerender } = render(alone); rerender(alone); - await settledReveals(); expect(screen.queryByText('alone')).toBeNull(); }); @@ -174,11 +174,10 @@ it('slides along a column container vertically', () => { expect(enter?.keyframes[0].translate).toBe('0px 200px'); }); -it('lands in place without Web Animations', async () => { +it('lands in place without Web Animations', () => { delete (HTMLElement.prototype as any).animate; const { rerender } = render(); rerender(); - await settledReveals(); expect(screen.queryByText('panel')).toBeNull(); }); @@ -252,6 +251,7 @@ it('leaves once its entrance settles when the group ignores the close', async () rerender(); const enter = animations.find(({ el }) => el === screen.getByText('panel')); rerender(); + await settledReveals(); expect(screen.getByText('panel')).toBeTruthy(); await act(async () => enter?.animation.finish?.()); expect(screen.queryByText('panel')).toBeNull(); @@ -263,10 +263,29 @@ it('stays when reopened before a settling glide finishes', async () => { const enter = animations.find(({ el }) => el === screen.getByText('panel')); rerender(); rerender(); + await settledReveals(); await act(async () => enter?.animation.finish?.()); expect(screen.getByText('panel')).toBeTruthy(); }); +it('leaves once the glide that retargets its exit settles', async () => { + function Steps({ step }: { step: number }) { + return ( + + panel + + ); + } + const { rerender } = render(); + rerender(); + await settledReveals(); + rerender(); + await settledReveals(); + expect(screen.getByText('panel')).toBeTruthy(); + await act(async () => animations.at(-1)?.animation.finish?.()); + expect(screen.queryByText('panel')).toBeNull(); +}); + it('rejoins the layout when reopened mid-exit under reduced motion', () => { const { rerender } = render(); rerender();