From 7e6d9cc3e4d2f6c13787910c1510646fbb6f4794 Mon Sep 17 00:00:00 2001 From: Guido Wagner Date: Wed, 9 Sep 2026 03:10:10 -0300 Subject: [PATCH] feat: motion recipes and a working springConfig springConfig could not change the feel: react-spring ignores mass, tension and friction whenever duration is set, and the sheet always set it. easing, the one knob that does work in that branch, was missing from the type. Adds easing and clamp, four presets including Material 3 motion, a cubic-bezier solver and a demo to compare them. No default changes. --- README.md | 90 +++++++++++++++++++---- docs/headings.ts | 1 + pages/fixtures/motion.tsx | 146 ++++++++++++++++++++++++++++++++++++++ src/index.tsx | 2 + src/presets.ts | 82 +++++++++++++++++++++ src/types.ts | 11 ++- test/presets.test.ts | 77 ++++++++++++++++++++ 7 files changed, 395 insertions(+), 14 deletions(-) create mode 100644 pages/fixtures/motion.tsx create mode 100644 src/presets.ts create mode 100644 test/presets.test.ts diff --git a/README.md b/README.md index c6171557..69547cb0 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,6 @@ [![npm version](https://img.shields.io/npm/v/@nipe-solutions/react-spring-bottom-sheet.svg?style=flat-square)](https://www.npmjs.com/package/@nipe-solutions/react-spring-bottom-sheet) [![Netlify Status](https://api.netlify.com/api/v1/badges/6348db32-4930-4fca-a11d-c3098c9fda4f/deploy-status)](https://app.netlify.com/sites/react-spring-bottom-sheet-updated/deploys) [![module formats: cjs, es, and modern][module-formats-badge]][unpkg-dist] - ![Logo with the text Accessible, Delightful and Performant](https://react-spring-bottom-sheet.nipesolutions.com/readme.svg) @@ -60,7 +59,10 @@ TS support is baked in, and if you're using the `snapTo` API use `BottomSheetRef ```tsx import { useRef } from 'react' -import { BottomSheet, BottomSheetRef } from '@nipe-solutions/react-spring-bottom-sheet' +import { + BottomSheet, + BottomSheetRef, +} from '@nipe-solutions/react-spring-bottom-sheet' export default function Example() { const sheetRef = useRef() @@ -106,7 +108,8 @@ module.exports = { plugins: { // Ensures the default variables are available 'postcss-custom-properties-fallback': { - importFrom: require.resolve('@nipe-solutions/react-spring-bottom-sheet/defaults.json'), + importFrom: + require.resolve('@nipe-solutions/react-spring-bottom-sheet/defaults.json'), }, }, } @@ -138,6 +141,12 @@ If you provide either a `header` or `footer` prop you'll enable the special beha In most cases you use a bottom sheet the same way you do with a dialog: you want it to overlay the page and block out distractions. But there are times when you want a bottom sheet but without it taking all the attention and overlaying the entire page. Providing `blocking={false}` helps this use case. By doing so you disable a couple of behaviors that are there for accessibility (focus-locking and more) that prevents a screen reader or a keyboard user from accidentally leaving the bottom sheet. +### [Motion recipes](https://react-spring-bottom-sheet.nipesolutions.com/fixtures/motion) + +> [View demo code](/pages/fixtures/motion.tsx) + +Switch between the four `springConfig` recipes with the sheet open and drag it between +snap points to feel the difference. A long throw is where they separate. ## API @@ -245,14 +254,62 @@ Disabled by default. By default, a user can expand the bottom sheet only by drag #### springConfig -Type: `{ mass: number; tension: number; friction: number }` +Type: `Partial`, where `SpringConfig` is +`{ mass, tension, friction, velocity, duration, easing, clamp }`. + +Customizes the movement and speed of the animations. **The one rule that decides +everything: if `duration` is set, react-spring runs a tween and ignores `mass`, `tension`, +`friction` and `velocity` entirely.** Only `easing` still applies. To get spring physics +you have to clear it with `duration: undefined`. + +This matters because the sheet ships a `duration` of its own, so passing tension and +friction alone changes nothing. + +##### Recipes + +Four ready made configs are exported, so you rarely need to tune this by hand. Compare +them side by side in the [motion demo](https://react-spring-bottom-sheet.nipesolutions.com/fixtures/motion). + +```jsx +import { BottomSheet, presets } from 'guiw5-bottom-sheet' + +; +``` + +| Recipe | Config | Feel | +| ------------------ | --------------------------------------------------- | ---------------------------------------------------------------- | +| `presets.linear` | `115ms`, linear | The default. Constant speed, stops dead on arrival. | +| `presets.eased` | `190ms`, `easeOutCubic` | Same pace, softer landing. | +| `presets.material` | `300ms`, emphasized decelerate | Matches Material 3, for apps built on Material components. | +| `presets.springy` | `duration: undefined`, tension `210`, friction `26` | Real physics. Speed follows the distance and your drag velocity. | + +The longer durations are not slower animations. An eased curve front loads the distance, +so all three tweens above cover 90% of the travel within about 10ms of each other. What +changes is the tail. + +##### Choosing a duration + +- **Below ~17ms is instant.** react-spring advances the first frame with a fixed 16.667ms + delta, so anything at or under that finishes in one frame. `duration: 0` is the explicit + way to disable the animation. +- **A frame's progress is capped at 64ms.** On a device that stutters the animation + stretches in real time instead of jumping, so it degrades gracefully rather than + teleporting. +- **For reference**, Material 3 puts a surface of this size at `medium2`, 300ms, and + reserves the 100 to 150ms range the default sits in for small controls. Shorter is a + legitimate choice for a sheet that is opened constantly; it is a taste call, not a bug. -Helps you to customize the movement and speed of the animations. +Anything not covered by a recipe can still be passed through: ```jsx ``` @@ -395,11 +452,20 @@ Type: `(numberOrCallback: number | (state => number)) => void, options?: {source Same signature as the `defaultSnap` prop, calling it will animate the sheet to the new snap point you return. You can either call it with a number, which is the height in px (it'll select the closest snap point that matches your value): `ref.current.snapTo(200)`. Or: ```js -ref.current.snapTo(({ // Showing all the available props - headerHeight, footerHeight, height, minHeight, maxHeight, snapPoints, lastSnap }) => - // Selecting the largest snap point, if you give it a number that doesn't match a snap point then it'll - // select whichever snap point is nearest the value you gave - Math.max(...snapPoints) +ref.current.snapTo( + ({ + // Showing all the available props + headerHeight, + footerHeight, + height, + minHeight, + maxHeight, + snapPoints, + lastSnap, + }) => + // Selecting the largest snap point, if you give it a number that doesn't match a snap point then it'll + // select whichever snap point is nearest the value you gave + Math.max(...snapPoints) ) ``` diff --git a/docs/headings.ts b/docs/headings.ts index 7e1fe3a3..bcc60281 100644 --- a/docs/headings.ts +++ b/docs/headings.ts @@ -2,3 +2,4 @@ export const simple = 'Easy to dismiss' export const scrollable = 'Snap points & overflow' export const sticky = 'Sticky Header & Footer' export const aside = 'Non-blocking mode' +export const motion = 'Motion recipes' diff --git a/pages/fixtures/motion.tsx b/pages/fixtures/motion.tsx new file mode 100644 index 00000000..275b0261 --- /dev/null +++ b/pages/fixtures/motion.tsx @@ -0,0 +1,146 @@ +import type { NextPage } from 'next' +import { useState } from 'react' +import Button from '../../docs/fixtures/Button' +import Code from '../../docs/fixtures/Code' +import Container from '../../docs/fixtures/Container' +import SheetContent from '../../docs/fixtures/SheetContent' +import { motion } from '../../docs/headings' +import MetaTags from '../../docs/MetaTags' +import { BottomSheet, presets } from '../../src' +import type { SpringConfig } from '../../src' +import type { GetStaticProps } from '../_app' + +export { getStaticProps } from '../_app' + +const recipes: { + key: string + label: string + blurb: string + config: Partial +}[] = [ + { + key: 'linear', + label: 'Linear', + blurb: 'The current default. Constant speed, so it stops dead on arrival.', + config: presets.linear, + }, + { + key: 'eased', + label: 'Eased', + blurb: + 'Leaves fast and settles. Reaches 90% of the travel at the same moment linear does, so it reads as the same speed.', + config: presets.eased, + }, + { + key: 'material', + label: 'Material', + blurb: + "Material 3's motion for a surface entering: 300ms on the emphasized decelerate curve. Reaches 90% of the travel at 108ms, so it is not slower where you notice it.", + config: presets.material, + }, + { + key: 'springy', + label: 'Spring', + blurb: + 'Real physics. Short snaps are quick, long ones carry momentum, and your drag velocity feeds into it.', + config: presets.springy, + }, +] + +const MotionFixturePage: NextPage = ({ + description, + homepage, + meta, + name, +}) => { + const [open, setOpen] = useState(false) + const [recipe, setRecipe] = useState(recipes[0]) + + // Rendered through `sibling` so it stays above the backdrop and clickable while the + // sheet is open, which is the whole point of being able to compare recipes back to back + const switcher = ( +
+
+ {recipes.map((r) => ( + + ))} +
+ {JSON.stringify(serialize(recipe.config))} +
+ ) + + return ( + <> + + + {/* While the sheet is open the same switcher is rendered through `sibling`, + which is the only way to stay above the backdrop and keep taking taps */} + {!open && switcher} +

+ Pick a recipe, then drag the sheet between snap points. The difference + shows up most on a long throw. Switching stays available while the + sheet is open. +

+ + setOpen(false)} + springConfig={recipe.config} + sibling={switcher} + defaultSnap={({ snapPoints }) => Math.min(...snapPoints)} + snapPoints={({ maxHeight }) => [ + maxHeight * 0.25, + maxHeight * 0.6, + maxHeight * 0.95, + ]} + header={{recipe.label}} + > + +

+ Drag the handle up and down. With Linear a short snap + and a long one take exactly the same time, so the long one looks + slow and the short one looks abrupt. +

+

+ Spring is the only recipe where the speed follows the + distance and how hard you flick. +

+ +
+
+
+ + ) +} + +/** easing is a function, so it needs a readable stand-in for display */ +function serialize(config: Partial) { + const out: Record = {} + for (const [key, value] of Object.entries(config)) { + out[key] = typeof value === 'function' ? 'easing fn' : value + } + return out +} + +export default MotionFixturePage diff --git a/src/index.tsx b/src/index.tsx index 3b7f7f4c..7e3c11e4 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -7,7 +7,9 @@ import { Portal } from './Portal' export type { RefHandles as BottomSheetRef, Props as BottomSheetProps, + SpringConfig, } from './types' +export * as presets from './presets' // Because SSR is annoying to deal with, and all the million complaints about window, navigator and dom elenents! export const BottomSheet = forwardRef(function BottomSheet( diff --git a/src/presets.ts b/src/presets.ts new file mode 100644 index 00000000..199376fa --- /dev/null +++ b/src/presets.ts @@ -0,0 +1,82 @@ +import { easings } from '@react-spring/web' +import type { SpringConfig } from './types' + +/** + * Ready made values for the `springConfig` prop. + * + * A note on how react-spring reads these: when `duration` is set it runs a tween and + * ignores `tension`, `friction`, `mass` and `velocity` entirely. Only a config without + * `duration` reaches the spring solver, which is why `springy` sets it to undefined. + * + * The floor for any duration is one frame. react-spring advances the first frame with a + * fixed 16.667ms delta, so anything at or below that finishes instantly. It also caps a + * frame's progress at 64ms, so on a device that stutters the animation stretches in real + * time rather than jumping. + */ + +/** Solves a CSS style `cubic-bezier(x1, y1, x2, y2)` curve, which react-spring has no helper for. */ +export function cubicBezier(x1: number, y1: number, x2: number, y2: number) { + const axis = (a: number, b: number, t: number) => { + const u = 1 - t + return 3 * u * u * t * a + 3 * u * t * t * b + t * t * t + } + return (progress: number) => { + if (progress <= 0) return 0 + if (progress >= 1) return 1 + let lo = 0 + let hi = 1 + let t = progress + for (let i = 0; i < 20; i++) { + const x = axis(x1, x2, t) + if (Math.abs(x - progress) < 1e-4) break + if (x < progress) lo = t + else hi = t + t = (lo + hi) / 2 + } + return axis(y1, y2, t) + } +} + +/** The historical default. A linear tween, so it starts and stops abruptly. */ +export const linear: Partial = { + duration: 115, +} + +/** + * Same pace, but eased so it leaves quickly and settles instead of stopping dead. + * + * The longer duration is not a slower animation. `easeOutCubic` front loads the distance: + * it covers 90% of the travel in 54% of its duration, against 90% in 90% for a linear + * tween. 190ms here reaches 90% at the same moment 115ms linear does, so it reads as the + * same speed with a softer landing. + */ +export const eased: Partial = { + duration: 190, + easing: easings.easeOutCubic, +} + +/** + * Material 3's motion for a surface entering the screen, for apps that sit next to + * Material components and should not feel faster or slower than the rest of them. + * + * 300ms is `md.sys.motion.duration.medium2` and the curve is `emphasized.decelerate`, + * both taken from Google's own token repository. Noticeably more deliberate than the + * default, which sits between M3's short2 and short3, the range meant for small controls + * rather than a full width sheet. + */ +export const material: Partial = { + duration: 300, + easing: cubicBezier(0.05, 0.7, 0.1, 1), +} + +/** + * Actual spring physics, which is what this library was built on. + * + * Unlike the tweens, the speed follows the distance travelled and the velocity of your + * drag, so a short snap is quick and a long one carries momentum. Costs a little overshoot. + */ +export const springy: Partial = { + duration: undefined, + tension: 210, + friction: 26, +} diff --git a/src/types.ts b/src/types.ts index a78cfde6..60939c0b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -49,8 +49,11 @@ export type SpringEvent = /** * Properties that can be used to customize the animation. - * By default transitions use a 115ms tween, remove `duration` (set it to `undefined`) to get spring physics driven by `tension` and `friction`. - * see https://react-spring.dev/docs/advanced/config#config-visualizer + * + * Setting `duration` runs a tween and makes react-spring ignore `mass`, `tension`, + * `friction` and `velocity`; only `easing` still applies. Set `duration` to `undefined` + * to get spring physics instead. See the `presets` export for ready made values. + * @see https://react-spring.dev/docs/advanced/config */ export type SpringConfig = { mass: number @@ -58,6 +61,10 @@ export type SpringConfig = { friction: number velocity: number duration: number | undefined + /** Shapes a tween's progress curve. Ignored unless `duration` is set. Defaults to linear. */ + easing: (t: number) => number + /** Stops a spring from overshooting its target. Ignored when `duration` is set. */ + clamp: boolean } export type Props = { diff --git a/test/presets.test.ts b/test/presets.test.ts new file mode 100644 index 00000000..9fbd8c99 --- /dev/null +++ b/test/presets.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { cubicBezier, eased, linear, material, springy } from '../src/presets' + +/** First progress value at which the curve has covered `fraction` of the travel */ +function timeToFraction(easing: (t: number) => number, fraction: number) { + for (let p = 0; p <= 1; p += 0.0005) { + if (easing(p) >= fraction) return p + } + return 1 +} + +describe('cubicBezier', () => { + it('is pinned at both ends', () => { + const curve = cubicBezier(0.05, 0.7, 0.1, 1) + expect(curve(0)).toBe(0) + expect(curve(1)).toBe(1) + expect(curve(-1)).toBe(0) + expect(curve(2)).toBe(1) + }) + + it('reproduces linear for the identity curve', () => { + const curve = cubicBezier(0, 0, 1, 1) + for (const p of [0.1, 0.25, 0.5, 0.75, 0.9]) { + expect(curve(p)).toBeCloseTo(p, 2) + } + }) + + it('never goes backwards', () => { + const curve = cubicBezier(0.05, 0.7, 0.1, 1) + let previous = 0 + for (let p = 0; p <= 1; p += 0.01) { + const value = curve(p) + expect(value).toBeGreaterThanOrEqual(previous - 1e-9) + previous = value + } + }) + + it('decelerates, so most of the travel happens early', () => { + const curve = cubicBezier(0.05, 0.7, 0.1, 1) + expect(curve(0.25)).toBeGreaterThan(0.75) + }) +}) + +describe('presets', () => { + it('only the spring one reaches the spring solver', () => { + // react-spring runs a tween whenever duration is set, ignoring tension and friction + expect(linear.duration).toBeDefined() + expect(eased.duration).toBeDefined() + expect(material.duration).toBeDefined() + expect(springy.duration).toBeUndefined() + expect(springy.tension).toBeDefined() + }) + + it('every duration clears the one frame floor', () => { + // react-spring advances the first frame with a fixed 16.667ms delta + for (const preset of [linear, eased, material]) { + expect(preset.duration).toBeGreaterThan(16.667) + } + }) + + it('the eased and material recipes match linear where it is noticed', () => { + // linear covers 90% of the travel in 90% of its duration + const linearAt90 = 0.9 * linear.duration! + + const easedAt90 = timeToFraction(eased.easing!, 0.9) * eased.duration! + const materialAt90 = + timeToFraction(material.easing!, 0.9) * material.duration! + + // within 15ms of each other, so none of them reads as slower than the default + expect(Math.abs(easedAt90 - linearAt90)).toBeLessThan(15) + expect(Math.abs(materialAt90 - linearAt90)).toBeLessThan(15) + + // and both still take longer overall, which is the soft landing + expect(eased.duration!).toBeGreaterThan(linear.duration!) + expect(material.duration!).toBeGreaterThan(linear.duration!) + }) +})