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/.
When to use this variant
Section titled “When to use this variant”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.
Anatomy of the runtime
Section titled “Anatomy of the runtime”The runtime has three moving parts:
- A module-level
drawerInstancesmap (src/runtime/registry.ts) — keeps aMap<id, DrawerRuntimeInstance>for every drawer you create. All helpers from one loaded package instance share that registry. - A vanilla host + dialog (
src/vanilla/host.ts+src/vanilla/dialog.ts) — every drawer owns a dedicated<div data-drawer-vanilla-root>insidedocument.bodyor itscontainer. The host and optional built-in trigger mount immediately; the dialog surface and overlay use lazy presence and exist only while open or exiting. - The
createDrawerfactory (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 itdrawer.update({ activeSnapPoint: '420px' }) // jump to a snapdrawer.destroy() // tear it downRead the current runtime through drawer.id, drawer.options, drawer.element, and drawer.getSnapshot().
Custom HTML content
Section titled “Custom HTML content”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.
Observable contract
Section titled “Observable contract”Once a drawer is created, you can rely on the following behaviour:
- A dedicated
<div data-drawer-vanilla-root="id">is appended immediately todocument.bodyor the preferredcontainer. 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 withdata-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, anddata-drawer-id. The runtime id is a data-attribute, not an HTMLid, which avoids collisions with consumer content. - A
<div data-drawer-overlay>is present for open or exiting modal drawers (default). It carriesdata-stateanddata-drawer-snap-points-overlayfor fade behavior. The runtime does not usedocument.body.style.pointerEvents; the overlay and consumer CSS own hit testing. - An optional
<div data-drawer-handle>is mounted whenhandleOnly: trueorshowHandle: true. Clicking it advances the active snap point (see Examples → Handle cycle). - A built-in
<button data-drawer-vanilla-trigger>is mounted whentriggerTextis set. Clicking it opens the drawer. - A built-in
<button data-drawer-close>is mounted whencloseButtonis set. Clicking it closes the drawer. See Examples → Built-in close button. - Eligible open drawers support drag gestures. A snap-free drawer with
dismissible: falsedoes 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
snapPointsis 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: truescales the page shell (the element withdata-drawer-wrapper). Background color handling is enabled unlesssetBackgroundColorOnScale: falseornoBodyStyles: trueis set.- Nested drawers declared via
parentIdscale 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.resizeupdatesstyle.bottomonly when a keyboard-capable input inside that drawer is focused, or while an already-detected keyboard is settling. SetrepositionInputs: falseto disable the offset;fixed: truecan still apply a height override. window.history.scrollRestorationis toggled to'manual'whenpreventScrollRestoration: trueand restored to its previous value on close or destroy after the final owner releases it.
Lifecycle
Section titled “Lifecycle”The recommended flow:
- 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. - Open — call
drawer.setOpen(true)oropenDrawer(id?). The overlay and dialog mount withdata-state="open", and modal scroll ownership is acquired unlessdisablePreventScroll: true. Auto-focus is off by default; setautoFocus: trueto focus the first focusable element. - Interact — drag the content, click the handle to cycle snap points, press
Escapeto dismiss, click the overlay to dismiss, or call the imperative helpers to drive the state. - Update — call
drawer.update(options?)(orupdateDrawer(idOrOptions?, options?)) to merge new options into the same instance. The registry re-renders the dialog so the new options take effect. - Close — call
drawer.setOpen(false)orcloseDrawer(id?). The runtime freezes the current rendered transform, flips the visual nodes todata-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. - Destroy — call
drawer.destroy()ordestroyDrawer(id?)to remove that drawer’s host immediately and delete its registry entry. UsedestroyDrawers()to clear every live instance.
Lifecycle example with all callbacks
Section titled “Lifecycle example with all callbacks”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')Side effects per method
Section titled “Side effects per method”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.
Recommended usage patterns
Section titled “Recommended usage patterns”- Pass
idwhen you need more than the default runtime instance. Reusing anidupdates the same instance; it does not create a second drawer. - Pass
parentIdwhen 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
triggerTextto render a built-in button inside the mounted host. PasstriggerElementinstead 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: trueto render the built-in handle but still allow drag to start from the full drawer surface. UsehandleOnly: trueto also restrict the drag to the handle. - Pass
containerwhen the host should live inside a specific DOM subtree.mountElementis deprecated and remains a fallback. - Pass
content,title, anddescriptionasstring,number,HTMLElement, or() => HTMLElementdepending 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)(ordrawer.update(options?)) to merge new options into the same instance without losing the controller. - Use
drawer.setOpen(false)orcloseDrawer(id)to dismiss, anddrawer.destroy()to release the host. - When
shouldScaleBackground: true, adddata-drawer-wrapperto the page shell element that should scale behind the drawer. - When a child element should not start a drag, add
data-drawer-no-dragto it. - When
preventScrollRestoration: true, the runtime flipshistory.scrollRestorationto'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.
Multiple drawers and ownership
Section titled “Multiple drawers and ownership”- 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,
htmlscroll 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.
Browser global helpers
Section titled “Browser global helpers”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)Submission examples
Section titled “Submission examples”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() // cleanupNested drawer
Section titled “Nested drawer”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']Custom HTML content
Section titled “Custom HTML content”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})Next steps
Section titled “Next steps”- Need a full options reference? See Configuration.
- Looking up the exact signature of a method? See API reference.
- Working with the type system? See TypeScript reference.
- Want end-to-end patterns? See Examples.
- Want the stylesheet contract? See CSS styling.
- Working with the IIFE bundle? See Browser global.