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 €
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.
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.
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.
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.
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.
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; }Lightbox: a full-screen dialog with a carousel inside
The centred presentation stretched to the whole screen, dark, with a scroll-carousel inside: arrows, dots, arrow keys, and it opens at the image you picked. Escape or the close button end it; the page does not move. The two packages do not depend on each other; the page uses both.
open falsesnap –closed by –page kept at –
How it is built
<dialog class="ss lightbox" data-ss="center" id="lightbox" aria-label="Shades">
<div class="ss-panel">
<header class="ss-header">…</header>
<div class="sc">
<ul class="sc-track" data-sc-track tabindex="0" aria-label="Shades">…</ul>
<button class="sc-nav sc-prev" type="button" data-sc-prev aria-label="Previous shade">…</button>
<button class="sc-nav sc-next" type="button" data-sc-next aria-label="Next shade">…</button>
<div class="sc-dots" data-sc-dots></div>
</div>
</div>
<div class="ss-rest"></div>
</dialog>import { attach as attachSheet } from '@nordwerk/scroll-sheet';
import { attach as attachCarousel } from '@nordwerk/scroll-carousel';
const dialog = document.getElementById('lightbox');
attachSheet(dialog);
const carousel = attachCarousel(dialog.querySelector('.sc'));
// Open at the image whose thumbnail was pressed (data-slide on it).
dialog.addEventListener('ss:open', (event) => {
const index = Number(event.detail.invoker?.dataset.slide ?? 0);
carousel.update(); // measured while closed
carousel.slideTo(index, { instant: true });
});.lightbox { --ss-dialog-size: 100vw; --ss-radius: 0; --ss-bg: #111; --ss-fg: #fff; }
.lightbox .ss-panel { block-size: 100dvh; max-block-size: none; }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.
| Behaviour | Without script | With the script |
|---|---|---|
| Open and close | commandfor, Escape | Same, plus a fallback for browsers without invoker commands |
| Page behind | Locked in CSS, never moved; inert | Back where it was after the iOS keyboard moved it (keyboard plugin) |
| Drag and snap points | CSS scroll snap; the sheet cannot be dragged away completely | Dragged away, it closes; opens at a marked snap point |
| Tap on the dimmed area | Nothing | Closes; nothing underneath is clicked |
| Backdrop while dragging | Follows the sheet where scroll-driven animations exist (Chrome, Safari 26 and later) | |
| Back button, back swipe | Chrome on Android closes the dialog | Closes the top sheet (history plugin) |
| Focus back to the button | Chrome and Firefox | Also Safari, which does not focus tapped buttons |
| On-screen keyboard | 16 px fields avoid the iOS zoom | Sheet 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.
npx shadcn@latest add studio-nordwerk/oss-ui/drawer --overwrite
npx shadcn@latest add studio-nordwerk/oss-ui/scroll-sheet
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
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>