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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 78 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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<BottomSheetRef>()
Expand Down Expand Up @@ -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'),
},
},
}
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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<SpringConfig>`, 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'

;<BottomSheet springConfig={presets.material} />
```

| 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
<BottomSheet
// Animation faster than the default
springConfig={{mass: 0.1, tension: 370, friction: 26}}
// A snappier spring, and no overshoot
springConfig={{
duration: undefined,
tension: 370,
friction: 26,
clamp: true,
}}
/>
```

Expand Down Expand Up @@ -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)
)
```

Expand Down
1 change: 1 addition & 0 deletions docs/headings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
146 changes: 146 additions & 0 deletions pages/fixtures/motion.tsx
Original file line number Diff line number Diff line change
@@ -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<SpringConfig>
}[] = [
{
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<GetStaticProps> = ({
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 = (
<div className="fixed inset-x-0 top-0 z-10 flex flex-col items-center gap-2 p-4">
<div className="flex flex-wrap justify-center gap-2">
{recipes.map((r) => (
<Button
key={r.key}
onClick={() => {
setRecipe(r)
setOpen(true)
}}
className={
r.key === recipe.key ? 'ring-2 ring-gray-400' : undefined
}
>
{r.label}
</Button>
))}
</div>
<Code>{JSON.stringify(serialize(recipe.config))}</Code>
</div>
)

return (
<>
<MetaTags
{...meta}
name={name}
description={description}
homepage={homepage}
title={motion}
/>
<Container>
{/* 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}
<p className="max-w-md px-6 text-center text-gray-600">
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.
</p>

<BottomSheet
// Remounts on change so each recipe starts from a clean spring
key={recipe.key}
open={open}
onDismiss={() => setOpen(false)}
springConfig={recipe.config}
sibling={switcher}
defaultSnap={({ snapPoints }) => Math.min(...snapPoints)}
snapPoints={({ maxHeight }) => [
maxHeight * 0.25,
maxHeight * 0.6,
maxHeight * 0.95,
]}
header={<span>{recipe.label}</span>}
>
<SheetContent>
<p>
Drag the handle up and down. With <Code>Linear</Code> a short snap
and a long one take exactly the same time, so the long one looks
slow and the short one looks abrupt.
</p>
<p>
<Code>Spring</Code> is the only recipe where the speed follows the
distance and how hard you flick.
</p>
<Button onClick={() => setOpen(false)} className="w-full">
Close
</Button>
</SheetContent>
</BottomSheet>
</Container>
</>
)
}

/** easing is a function, so it needs a readable stand-in for display */
function serialize(config: Partial<SpringConfig>) {
const out: Record<string, unknown> = {}
for (const [key, value] of Object.entries(config)) {
out[key] = typeof value === 'function' ? 'easing fn' : value
}
return out
}

export default MotionFixturePage
2 changes: 2 additions & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<RefHandles, Props>(function BottomSheet(
Expand Down
82 changes: 82 additions & 0 deletions src/presets.ts
Original file line number Diff line number Diff line change
@@ -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<SpringConfig> = {
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<SpringConfig> = {
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<SpringConfig> = {
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<SpringConfig> = {
duration: undefined,
tension: 210,
friction: 26,
}
Loading