From 3d3825b18b2af072c46c09128a51714b407f07be Mon Sep 17 00:00:00 2001 From: Guido Wagner Date: Mon, 12 Oct 2026 19:34:46 -0300 Subject: [PATCH 1/2] feat: per-transition motion, velocity on drag release and opaque content option Adds the motion prop (open, close, release, reduced), presets.sheet and fadeContent. --- README.md | 30 +++ src/BottomSheet.tsx | 72 ++++++- src/hooks/useSpringInterpolations.tsx | 8 +- src/index.tsx | 1 + src/presets.ts | 11 +- src/types.ts | 13 ++ test/motion.test.tsx | 263 ++++++++++++++++++++++++++ test/presets.test.ts | 26 ++- 8 files changed, 415 insertions(+), 9 deletions(-) create mode 100644 test/motion.test.tsx diff --git a/README.md b/README.md index 0a58d54f..eb0ccfef 100644 --- a/README.md +++ b/README.md @@ -315,6 +315,36 @@ Anything not covered by a recipe can still be passed through: /> ``` +#### motion + +Type: `{ open?, close?, release?, reduced? }`, each a `Partial`. + +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' + +; +``` + +`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. diff --git a/src/BottomSheet.tsx b/src/BottomSheet.tsx index 58e658c2..16c732ce 100644 --- a/src/BottomSheet.tsx +++ b/src/BottomSheet.tsx @@ -49,6 +49,7 @@ type SpringUpdate = { maxSnap?: number immediate?: boolean config?: Partial + motion?: Partial } export const BottomSheet = React.forwardRef< @@ -77,6 +78,8 @@ export const BottomSheet = React.forwardRef< scrollLocking = true, style, springConfig, + motion, + fadeContent = true, onSpringStart, onSpringCancel, onSpringEnd, @@ -94,12 +97,16 @@ export const BottomSheet = React.forwardRef< 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(null) // Behold, the engine of it all! const [spring, api] = useSpring() @@ -167,7 +174,11 @@ export const BottomSheet = React.forwardRef< // 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, @@ -181,17 +192,24 @@ export const BottomSheet = React.forwardRef< // "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( () => @@ -298,6 +316,20 @@ export const BottomSheet = React.forwardRef< }) }), openSmoothly: fromPromise(async () => { + const reduced = motionRef.current?.reduced + if (prefersReducedMotion.current && reduced) { + heightRef.current = defaultSnapRef.current + api.set({ + y: defaultSnapRef.current, + ready: 0, + maxHeight: maxHeightRef.current, + maxSnap: maxSnapRef.current, + minSnap: defaultSnapRef.current, + }) + await asyncSet({ ready: 1, motion: reduced }) + return + } + api.set({ y: 0, ready: 1, @@ -317,6 +349,7 @@ export const BottomSheet = React.forwardRef< // Using defaultSnapRef instead of minSnapRef to avoid animating `height` on open minSnap: defaultSnapRef.current, immediate: prefersReducedMotion.current, + motion: motionRef.current?.open, }) }), snapSmoothly: fromPromise( @@ -324,6 +357,7 @@ export const BottomSheet = React.forwardRef< const snap = findSnapRef.current(input.y) heightRef.current = snap lastSnapRef.current = snap + const releaseVelocity = takeReleaseVelocity() await asyncSet({ y: snap, ready: 1, @@ -331,7 +365,12 @@ export const BottomSheet = React.forwardRef< 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, + }), }) } ), @@ -356,12 +395,26 @@ export const BottomSheet = React.forwardRef< api.set({ minSnap: heightRef.current }) heightRef.current = 0 + const releaseVelocity = takeReleaseVelocity() + + const reduced = motionRef.current?.reduced + if (prefersReducedMotion.current && reduced) { + await asyncSet({ ready: 0, motion: reduced }) + 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 }) @@ -376,6 +429,7 @@ export const BottomSheet = React.forwardRef< lastSnapRef, prefersReducedMotion, scrollLockRef, + takeReleaseVelocity, ] ) const actorRef = useActorRef(machine, { input: { initialState } }) @@ -423,6 +477,7 @@ export const BottomSheet = React.forwardRef< forwardRef, () => ({ snapTo: (numberOrCallback, { velocity = 1, source = 'custom' } = {}) => { + releaseVelocityRef.current = null send({ type: 'SNAP', payload: { @@ -518,6 +573,13 @@ export const BottomSheet = React.forwardRef< Math.min(maxSnapRef.current, rawY + predictedDistance * 2) ) + if (first) { + releaseVelocityRef.current = null + } + if (!down) { + releaseVelocityRef.current = -direction * velocity + } + if ( !down && onDismiss && @@ -606,7 +668,7 @@ export const BottomSheet = React.forwardRef< throw new TypeError('minSnapRef is NaN!!') } - const interpolations = useSpringInterpolations({ spring }) + const interpolations = useSpringInterpolations({ spring, fadeContent }) return ( = { tension: 210, friction: 26, } + +const sheetCurve = cubicBezier(0.32, 0.72, 0, 1) + +export const sheet: Motion = { + open: { duration: 450, easing: sheetCurve }, + close: { duration: 280, easing: sheetCurve }, + release: { duration: undefined, mass: 1, tension: 1000, friction: 63 }, + reduced: { duration: 150, easing: easings.linear }, +} diff --git a/src/types.ts b/src/types.ts index 60939c0b..dd51f5c3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -67,6 +67,13 @@ export type SpringConfig = { clamp: boolean } +export type Motion = { + open?: Partial + close?: Partial + release?: Partial + reduced?: Partial +} + export type Props = { /** * Ensure that whatever you put in here have at least 1px height, or else the bottom sheet won't open @@ -85,6 +92,12 @@ export type Props = { */ springConfig?: Partial + /** Per transition configs merged over `springConfig`, see `presets.sheet`. */ + motion?: Motion + + /** @default true */ + fadeContent?: boolean + /** * Scroller target */ diff --git a/test/motion.test.tsx b/test/motion.test.tsx new file mode 100644 index 00000000..235d70c6 --- /dev/null +++ b/test/motion.test.tsx @@ -0,0 +1,263 @@ +import { useState } from 'react' +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { render, waitFor, fireEvent, act } from '@testing-library/react' +import { BottomSheet, presets } from '../src' +import type { SpringConfig } from '../src' + +type StartCall = { + y?: number + ready?: number + config?: Partial +} + +const starts: StartCall[] = [] + +vi.mock('../src/hooks/useSpring', async (importOriginal) => { + const original = + await importOriginal() + const patched = new WeakSet() + return { + ...original, + useSpring: () => { + const result = original.useSpring() + const api = result[1] + if (!patched.has(api)) { + patched.add(api) + const start = api.start.bind(api) + api.start = ((props: StartCall) => { + starts.push(props) + return start(props) + }) as typeof api.start + } + return result + }, + } +}) + +const content =
sheet content
+ +const sheetRoot = () => document.querySelector('[data-rsbs-root]') +const sheetState = () => sheetRoot()?.getAttribute('data-rsbs-state') +const waitForState = (state: string) => + waitFor(() => expect(sheetState()).toBe(state), { timeout: 4000 }) + +function Dismissable(props: Partial>) { + const [open, setOpen] = useState(true) + return ( + setOpen(false)} + header={
header
} + {...props} + > + {content} +
+ ) +} + +function swipe(fromY: number, toY: number) { + const header = document.querySelector('[data-rsbs-header]')! + const pointer = { pointerId: 1, buttons: 1, clientX: 10 } + fireEvent.pointerDown(header, { ...pointer, clientY: fromY }) + fireEvent.pointerMove(header, { ...pointer, clientY: (fromY + toY) / 2 }) + fireEvent.pointerMove(header, { ...pointer, clientY: toY }) + fireEvent.pointerUp(header, { ...pointer, buttons: 0, clientY: toY }) +} + +const lastStart = () => starts[starts.length - 1] + +beforeEach(() => { + starts.length = 0 +}) + +describe('motion', () => { + it('keeps the historical 115ms tween when no motion is given', async () => { + const { rerender } = render({content}) + await waitForState('open') + expect(lastStart().config?.duration).toBe(115) + + rerender({content}) + await waitFor(() => expect(sheetRoot()).toBeNull(), { timeout: 4000 }) + expect(lastStart().config?.duration).toBe(115) + }) + + it('springConfig still applies to every transition', async () => { + render( + + {content} + + ) + await waitForState('open') + expect(lastStart().config?.duration).toBe(presets.material.duration) + }) + + it('opens with the open config and closes with the close config', async () => { + const { rerender } = render( + + {content} + + ) + await waitForState('open') + expect(lastStart().config?.duration).toBe(presets.sheet.open!.duration) + expect(lastStart().config?.easing).toBe(presets.sheet.open!.easing) + + rerender( + + {content} + + ) + await waitFor(() => expect(sheetRoot()).toBeNull(), { timeout: 4000 }) + expect(lastStart().config?.duration).toBe(presets.sheet.close!.duration) + }) + + it('a drag release snaps with a velocity spring, not a tween', async () => { + render( + header} + snapPoints={({ maxHeight }) => [250, maxHeight]} + > + {content} + + ) + await waitForState('open') + starts.length = 0 + + swipe(400, 300) + await waitFor(() => expect(starts.length).toBeGreaterThan(0)) + + const config = lastStart().config! + expect('duration' in config).toBe(true) + expect(config.duration).toBeUndefined() + expect(config.tension).toBe(presets.sheet.release!.tension) + expect(config.friction).toBe(presets.sheet.release!.friction) + expect(Number.isFinite(config.velocity)).toBe(true) + }) + + it('a swipe down dismisses with the release spring and a downward velocity', async () => { + render() + await waitForState('open') + starts.length = 0 + + swipe(0, 400) + await waitFor(() => expect(sheetRoot()).toBeNull(), { timeout: 4000 }) + + const close = starts.find((call) => call.y === 0)! + expect(close.config?.duration).toBeUndefined() + expect(close.config?.tension).toBe(presets.sheet.release!.tension) + expect(close.config!.velocity!).toBeLessThanOrEqual(0) + }) + + it('a backdrop tap closes with the close config, not the release spring', async () => { + render() + await waitForState('open') + starts.length = 0 + + const backdrop = document.querySelector('[data-rsbs-backdrop]')! + const pointer = { pointerId: 1, buttons: 1, clientX: 10, clientY: 10 } + fireEvent.pointerDown(backdrop, pointer) + fireEvent.pointerUp(backdrop, { ...pointer, buttons: 0 }) + + await waitFor(() => expect(sheetRoot()).toBeNull(), { timeout: 4000 }) + expect(lastStart().config?.duration).toBe(presets.sheet.close!.duration) + }) + + it('without a release config a drag keeps using the default tween', async () => { + render( + header} + snapPoints={({ maxHeight }) => [250, maxHeight]} + > + {content} + + ) + await waitForState('open') + starts.length = 0 + + swipe(400, 300) + await waitFor(() => expect(starts.length).toBeGreaterThan(0)) + expect(lastStart().config?.duration).toBe(115) + }) +}) + +describe('fadeContent', () => { + const contentOpacity = () => + sheetRoot()?.style.getPropertyValue('--rsbs-content-opacity') + const sliding = () => + waitFor(() => + expect( + sheetRoot()?.style.getPropertyValue('--rsbs-overlay-translate-y') + ).toMatch(/^[1-9]/) + ) + + it('fades the content in by default', async () => { + render( + + {content} + + ) + await sliding() + expect(Number(contentOpacity())).toBeLessThan(1) + }) + + it('keeps the content opaque when disabled', async () => { + render( + + {content} + + ) + await sliding() + expect(contentOpacity()).toBe('1') + }) +}) + +describe('reduced motion', () => { + function preferReducedMotion() { + vi.mocked(window.matchMedia).mockImplementation( + (query: string) => + ({ + matches: query.includes('reduce'), + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }) as unknown as MediaQueryList + ) + } + + it('fades the whole sheet in place instead of sliding it', async () => { + preferReducedMotion() + const { rerender } = render( + + {content} + + ) + await waitForState('open') + + const open = starts.find((call) => call.ready === 1)! + expect(open.y).toBeUndefined() + expect(open.config?.duration).toBe(presets.sheet.reduced!.duration) + + starts.length = 0 + rerender( + + {content} + + ) + await waitFor(() => expect(sheetRoot()).toBeNull(), { timeout: 4000 }) + const close = starts.find((call) => call.ready === 0)! + expect(close.y).toBeUndefined() + }) + + it('stays instant without a reduced config', async () => { + preferReducedMotion() + render({content}) + await waitForState('open') + act(() => {}) + expect(starts.every((call) => call.ready !== 1 || 'y' in call)).toBe(true) + }) +}) diff --git a/test/presets.test.ts b/test/presets.test.ts index 9fbd8c99..5cd4e7d0 100644 --- a/test/presets.test.ts +++ b/test/presets.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from 'vitest' -import { cubicBezier, eased, linear, material, springy } from '../src/presets' +import { + cubicBezier, + eased, + linear, + material, + sheet, + 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) { @@ -75,3 +82,20 @@ describe('presets', () => { expect(material.duration!).toBeGreaterThan(linear.duration!) }) }) + +describe('sheet', () => { + it('opens slower than it closes, on the same curve', () => { + expect(sheet.open!.duration).toBeGreaterThan(sheet.close!.duration!) + expect(sheet.open!.easing).toBe(sheet.close!.easing) + }) + + it('releases a drag into a critically damped spring', () => { + const { duration, tension, friction, mass = 1 } = sheet.release! + expect(duration).toBeUndefined() + expect(friction).toBeCloseTo(2 * Math.sqrt(tension! * mass), 0) + }) + + it('keeps reduced motion short', () => { + expect(sheet.reduced!.duration).toBeLessThanOrEqual(200) + }) +}) From 88287f936c2b2edcd546989bb535cc6ec026865d Mon Sep 17 00:00:00 2001 From: Guido Wagner Date: Wed, 14 Oct 2026 19:21:05 -0300 Subject: [PATCH 2/2] feat: fade content only on programmatic open and close Reopening mid-close reverses from the current position. The sheet preset is now symmetric: 400 ms, closing on the opening curve played backwards. --- src/BottomSheet.tsx | 31 ++++++++++++++++++++++----- src/hooks/useSpring.tsx | 1 + src/hooks/useSpringInterpolations.tsx | 16 ++++++++------ src/presets.ts | 6 ++++-- src/types.ts | 1 + test/presets.test.ts | 8 ++++--- 6 files changed, 47 insertions(+), 16 deletions(-) diff --git a/src/BottomSheet.tsx b/src/BottomSheet.tsx index 16c732ce..226b1767 100644 --- a/src/BottomSheet.tsx +++ b/src/BottomSheet.tsx @@ -107,6 +107,8 @@ export const BottomSheet = React.forwardRef< }, [onSpringCancel, onSpringStart, onSpringEnd, springConfig, motion]) const releaseVelocityRef = useRef(null) + const transitionRef = useRef(0) + const resumeOpenRef = useRef(false) // Behold, the engine of it all! const [spring, api] = useSpring() @@ -281,6 +283,12 @@ export const BottomSheet = React.forwardRef< }) }), 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, @@ -313,6 +321,7 @@ export const BottomSheet = React.forwardRef< maxSnap: maxSnapRef.current, // Using defaultSnapRef instead of minSnapRef to avoid animating `height` on open minSnap: defaultSnapRef.current, + fade: 0, }) }), openSmoothly: fromPromise(async () => { @@ -321,22 +330,24 @@ export const BottomSheet = React.forwardRef< heightRef.current = defaultSnapRef.current api.set({ y: defaultSnapRef.current, - ready: 0, + ...(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 @@ -392,7 +403,12 @@ export const BottomSheet = React.forwardRef< }), 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() @@ -400,7 +416,9 @@ export const BottomSheet = React.forwardRef< const reduced = motionRef.current?.reduced if (prefersReducedMotion.current && reduced) { await asyncSet({ ready: 0, motion: reduced }) - api.set({ y: 0 }) + if (transition === transitionRef.current) { + api.set({ y: 0 }) + } return } @@ -417,7 +435,9 @@ export const BottomSheet = React.forwardRef< }), }) - api.set({ ready: 0 }) + if (transition === transitionRef.current) { + api.set({ ready: 0 }) + } }), }, }), @@ -575,6 +595,7 @@ export const BottomSheet = React.forwardRef< if (first) { releaseVelocityRef.current = null + api.set({ fade: 0 }) } if (!down) { releaseVelocityRef.current = -direction * velocity diff --git a/src/hooks/useSpring.tsx b/src/hooks/useSpring.tsx index ada6a12e..0f0502d3 100644 --- a/src/hooks/useSpring.tsx +++ b/src/hooks/useSpring.tsx @@ -9,6 +9,7 @@ export function useSpring() { maxHeight: 0, minSnap: 0, maxSnap: 0, + fade: 0, })) } diff --git a/src/hooks/useSpringInterpolations.tsx b/src/hooks/useSpringInterpolations.tsx index c26e7b9e..d54a347d 100644 --- a/src/hooks/useSpringInterpolations.tsx +++ b/src/hooks/useSpringInterpolations.tsx @@ -15,7 +15,7 @@ export function useSpringInterpolations({ 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 @@ -44,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) @@ -60,9 +66,7 @@ export function useSpringInterpolations({ return { // Fancy content fade-in effect - ['--rsbs-content-opacity' as any]: fadeContent - ? interpolateContentOpacity - : 1, + ['--rsbs-content-opacity' as any]: interpolateContentOpacity, // Fading in the backdrop ['--rsbs-backdrop-opacity' as any]: interpolateBackdrop, // Scaling the antigap in the bottom @@ -74,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, fadeContent]) + }, [y, maxHeight, minSnap, maxSnap, fade, fadeContent]) } diff --git a/src/presets.ts b/src/presets.ts index ea90c0ea..fc350627 100644 --- a/src/presets.ts +++ b/src/presets.ts @@ -82,10 +82,12 @@ export const springy: Partial = { } const sheetCurve = cubicBezier(0.32, 0.72, 0, 1) +const sheetCurveReversed = cubicBezier(1, 0, 0.68, 0.28) export const sheet: Motion = { - open: { duration: 450, easing: sheetCurve }, - close: { duration: 280, easing: sheetCurve }, + 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, } diff --git a/src/types.ts b/src/types.ts index dd51f5c3..2a3b0c8d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -72,6 +72,7 @@ export type Motion = { close?: Partial release?: Partial reduced?: Partial + contentFade?: number } export type Props = { diff --git a/test/presets.test.ts b/test/presets.test.ts index 5cd4e7d0..2ee7cf30 100644 --- a/test/presets.test.ts +++ b/test/presets.test.ts @@ -84,9 +84,11 @@ describe('presets', () => { }) describe('sheet', () => { - it('opens slower than it closes, on the same curve', () => { - expect(sheet.open!.duration).toBeGreaterThan(sheet.close!.duration!) - expect(sheet.open!.easing).toBe(sheet.close!.easing) + it('closes as the opening played backwards', () => { + expect(sheet.close!.duration).toBe(sheet.open!.duration) + for (const t of [0.1, 0.25, 0.5, 0.75, 0.9]) { + expect(sheet.close!.easing!(t)).toBeCloseTo(1 - sheet.open!.easing!(1 - t), 2) + } }) it('releases a drag into a critically damped spring', () => {