How to make an accessible bottom sheet on the web with CSS scroll snap: we tested and measured it ✅ yoDEV Tested

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 the stopping 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.

:white_check_mark: yoDEV Tested · October 12, 2026
We installed pure-web-bottom-sheet 1.1.1 and measured it on Chromium 141 headless with Playwright, on a 390×844 mobile viewport. The results we report as ours come from that test.

What’s a bottom sheet and why do homemade 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 manually recreate things the browser already does well: inertia, bounce, keyboard scroll, and semantics for screen readers. Each one you leave out 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 the computed styles), and each stopping 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 key, the browser scrolls and snaps to the nearest point; the panel’s position simply follows the scroll position.

The author describes it as near-zero JavaScript functionality in modern browsers: JS is reserved for backward compatibility and optional features like dismiss on swipe down. For visual effects it also uses CSS scroll-driven animations.

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 it from npm:

npm install pure-web-bottom-sheet

The modal version wraps a native <dialog> inside <bottom-sheet-dialog-manager>, which provides the dialog styles, closes it on background click, and implements dismiss on swipe down. This is 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 snap 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 (the 75 before the 50, and this before the 25), because the index calculation assumes that order.
  • Add class="initial" to a single point to choose where the panel opens.
  • Add class="top" to the full-height point so it reports as expanded; 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 for 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 modal in HTML?

The most reliable way is to use the native <dialog> element opened with showModal(), and that’s exactly what this component does instead of building its own modal. The browser handles the dialog role, moves focus on open, closes on Escape, and returns 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 manually written ARIA. As an alternative, the component also works with the Popover API.

What did we measure when testing it?

We installed version 1.1.1 and ran the modal structure from the README 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 with gzip (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
Arrow keys Each down arrow press raises 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 midway 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 ran far less than 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.

Watch out: 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 lowers 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 with keyboard, test it and decide whether 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 (requires nested-scroll).
  • content-height: the max height follows the content instead of --sheet-max-height (not compatible with nested-scroll).
  • nested-scroll-optimization: avoids reflows when resizing in nested-scroll mode. The README marks it as experimental.

Styles adjust with custom properties (--sheet-max-height, --sheet-background, --sheet-border-radius) and with parts: ::part(sheet), ::part(handle), ::part(content), ::part(header), and ::part(footer).

Which 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 that explains the technique in detail.


And you? What interface component have you had to rebuild because it didn’t work well with keyboard or screen reader?

How do you implement bottom sheets in your web projects today?
  • With a library from my framework (React, Vue…)
  • With my own drag JavaScript code
  • With CSS scroll snap or a web component like this
  • I prefer another option (tell us which)
0 votantes

Comment below or, if this article reached you by email, reply directly to the email: your response gets published here.

Related: Neobrutalism Components: free neobrutalist components for React and shadcn, now on Base UI