@samline/drawer
v4.2.1
Published
Framework-agnostic drawer runtime with a vanilla/browser API and an IIFE bundle for direct CDN use.
Downloads
900
Maintainers
Readme
Drawer
A small, framework-free drawer runtime for vanilla JS and direct browser usage.
It binds to a host element, keeps drawer state in sync with the DOM, runs the drag / snap / scale pipeline, and ships a typed controller for the consumer.
Table of Contents
- Installation
- CDN / Browser
- Entrypoints
- Quick Start
- What You Can Build
- Custom Content
- Title and Close Button
- API at a Glance
- Presence and Mount Lifecycle
- Documentation
- License
Installation
npm install @samline/drawerpnpm add @samline/drawerbun add @samline/draweryarn add @samline/drawerRequires Node 20+ when bundling. Runtime target is ES2020.
CDN / Browser
Use the browser build when you do not have a bundler and need to run the package directly in HTML, Shopify, WordPress, or any traditional template.
<link rel="stylesheet" href="https://unpkg.com/@samline/[email protected]/dist/style.css" />
<script src="https://unpkg.com/@samline/[email protected]/dist/browser/global.global.js"></script>Pin the version in production. Replace
4.2.1with the version you ship.
The browser bundle exposes a single global: window.Drawer.
Bundler consumers can import the exact same singleton as browser; server-rendered integrations may use its newDrawer({ id, html, options }) helper and read-only available compatibility view.
<form id="contact-form">
<button id="open-drawer" type="button">Open</button>
</form>
<link rel="stylesheet" href="https://unpkg.com/@samline/[email protected]/dist/style.css" />
<script src="https://unpkg.com/@samline/[email protected]/dist/browser/global.global.js"></script>
<script>
window.Drawer.createDrawer({
id: 'demo',
triggerElement: document.getElementById('open-drawer'),
direction: 'bottom',
title: 'Demo',
content: 'Hello from the browser'
})
</script>The methods on window.Drawer share a module-level registry keyed by id. Controllers are not stored as properties on the namespace; inspect them with window.Drawer.getDrawer(id) or window.Drawer.getDrawers(). window.Drawer.destroyDrawer(id) tears down the matching instance and removes it from that registry.
See docs/browser.md for the full browser surface.
Entrypoints
| Entrypoint | When to use |
| ------------------------- | ---------------------------------------------------------------------- |
| @samline/drawer | Main vanilla API for bundlers, ESM, or CJS consumers. |
| @samline/drawer/browser | ESM/CJS browser namespace with named and default exports for bundlers. |
The root entrypoint exports the individual helpers. Bundled applications that prefer a namespace can import Drawer from @samline/drawer/browser. Plain <script> usage loads the dedicated IIFE at dist/browser/global.global.js, as shown above.
Quick Start
import { createDrawer } from '@samline/drawer'
import '@samline/drawer/styles.css'
const drawer = createDrawer({
id: 'filters',
direction: 'bottom',
title: 'Filters',
content: 'Filter body',
showHandle: true,
snapPoints: ['180px', '420px', 1],
activeSnapPoint: '180px'
})
drawer.setOpen(true)What this does:
- Binds a controller to the id
filters. Reusing the id is an update, not a second mount. - Mounts the dialog in the bottom direction with the open transition.
- Renders the built-in handle. Clicking the handle advances the active snap point.
- Positions the content at the
180pxsnap on open; the user can drag to420pxor the zero-offset snap represented by numeric1. - Locks body scroll while the drawer is open (because
modaldefaults totrue).
What You Can Build
- Mobile-style bottom sheets, side panels, and modal dialogs with a typed controller.
- Nested drawer flows (parent → child) where the parent scales and shifts when the child opens.
- Snap-point flows (Spotify-like mini-player, sheet with a handle, drawer with a "show more" anchor).
- Scale-background flows that dim and shift the page shell while the drawer is dragged open.
- Drawers with a built-in handle, built-in trigger button, or built-in close button — no manual
document.addEventListenerboilerplate. - Browser / CDN / Shopify / WordPress embeds via the
window.DrawerIIFE bundle. - HMR-safe SPAs under Vite using stable ids and explicit teardown.
- Drawers that honour the mobile keyboard through the default-enabled, focus-gated
repositionInputspipeline (fixedis optional). - Drawers that opt out of scroll restoration (
preventScrollRestoration: true). - Test-friendly flows via the headless
createDrawerControllerAPI (no DOM, no side effects).
Custom Content
The content slot (and the title / description slots) accept the same VanillaRenderable shape: string | number | HTMLElement | (() => HTMLElement) | null | undefined. Pick the form that matches how you build your UI.
import { createDrawer } from '@samline/drawer'
// 1. Plain text.
createDrawer({ id: 'a', content: 'Hello' })
// 2. 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 })
// 3. 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
}
})Move semantics: when you pass an
HTMLElement, the runtime adopts it. 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.Lazy presence: the dialog subtree unmounts on close, so any
() => HTMLElementis invoked again on the next open. Use this to refresh dynamic content, or cache expensive work outside the thunk.
For the full contract, end-to-end patterns, and how content lands in the DOM, see docs/options.md → Renderable content and docs/recipes.md → Custom HTML content.
Title and Close Button
The package ships two convenience slots that cover the most common consumer needs.
Title slot
The [data-drawer-title] slot has two roles:
- Visible title — pass a string or
HTMLElementto thetitleoption. The package renders it visibly at the top of the drawer body. - Accessibility target — when only
ariaLabelis provided (notitle), the package auto-promotes theariaLabelvalue into the title slot for thearia-labelledbyreference, and auto-hides the slot so the a11y text does not leak visually into the drawer. Pass an explicittitleVisuallyHidden: falseto opt out of the auto-hide.
import { createDrawer } from '@samline/drawer'
// Visible title (a heading the user sees)
createDrawer({
id: 'filters',
title: 'Filters',
content: 'Body'
})
// Accessibility-only title (proxy from ariaLabel, auto-hidden)
createDrawer({
id: 'filters',
ariaLabel: 'Filters',
content: 'Body'
})Built-in close button
Pass closeButton: true (or a config object) and the package renders a <button data-drawer-close> inside the drawer, wired to onOpenChange(false). This eliminates the need to write a manual document.addEventListener('click', ...) listener (which accumulates on Vite HMR cycles and triggers stale-controller bugs).
import { createDrawer } from '@samline/drawer'
const drawer = createDrawer({
id: 'filters',
content: 'Body',
closeButton: {
className: 'absolute top-5 right-5',
icon: 'xmark', // any string; rendered as <span aria-hidden="true">
ariaLabel: 'Close'
}
})The button is removed automatically on re-mount (HMR safety) and on destroyDrawer. Its click event stopPropagation()s so it does not bubble to the drawer's content.
See the close-button option shape. It is part of VanillaDrawerOptions, not a named root type export.
API at a Glance
The runtime is built around one factory plus a focused set of helpers. State mutators return snapshots, update returns a controller for the same id, and each registry helper's return shape is documented in the per-method reference.
| Group | Methods |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Factory | createDrawer · configureDrawer · createDrawerController |
| Inspectors | getDrawer · getDrawers · getParentDrawer · getChildDrawers |
| Mutators | updateDrawer · openDrawer · closeDrawer · toggleDrawer |
| Teardown | destroyDrawer · destroyDrawers |
| Properties (controller) | id · element · options |
| Lifecycle (controller) | setOpen · setActiveSnapPoint · patch · update · subscribe · getSnapshot · destroy |
See the full per-method reference in docs/api/.
Presence and Mount Lifecycle
createDrawer immediately creates a dedicated <div data-drawer-vanilla-root> for that drawer in document.body or its container. An optional built-in trigger is also rendered immediately. Each drawer gets its own host, including drawers that share the same custom container.
The visual dialog uses lazy presence:
- Initially closed drawers have no overlay or content in the DOM.
[data-drawer-overlay],[data-drawer], the handle, and the content slots mount only when the drawer opens. - Closing keeps the overlay and content present for the exit. Their state changes to
closed, the animation starts from the current rendered transform, and the nodes are removed after the 500 ms transition plus a 100 ms safety window. The host and optional trigger remain until destroy. open: trueat creation means "open immediately." The dialog is visible without an entrance animation. For an animated programmatic open, create it closed and callsetOpen(true)after mount.- HMR considerations: if consumer code recreates a drawer with
open: true, it is opened again on every HMR run. Prefer the closed-then-open pattern when that flash is undesirable.
Recommended pattern for "open on mount" dialogs:
import { createDrawer } from '@samline/drawer'
const drawer = createDrawer({
id: 'my-drawer'
// no `open` here; defaults to closed
})
// Defer open to the next microtask so the entrance animation
// runs after the mount is fully wired.
queueMicrotask(() => drawer.setOpen(true))See CommonDrawerOptions.open for the full type contract.
Documentation
Full API reference, guides, and examples are available at samline.github.io/drawer.
| Doc | Purpose |
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| docs/README.md | Overview, anatomy, and entrypoint selection. |
| docs/getting-started.md | Concepts, observable contract, lifecycle, side-effect table, registry helpers. |
| docs/options.md | Every CommonDrawerOptions and VanillaDrawerOptions field with defaults, behaviour, and an example per row. |
| docs/recipes.md | End-to-end patterns: custom HTML content, renderable slots, lifecycle callbacks, nested drawers, snap points, scale background. |
| docs/css-styling.md | The data-attribute contract the stylesheet expects; inline writes; scale ownership; position all four directions. |
| docs/typescript.md | Exported types, callback signatures, helper return shapes, numeric constants, browser global type. |
| docs/api/index.md | One page per public method. |
| docs/vanilla.md | The root entrypoint (vanilla JS) in depth. |
| docs/browser.md | Using window.Drawer with a plain <script> tag. |
| CHANGELOG.md | Version history. |
License
MIT
