Examples
These examples target @samline/drawer@3.0.0 and its lazy-Presence lifecycle. Each registered id owns a host; overlay/content exist only while open or exiting.
The standard entrypoint — import { createDrawer } from '@samline/drawer'. Works with Vite, Webpack, Rollup, esbuild, Bun, and any modern bundler.
import '@samline/drawer/styles.css'import { createDrawer } from '@samline/drawer'
const drawer = createDrawer({ id: 'filters', direction: 'bottom', title: 'Filters', content: 'Drawer body'})
drawer.setOpen(true)No bundler required. Load the separate CSS and JS assets, then use window.Drawer.createDrawer(options).
<div data-drawer-wrapper id="app-shell"> <main>App shell</main></div>
<link rel="stylesheet" href="https://unpkg.com/@samline/drawer@3.0.0/dist/style.css" /><script src="https://unpkg.com/@samline/drawer@3.0.0/dist/browser/global.global.js"></script><script> window.Drawer.createDrawer({ id: 'filters', triggerText: 'Open drawer', showHandle: true, direction: 'bottom', title: 'Drawer title', content: 'Drawer content' })</script>Recipe: Custom HTML content
Section titled “Recipe: Custom HTML content”The content slot accepts strings, numbers, HTMLElement instances, and thunks. Pick the form that matches how you build your UI.
import { createDrawer } from '@samline/drawer'
// 1. Plain text.createDrawer({ id: 'a', content: 'Hello' })
// 2. Numeric badge as the title.createDrawer({ id: 'b', title: 3, content: 'Tag' })Pre-built element
Section titled “Pre-built element”When the consumer already owns the DOM, pass the element directly. The runtime moves it into the dialog body slot.
import { createDrawer } from '@samline/drawer'
const form = document.createElement('form')form.id = 'filters'form.innerHTML = ` <label>Search <input name="q" /></label> <button type="submit">Apply</button>`
createDrawer({ id: 'filters', title: 'Filters', content: form})Move semantics: the runtime adopts the element. After
destroyDrawer, the element stays in its previous location and you can keep using it. The same element instance cannot be passed to a secondcontentwhile the first drawer still owns it.
Lazy thunk
Section titled “Lazy thunk”Use a function when the content depends on state that may change, or when you want the runtime to rebuild it every time the dialog subtree is rebuilt (mount on open, rebuild on option-driven remount, re-invoke on every reopen).
import { createDrawer } from '@samline/drawer'
createDrawer({ id: 'clock', title: 'Current time', content: () => { const node = document.createElement('p') node.className = 'clock' node.textContent = new Date().toLocaleTimeString() return node }})Mixed slots
Section titled “Mixed slots”The same rules apply to title and description. Mix and match per slot.
import { createDrawer } from '@samline/drawer'
const heading = document.createElement('h2')heading.textContent = 'Filters'
const helpText = document.createElement('p')helpText.className = 'hint'helpText.textContent = 'Refine the result set.'
createDrawer({ id: 'filters', title: heading, description: helpText, content: () => { const body = document.createElement('div') body.append(buildFormFields()) return body }})Opt descendants out of drag
Section titled “Opt descendants out of drag”Add data-drawer-no-drag to any element inside content that should not start a drawer drag (inputs, scrollable lists, buttons).
const scrollList = document.createElement('ul')scrollList.setAttribute('data-drawer-no-drag', '')scrollList.innerHTML = '<li>One</li><li>Two</li><li>Three</li>'
createDrawer({ id: 'list', content: scrollList })Recipe: Render a pre-built form in a drawer
Section titled “Recipe: Render a pre-built form in a drawer”This is the typical “drawer that wraps an existing form” pattern. Build the form once, hand the element to the drawer, and let the runtime own the lifecycle.
<button id="open-feedback" type="button">Send feedback</button>import { createDrawer, destroyDrawer } from '@samline/drawer'
const form = document.createElement('form')form.id = 'feedback'form.innerHTML = ` <label>Subject <input name="subject" required /></label> <label>Message <textarea name="message" required></textarea></label> <button type="submit">Send</button>`
form.addEventListener('submit', (event) => { event.preventDefault() const data = new FormData(form) console.log('submitted', Object.fromEntries(data)) destroyDrawer('feedback')})
createDrawer({ id: 'feedback', title: 'Send feedback', content: form, triggerElement: document.getElementById('open-feedback'), closeButton: true})Recipe: Update content after creation
Section titled “Recipe: Update content after creation”Use update() (or updateDrawer(id, options)) to merge new options into a live drawer. The runtime rebuilds the dialog subtree when the renderable slots change, which re-invokes thunks and re-mounts elements.
import { createDrawer, getDrawer } from '@samline/drawer'
const drawer = createDrawer({ id: 'list', title: 'List' })
// Replace the body with a new pre-built element.const listA = document.createElement('ul')listA.innerHTML = '<li>A</li><li>B</li>'
drawer.update({ content: listA })
// Or via the registry helper.const newBody = document.createElement('div')newBody.textContent = 'Now showing something else.'getDrawer('list')?.update({ content: newBody })Recipe: Listen to lifecycle callbacks
Section titled “Recipe: Listen to lifecycle callbacks”The runtime exposes seven callbacks. Wire them when you need to mirror the drawer’s state into your own store, log, or analytics pipeline.
import { createDrawer, destroyDrawer } from '@samline/drawer'
const drawer = createDrawer({ id: 'profile', title: 'Profile',
onOpenChange(open) { console.log('isOpen:', open) }, onClose() { console.log('about to close') }, onAnimationEnd(open) { console.log('animation finished, open:', open) }, onActiveSnapPointChange(snapPoint) { console.log('snap:', snapPoint) }, onDragChange(percentageDragged) { console.log('drag:', percentageDragged.toFixed(2)) }, onReleaseChange(keptOpen) { console.log('release kept open:', keptOpen) }})
drawer.setOpen(true)Notes:
onCloseonly fires on atrue → falsetransition; destroying an open drawer does not call it.onAnimationEndis a timer-based notification 500 ms after the latest open/close transition, not aDOM animationendevent.onActiveSnapPointChangedoes not echo directsetActiveSnapPoint()calls.onReleaseChangedoes not fire for programmatic closes or overlay clicks.
Recipe: All four directions
Section titled “Recipe: All four directions”Every direction uses its matching axis for open, close, drag, and snap behavior:
direction |
Enters from | Drag toward close |
|---|---|---|
top |
top | up |
bottom |
bottom | down |
left |
left | left |
right |
right | right |
import { createDrawer } from '@samline/drawer'import '@samline/drawer/styles.css'
const drawers = { top: createDrawer({ id: 'from-top', direction: 'top', content: 'Top drawer' }), bottom: createDrawer({ id: 'from-bottom', direction: 'bottom', content: 'Bottom drawer' }), left: createDrawer({ id: 'from-left', direction: 'left', content: 'Left drawer' }), right: createDrawer({ id: 'from-right', direction: 'right', content: 'Right drawer' })}
drawers.right.setOpen(true)The shared stylesheet supplies direction-aware transforms, but your CSS must position each panel on the matching edge. Start with the four-direction positioning recipe.
Recipe: Nested drawers
Section titled “Recipe: Nested drawers”Use parentId to relate a child drawer to a parent. The runtime scales and shifts the parent when the child opens, and closes / destroys the child with the parent.
import { createDrawer, destroyDrawer, getChildDrawers, getDrawer, getParentDrawer } from '@samline/drawer'
const parent = createDrawer({ id: 'parent', direction: 'bottom', title: 'Account', content: 'Primary drawer body'})
const child = createDrawer({ id: 'child', parentId: 'parent', direction: 'right', title: 'Security', content: 'Nested drawer body', open: true})
// Opening the child also opens its registered ancestor chain.getParentDrawer('child')?.id // 'parent'getChildDrawers('parent').map((d) => d.id) // ['child']
// Destroying the parent recursively destroys the child.destroyDrawer('parent')getDrawer('child') // nullThe parent’s transform during the child’s drag is driven by runtime/nested.ts#getParentNestedVisualState. The runtime re-applies the transform on every onDragChange of the child.
Recipe: Snap points
Section titled “Recipe: Snap points”Snap points let the user drag between predefined positions. Numbers are fractions of the viewport or custom container (0.5 is 50%). Strings are parsed as absolute pixel counts ('180px' is 180); percent-suffixed strings do not use percentage math in 3.0.0.
import { createDrawer } from '@samline/drawer'
const drawer = createDrawer({ id: 'filters', direction: 'bottom', title: 'Filters', content: 'Body', snapPoints: ['180px', '420px', 1], activeSnapPoint: '180px', fadeFromIndex: 1, // overlay fades at the second snap and beyond snapToSequentialPoint: false, // allow a high-velocity skip onActiveSnapPointChange(snapPoint) { console.log('runtime-selected snap:', snapPoint) }})
drawer.setOpen(true)
// Programmatic snap jump:drawer.setActiveSnapPoint(1) // jump to the last snap (1 = zero translation offset)drawer.setActiveSnapPoint('420px') // jump to the middle snapBehaviour
Section titled “Behaviour”- On open, the content positions itself at the active snap’s offset.
- During drag, the content interpolates between snaps via
getSnapDragValue(activeOffset, draggedDistance, direction). - On release,
getSnapPointReleaseActiondecides whether to close, snap to a neighbor, or noop (stays put). - The overlay is hidden below
fadeFromIndexand visible at that index or above. When omitted,fadeFromIndexdefaults to the last snap. - With
snapToSequentialPoint: true, a high-velocity release that moved less than 40% of the drawer dimension advances at most one snap. Longer releases still choose the closest snap and may skip points. The default isfalse. - Runtime-driven changes (drag, handle cycle, post-close reset) call
onActiveSnapPointChangeafter state updates. DirectsetActiveSnapPoint()calls do not echo it.
Recipe: Scale background
Section titled “Recipe: Scale background”When shouldScaleBackground: true, the first page shell marked with data-drawer-wrapper scales and shifts as soon as the drawer opens. A close-direction drag moves the wrapper toward its normal state.
<div data-drawer-wrapper id="app-shell"> <main>App content</main></div>import { createDrawer } from '@samline/drawer'import '@samline/drawer/styles.css'
const drawer = createDrawer({ id: 'filters', title: 'Filters', content: 'Body', shouldScaleBackground: true})
drawer.setOpen(true)setBackgroundColorOnScale defaults to true, so the body becomes black while scaling is owned and the wrapper can receive a translucent drag color. Set it to false to opt out. On close, the wrapper animates to normal and its original inline styles are restored after the transition.
Scale ownership is stacked. If two open drawers target the same wrapper, the most recently opened owner controls it; closing that drawer reapplies the earlier owner’s transform instead of clearing shared state.
Recipe: Handle cycle
Section titled “Recipe: Handle cycle”When handleOnly: true or showHandle: true, the runtime renders a built-in handle inside the dialog. Clicking the handle advances the active snap point via runtime/handle.ts#getNextHandleState.
- At any non-last snap, the click moves to the next snap.
- At the last snap with
dismissible: true, the click closes the drawer. - At the last snap with
dismissible: false, the click cycles back to the first snap. - With no
snapPointsconfigured, the click is a noop regardless ofdismissible. - When
preventCycle: true, the click is a noop. - When a drag is in progress, the click is suppressed.
import { createDrawer } from '@samline/drawer'
const drawer = createDrawer({ id: 'filters', title: 'Filters', content: 'Body', showHandle: true, snapPoints: ['120px', '320px', 1], handleClassName: 'my-handle'})The drag is restricted to the handle when handleOnly: true. With showHandle: true, the handle is visible but the drag can start from anywhere on the content surface.
Recipe: Viewport keyboard handling
Section titled “Recipe: Viewport keyboard handling”repositionInputs defaults to true. While an open drawer has window.visualViewport, the runtime listens for viewport resize but ignores the opening resize unless a keyboard-producing input, textarea, or editable element is focused inside the dialog. Once the keyboard is open, it also handles the closing resize.
- Default
repositionInputs: truewritesstyle.bottomso the focused input stays above the mobile keyboard. fixed: truealso writesstyle.height; because repositioning remains on by default, both values are normally applied.repositionInputs: falseopts out of bottom repositioning. Iffixedis also false, no viewport listener is attached.preventScrollRestoration: trueowns globalwindow.history.scrollRestoration = 'manual'while open and restores the original after the final owning drawer closes or is destroyed.
import { createDrawer } from '@samline/drawer'
const drawer = createDrawer({ id: 'composer', title: 'Compose', content: () => { const input = document.createElement('textarea') input.placeholder = 'Write a message' return input }, fixed: true, preventScrollRestoration: true})On browsers without visualViewport, CSS positioning is left unchanged. Input repositioning does not control the modal scroll lock; use disablePreventScroll for that separate concern.
Recipe: Built-in close button
Section titled “Recipe: Built-in close button”closeButton: true (or an object) renders an in-drawer close control. The button is HMR-safe: the runtime cleans it up on every re-mount.
import { createDrawer } from '@samline/drawer'
createDrawer({ id: 'filters', content: 'Body', closeButton: true})
// With overrides.createDrawer({ id: 'settings', content: 'Body', closeButton: { className: 'absolute top-5 right-5', icon: '\u2715', // rendered inside a <span aria-hidden="true"> ariaLabel: 'Close settings' }})The button’s click event stopPropagation()s so it does not bubble to the content. The defaults are class drawer-close-button, icon text xmark, and label Close. See TypeScript → Close-button option shape for the full object contract.
Recipe: Programmatic open / close with a controller
Section titled “Recipe: Programmatic open / close with a controller”import { createDrawer, destroyDrawers } from '@samline/drawer'
const drawer = createDrawer({ id: 'profile', direction: 'bottom', title: 'Profile', content: 'Drawer body', showHandle: true, snapPoints: ['120px', '320px', 1], activeSnapPoint: '120px'})
drawer.subscribe((snapshot) => { console.log('drawer state:', snapshot.state.isOpen, snapshot.state.activeSnapPoint)})
drawer.setOpen(true)// ... user interacts ...drawer.setActiveSnapPoint(1)drawer.setOpen(false)
destroyDrawers() // cleanupRecipe: Imperative helpers (no controller)
Section titled “Recipe: Imperative helpers (no controller)”For one-off drawers where you do not need to keep the controller around, the imperative helpers cover the common cases.
import { openDrawer, closeDrawer, toggleDrawer, getDrawer, getDrawers, destroyDrawer, destroyDrawers} from '@samline/drawer'
openDrawer('filters')closeDrawer('filters')toggleDrawer('filters')
getDrawer('filters')?.update({ activeSnapPoint: 1 })getDrawers() // { filters: <controller> }
destroyDrawer('filters')destroyDrawers() // clear every drawerThe helpers all target the same module-level registry as createDrawer. Reusing an id is an update, not a second mount.
Recipe: Triggered by an external button
Section titled “Recipe: Triggered by an external button”<button id="open-filters">Open filters</button>import { createDrawer, destroyDrawer } from '@samline/drawer'
const trigger = document.getElementById('open-filters')
const drawer = createDrawer({ id: 'filters', triggerElement: trigger, title: 'Filters', content: 'Body'})
// Replace the trigger later (the runtime rebinds the click listener):drawer.update({ triggerElement: document.getElementById('open-filters-2') })
// Tear down:destroyDrawer('filters')The runtime attaches a click listener on the trigger element when the drawer is created, and detaches / rebinds it on update and destroy.
Recipe: Built-in trigger button (no external element)
Section titled “Recipe: Built-in trigger button (no external element)”import { createDrawer } from '@samline/drawer'
const drawer = createDrawer({ id: 'filters', triggerText: 'Open filters', title: 'Filters', content: 'Body', closeButton: true})The runtime mounts a <button data-drawer-vanilla-trigger> inside the per-id host. Closed overlay/content use lazy Presence, but this trigger persists and can reopen the drawer. It is removed when triggerText is cleared or the drawer is destroyed. closeButton: true creates an HMR-safe close control only while the dialog content is present.
Recipe: Custom container
Section titled “Recipe: Custom container”Prefer container when a drawer belongs inside a specific DOM region. The deprecated mountElement alias remains a nullish fallback only.
<div id="drawer-region"></div>import { createDrawer } from '@samline/drawer'
const container = document.getElementById('drawer-region')if (!container) throw new Error('Missing drawer region')
createDrawer({ id: 'region-a', container, content: 'A' })createDrawer({ id: 'region-b', container, content: 'B' })The container receives two dedicated [data-drawer-vanilla-root] children, one per id. Fractional snap points use container.getBoundingClientRect() rather than the full viewport.
Recipe: Multiple independent drawers
Section titled “Recipe: Multiple independent drawers”import { createDrawer, getDrawers, destroyDrawers } from '@samline/drawer'
createDrawer({ id: 'a', direction: 'bottom', title: 'A', content: 'A' })createDrawer({ id: 'b', direction: 'right', title: 'B', content: 'B' })
console.log(Object.keys(getDrawers())) // ['a', 'b']
destroyDrawers()Each id has isolated host/dialog state. Shared page-level effects still compose globally: Escape closes only the most recently opened drawer, scroll/history locks restore after their final owner, and background scale uses the newest owner for a shared wrapper.
Recipe: Subscribe to state changes from a higher-level component
Section titled “Recipe: Subscribe to state changes from a higher-level component”import { createDrawer, destroyDrawer } from '@samline/drawer'
const drawer = createDrawer({ id: 'filters', title: 'Filters', content: 'Body' })
const unsubscribe = drawer.subscribe((snapshot) => { // Dispatch a Redux / Zustand / Pinia action, run a useEffect, etc. console.log('state changed:', snapshot.state.isOpen)})
// Later, when the consumer unmounts:unsubscribe()destroyDrawer('filters')getSnapshot() returns the current snapshot synchronously, which is useful for selectors that read on every render.
Recipe: SPA / dynamic mount and unmount
Section titled “Recipe: SPA / dynamic mount and unmount”Pair createDrawer with the consumer’s mount/unmount lifecycle. The runtime registers and mounts the per-id host immediately; merely dropping the controller reference does not remove it. Return an explicit destroy cleanup.
function showFilters() { const drawer = createDrawer({ id: 'filters', title: 'Filters', content: 'Body' }) drawer.setOpen(true) return () => drawer.destroy() // return the cleanup}
// In the consumer (React, Vue, Svelte, vanilla — anything):const cleanup = showFilters()// ... later:cleanup()For a <script> integration, call window.Drawer.createDrawer on mount and window.Drawer.destroyDrawer on teardown. For a bundler, use the root named registry helpers.
Recipe: Open immediately on mount with animation
Section titled “Recipe: Open immediately on mount with animation”Creating a drawer with open: true mounts the dialog and skips the entrance animation. To get an animated “open on mount” instead, create the drawer closed and call setOpen(true) after the mount is fully wired.
import { createDrawer } from '@samline/drawer'
const drawer = createDrawer({ id: 'flash', title: 'New message', content: 'You have a new reply.' // no `open`; the drawer is initially closed and host-only})
// Defer the open to the next microtask so the runtime can// finish wiring the dialog subtree before the animation runs.queueMicrotask(() => drawer.setOpen(true))defaultOpen: true is the equivalent when you want to skip the animation entirely (e.g. a flash message that should be visible on every page load).
Recipe: Build a sidebar panel with the right direction
Section titled “Recipe: Build a sidebar panel with the right direction”The runtime owns the slide and drag axis, but the consumer still positions the panel. Use direction: 'left' or 'right' and pair it with a width in your own CSS.
import { createDrawer } from '@samline/drawer'
createDrawer({ id: 'side-panel', direction: 'right', title: 'Filters', content: 'Body', modal: true, dismissible: true, showHandle: false})[data-drawer-direction='right'] { width: min(24rem, 90vw); right: 0; top: 0; bottom: 0;}The drag axis is x for left / right drawers. Perpendicular page scrolls will not start a drawer drag. See CSS styling → Position all four directions for the full CSS shell.
Common pitfalls
Section titled “Common pitfalls”- Reusing the same
idupdates the same drawer. It does not create a second one. If you want a transient second drawer, use a unique id (e.g.filters-${Date.now()}) and destroy it on close. - The drag pipeline fires only on the content element. A child element with
data-drawer-no-dragdoes not start a drag (e.g. an input, a button, a scrollable list). - Input repositioning defaults on but is focus-gated. It also requires
window.visualViewport; the runtime skips the listener when the API is absent. setActiveSnapPointupdates an unchanged open mount in place. It updates transform/overlay state without requiring consumers to rebuild content.history.scrollRestorationis globally owned. WithpreventScrollRestoration: true, the original value returns only after the final owner closes or is destroyed.- Closing is not destroying. Close removes overlay/content after the exit transition but keeps the id, host, and optional trigger registered.
- Body pointer events are application-owned. Drawer open, non-modal open, close, and destroy never write
document.body.style.pointerEvents. HTMLElementcontent is moved, not cloned. Do not append the same element to a secondcontentwhile the first drawer still owns it. Use a thunk if you need a fresh node per open.- Thunks re-run on every reopen. Lazy presence unmounts the dialog subtree on close, so any
() => HTMLElementis invoked again on the next open. Cache expensive work outside the thunk. - Auto-focus is opt-in. The default is
false. If your UX depends on focusing an input on open, setautoFocus: trueor focus the element yourself inonOpenChange(true).