Package Exports
- react-spring-bottom-sheet-updated
- react-spring-bottom-sheet-updated/dist/index.es.js
This package does not declare an exports field, so the exports above have been automatically detected and optimized by JSPM instead. If any package subpath is missing, it is recommended to post an issue to the original package (react-spring-bottom-sheet-updated) to support the "exports" field. If that is not possible, create a JSPM override to customize the exports field for this package.
Readme
react-spring-bottom-sheet-updated is built on top of react-spring and @use-gesture/react. It busts the myth that accessibility and supporting keyboard navigation and screen readers are allegedly at odds with delightful, beautiful, and highly animated UIs. Every animation and transition use CSS custom properties instead of manipulating them directly, allowing complete control over the experience from CSS alone.
⚡ This is an updated and modernized fork of the original react-spring-bottom-sheet, featuring the latest dependencies, enhanced performance optimizations, improved memory leak prevention, and full React 19 compatibility.
What's New in 4.0.0
🚀 Major Updates & Improvements
- React 19 Support: Full compatibility with React 19 including proper ref forwarding and effect timing
- Memory Leak Prevention: Enhanced cleanup logic prevents timeout-related memory leaks
- Modern Dependencies: Updated to latest versions (XState 5, @react-spring/web 10, @use-gesture/react 10)
- Improved Performance: Optimized spring configuration and reduced re-renders
- Better TypeScript: Enhanced type definitions and stricter type checking
- ESLint 9 Support: Migrated to modern ESLint flat config format
- Enhanced Focus Management: Better integration with portal-rendered components
🔧 Breaking Changes from 3.x
- Minimum React version is now 16.14.0 (React 18+ recommended)
- Some internal APIs have changed (affects only advanced customizations)
- CSS custom properties structure remains the same for easy migration
📦 Migration from react-spring-bottom-sheet
# Remove old package
npm uninstall react-spring-bottom-sheet
# Install updated version
npm install react-spring-bottom-sheet-updated
// Update imports
- import { BottomSheet } from 'react-spring-bottom-sheet'
+ import { BottomSheet } from 'react-spring-bottom-sheet-updated'
// CSS imports
- import 'react-spring-bottom-sheet/dist/style.css'
+ import 'react-spring-bottom-sheet-updated/dist/style.css'
All component APIs remain the same - no code changes needed beyond import updates!
Installation
Install using npm
npm i react-spring-bottom-sheet-updated
Install using yarn
yarn add react-spring-bottom-sheet-updated
Getting started
Basic usage
import { useState } from 'react'
import { BottomSheet } from 'react-spring-bottom-sheet-updated'
// if setting up the CSS is tricky, you can add this to your page somewhere:
// <link rel="stylesheet" href="https://unpkg.com/react-spring-bottom-sheet-updated/dist/style.css" crossorigin="anonymous">
import 'react-spring-bottom-sheet-updated/dist/style.css'
export default function Example() {
const [open, setOpen] = useState(false)
return (
<>
<button onClick={() => setOpen(true)}>Open</button>
<BottomSheet open={open}>My awesome content here</BottomSheet>
</>
)
}
TypeScript
TS support is baked in, and if you're using the snapTo
API use BottomSheetRef
:
import { useRef } from 'react'
import { BottomSheet, BottomSheetRef } from 'react-spring-bottom-sheet-updated'
export default function Example() {
const sheetRef = useRef<BottomSheetRef>()
return (
<BottomSheet open ref={sheetRef}>
<button
onClick={() => {
// Full typing for the arguments available in snapTo, yay!!
sheetRef.current.snapTo(({ maxHeight }) => maxHeight)
}}
>
Expand to full height
</button>
</BottomSheet>
)
}
Customizing the CSS
Using CSS Custom Properties
These are all the variables available to customize the look and feel when using the provided CSS.
:root {
--rsbs-backdrop-bg: rgba(0, 0, 0, 0.6);
--rsbs-bg: #fff;
--rsbs-handle-bg: hsla(0, 0%, 0%, 0.14);
--rsbs-max-w: auto;
--rsbs-ml: env(safe-area-inset-left);
--rsbs-mr: env(safe-area-inset-right);
--rsbs-overlay-rounded: 16px;
}
Custom CSS
It's recommended that you copy from style.css into your own project, and add this to your postcss.config.js
setup (npm i postcss-custom-properties-fallback
):
module.exports = {
plugins: {
// Ensures the default variables are available
'postcss-custom-properties-fallback': {
importFrom: require.resolve('react-spring-bottom-sheet-updated/defaults.json'),
},
},
}
Live Demos
Basic
MVP example, showing what you get by implementing open
, onDismiss
and a single snap point always set to minHeight
.
Snap points & overflow
A more elaborate example that showcases how snap points work. It also shows how it behaves if you want it to be open by default, and not closable. Notice how it responds if you resize the window, or scroll to the bottom and starts adjusting the height of the sheet without scrolling back up first.
Sticky header & footer
If you provide either a header
or footer
prop you'll enable the special behavior seen in this example. And they're not just sticky positioned, both areas support touch gestures.
Non-blocking overlay mode
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.
API
props
All props you provide, like className
, style
props or whatever else are spread onto the underlying <animated.div>
instance, that you can style in your custom CSS using this selector: [data-rsbs-root]
.
Just note that the component is mounted in a @radix-ui/react-portal
at the bottom of <body>
, and not in the DOM hierarchy you render it in.
open
Type: boolean
The only required prop, beyond children
. And it's controlled, so if you don't set this to false
then it's not possible to close the bottom sheet. It's worth knowing that the bottom sheet won't render anything but a @reach/dialog
placeholder while open
is false
. Thus ensure your components behave as expected with being unmounted when the sheet closed. We can't really allow it to render and mount while in a closed/hidden position as there's no stable way of preventing keyboard users or screen readers from accidentally interacting with the closed bottom sheet as long as it's in the dom. This is especially problematic given it implements ARIA to optimize for a11y.
onDismiss
Type: () => void
Called when the user do something that signal they want to dismiss the sheet:
- hit the
esc
key. - tap on the backdrop.
- swipes the sheet to the bottom of the viewport.
snapPoints
Type: (state) => number | number[]
This function should be pure as it's called often. You can choose to provide a single value or an array of values to customize the behavior. The state
contains these values:
headerHeight
– the current measured height of theheader
.footerHeight
– if afooter
prop is provided then this is its height.height
– the current height of the sheet.minHeight
– the minimum height needed to avoid a scrollbar. If there's not enough height available to avoid it then this will be the same asmaxHeight
.maxHeight
– the maximum available height on the page, equivalent towindow.innerHeight
and100vh
.
<BottomSheet
// Allow the user to select between minimun height to avoid a scrollbar, and fullscren
snapPoints={({ minHeight, maxHeight }) => [minHeight, maxHeight]}
/>
defaultSnap
Type: number | (state) => number
Provide either a number, or a callback returning a number for the default position of the sheet when it opens.
state
use the same arguments as snapPoints
, plus two more values: snapPoints
and lastSnap
.
<BottomSheet
// the first snap points height depends on the content, while the second one is equivalent to 60vh
snapPoints={({ minHeight, maxHeight }) => [minHeight, maxHeight / 0.6]}
// Opens the largest snap point by default, unless the user selected one previously
defaultSnap={({ lastSnap, snapPoints }) =>
lastSnap ?? Math.max(...snapPoints)
}
/>
header
Type: ReactNode
Supports the same value type as the children
prop.
footer
Type: ReactNode
Supports the same value type as the children
prop.
sibling
Type: ReactNode
Supports the same value type as the sibling
prop. Renders the node as a child of [data-rsbs-root]
, but as a sibling to [data-rsbs-backdrop]
and [data-rsbs-overlay]
. This allows you to access the animation state and render elements on top of the bottom sheet, while being outside the overlay itself.
initialFocusRef
Type: React.Ref | false
A react ref to the element you want to get keyboard focus when opening. If not provided it's automatically selecting the first interactive element it finds. If set to false keyboard focus when opening is disabled.
blocking
Type: boolean
Enabled by default. Enables focus trapping of keyboard navigation, so you can't accidentally tab out of the bottom sheet and into the background. Also sets aria-hidden
on the rest of the page to prevent Screen Readers from escaping as well.
scrollLocking
Type: boolean
iOS Safari, and some other mobile culprits, can be tricky if you're on a page that has scrolling overflow on document.body
. Mobile browsers often prefer scrolling the page in these cases instead of letting you handle the touch interaction for UI such as the bottom sheet. Thus it's enabled by default. However it can be a bit agressive and can affect cases where you're putting a drag and drop element inside the bottom sheet. Such as <input type="range" />
and more. For these cases you can wrap them in a container and give them this data attribute [data-body-scroll-lock-ignore]
to prevent intervention. Really handy if you're doing crazy stuff like putting mapbox-gl widgets inside bottom sheets.
expandOnContentDrag
Type: boolean
Disabled by default. By default, a user can expand the bottom sheet only by dragging a header or the overlay. This option enables expanding the bottom sheet on the content dragging.
springConfig
Type: Partial<SpringConfig>
Allows you to customize the React Spring animation configuration for fine-tuned control over how the bottom sheet animates. This configuration is merged with the default settings, so you only need to specify the properties you want to override.
The configuration affects all animations including opening, closing, snapping to different heights, and responding to drag gestures.
import { BottomSheet, BottomSheetSpringConfig } from 'react-spring-bottom-sheet'
// Bouncy animation
const bouncy: BottomSheetSpringConfig = {
tension: 80,
friction: 60,
mass: 2,
}
// Fast and snappy animation
const fast: BottomSheetSpringConfig = {
tension: 300,
friction: 30,
mass: 0.8,
}
// Smooth and precise animation
const smooth: BottomSheetSpringConfig = {
tension: 120,
friction: 50,
mass: 1.5,
precision: 0.001,
}
function Example() {
return (
<BottomSheet
open={true}
springConfig={bouncy}
>
Content with bouncy animations
</BottomSheet>
)
}
Available configuration properties:
mass
(default: 1) - Mass of the objecttension
(default: 170) - Controls the speed of the springfriction
(default: 26) - Controls the dampingclamp
(default: true) - Whether to clamp the spring valueprecision
(default: 0.01) - Precision of the animationvelocity
(default: 0) - Initial velocityduration
- Animation duration in milliseconds (overrides spring physics)easing
- Easing function for duration-based animations
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.
onSpringStart
Type: (event: SpringEvent) => void
Fired on: OPEN | RESIZE | SNAP | CLOSE
.
If you need to delay the open animation until you're ready, perhaps you're loading some data and showing an inline spinner meanwhile. You can return a Promise or use an async function to make the bottom sheet wait for your work to finish before it starts the open transition.
function Example() {
const [data, setData] = useState([])
return (
<BottomSheet
onSpringStart={async (event) => {
if (event.type === 'OPEN') {
// the bottom sheet gently waits
const data = await fetch(/* . . . */)
setData(data)
// and now we can proceed
}
}}
>
{data.map(/* . . . */)}
</BottomSheet>
)
}
onSpringCancel
Type: (event: SpringEvent) => void
Fired on: OPEN | CLOSE
.
OPEN
In order to be as fluid and delightful as possible, the open state can be interrupted and redirected by the user without waiting for the open transition to complete. Maybe they changed their mind and decided to close the sheet because they tapped a button by mistake. This interruption can happen in a number of ways:
- the user swipes the sheet below the fold, triggering an
onDismiss
event. - the user hits the
esc
key, triggering anonDismiss
event. - the parent component sets
open
tofalse
before finishing the animation. - a
RESIZE
event happens, like when an Android device shows its soft keyboard when an text editable input receives focus, as it changes the viewport height.
CLOSE
If the user reopens the sheet before it's done animating it'll trigger this event. Most importantly though it can fire if the bottom sheet is unmounted without enough time to clean animate itself out of the view before it rolls back things like body-scroll-lock
, focus-trap
and more. It'll still clean itself up even if React decides to be rude about it. But this also means that the event can fire after the component is unmounted, so you should avoid calling setState or similar without checking for the mounted status of your own wrapper component.
RESIZE
Type: { source: 'window' | 'maxheightprop' | 'element }
Fires whenever there's been a window resize event, or if the header, footer or content have changed its height in such a way that the valid snap points have changed.
source
tells you what caused the resize. If the resize comes from a window.onresize
event it's set to 'window'
. 'maxheightprop'
is if the maxHeight
prop is used, and is fired whenever it changes. And 'element'
is whenever the header, footer or content resize observers detect a change.
SNAP
Type: { source: 'dragging' | 'custom' | string }
Fired after dragging ends, or when calling ref.snapTo
, and a transition to a valid snap point is happening.
source
is 'dragging'
if the snapping is responding to a drag gesture that just ended. And it's set to 'custom'
when using ref.snapTo
.
function Example() {
return (
<BottomSheet
onSpringStart={(event) => {
if (event.type === 'SNAP' && event.source === 'dragging') {
console.log('Starting a spring animation to user selected snap point')
}
}}
/>
)
}
When using snapTo
it's possible to use a different source
than 'custom'
:
function Example() {
const sheetRef = useRef()
return (
<BottomSheet
ref={sheetRef}
snapPoints={({ minHeight, maxHeight }) => [minHeight, maxHeight]}
onSpringEnd={(event) => {
if (event.type === 'SNAP' && event.source === 'snap-to-bottom') {
console.log(
'Just finished an imperativ transition to the bottom snap point'
)
}
}}
>
<button
onClick={() => sheetRef.current.snapTo(0, { source: 'snap-to-bottom' })}
>
Snap to bottom
</button>
</BottomSheet>
)
}
onSpringEnd
Type: (event: SpringEvent) => void
Fired on: CLOSE
.
The yin
to onSpringStart
's yang
. It has the same characteristics. Including async/await
and Promise support for delaying a transition. For CLOSE
it gives you a hook into the step right after it has cleaned up everything after itself, and right before it unmounts itself. This can be useful if you have some logic that needs to perform some work before it's safe to unmount.
skipInitialTransition
Type: boolean
By default the initial open state is always transitioned to using an spring animation. Set skipInitialTransition
to true
and the initial open
state will render as if it were the default state. Useful to avoid scenarios where the opening transition would be distracting.
ref
Methods available when setting a ref
on the sheet:
export default function Example() {
const sheetRef = React.useRef()
return <BottomSheet open ref={sheetRef} />
}
snapTo
Type: (numberOrCallback: number | (state => number)) => void, options?: {source?: string, velocity?: number}
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:
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)
)
There's an optional second argument you can use to override event.source
, as well as changing the velocity
:
ref.current.snapTo(({ snapPoints }) => Math.min(...snapPoints), {
// Each property is optional, here showing their default values
source: 'custom',
velocity: 1,
})
height
Type: number
The current snap point, in other words the height, of the bottom sheet. This value is updated outside the React render cycle, for performance reasons.
export default function Example() {
const sheetRef = React.useRef()
return (
<BottomSheet
ref={sheetRef}
onSpringStart={() => {
console.log('Transition from:', sheetRef.current.height)
requestAnimationFrame(() =>
console.log('Transition to:', sheetRef.current.height)
)
}}
onSpringEnd={() =>
console.log('Finished transition to:', sheetRef.current.height)
}
/>
)
}
Troubleshooting & Integration Issues
Common Integration Problems
Focus and Scroll Lock Conflicts with Material UI Components
If you're using the bottom sheet with other components that manage focus or scroll locking (like Material UI Drawer), you may encounter conflicts:
Problem: Call stack overflow or scroll lock conflicts when using bottom sheet with Material UI components
Solution: Set disableEnforceFocus={true}
on the Material UI component when the bottom sheet is open:
import { Drawer } from '@mui/material'
import { BottomSheet } from 'react-spring-bottom-sheet-updated'
function MyComponent() {
const [drawerOpen, setDrawerOpen] = useState(true)
const [sheetOpen, setSheetOpen] = useState(false)
return (
<>
<Drawer
open={drawerOpen}
disableEnforceFocus={sheetOpen} // Disable when bottom sheet is open
>
{/* Drawer content */}
</Drawer>
<BottomSheet
open={sheetOpen}
onDismiss={() => setSheetOpen(false)}
>
{/* Sheet content */}
</BottomSheet>
</>
)
}
✅ AUTOMATIC SCROLL LOCK MANAGEMENT: This library now automatically handles scroll lock conflicts with other libraries like Material UI. The improved scroll lock implementation uses MutationObserver
to track external style changes and ensures proper restoration of scroll behavior when components are closed in any order. You no longer need to manually manage disableScrollLock
properties.
Portal-Rendered Components Inside Bottom Sheet
Components that render to portals (like Material UI Select, Tooltips, etc.) may not receive clicks properly when rendered inside the bottom sheet.
Problem: Cannot interact with Material UI Select or other portal-rendered components
Solution: Disable focus trap in the bottom sheet by setting initialFocusRef={false}
:
import { Select, MenuItem } from '@mui/material'
import { BottomSheet } from 'react-spring-bottom-sheet-updated'
function MyComponent() {
return (
<BottomSheet
open={true}
initialFocusRef={false} // Disable focus trap for portal components
>
<Select>
<MenuItem value="option1">Option 1</MenuItem>
<MenuItem value="option2">Option 2</MenuItem>
</Select>
</BottomSheet>
)
}
SSR and Hydration Issues
When using with Next.js or other SSR frameworks, you might encounter hydration mismatches:
Problem: Hydration mismatch errors in console
Solution: The component handles SSR automatically, but ensure you're using the latest React version (18+) and that your open
state is properly initialized:
import { useState, useEffect } from 'react'
import { BottomSheet } from 'react-spring-bottom-sheet-updated'
function MyComponent() {
const [mounted, setMounted] = useState(false)
const [open, setOpen] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
if (!mounted) return null
return (
<BottomSheet open={open} onDismiss={() => setOpen(false)}>
Content
</BottomSheet>
)
}
React 19 Compatibility
This library is fully compatible with React 19. If you encounter any issues:
- Ensure you're using the latest version of this package
- Check that your
@types/react
version matches your React version - Verify that other libraries in your project support React 19
Memory Leaks Prevention
The library automatically handles cleanup to prevent memory leaks. However, ensure you:
- Always provide an
onDismiss
callback - Don't manually manage the component's lifecycle
- The component will automatically unmount when
open={false}
TypeScript Issues
Problem: Type errors when using with strict TypeScript settings Solution: Ensure you have the correct peer dependencies installed and your TypeScript version is 4.5+:
npm install --save-dev @types/react@^19.0.0 typescript@^5.0.0
Performance Optimization Tips
- Lazy Loading: Load bottom sheet content only when needed
- Spring Configuration: Customize
springConfig
for your specific use case - Reduce Motion: The component automatically respects
prefers-reduced-motion
- Avoid Heavy Renders: Use
React.memo()
for complex sheet content
Getting Help
- Bug Reports: GitHub Issues
- Feature Requests: GitHub Discussions
- Questions: Check existing issues or create a new discussion
Credits
- Play icon used on frame overlays: font-awesome
- Phone frame used in logo: Mono Devices 1.0
- iPhone frame used to wrap examples: iOS 14 UI Kit for Figma
\nTrigger CI for prerelease.