You can build an accessible bottom sheet on the web without writing drag logic: let the browser’s native scroll move the panel, use CSS scroll snap for stop points, and wrap it in a native <dialog> for focus and the Escape key. pure-web-bottom-sheet packages exactly that as a web component that works with any framework, and we installed and measured it to see what it actually does.
What is a bottom sheet and why do handmade ones fail?
A bottom sheet is the panel that slides up from the bottom of the mobile screen (filters, share menus, map details) and stops at one or several heights. Most web implementations listen to touch and pointer events and move the panel with JavaScript. That forces you to recreate manually things the browser already does well: inertia, bounce, keyboard scrolling, and semantics for screen readers. Each one you skip is a bug for some user.
How does a bottom sheet work with CSS scroll snap?
The idea is to turn the panel into a scroll container instead of a draggable object. The <bottom-sheet> element itself scrolls vertically with scroll-snap-type: y mandatory (we confirmed it in computed styles), and each stop point is an element in a slot marked with the custom property --snap. When the user swipes, uses the mouse wheel, or presses an arrow, the browser scrolls and snaps to the nearest point; the panel’s position simply follows the scroll position.
The author describes it as working almost without JavaScript in modern browsers: JS is reserved for backward compatibility and optional features like closing by swiping down. It also uses CSS scroll-driven animations for visual effects.
What’s the advantage of it being a web component?
It works the same in React, Vue, Astro, or plain HTML. Web components are custom HTML elements that the browser registers natively (with customElements.define), so they don’t depend on the framework you use. For React and Vue, the package also includes its own wrappers, and for server rendering it offers a declarative Shadow DOM template.
How do you install and use it?
Install from npm:
npm install pure-web-bottom-sheet
The modal version wraps a native <dialog> inside <bottom-sheet-dialog-manager>, which provides dialog styles, closes it when clicking the backdrop, and implements close-by-swiping-down. Here’s the structure from the README:
<bottom-sheet-dialog-manager>
<dialog id="bottom-sheet-dialog">
<bottom-sheet swipe-to-dismiss tabindex="0">
<!-- Snap points -->
<div slot="snap" style="--snap: 75%"></div>
<div slot="snap" style="--snap: 50%" class="initial"></div>
<div slot="snap" style="--snap: 25%"></div>
<div slot="header">
<h2>Custom header</h2>
</div>
<div slot="footer">
<h2>Custom footer</h2>
</div>
<p>Custom content</p>
</bottom-sheet>
</dialog>
</bottom-sheet-dialog-manager>
<button id="show-button">Open bottom sheet</button>
<script type="module">
import { registerSheetElements } from "pure-web-bottom-sheet";
registerSheetElements();
document.getElementById("show-button").addEventListener("click", () => {
document.getElementById("bottom-sheet-dialog").showModal();
});
</script>
registerSheetElements() defines the two custom elements. Without a bundler, the README imports the same function from https://unpkg.com/pure-web-bottom-sheet.
In React you import BottomSheet and BottomSheetDialogManager from pure-web-bottom-sheet/react; in Vue, VBottomSheet and VBottomSheetDialogManager from pure-web-bottom-sheet/vue. For SSR and static sites, pure-web-bottom-sheet/ssr exports bottomSheetTemplate, which prevents flash of unstyled content when the panel appears open on page load.
How do snap points work?
Each stop point is a <div slot="snap"> with a --snap value: any CSS length (px, vh) or a percentage of the panel’s max height. There are three documentation rules that are easy to miss:
- Place them in the DOM top to bottom (75 before 50, 50 before 25), because the index calculation assumes that order.
- Add
class="initial"to one point to choose where the panel opens. - Add
class="top"to the full-height point so it reports asexpanded; it must be the first snap point in the DOM.
With swipe-to-dismiss there’s also an implicit point at the bottom of the screen: reaching it closes the panel.
To react to position changes, listen to the snap-position-change event, whose detail includes snapIndex (0 is collapsed) and sheetState (collapsed, partially-expanded, or expanded). To move it from code, call snapToPoint(index).
How do you make an accessible HTML modal?
The most reliable way is to use the native <dialog> element opened with showModal(), which is exactly what this component does instead of building its own modal. The browser handles the dialog role, moving focus on open, closing with Escape, and returning focus to the button that opened it. In our test, the accessibility tree exposed the panel as dialog, with its label and marked as modal, without a single line of hand-written ARIA. Alternatively, the component also works with the Popover API.
What did we measure when testing it?
We installed version 1.1.1 and ran the README’s modal structure on Chromium 141 headless, with a mobile viewport (390×844) and Playwright. Results:
| Test | Result |
|---|---|
| Installation | 1 package, zero runtime dependencies |
| Size | 14.5 KB minified, 4.3 KB gzipped (dist/browser.min.js) |
| Open with keyboard only | Enter on the button opens it; focus enters the panel at the initial snap point |
| Accessibility tree | Exposed as dialog, with its label, modal: true |
| Keyboard arrows | Each down arrow press moves up one point (50 → 75 → expanded); snap-position-change fires with the correct index |
snapToPoint() |
Reaches each requested point; the event always matches |
| Escape | Closes the panel and returns focus to the button that opened it |
| JavaScript when moving | 30 wheel steps, from halfway to fully expanded: 0.64 ms script, 0 ms layout |
The last row is the point of the design: the panel traveled its full range and the page executed well under a millisecond of JavaScript. The browser did the work.
What we didn’t verify: real touch gestures (synthetic gestures didn’t register on headless Chromium), Safari, or Firefox. The author indicates it’s tested on Chrome, Safari, and Firefox, on desktop and mobile.
Warning: the Home key closes the panel
The scroll direction is opposite to what you might expect. Since the panel is the scroll container, scrolling down expands it and scrolling up collapses it. With swipe-to-dismiss, the lowest point means “closed”, so in our test the Home key jumped straight to that point and closed the dialog (focus correctly returned to the button). It’s consistent with the component’s design, but if your users navigate by keyboard, test it and decide if you want swipe-to-dismiss on that panel.
What attributes are worth knowing?
nested-scroll: the panel’s content scrolls independently of its position.expand-to-scroll: the content only scrolls when the panel is fully expanded (requiresnested-scroll).content-height: the max height follows the content instead of--sheet-max-height(not compatible withnested-scroll).nested-scroll-optimization: avoids reflows when resizing innested-scrollmode. The README marks it as experimental.
Styles adjust with custom properties (--sheet-max-height, --sheet-background, --sheet-border-radius) and parts: ::part(sheet), ::part(handle), ::part(content), ::part(header), and ::part(footer).
What browsers does it work on?
The package’s browserslist sets Chrome and Edge 121 or higher, Firefox 123 or higher, and Safari and iOS 18.2 or higher. Safari needs 18.2 because the initial position depends on scroll-snap-* properties supporting discrete animations, something that arrived in that version (December 2024). The package declares Node 24 or higher in engines; in our test it installed without errors on Node 22.
Is it free?
Yes. It’s MIT licensed, maintained by Vili Ketonen, and published on npm with provenance attestations generated from GitHub Actions. In the README you’ll find the author’s article explaining the technique in detail.
What about you? What interface component have you had to rebuild because it didn’t work well with keyboard or screen reader?
- With a framework library (React, Vue…)
- With custom drag JavaScript code
- With CSS scroll snap or a web component like this
- I prefer another option (tell us which)
Comment below, or if this article reached you by email, reply directly to the email: your reply gets published here.
Related: Neobrutalism Components: free neobrutalist components for React and shadcn, now on Base UI