Skip to content

Getting started

This page walks through what @samline/drawer is, how the runtime is wired, and which side effects each public method produces. Use it as the mental model before you dive into the per-method reference under docs/api/.

Use the vanilla variant when you work with native HTML pages, embedded scripts, static sites, or applications where you do not need a framework wrapper. The root package is the primary module entrypoint.

If you want a <script>-only setup without a bundler, see Browser global.

The runtime has three moving parts:

  1. A module-level drawerInstances map (src/runtime/registry.ts) — keeps a Map<id, DrawerRuntimeInstance> for every drawer you create. All helpers from one loaded package instance share that registry.
  2. A vanilla host + dialog (src/vanilla/host.ts + src/vanilla/dialog.ts) — every drawer owns a dedicated <div data-drawer-vanilla-root> inside document.body or its container. The host and optional built-in trigger mount immediately; the dialog surface and overlay use lazy presence and exist only while open or exiting.
  3. The createDrawer factory (src/runtime/registry.ts) — the public surface that the rest of your code talks to. createDrawer(options?) and the imperative helpers (openDrawer, closeDrawer, getDrawer, etc.) all hit the registry.

The controller returned by createDrawer has a small, focused method surface. State mutators return the new snapshot, update returns a controller for the same id, and destroy returns void:

import { createDrawer } from '@samline/drawer'
import '@samline/drawer/styles.css'
const drawer = createDrawer({
id: 'filters',
direction: 'bottom',
snapPoints: ['180px', '420px'],
title: 'Filters',
description: 'Refine the result set',
content: 'Drawer body'
})
drawer.setOpen(true) // open it
drawer.update({ activeSnapPoint: '420px' }) // jump to a snap
drawer.destroy() // tear it down

Read the current runtime through drawer.id, drawer.options, drawer.element, and drawer.getSnapshot().

The content slot (and title, description) accept string | number | HTMLElement | (() => HTMLElement) | null | undefined. Pick the form that matches how you build your UI.

import { createDrawer } from '@samline/drawer'
// String.
createDrawer({ id: 'a', content: 'Hello' })
// Pre-built element (the runtime moves it into the body slot).
const form = document.createElement('form')
form.innerHTML = '<input name="q" /><button>Search</button>'
createDrawer({ id: 'b', title: 'Search', content: form })
// Lazy thunk (re-invoked every time the dialog subtree is rebuilt).
createDrawer({
id: 'c',
content: () => {
const node = document.createElement('p')
node.textContent = new Date().toLocaleTimeString()
return node
}
})

See Configuration → Renderable content and Examples → Custom HTML content for the full contract and end-to-end patterns.

Once a drawer is created, you can rely on the following behaviour:

  • A dedicated <div data-drawer-vanilla-root="id"> is appended immediately to document.body or the preferred container. Two drawers in the same custom container receive two independent hosts. The consumer-owned container is never removed by drawer teardown.
  • Closed drawers use lazy presence. The host and optional <button data-drawer-vanilla-trigger> remain mounted, but [data-drawer], its handle and slots, and [data-drawer-overlay] do not mount until open. On close, the visual nodes remain with data-state="closed" for the exit transition and are then removed.
  • An open <div data-drawer> carries the visual and accessibility contract: data-state, data-drawer-direction, snap and animation flags, role="dialog", aria-modal, and data-drawer-id. The runtime id is a data-attribute, not an HTML id, which avoids collisions with consumer content.
  • A <div data-drawer-overlay> is present for open or exiting modal drawers (default). It carries data-state and data-drawer-snap-points-overlay for fade behavior. The runtime does not use document.body.style.pointerEvents; the overlay and consumer CSS own hit testing.
  • An optional <div data-drawer-handle> is mounted when handleOnly: true or showHandle: true. Clicking it advances the active snap point (see Examples → Handle cycle).
  • A built-in <button data-drawer-vanilla-trigger> is mounted when triggerText is set. Clicking it opens the drawer.
  • A built-in <button data-drawer-close> is mounted when closeButton is set. Clicking it closes the drawer. See Examples → Built-in close button.
  • Eligible open drawers support drag gestures. A snap-free drawer with dismissible: false does not start a drag. Otherwise, pointer capture waits for dominant Y-axis intent on top/bottom drawers or X-axis intent on left/right drawers, leaving perpendicular panning to the page. Snap-free releases use the 25% distance or 0.4 velocity close thresholds; snap drawers use their own release policy.
  • Snap points are wired when snapPoints is set. The drawer positions itself at the active snap on open, the drag interpolates between snaps, and the release either snaps to the closest point or closes on high velocity.
  • shouldScaleBackground: true scales the page shell (the element with data-drawer-wrapper). Background color handling is enabled unless setBackgroundColorOnScale: false or noBodyStyles: true is set.
  • Nested drawers declared via parentId scale and shift the parent when the child opens (runtime/nested.ts). Drag the child and the parent follows along.
  • Viewport keyboard handling is enabled by default. While open, a visualViewport.resize updates style.bottom only when a keyboard-capable input inside that drawer is focused, or while an already-detected keyboard is settling. Set repositionInputs: false to disable the offset; fixed: true can still apply a height override.
  • window.history.scrollRestoration is toggled to 'manual' when preventScrollRestoration: true and restored to its previous value on close or destroy after the final owner releases it.

The recommended flow:

  1. Create — call createDrawer(options?) with the drawer’s initial options. The runtime wires the controller and mounts its host and optional trigger. A closed drawer has no overlay or dialog content.
  2. Open — call drawer.setOpen(true) or openDrawer(id?). The overlay and dialog mount with data-state="open", and modal scroll ownership is acquired unless disablePreventScroll: true. Auto-focus is off by default; set autoFocus: true to focus the first focusable element.
  3. Interact — drag the content, click the handle to cycle snap points, press Escape to dismiss, click the overlay to dismiss, or call the imperative helpers to drive the state.
  4. Update — call drawer.update(options?) (or updateDrawer(idOrOptions?, options?)) to merge new options into the same instance. The registry re-renders the dialog so the new options take effect.
  5. Close — call drawer.setOpen(false) or closeDrawer(id?). The runtime freezes the current rendered transform, flips the visual nodes to data-state="closed", releases listeners and shared side effects, and removes those nodes after the exit transition. onClose() fires before the state change; onAnimationEnd(false) fires after 500 ms unless superseded.
  6. Destroy — call drawer.destroy() or destroyDrawer(id?) to remove that drawer’s host immediately and delete its registry entry. Use destroyDrawers() to clear every live instance.
import { createDrawer, destroyDrawer } from '@samline/drawer'
const drawer = createDrawer({
id: 'profile',
title: 'Profile',
content: 'Drawer body',
showHandle: true,
snapPoints: ['120px', '320px', 1],
activeSnapPoint: '120px',
onOpenChange(open) {
console.log('open state changed:', open)
},
onClose() {
console.log('about to close — snapshot still shows isOpen: true')
},
onAnimationEnd(open) {
console.log('500 ms after the latest transition. isOpen =', open)
},
onActiveSnapPointChange(snapPoint) {
console.log('runtime changed the active snap:', snapPoint)
},
onDragChange(percentageDragged) {
console.log('drag progress:', percentageDragged.toFixed(2))
},
onReleaseChange(keptOpen) {
console.log('release kept the drawer open:', keptOpen)
}
})
drawer.setOpen(true)
// ... user interacts ...
drawer.setActiveSnapPoint(1)
drawer.setOpen(false)
destroyDrawer('profile')

Use this as a quick lookup when you need to know what a method will touch.

Method DOM mutation Subscribers Shared page effects Focus
createDrawer(options?) mounts host and optional trigger; mounts dialog only when initially open new controller has none yet acquired only when initially open may blur outside focus; content focus is opt-in
configureDrawer(options?) same as createDrawer same same same
drawer.setOpen(true | false) mounts on open; marks current nodes closed, then removes them after exit yes acquired on open, released on close may blur on open; stack-aware restoration on close
drawer.setActiveSnapPoint(snap) updates the open dialog offset yes active scale owner may re-render unchanged
drawer.patch(options) / drawer.update(options?) reconciles host, trigger, presence, and visible options yes follows any changed open/modal/scale options follows any changed open/focus options
drawer.subscribe(listener) none; listener fires immediately and on later state changes registers the listener unchanged unchanged
drawer.getSnapshot() none no unchanged unchanged
drawer.destroy() removes only this drawer’s host immediately no destroy notification releases only this drawer’s ownership restores within the remaining open stack
Registry inspectors none no unchanged unchanged
openDrawer / closeDrawer / toggleDrawer same presence behavior as setOpen yes for a live state change same as setOpen same as setOpen
destroyDrawer(id?) removes that host and recursively removes child hosts no releases only destroyed owners restores within the remaining open stack
destroyDrawers() removes every owned host no original styles return after the final owner each teardown reconciles against the remaining stack
createDrawerController(options?) none only when subscribe is used none none

The runtime never takes ownership of document.body.style.pointerEvents. noBodyStyles also does not disable modal scroll locking; use disablePreventScroll: true for that specific opt-out.

  • Pass id when you need more than the default runtime instance. Reusing an id updates the same instance; it does not create a second drawer.
  • Pass parentId when this drawer should follow another drawer’s lifecycle. Closing the parent closes the registered children; destroying the parent recursively destroys them. See Examples → Nested drawers.
  • Pass triggerText to render a built-in button inside the mounted host. Pass triggerElement instead when you want an external button in your own DOM tree.
  • Pass closeButton: true (or an object) to render a built-in in-drawer close control. See Examples → Built-in close button.
  • Pass showHandle: true to render the built-in handle but still allow drag to start from the full drawer surface. Use handleOnly: true to also restrict the drag to the handle.
  • Pass container when the host should live inside a specific DOM subtree. mountElement is deprecated and remains a fallback.
  • Pass content, title, and description as string, number, HTMLElement, or () => HTMLElement depending on how you build your UI. See Renderable content.
  • Use drawer.subscribe(snapshot => …) when a higher-level component (router, store, view layer) needs to react to the whole drawer state.
  • Use drawer.patch(options) (or drawer.update(options?)) to merge new options into the same instance without losing the controller.
  • Use drawer.setOpen(false) or closeDrawer(id) to dismiss, and drawer.destroy() to release the host.
  • When shouldScaleBackground: true, add data-drawer-wrapper to the page shell element that should scale behind the drawer.
  • When a child element should not start a drag, add data-drawer-no-drag to it.
  • When preventScrollRestoration: true, the runtime flips history.scrollRestoration to 'manual' while the drawer is open; the previous value is restored on close or destroy after the final owner releases it.
  • For SPA / dynamic lifecycles, return a destroy cleanup from your mount function so each created instance is released on unmount.
  • Open order is assigned when a drawer changes from closed to open. Updating or re-rendering an already-open drawer does not promote it.
  • Escape targets only the most recently opened drawer. If that drawer is not dismissible, an older drawer is not closed instead.
  • Opening a nested child opens its ancestors first, so the child becomes the active top drawer. Closing a parent closes its descendants; closing a child leaves its parent open.
  • Focus restoration, body scroll prevention, html scroll behavior, history scroll restoration, Safari body positioning, and scale-background styles track ownership. Closing or destroying one drawer does not restore a shared resource while another owner remains.
  • For one shared scale wrapper, the most recently opened scale owner controls the transform. Closing it reapplies the previous owner’s state.

The browser IIFE bundle attaches the same method names to window.Drawer. The root module does not export a browser object; bundled consumers use the named exports directly.

<script>
window.Drawer.createDrawer({ id: 'filters', title: 'Filters', content: 'Body' })
window.Drawer.openDrawer('filters')
window.Drawer.destroyDrawers()
</script>

Methods on one window.Drawer namespace share its id-keyed registry. Use distinct ids for independent drawers.

import { createDrawer, getDrawer } from '@samline/drawer'
const account = createDrawer({ id: 'account', title: 'Account', content: 'Primary drawer' })
const security = createDrawer({ id: 'security', parentId: 'account', title: 'Security', content: 'Nested drawer' })
getDrawer('security')?.setOpen(true) // opens both (account first because security is nested)

Programmatic open / close with a controller

Section titled “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
import { createDrawer, getParentDrawer, getChildDrawers } from '@samline/drawer'
const parent = createDrawer({ id: 'parent', title: 'Parent', content: 'Primary' })
const child = createDrawer({
id: 'child',
parentId: 'parent',
title: 'Child',
content: 'Nested',
open: true
})
console.log(getParentDrawer('child')?.id) // 'parent'
console.log(getChildDrawers('parent').map((d) => d.id)) // ['child']
import { createDrawer } from '@samline/drawer'
const form = document.createElement('form')
form.innerHTML = '<input name="q" /><button>Search</button>'
createDrawer({
id: 'search',
title: 'Search',
content: form,
closeButton: true
})