Recipes
These recipes target @samline/drawer@4.0.1 and its lazy-presence lifecycle. Start with the complete example below; later snippets focus on one behavior and assume the package stylesheet plus your panel geometry are already loaded.
Recipe index
Section titled “Recipe index”- Start and render: complete bundler example, custom HTML, pre-built form, update content
- Interaction: directions, snap points, handle cycle, viewport keyboard
- Controls: external trigger, built-in trigger, close button
- Architecture: nested drawers, multiple drawers, custom container, headless renderer
- Accessibility and lifecycle: modal choices, external ARIA targets, callbacks, SPA cleanup
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@4.0.1/dist/style.css" /><script src="https://unpkg.com/@samline/drawer@4.0.1/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: Complete bundler example
Section titled “Recipe: Complete bundler example”This is a copy-paste baseline with markup, package/runtime code, visible geometry, trigger wiring, and cleanup.
<button id="open-account" type="button">Open account</button>[data-drawer-overlay] { position: fixed; inset: 0; z-index: 40; background: rgb(15 23 42 / 55%);}
[data-drawer] { position: fixed; z-index: 41; box-sizing: border-box; padding: 1.25rem; color: #172033; background: white; outline: none;}
[data-drawer-direction='bottom'] { right: 0; bottom: 0; left: 0; max-height: 85dvh; border-radius: 1.25rem 1.25rem 0 0;}import { createDrawer } from '@samline/drawer'import '@samline/drawer/styles.css'
const trigger = document.querySelector<HTMLElement>('#open-account')const drawer = createDrawer({ id: 'account', triggerElement: trigger, title: 'Account', description: 'Update your profile settings', content: 'Profile controls go here.', showHandle: true, closeButton: true})
window.addEventListener('pagehide', () => drawer.destroy(), { once: true })For a complete no-bundler HTML document, see Browser / CDN → Basic usage.
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. Closing or destroying removes its ancestor subtree, so the element becomes detached from the document but remains reachable through your JavaScript reference. Reappend it before reuse. Do not pass one connected instance to two drawers.
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.'
function buildFormFields() { const field = document.createElement('label') field.innerHTML = 'Search <input name="q" />' return field}
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.
Only open, activeSnapPoint, and callback-only changes can reconcile an open drawer without a full subtree rebuild. Structural updates can also refocus a modal, so preserve form state outside the renderable when updates are frequent.
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 six 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 follows the child’s drag percentage. Nested behavior writes transform and transition directly on the parent panel and resets to an explicit scale-1 transform; put application transforms on an inner wrapper rather than on [data-drawer].
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 use parseInt as pixel counts ('180px' is 180, '50%' is 50px, and '1rem' is 1px). Inputs are not validated; use finite, ordered, unique values and make activeSnapPoint an exact array member.
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 keyboard-operable handle button inside the dialog. Clicking it advances the active snap point.
- 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.
The runtime does not manage aria-controls or aria-expanded on an external trigger. Add and synchronize those attributes in your application when they are part of your accessibility contract.
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.
Recipe: Modal and dismissible behavior
Section titled “Recipe: Modal and dismissible behavior”modal controls page isolation, overlay controls backdrop rendering, and dismissible controls user dismissal paths. They are independent.
| Options | Overlay | Focus/scroll lock | Escape, overlay, drag close | Programmatic and built-in close button |
|---|---|---|---|---|
modal: true, dismissible: true |
Yes | Yes | Enabled | Enabled |
modal: true, dismissible: false |
Yes | Yes | Disabled | Enabled |
modal: false, dismissible: true |
No | No | Escape and drag enabled | Enabled |
modal: false, overlay: true, dismissible: true |
Yes | No | Enabled | Enabled |
modal: false, overlay: true, dismissible: false |
Yes | No | Disabled | Enabled |
const drawer = createDrawer({ id: 'required-choice', title: 'Choose an option', content: 'This modal cannot be dismissed accidentally.', modal: true, dismissible: false, closeButton: { ariaLabel: 'Cancel choice' }, autoFocus: true})
drawer.setOpen(true)autoFocus: false still moves modal focus to the dialog itself. Set autoFocus: true to focus its first visible focusable descendant. When closing, focus restoration is stack-aware across multiple drawers.
Recipe: External ARIA targets
Section titled “Recipe: External ARIA targets”Use ariaLabel for a direct accessible name. Use ariaLabelledBy / ariaDescribedBy only when you provide matching ids. Without built-in title or description, the runtime does not create fallback target elements.
const content = document.createElement('div')content.innerHTML = ` <h2 id="account-heading">Account</h2> <p id="account-help">Update your profile details.</p> <label>Name <input name="name" /></label>`
createDrawer({ id: 'account', ariaLabelledBy: 'account-heading', ariaDescribedBy: 'account-help', content, autoFocus: true})When neither title nor ariaLabel is supplied, aria-label falls back to the drawer id. Prefer a human-readable title or label rather than relying on that safety fallback.
Recipe: Headless custom renderer
Section titled “Recipe: Headless custom renderer”createDrawerController() provides synchronous observable state without DOM, registry lifecycle, callback invocation, or page effects. Render and clean up your own UI from subscriptions.
import { createDrawerController } from '@samline/drawer'
const customPanel = document.querySelector<HTMLElement>('#custom-panel')const openButton = document.querySelector<HTMLButtonElement>('#open-custom')const closeButton = document.querySelector<HTMLButtonElement>('#close-custom')if (!customPanel || !openButton || !closeButton) throw new Error('Missing custom renderer elements')
const state = createDrawerController({ id: 'filters', direction: 'right', defaultOpen: false})
const unsubscribe = state.subscribe((snapshot) => { customPanel.hidden = !snapshot.state.isOpen customPanel.dataset.direction = snapshot.state.direction})
openButton.addEventListener('click', () => state.setOpen(true))closeButton.addEventListener('click', () => state.setOpen(false))
// On teardown:unsubscribe()Callbacks stored in CommonDrawerOptions do not run in headless mode. Parent validation, focus, scroll locking, snap reset, and animations are also the custom renderer’s responsibility.
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. - Child auto-focus is opt-in. Modal focus always moves inside the drawer. The default
autoFocus: falsefocuses the dialog container; setautoFocus: trueto focus its first visible control. - Snapshots and
drawer.optionsare not deep copies. Treat them as read-only and update through controller methods. - A retained controller can recreate a destroyed id. Discard stale controller facades during SPA cleanup unless resurrection is intentional.
- Not every option has a typed unset value. For fields such as
parentId, destroy and recreate the id when you must remove a shallow-merged value.