# react-spring-bottom-sheet keeps the accessibility behaviour and the animation

> react-spring-bottom-sheet is a React bottom sheet built on react-spring and react-use-gesture, and its argument is that accessibility with keyboard navigation and screen reader support is not at odds with a highly animated interface. Every animation and transition runs through CSS custom properties rather than by manipulating them directly, so the whole experience is controllable from CSS, and the component handles focus trapping, scroll locking and the portal mounting that a modal needs.

**stipsan/react-spring-bottom-sheet** — Accessible ♿️, Delightful ✨, & Fast 🚀

- Repository: https://github.com/stipsan/react-spring-bottom-sheet
- Website: https://react-spring.bottom-sheet.dev/
- Stars: 1,067 · Forks: 156
- Language: TypeScript
- License: MIT
- Published: 2026-10-19 · Updated: 2026-10-19 · Language: en
- Canonical page: https://hysenlabs.com/projects/stipsan-react-spring-bottom-sheet

## The claim is that a11y and animation are compatible

The README states the position plainly, that the library busts the myth that accessibility and supporting keyboard navigation and screen readers are allegedly at odds with delightful, beautiful and highly animated user interfaces. That framing matters because the usual argument against a sheet is that the animation and the focus management fight each other, and a lot of the resulting code is a workaround for a component that was never designed for a screen reader. This one starts from the other end. The second design decision is that every animation and transition uses CSS custom properties instead of manipulating them directly, allowing complete control over the experience from CSS alone. The audience is a front-end team that ships a product used with a keyboard and with assistive technology, and the differentiator over a hand-rolled sheet is that the accessibility behaviour is a feature rather than a gap you close later.

## The theme is a list of CSS variables

The look and feel is customised entirely through custom properties, and the provided stylesheet exposes this set:

```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;
}
```

The variables cover the backdrop, the sheet background, the drag handle, the maximum width, the safe area insets on the left and the right, and the corner radius of the overlay. The safe area entries are the ones that matter on a phone, since a sheet that ignores the inset sits under the notch or the home indicator. The recommended workflow is to copy the style.css into your own project and, if you use PostCSS, add the custom properties fallback plugin so the default variables are available:

```js
module.exports = {
  plugins: {
    // Ensures the default variables are available
    'postcss-custom-properties-fallback': {
      importFrom: require.resolve('react-spring-bottom-sheet/defaults.json'),
    },
  },
}
```

## Using it, with the stylesheet imported

The install is one command, and the basic usage is a controlled open flag:

```bash
npm i react-spring-bottom-sheet
```

```jsx
import { useState } from 'react'
import { BottomSheet } from 'react-spring-bottom-sheet'

// 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/dist/style.css" crossorigin="anonymous">
import 'react-spring-bottom-sheet/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>
    </>
  )
}
```

The stylesheet import is required, and the comment about a link tag is there for the case where your bundler does not handle the CSS import, since the sheet is unstyled without it. TypeScript support is baked in, and if you are using the snapTo API you use the BottomSheetRef type:

```tsx
import { useRef } from 'react'
import { BottomSheet, BottomSheetRef } from 'react-spring-bottom-sheet'

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>
  )
}
```

## Snap points are computed from the measured layout

The snapPoints prop is a function of the current state, and the state is what makes it useful. It receives headerHeight, the current measured height of the header, footerHeight, which is the height of the footer when a footer prop is provided, height, the current height of the sheet, minHeight, the minimum height needed to avoid a scrollbar, which is the same as maxHeight if there is not enough room, and maxHeight, the maximum available height on the page, equivalent to window.innerHeight and 100vh. The function should be pure since it is called often, and you can return a single value or an array of values. The minHeight entry is the one that shows the design, since a sheet that is too short for its own content produces a scrollbar, and minHeight is the value that avoids it. That means a sheet whose content changes size can have a snap point that moves, which is why the function is called with measurements rather than a fixed number.

## A closed sheet renders nothing, and that is deliberate

The open prop is the only required prop beyond children, it is controlled, and if you do not set it to false it is not possible to close the bottom sheet. The important detail is the rendering rule, that the bottom sheet will render nothing but a @reach/dialog placeholder while open is false, and the reason given is that the component cannot allow it to render and mount while in a closed or hidden position, since there is no stable way of preventing keyboard users or screen readers from accidentally interacting with the closed sheet as long as it is in the DOM, which is especially problematic given the component implements ARIA to optimise for accessibility. That means your components have to behave correctly when unmounted, and it is the single most common surprise for an application that assumes a sheet is always in the tree. The dismissal triggers are the escape key, a tap on the backdrop, and a swipe of the sheet to the bottom of the viewport, all of which call onDismiss.

## The non-blocking mode disables the focus trap on purpose

There is a documented use case for breaking the accessibility rules, and it is the one place the component lets you. In most cases you use a bottom sheet the same way you do with a dialog, where it overlays the page and blocks out distractions, but there are times when you want a bottom sheet without it taking all the attention and overlaying the entire page. Providing blocking={false} helps with that, and by doing so you disable a couple of behaviours that are there for accessibility, focus-locking and more, that prevents a screen reader or a keyboard user from accidentally leaving the bottom sheet. The example is an aside style panel. The other props are more conventional. A header or footer prop enables the sticky behaviour, and the README notes that they are not just sticky positioned, both areas support touch gestures. The component is mounted in a @reach/portal at the bottom of the body rather than in the DOM hierarchy you render it in, so any selector you write has to account for that, which is why the README suggests the data-rsbs-root attribute for styling.

## The dependency stack, and the version question

The dependencies tell you what the component is made of. The animation is react-spring, the gestures are react-use-gesture, and the state machine is xstate with the @xstate/react binding, which is a heavier choice than a set of useState hooks and explains why the sheet has a state model rather than a boolean. The accessibility pieces are focus-trap for the focus lock, body-scroll-lock to stop the page scrolling behind the sheet, and @reach/portal for the portal mount. The resize observation is @juggle/resize-observer, which is there to measure the content so the snap points can be computed. The peer dependency is React, and the range is 16.14, 17 or 18. The version in the package metadata is 3.4.1, with the npm release dated 2022-06-01, while the last push to the repository was on 2026-10-15, so the tree is active while the published version is from a few years back. The build is microbundle for the distribution and Next.js for the documentation site, with the dist build producing cjs, es and modern formats.

## Conclusion

react-spring-bottom-sheet is the right component if you need a bottom sheet that a keyboard user can operate, because the accessibility decisions are made for you rather than left to the consumer, in particular the focus trap, the scroll lock and the rule that a closed sheet renders nothing at all. It is the wrong choice if you need the sheet to stay in the DOM while closed, since the component deliberately unmounts its content in that state, and it is a poor fit if you are on React 19, since the peer dependency range stops at React 18. Before you adopt it, read the open and onDismiss sections rather than the demos, because the controlled prop and the dismissal triggers define how your application has to be written, and check the peer dependency range against your React version.

## FAQ

### How do I install and use react-spring-bottom-sheet?

Run npm i react-spring-bottom-sheet, import the component and its stylesheet with import 'react-spring-bottom-sheet/dist/style.css', and render <BottomSheet open={open}> with your content. The open prop is controlled and is the only required prop beyond children.

### What are snap points and what state are they computed from?

The snapPoints prop is a function that should be pure, returning a single value or an array. It receives headerHeight, footerHeight, height, minHeight, which is the minimum height needed to avoid a scrollbar, and maxHeight, the maximum available height on the page.

### What happens to the content of a closed bottom sheet?

It is not rendered. While open is false the component renders nothing but a @reach/dialog placeholder, because there is no stable way of preventing keyboard users or screen readers from interacting with a closed sheet that is still in the DOM, and the component implements ARIA for accessibility. Your content has to behave correctly when unmounted.

### When should I use the non-blocking mode?

When you want a bottom sheet that does not overlay the whole page and block out distractions, as in an aside panel. Setting blocking={false} disables the accessibility behaviours such as focus-locking, which prevents a screen reader or keyboard user from accidentally leaving the sheet, so it is a deliberate trade.

## Sources

- [License: MIT](https://github.com/stipsan/react-spring-bottom-sheet/blob/main/LICENSE)
- [Project website](https://react-spring.bottom-sheet.dev/)
- [README](https://github.com/stipsan/react-spring-bottom-sheet/blob/main/README.md)
- [Releases](https://github.com/stipsan/react-spring-bottom-sheet/releases)
- [stipsan/react-spring-bottom-sheet on GitHub](https://github.com/stipsan/react-spring-bottom-sheet)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/stipsan-react-spring-bottom-sheet
