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!) + }) +})