pure-web-bottom-sheet
v0.1.0
Published
A performant, lightweight, and accessible bottom sheet web component powered by CSS scroll snap and CSS scroll-driven animations. Works with any framework, supports SSR, multiple snap points, and nested scrolling mode.
Maintainers
Readme
pure-web-bottom-sheet
A lightweight, framework-agnostic bottom sheet component leveraging CSS scroll snap and implemented as a Web Component. Its key features include:
- Native scroll-driven sheet movement and snap points - uses the browser’s own scroll mechanics instead of JavaScript-driven animations to adjust the sheet position through CSS scroll snapping
- Near Zero-JavaScript operation on modern browsers - core functionality is pure CSS
- Framework-agnostic - works with any framework or vanilla HTML
- Easy customization with a simple API
- Accessibility through native elements (Dialog or Popover API) supporting touch, keyboard, or mouse scrolling
- Server-side rendering (SSR) compatible with declarative Shadow DOM support
- Cross-browser support - tested on Chrome, Safari, and Firefox (desktop and mobile)
The component uses CSS scroll snap and CSS scroll-driven animations for its core functionality. It uses minimal JavaScript for backward compatibility and optional features, such as swipe-to-dismiss. Relying on browser-driven scrolling physics ensures a native-like feel across different browsers and a performant implementation by not relying on JavaScript-driven animation logic.
For server-side rendering or static site generation, the component includes declarative Shadow DOM templates to avoid flash of unstyled content (FOUC) when displaying the bottom sheet initially open on page load.
https://github.com/user-attachments/assets/d25beef6-7256-4b7c-93ca-7605f73045b8
📦 Installation
npm install pure-web-bottom-sheet💻 Usage
Vanilla HTML
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vanilla HTML example</title>
</head>
<body>
<bottom-sheet tabindex="0">
<!-- Snap points -->
<div slot="snap" style="--snap: 25%"></div>
<div slot="snap" style="--snap: 50%" class="initial"></div>
<div slot="snap" style="--snap: 75%"></div>
<div slot="header">
<h2>Custom header</h2>
</div>
<div slot="footer">
<h2>Custom footer</h2>
</div>
<p>Custom content</p>
</bottom-sheet>
<script type="module">
import { BottomSheet } from "./path/to/pure-web-bottom-sheet";
customElements.define("bottom-sheet", BottomSheet);
</script>
</body>
</html><!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vanilla HTML example</title>
</head>
<body>
<bottom-sheet-dialog-manager>
<dialog id="bottom-sheet-dialog">
<bottom-sheet swipe-to-dismiss tabindex="0">
<!-- Snap points -->
<div slot="snap" style="--snap: 25%"></div>
<div slot="snap" style="--snap: 50%" class="initial"></div>
<div slot="snap" style="--snap: 75%"></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 "./path/to/pure-web-bottom-sheet";
registerSheetElements();
document.getElementById("show-button").addEventListener("click", () => {
document.getElementById("bottom-sheet-dialog").showModal();
});
</script>
</body>
</html>Astro
---
import { bottomSheetTemplate } from "pure-web-bottom-sheet/ssr";
---
<bottom-sheet tabindex="0">
{/* Declarative shadow DOM for SSR support (optional) */}
<template shadowrootmode="open">
<Fragment set:html={bottomSheetTemplate} />
</template>
{/* Snap points */}
<div slot="snap" style="--snap: 25%"></div>
<div slot="snap" style="--snap: 50%" class="initial"></div>
<div slot="snap" style="--snap: 75%"></div>
<div slot="header">
<h2>Custom header</h2>
</div>
<div slot="footer">
<h2>Custom footer</h2>
</div>
Custom content
</bottom-sheet>
<script>
import { BottomSheet } from "pure-web-bottom-sheet";
customElements.define("bottom-sheet", BottomSheet);
</script>---
import { bottomSheetTemplate } from "pure-web-bottom-sheet/ssr";
---
<bottom-sheet-dialog-manager>
<dialog id="bottom-sheet-dialog">
<bottom-sheet swipe-to-dismiss tabindex="0">
{/* Declarative shadow DOM for SSR support (optional) */}
<template shadowrootmode="open">
<Fragment set:html={bottomSheetTemplate} />
</template>
<!-- Snap points -->
<div slot="snap" style="--snap: 25%"></div>
<div slot="snap" style="--snap: 50%" class="initial"></div>
<div slot="snap" style="--snap: 75%"></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>React
For React, the library provides wrapper components to make it easier to use the component and provide SSR support out of the box.
import { BottomSheet } from "pure-web-bottom-sheet/react";
function Example() {
return (
<BottomSheet tabIndex={0}>
<div slot="snap" style={{ "--snap": "25%" }} />
<div slot="snap" style={{ "--snap": "50%" }} className="initial" />
<div slot="snap" style={{ "--snap": "75%" }} />
<div slot="header">
<h2>Custom header</h2>
</div>
<div slot="footer">
<h2>Custom footer</h2>
</div>
<p>Custom content</p>
</BottomSheet>
);
}import {
BottomSheet,
BottomSheetDialogManager,
} from "pure-web-bottom-sheet/react";
import { useRef } from "react";
function Example() {
const dialog = useRef<HTMLDialogElement | null>(null);
return (
<section>
<p>
<button onClick={() => dialog.current?.showModal()}>
Open as modal
</button>
<button onClick={() => dialog.current?.show()}>
Open as non-modal
</button>
</p>
<BottomSheetDialogManager>
<dialog ref={dialog}>
<BottomSheet swipe-to-dismiss tabIndex={0}>
<div slot="snap" style={{ "--snap": "25%" }} />
<div slot="snap" style={{ "--snap": "50%" }} className="initial" />
<div slot="snap" style={{ "--snap": "75%" }} />
<div slot="header">
<h2>Custom header</h2>
</div>
<div slot="footer">
<h2>Custom footer</h2>
</div>
<DummyContent />
</BottomSheet>
</dialog>
</BottomSheetDialogManager>
</section>
);
}Vue
Similarly, for Vue, the library provides wrapper components to make it easier to use the component and provide SSR support out of the box.
<template>
<VBottomSheet tabindex="0">
<div slot="snap" style="--snap: 25%"></div>
<div slot="snap" style="--snap: 50%" class="initial"></div>
<div slot="snap" style="--snap: 75%"></div>
<div slot="header">
<h2>Custom header</h2>
</div>
<div slot="footer">
<h2>Custom footer</h2>
</div>
Custom content
</VBottomSheet>
</template>
<script setup>
import { VBottomSheet } from "pure-web-bottom-sheet/vue";
</script><template>
<section>
<p>
<button @click="dialog.showModal()">Open as modal</button>
<button @click="dialog.show()">Open as non-modal</button>
</p>
<VBottomSheetDialogManager>
<dialog ref="bottom-sheet-dialog">
<VBottomSheet class="example" swipe-to-dismiss tabindex="0">
<div slot="header">
<h2>Custom header</h2>
</div>
<div slot="footer">
<h2>Custom footer</h2>
</div>
<div slot="snap" style="--snap: 25%"></div>
<div slot="snap" style="--snap: 50%" class="initial"></div>
<div slot="snap" style="--snap: 75%"></div>
<DummyContent />
</VBottomSheet>
</dialog>
</VBottomSheetDialogManager>
</section>
</template>
<script setup>
import {
VBottomSheet,
VBottomSheetDialogManager,
} from "pure-web-bottom-sheet/vue";
const dialog = useTemplateRef("bottom-sheet-dialog");
</script>▶️ Demos
See examples.
📄 API reference
<bottom-sheet>: The bottom sheet element
The <bottom-sheet> element can be used as a standalone component, optionally
with the HTML Popover API (see the Examples section below), or together with
a dialog element (see <bottom-sheet-dialog-manager> element in the following
section). When used without a dialog wrapper element or without the HTML Popover
API, the bottom sheet is non-dismissable. This approach can be useful, e.g., when
using the bottom sheet as an overlay that should always remain visible.
Example composition
<bottom-sheet>
<!-- Snap points -->
<div slot="snap" style="--snap: 25%"></div>
<div slot="snap" style="--snap: 50%" class="initial"></div>
<div slot="snap" style="--snap: 75%"></div>
<!-- Custom header -->
<div slot="header">
<h2>Custom header</h2>
</div>
<!-- Custom footer -->
<div slot="footer">
<h2>Custom footer</h2>
</div>
<!-- Custom content (default slot) -->
Custom content
</bottom-sheet>Attributes
content-height
Specifies that the sheet's maximum height is based on the height of its contents. By default, when this attribute is not set, the sheet's maximum height is based on the--sheet-max-heightproperty (see below)ℹ️ Note: Not applicable when using the
nested-scrollattributenested-scroll
Specifies whether the bottom sheet acts as a scrollable container that allows scrolling its contents independent of the bottom sheet's snap positionnested-scroll-optimization
Specifies that the bottom sheet uses resize optimization for the nested scroll mode to avoid reflows during sheet resizing. Only relevant whennested-scrollis also true. Not relevant forexpand-to-scrollmode since it already avoids reflows.ℹ️ Note: This attribute is experimental.
expand-to-scroll
Specifies that the content of the bottom sheet can only be scrolled after the bottom sheet has been expanded to its full heightℹ️ Note: Only applicable when
nested-scrollattribute is also setswipe-to-dismiss
Specifies that the bottom sheet can be swiped down to dismiss, and it will have a snap point on the bottom to snap to close itℹ️ Note: Only relevant when either:
- the
<bottom-sheet>is placed inside a<dialog>wrapped inbottom-sheet-dialog-managerelement - using the Popover API (
popoverattribute on the bottom-sheet).
- the
Slots
- Default slot
Defines the main content of the bottom sheet. snap(optional)
Defines snap points for positioning the bottom sheet. If not specified, the bottom sheet will have a single snap point--snap: 100%(maximum height). Note that when the<bottom-sheet>has theswipe-to-dismissattribute set, it also has a snap point at the bottom of the viewport to allow swiping down to dismiss it. Each snap point element should:- Be assigned to this slot
- Specify the
--snapcustom property to set to the wanted offset from the viewport top. For instance,<div slot="snap" style="--snap: 50vh"></div>creates a snap point at 50vh from the bottom of the viewport (vertical center), and a snap point with--snap: 25vhwould position the sheet at 25vh from the viewport bottom. You may use any CSS length units, such aspx,vh(based on viewport height), or%(based on sheet max height). - Optionally specify the class
initialto make the bottom sheet initially snap to that point each time it is opened. Note that only a single snap point should specify this class.
header(optional)
Optional header content that is displayed at the top of the bottom sheet.footer(optional)
Optional content that is displayed at the bottom of the bottom sheet.
CSS custom properties
--sheet-max-height
Controls the maximum height of the bottom sheet. E.g.,--sheet-max-height: 50dvh;means that the dialog max height is half of the dynamic viewport height, and--sheet-max-height: 100dvh;means that the dialog max height is the full dynamic viewport height--sheet-background
Specifies thebackgroundproperty of the bottom sheet--sheet-border-radius
Specifies the border radius of the bottom sheet
Events
snap-position-change- type:CustomEvent<{ snapPosition: string; }>
Notifies that the sheet snap position has changed. Positions:"0"indicates a fully expanded position,"2"indicates a fully collapsed (closed) position, and"1"indicates an intermediate position.
<bottom-sheet-dialog-manager>: A utility element for the native <dialog> element to use the <bottom-sheet> element as a dialog
The <bottom-sheet-dialog-manager> element is used when the bottom sheet should
act as a modal that can be opened and closed. This element should have
a single native dialog element as its child, which itself should have a single
bottom-sheet element as its child. The purpose of the <bottom-sheet-dialog-manager>
is to provide additional CSS styles to the native dialog element, and to handle
closing the dialog when clicking on the backdrop, and to implement the swipe-to-dismiss
functionality.
Example HTML structure:
<bottom-sheet-dialog-manager>
<dialog id="my-dialog">
<!--
Remember to specify `swipe-to-dismiss` attribute to allow the manager to
close the dialog when the bottom sheet is snapped to bottom of the viewport.
Specify `tabindex="0"` when you want the bottom sheet element itself to be
focusable. If set, it will appear in the tab order even if it has other focusable
content.
-->
<bottom-sheet swipe-to-dismiss tabindex="0">
<!-- Bottom sheet contents -->
</bottom-sheet>
</dialog>
</bottom-sheet-dialog-manager>
<button>Show the dialog</button>
<script>
import { registerSheetElements } from "pure-web-bottom-sheet";
registerSheetElements();
const dialog = document.getElementById("my-dialog");
const showButton = document.querySelector(
"bottom-sheet-dialog-manager + button",
);
showButton.addEventListener("click", () => {
dialog.showModal();
});
</script>🎨 Customization
The bottom sheet exposes all its relevant parts to allow adding custom styles or overriding the default styles.
Here are the relevant CSS selectors for the sheet customization:
bottom-sheetbottom-sheet::part(sheet)bottom-sheet::part(handle)bottom-sheet::part(content)bottom-sheet::part(header)bottom-sheet::part(footer)
🧑💻 Development
- Install the dependencies by running
npm install - Build the library by running
npm run build - Build the production build of the library by running
npm run build:prod
Launch Astro examples:
npm run dev -w examples/astroLaunch React/Next.js examples:
npm run dev -w examples/react-nextjsLaunch Vue/Nuxt examples:
npm run dev -w examples/vue-nuxt