Skip to content

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)

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' })

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 second content while the first drawer still owns it.

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
}
})

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
}
})

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
})

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 })

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:

  • onClose only fires on a true → false transition; destroying an open drawer does not call it.
  • onAnimationEnd is a timer-based notification 500 ms after the latest open/close transition, not a DOM animationend event.
  • onActiveSnapPointChange does not echo direct setActiveSnapPoint() calls.
  • onReleaseChange does not fire for programmatic closes or overlay clicks.

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.

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') // null

The 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.

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 snap
  • 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, getSnapPointReleaseAction decides whether to close, snap to a neighbor, or noop (stays put).
  • The overlay is hidden below fadeFromIndex and visible at that index or above. When omitted, fadeFromIndex defaults 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 is false.
  • Runtime-driven changes (drag, handle cycle, post-close reset) call onActiveSnapPointChange after state updates. Direct setActiveSnapPoint() calls do not echo it.

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.

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 snapPoints configured, the click is a noop regardless of dismissible.
  • 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.

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: true writes style.bottom so the focused input stays above the mobile keyboard.
  • fixed: true also writes style.height; because repositioning remains on by default, both values are normally applied.
  • repositionInputs: false opts out of bottom repositioning. If fixed is also false, no viewport listener is attached.
  • preventScrollRestoration: true owns global window.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.

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() // cleanup

Recipe: 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 drawer

The helpers all target the same module-level registry as createDrawer. Reusing an id is an update, not a second mount.

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

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.

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.

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.

  • Reusing the same id updates 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-drag does 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.
  • setActiveSnapPoint updates an unchanged open mount in place. It updates transform/overlay state without requiring consumers to rebuild content.
  • history.scrollRestoration is globally owned. With preventScrollRestoration: 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.
  • HTMLElement content is moved, not cloned. Do not append the same element to a second content while 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 () => HTMLElement is 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, set autoFocus: true or focus the element yourself in onOpenChange(true).