Skip to content
Open
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
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,36 @@ Anything not covered by a recipe can still be passed through:
/>
```

#### motion

Type: `{ open?, close?, release?, reduced? }`, each a `Partial<SpringConfig>`.

Separate configs per transition, merged over `springConfig`. `open` and `close` drive the
sheet entering and leaving. `release` runs when a drag is let go, both for a snap and for a
swipe to dismiss, and receives the gesture's velocity, so it only makes sense as a spring
(`duration: undefined`). `reduced` is used when the user prefers reduced motion: instead of
sliding, the whole sheet fades in place. Without it a reduced motion user gets no animation.

```jsx
import { BottomSheet, presets } from 'guiw5-bottom-sheet'

;<BottomSheet motion={presets.sheet} />
```

`presets.sheet` is the iOS sheet curve, `cubic-bezier(0.32, 0.72, 0, 1)`, 450ms in and 280ms
out, a critically damped release spring (tension `1000`, friction `63`) and a 150ms fade for
reduced motion.

#### fadeContent

Type: `boolean`, `true` by default.

The content fades while the sheet is near the bottom of its travel. Set it to `false` to
keep it opaque and only move the sheet.

The backdrop fades from transparent to `--rsbs-backdrop-bg`, so its maximum opacity is the
alpha of that color: `--rsbs-backdrop-bg: rgba(0, 0, 0, 0.4)` caps it at 0.4.

### Events

All events receive `SpringEvent` as their argument. The payload varies, but `type` is always present, which can be `'OPEN' | 'RESIZE' | 'SNAP' | 'CLOSE'` depending on the scenario.
Expand Down
99 changes: 91 additions & 8 deletions src/BottomSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
maxSnap?: number
immediate?: boolean
config?: Partial<SpringConfig>
motion?: Partial<SpringConfig>
}

export const BottomSheet = React.forwardRef<
Expand Down Expand Up @@ -77,6 +78,8 @@
scrollLocking = true,
style,
springConfig,
motion,
fadeContent = true,
onSpringStart,
onSpringCancel,
onSpringEnd,
Expand All @@ -94,12 +97,18 @@
const onSpringCancelRef = useRef(onSpringCancel)
const onSpringEndRef = useRef(onSpringEnd)
const springConfigRef = useRef(springConfig)
const motionRef = useRef(motion)
useEffect(() => {
onSpringStartRef.current = onSpringStart
onSpringCancelRef.current = onSpringCancel
onSpringEndRef.current = onSpringEnd
springConfigRef.current = springConfig
}, [onSpringCancel, onSpringStart, onSpringEnd, springConfig])
motionRef.current = motion
}, [onSpringCancel, onSpringStart, onSpringEnd, springConfig, motion])

const releaseVelocityRef = useRef<number | null>(null)
const transitionRef = useRef(0)
const resumeOpenRef = useRef(false)

// Behold, the engine of it all!
const [spring, api] = useSpring()
Expand Down Expand Up @@ -167,7 +176,11 @@

// Starts a spring transition and resolves when it settles (or gets interrupted)
const asyncSet = useCallback(
({ config: { velocity = 1, ...userConfig } = {}, ...opts }: SpringUpdate) =>
({
config: { velocity = 1, ...userConfig } = {},
motion: motionConfig,
...opts
}: SpringUpdate) =>
Promise.all(
api.start({
...opts,
Expand All @@ -181,17 +194,24 @@
// "damping"
friction: Math.max(
friction,
friction + (friction - friction * velocity)
friction + (friction - friction * Math.abs(velocity))
),
// When duration is set the spring physics above are ignored in favor of a fixed length tween
duration: 115,
...springConfigRef.current,
...motionConfig,
},
})
),
[api]
)

const takeReleaseVelocity = useCallback(() => {
const velocity = releaseVelocityRef.current
releaseVelocityRef.current = null
return motionRef.current?.release ? velocity : null
}, [])

// Every implementation reads from refs, so the machine only needs to be wired up once
const machine = useMemo(
() =>
Expand Down Expand Up @@ -263,6 +283,12 @@
})
}),
renderVisuallyHidden: fromPromise(async () => {
transitionRef.current++
resumeOpenRef.current =
spring.ready.get() > 0 && spring.y.get() > 0
if (resumeOpenRef.current) {
return
}
api.set({
y: defaultSnapRef.current,
ready: 0,
Expand Down Expand Up @@ -295,16 +321,33 @@
maxSnap: maxSnapRef.current,
// Using defaultSnapRef instead of minSnapRef to avoid animating `height` on open
minSnap: defaultSnapRef.current,
fade: 0,
})
}),
openSmoothly: fromPromise(async () => {
const reduced = motionRef.current?.reduced
if (prefersReducedMotion.current && reduced) {
heightRef.current = defaultSnapRef.current
api.set({
y: defaultSnapRef.current,
...(resumeOpenRef.current ? {} : { ready: 0 }),
maxHeight: maxHeightRef.current,
maxSnap: maxSnapRef.current,
minSnap: defaultSnapRef.current,
fade: 0,
})
await asyncSet({ ready: 1, motion: reduced })
return
}

api.set({
y: 0,
...(resumeOpenRef.current ? {} : { y: 0 }),
ready: 1,
maxHeight: maxHeightRef.current,
maxSnap: maxSnapRef.current,
// Using defaultSnapRef instead of minSnapRef to avoid animating `height` on open
minSnap: defaultSnapRef.current,
fade: motionRef.current?.contentFade ?? 0,
})

heightRef.current = defaultSnapRef.current
Expand All @@ -317,21 +360,28 @@
// Using defaultSnapRef instead of minSnapRef to avoid animating `height` on open
minSnap: defaultSnapRef.current,
immediate: prefersReducedMotion.current,
motion: motionRef.current?.open,
})
}),
snapSmoothly: fromPromise<void, { y: number; velocity: number }>(
async ({ input }) => {
const snap = findSnapRef.current(input.y)
heightRef.current = snap
lastSnapRef.current = snap
const releaseVelocity = takeReleaseVelocity()
await asyncSet({
y: snap,
ready: 1,
maxHeight: maxHeightRef.current,
maxSnap: maxSnapRef.current,
minSnap: minSnapRef.current,
immediate: prefersReducedMotion.current,
config: { velocity: input.velocity },
...(releaseVelocity === null
? { config: { velocity: input.velocity } }
: {
config: { velocity: releaseVelocity },
motion: motionRef.current?.release,
}),
})
}
),
Expand All @@ -353,22 +403,45 @@
}),
closeSmoothly: fromPromise(async () => {
// Avoid animating the height property on close and stay within FLIP bounds by upping the minSnap
api.set({ minSnap: heightRef.current })
const transition = ++transitionRef.current
const dismissedByDrag = releaseVelocityRef.current !== null
api.set({
minSnap: heightRef.current,
fade: dismissedByDrag ? 0 : (motionRef.current?.contentFade ?? 0),
})

heightRef.current = 0
const releaseVelocity = takeReleaseVelocity()

const reduced = motionRef.current?.reduced
if (prefersReducedMotion.current && reduced) {
await asyncSet({ ready: 0, motion: reduced })
if (transition === transitionRef.current) {
api.set({ y: 0 })
}
return
}

await asyncSet({
y: 0,
maxHeight: maxHeightRef.current,
maxSnap: maxSnapRef.current,
immediate: prefersReducedMotion.current,
...(releaseVelocity === null
? { motion: motionRef.current?.close }
: {
config: { velocity: releaseVelocity },
motion: motionRef.current?.release,
}),
})

api.set({ ready: 0 })
if (transition === transitionRef.current) {
api.set({ ready: 0 })
}
}),
},
}),
[

Check warning on line 444 in src/BottomSheet.tsx

View workflow job for this annotation

GitHub Actions / Test on Node.js 24

React Hook useMemo has missing dependencies: 'spring.ready' and 'spring.y'. Either include them or remove the dependency array

Check warning on line 444 in src/BottomSheet.tsx

View workflow job for this annotation

GitHub Actions / Test on Node.js 22

React Hook useMemo has missing dependencies: 'spring.ready' and 'spring.y'. Either include them or remove the dependency array
api,
ariaHiderRef,
asyncSet,
Expand All @@ -376,6 +449,7 @@
lastSnapRef,
prefersReducedMotion,
scrollLockRef,
takeReleaseVelocity,
]
)
const actorRef = useActorRef(machine, { input: { initialState } })
Expand Down Expand Up @@ -423,6 +497,7 @@
forwardRef,
() => ({
snapTo: (numberOrCallback, { velocity = 1, source = 'custom' } = {}) => {
releaseVelocityRef.current = null
send({
type: 'SNAP',
payload: {
Expand Down Expand Up @@ -518,6 +593,14 @@
Math.min(maxSnapRef.current, rawY + predictedDistance * 2)
)

if (first) {
releaseVelocityRef.current = null
api.set({ fade: 0 })
}
if (!down) {
releaseVelocityRef.current = -direction * velocity
}

if (
!down &&
onDismiss &&
Expand Down Expand Up @@ -606,7 +689,7 @@
throw new TypeError('minSnapRef is NaN!!')
}

const interpolations = useSpringInterpolations({ spring })
const interpolations = useSpringInterpolations({ spring, fadeContent })

return (
<animated.div
Expand Down
1 change: 1 addition & 0 deletions src/hooks/useSpring.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export function useSpring() {
maxHeight: 0,
minSnap: 0,
maxSnap: 0,
fade: 0,
}))
}

Expand Down
14 changes: 11 additions & 3 deletions src/hooks/useSpringInterpolations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ import { clamp } from '../utils'

export function useSpringInterpolations({
spring,
fadeContent = true,
}: {
spring: Spring
fadeContent?: boolean
}): React.CSSProperties {
const { y, maxHeight, minSnap, maxSnap } = spring
const { y, maxHeight, minSnap, maxSnap, fade } = spring

return useMemo(() => {
// Removes rounded corners on phones when the sheet touches the top of the browser chrome
Expand Down Expand Up @@ -42,10 +44,16 @@ export function useSpringInterpolations({
y >= maxSnap ? Math.ceil(y - maxSnap) : 0
)

const interpolateContentOpacity = to([y, minSnap], (y, minSnap) => {
const interpolateContentOpacity = to([y, minSnap, fade], (y, minSnap, fade) => {
if (!minSnap) {
return 0
}
if (fade > 0) {
return clamp((y / minSnap - (1 - fade)) / fade, 0, 1)
}
if (!fadeContent) {
return 1
}
const minX = Math.max(minSnap / 2 - 45, 0)
const maxX = Math.min(minSnap / 2 + 45, minSnap)
const slope = 1 / (maxX - minX)
Expand All @@ -70,5 +78,5 @@ export function useSpringInterpolations({
// Animates the height state, not the most performant way but it's the safest with regards to mobile browser and focus/scrolling that could happen while animating
['--rsbs-overlay-h' as any]: interpolateHeight,
}
}, [y, maxHeight, minSnap, maxSnap])
}, [y, maxHeight, minSnap, maxSnap, fade, fadeContent])
}
1 change: 1 addition & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type {
RefHandles as BottomSheetRef,
Props as BottomSheetProps,
SpringConfig,
Motion,
} from './types'
export * as presets from './presets'

Expand Down
13 changes: 12 additions & 1 deletion src/presets.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { easings } from '@react-spring/web'
import type { SpringConfig } from './types'
import type { Motion, SpringConfig } from './types'

/**
* Ready made values for the `springConfig` prop.
Expand Down Expand Up @@ -80,3 +80,14 @@ export const springy: Partial<SpringConfig> = {
tension: 210,
friction: 26,
}

const sheetCurve = cubicBezier(0.32, 0.72, 0, 1)
const sheetCurveReversed = cubicBezier(1, 0, 0.68, 0.28)

export const sheet: Motion = {
open: { duration: 400, easing: sheetCurve },
close: { duration: 400, easing: sheetCurveReversed },
release: { duration: undefined, mass: 1, tension: 1000, friction: 63 },
reduced: { duration: 150, easing: easings.linear },
contentFade: 0.15,
}
14 changes: 14 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ export type SpringConfig = {
clamp: boolean
}

export type Motion = {
open?: Partial<SpringConfig>
close?: Partial<SpringConfig>
release?: Partial<SpringConfig>
reduced?: Partial<SpringConfig>
contentFade?: number
}

export type Props = {
/**
* Ensure that whatever you put in here have at least 1px height, or else the bottom sheet won't open
Expand All @@ -85,6 +93,12 @@ export type Props = {
*/
springConfig?: Partial<SpringConfig>

/** Per transition configs merged over `springConfig`, see `presets.sheet`. */
motion?: Motion

/** @default true */
fadeContent?: boolean

/**
* Scroller target
*/
Expand Down
Loading
Loading