Open source, MIT

scroll-sheet

Bottom sheets, side drawers and dialogs on a native modal dialog. Dragging, momentum and snap points are plain CSS scroll snap, and buttons open and close a sheet before any script runs; a small script adds the rest. No runtime dependencies.

pnpm add @nordwerk/scroll-sheet

Core 2.8 kB, history +0.7 kB, keyboard +0.7 kB, stylesheet 2.3 kB, gzip and minified. React, Preact and Astro adapters included.

Every example below is live. Switch JavaScript off in the bar to see what works before any JavaScript runs. All products, stores and names are made up.

JavaScript
Back button
Direction

Size picker: a bottom sheet with a snap point

Opens at half the screen; drag it up to the full height or down to close it, and the button stays at the bottom edge. Below the full height a drag on the list moves the sheet, at the full height the list scrolls. The page behind keeps its place, focus goes back to the button you pressed, and with the keyboard plugin the sheet stays above the on-screen keyboard while you type an engraving. The size guide opens a second sheet on top.

  • Sample product 1

    18.95 €

  • Sample product 2

    22.95 €

  • Sample product 3

    26.95 €

  • Sample product 4

    30.95 €

Choose a size

Size

Free delivery from 25 €. Returns within 30 days.

Size guide

30 ml lasts about six weeks with two sprays a day, 100 ml about five months. The travel sizes fit hand luggage.

open falsesnap –closed by –page kept at –

How it is built
<button type="button" commandfor="size-sheet" command="show-modal">Choose size</button>

<dialog class="ss" id="size-sheet" aria-labelledby="size-sheet-title">
  <div class="ss-panel">
    <i class="ss-snap" style="--ss-at: 50dvh" data-ss-initial></i>
    <button class="ss-handle" type="button" commandfor="size-sheet" command="--ss-cycle" aria-label="Change height"></button>
    <header class="ss-header">
      <h2 id="size-sheet-title">Choose a size</h2>
      <button class="ss-close" type="button" commandfor="size-sheet" command="close" aria-label="Close">…</button>
    </header>
    <div class="ss-body">…</div>
    <footer class="ss-footer"><button type="button">Add to bag</button></footer>
  </div>
  <div class="ss-rest"></div>
</dialog>
import { enhance } from '@nordwerk/scroll-sheet';
import '@nordwerk/scroll-sheet/sheet.css';
import { history } from '@nordwerk/scroll-sheet/history';
import { keyboard } from '@nordwerk/scroll-sheet/keyboard';

enhance(document, { plugins: [history(), keyboard()] });

Filter: a bottom sheet on phones, a drawer from the end on wide screens

One element, two presentations: data-ss="bottom md:end" switches at 48rem in CSS, so the server sends the same markup to every device and rotating a tablet just changes the layout. Drag the drawer towards its edge to close it.

128 products

Filter and sort

Brand

open falsesnap –closed by –page kept at –

How it is built
<dialog class="ss" data-ss="bottom md:end" id="filter-sheet" aria-labelledby="filter-sheet-title">
  <div class="ss-panel">…</div>
  <div class="ss-rest"></div>
</dialog>

Mini cart: a drawer from the end edge

After adding a product, the bag slides in from the end edge with its total and the way to checkout. data-ss-replace closes other open sheets first, so it can be opened from inside the size picker without stacking.

3 items

Your bag

  • Rose Water Mist
    Harbour Mist · 100 ml
    24.95 €
  • Night Repair Cream
    Isle Botanics · 50 ml
    39.00 €
  • Cedar Hand Balm
    Alder & Ash · 75 ml
    12.50 €

Total76.45 €

open falsesnap –closed by –page kept at –

How it is built
<dialog class="ss" data-ss="end" data-ss-replace id="cart-sheet" aria-labelledby="cart-sheet-title">…</dialog>

Contact form: a centred dialog

A dialog in the centre on every screen. <form method="dialog"> closes it with the pressed button as the return value; the sheet reports that close like any other. Fields are 16 pixels, so iOS does not zoom in.

Write to us

open falsesnap –closed by –page kept at –

How it is built
<dialog class="ss" data-ss="center" id="contact-sheet" aria-labelledby="contact-sheet-title">
  <div class="ss-panel">
    <form class="ss-body" method="dialog">…<button value="send">Send</button></form>
  </div>
  <div class="ss-rest"></div>
</dialog>

Store finder: three resting heights

Opens low, so the map above stays visible, and rests at a third, two thirds or the full height. Snap points are markers in the panel: --ss-at is how much of the sheet shows.

Stores near you

  • Harbour Street 12, Lindholm
    0.4 km · Open until 20:00
  • Market Square 3, Lindholm
    1.2 km · Open until 19:00
  • Station Arcade, Westerby
    3.8 km · Open until 21:00
  • Mill Lane 40, Westerby
    4.1 km · Closed today
  • Riverside Mall, Osterfeld
    7.5 km · Open until 20:00
  • Old Town 8, Osterfeld
    8.0 km · Open until 18:30

open falsesnap –closed by –page kept at –

How it is built
<div class="ss-panel">
  <i class="ss-snap" style="--ss-at: 32dvh" data-ss-initial></i>
  <i class="ss-snap" style="--ss-at: 66dvh"></i>
  …
</div>

Product details: full width, the page recedes

A bottom sheet across the whole width and almost the whole height. With data-ss-depth the page behind it shrinks, rounds its corners and sits on a dark ground, and follows the sheet while you drag it down. The page is <body>, or the element marked data-ss-page.

Rose Water Mist

Harbour Mist · 100 ml · 24.95 €

A light face mist with rose water and aloe. Spray it on after cleansing or over make-up during the day; it leaves no film and dries in seconds.

How to use

Hold the bottle an arm's length away, close your eyes and spray two or three times. Let it dry on its own.

Ingredients

Aqua, rosa damascena flower water, aloe barbadensis leaf juice, glycerin, sodium benzoate, citric acid.

Reviews

Fresh without being sticky. — M.

Nice after sport, the scent is gone after a minute. — J.

The spray is very fine, the bottle lasts long. — S.

open falsesnap –closed by –page kept at –

How it is built
<dialog class="ss details" id="details" data-ss-depth aria-labelledby="details-title">…</dialog>
/* Full width; the page needs a background of its own. */
.details { --ss-sheet-max-size: none; }
body { background: #fff; }

What the browser does, what the script adds

Switch JavaScript off in the bar above: every sheet still opens and closes with its buttons and Escape, sits in the top layer above everything else, keeps the page behind inert and in place, snaps while you drag, and returns focus in Chrome and Firefox. The script adds the rest.

BehaviourWithout scriptWith the script
Open and closecommandfor, EscapeSame, plus a fallback for browsers without invoker commands
Page behindLocked in CSS, never moved; inertBack where it was after the iOS keyboard moved it (keyboard plugin)
Drag and snap pointsCSS scroll snap; the sheet cannot be dragged away completelyDragged away, it closes; opens at a marked snap point
Tap on the dimmed areaNothingCloses; nothing underneath is clicked
Backdrop while draggingFollows the sheet where scroll-driven animations exist (Chrome, Safari 26 and later)
Back button, back swipeChrome on Android closes the dialogCloses the top sheet (history plugin)
Focus back to the buttonChrome and FirefoxAlso Safari, which does not focus tapped buttons
On-screen keyboard16 px fields avoid the iOS zoomSheet above the keyboard on iOS (keyboard plugin)

shadcn/ui: a drop-in Drawer, a component and eight blocks

The component follows the part names of shadcn's Sheet and Drawer with the ScrollSheet prefix: ScrollSheet, ScrollSheetTrigger, ScrollSheetContent, ScrollSheetHeader, ScrollSheetTitle, ScrollSheetDescription, ScrollSheetBody, ScrollSheetFooter, ScrollSheetClose and ScrollSheetHandle. It installs straight from the GitHub repository and uses your theme. The drawer item replaces components/ui/drawer.tsx: the same exports, and the props of both shadcn drawers (Vaul in the Radix styles, Base UI in the Base UI styles), so call sites stay as they are. Below are the blocks as shadcn add installs them; drawer-demo uses the drop-in.

Replace the Drawer
npx shadcn@latest add studio-nordwerk/oss-ui/drawer --overwrite
Add the component
npx shadcn@latest add studio-nordwerk/oss-ui/scroll-sheet
Or a block, which brings the component along
npx shadcn@latest add studio-nordwerk/oss-ui/size-picker
npx shadcn@latest add studio-nordwerk/oss-ui/filter-drawer
npx shadcn@latest add studio-nordwerk/oss-ui/mini-cart
npx shadcn@latest add studio-nordwerk/oss-ui/store-finder
npx shadcn@latest add studio-nordwerk/oss-ui/mobile-menu
npx shadcn@latest add studio-nordwerk/oss-ui/contact-dialog
npx shadcn@latest add studio-nordwerk/oss-ui/lightbox
npx shadcn@latest add studio-nordwerk/oss-ui/drawer-demo
Use it
import {
  ScrollSheet,
  ScrollSheetTrigger,
  ScrollSheetContent,
  ScrollSheetHeader,
  ScrollSheetTitle,
  ScrollSheetBody,
  ScrollSheetFooter,
  ScrollSheetClose,
} from "@/components/ui/scroll-sheet"

<ScrollSheet presentation="bottom md:end" snapPoints={["50dvh"]} initialSnap={0}>
  <ScrollSheetTrigger asChild><Button>Filter</Button></ScrollSheetTrigger>
  <ScrollSheetContent>
    <ScrollSheetHeader><ScrollSheetTitle>Filter</ScrollSheetTitle><ScrollSheetClose /></ScrollSheetHeader>
    <ScrollSheetBody>…</ScrollSheetBody>
    <ScrollSheetFooter><Button>Show 128 products</Button></ScrollSheetFooter>
  </ScrollSheetContent>
</ScrollSheet>

scroll-sheet 0.5.0

MIT licence. Not included on purpose: non-modal sheets over a usable page, horizontal drag handles without snap points, and animations other than slide and fade.