Zero-dependency drawers for filters, carts and mobile nav — focus-trapped, scroll-locked, theme-aware. Click anything below — every card is live.
Right is the default; top/bottom take a height instead of a width.
Drawer.open({ side: 'left',
title: 'Navigation',
size: '320px' }) // width, or height
// for top/bottom
The hidden #cart-panel below this card is adopted into
the drawer and restored on close.
<button data-vdr-open="#cart-panel">
<div id="cart-panel" hidden
data-vdr-title="Your cart">…
// or: new Drawer('#cart-panel').open()
Same shape as Modal's buttons — close becomes the
onClose result; return false to stay open.
Drawer.open({
buttons: [
{ label: 'Reset', onClick: h => false },
{ label: 'Apply', variant: 'primary',
close: 'applied' }
],
onClose: r => r === 'applied' && apply()
})
No ✕, and Esc / backdrop clicks are ignored — only the button closes it.
Drawer.open({ dismissible: false,
buttons: [{ label: 'Done',
variant: 'primary', close: true }] })
Each drawer opens above the last; Esc peels only the top one.
// inside a button's onClick:
Drawer.open({ side: 'bottom', … })
return false // keep the first one open
With the VC core loaded, one call themes every component.
VC.config({ accent: '#b45309' })
Total $59
Drawer.open(opts) builds a drawer from options and returns a
handle — { el, close(result), update(opts) }. Strings passed as
title/content are rendered as text; markup requires
the explicit html: true opt-in.
Drawer.open({
side: 'right', // 'right' | 'left' | 'top' | 'bottom'
title: 'Your cart',
content: 'Text', // string | DOM element | markup with {html: true}
size: '360px', // width for left/right, height for top/bottom
buttons: [
{ label: 'Clear', onClick: function (h) {} }, // return false = stay open
{ label: 'Checkout', variant: 'primary', close: true }
],
dismissible: true, // Esc, backdrop click, and the ✕ button
onOpen: function (h) {},
onClose: function (result) {} // result = the `close` value of the button used
})
// → handle: { el, close(result), update(opts) }
Enhance mode — new Drawer(target, opts) adopts an
in-page element (selector or element) as the drawer's content and restores it
where it came from — including its hidden attribute — on close or
destroy(). .open({...}) merges per-call options over
the constructor's.
var cart = new Drawer('#cart-panel', { side: 'right', title: 'Cart' })
cart.open() // .open({...extra}) merges per-call options
cart.close() // cart.isOpen() → boolean
cart.destroy() // restores the element, drops the instance
Drawer.create(el, opts) // constructor sugar → instance
Drawer.get(el) // → instance | null
No JS at all? See declarative init —
data-vdr-open triggers are wired automatically on load.
All keys of Drawer.defaults can be changed globally
(Drawer.defaults.side = 'left') or passed per call. The options
below the defaults are per-call only.
| Option | Type | Default | Description |
|---|---|---|---|
side | string | 'right' |
'right' | 'left' | 'top' | 'bottom'. Unknown values fall back to 'right'. |
size | string | '360px' |
The drawer's one dimension — width for left/right, height for top/bottom (the other axis is 100%). Any CSS length. |
dismissible | boolean | true |
Esc, backdrop click, and the ✕ button. false removes the ✕ and ignores Esc/backdrop. |
styles | boolean | true |
false = headless: no CSS is ever injected. See Theming. |
theme | string | 'auto' |
'auto' | 'light' | 'dark'. Auto follows the page; see Theming. |
labels | object | { close: 'Close', dialog: 'Drawer' } |
Accessible names: close for the ✕ button, dialog as the fallback aria-label when there is no title. Merged shallowly per call. |
title | string | — | Heading text (always rendered as text). Sets aria-labelledby. |
content | string | Element | — | String (text by default, markup with html: true) or a DOM element, which is adopted into the drawer and restored on close. |
html | boolean | false |
Opt-in: render a string content as trusted markup instead of text. |
buttons | array | — | Optional footer buttons; entry shape below. Omit for no footer. |
onOpen | function | — | onOpen(handle), called after the drawer is in the DOM and focused. |
onClose | function | — | onClose(result), called when the close begins. See Events. |
ariaLabel | string | — | Accessible name used when there is no title (falls back to labels.dialog). |
opener | Element | — | Element to return focus to on close. Defaults to document.activeElement at open time; declarative triggers pass themselves. |
buttons entry shape:
| Property | Type | Description |
|---|---|---|
label | string | Button text (rendered as text). |
variant | string | 'primary' or 'danger' for accent styling; anything else is the neutral default. |
close | any | Value passed to onClose(result) when this button closes the drawer. |
onClick | function | onClick(handle). Return false to keep the drawer open (validation etc.); otherwise the drawer closes with close as the result. |
Instances come from new Drawer(target, opts) /
Drawer.create(); handles come from Drawer.open()
(and handle.el is the root <dialog>/<div> element).
| Signature | Returns | Description |
|---|---|---|
instance.open(extra?) | instance | Opens the enhanced element as a drawer; extra merges over the constructor options for this call. No-op if already open. Reads data-vdr-title off the element when no title option is set. |
instance.close(result?) | instance | Closes if open; result is forwarded to onClose. |
instance.isOpen() | boolean | Whether the drawer is currently open. |
instance.destroy() | instance | Closes (restoring the adopted element) and drops the instance registration so the element can be re-enhanced. |
handle.close(result?) | — | Closes the drawer; result is forwarded to onClose. |
handle.update(opts) | handle | Merges new options and rebuilds the drawer in place (title, content, buttons, side, size…). Focus is re-seated only if the rebuild dropped it out of the drawer. No-op after close. |
handle.el | Element | The root element (a native <dialog> where supported). |
| Member | Description |
|---|---|
Drawer.open(opts) |
Opens a drawer, returns the handle. SSR-safe: without a DOM it returns an inert no-op handle. |
Drawer.create(target, opts?) |
Constructor sugar — same as new Drawer(target, opts), returns the instance. |
Drawer.get(target) |
The instance registered for an element (or selector), or null. |
Drawer.autoInit(root?) |
Wires all unbound [data-vdr-open] triggers under root (default: document) and pre-creates instances for their targets; returns the created instances. Runs automatically on DOMContentLoaded. |
Drawer.defaults |
The defaults object documented under Options; mutate it to change a default once. |
Drawer.salt |
CSS-isolation namespace token, default 'vc1'. Set your own token or false before the first drawer. See Theming. |
Drawer.css |
The full stylesheet as a string — a live getter, always rendered with the current salt. For headless setups. |
Drawer.version |
Version string, '1.0.0'. |
Drawer.displayName, Drawer.rootClass, Drawer.themeVars, Drawer.varScopes |
Convergence contract read by the VC family core (VC.register/VC.config); not needed in app code. |
The drawer communicates through option callbacks; it does not dispatch any
DOM CustomEvents. (Internally it listens to the native
<dialog> cancel/close events so
platform-initiated closes stay in sync.)
| Callback | When | Notes |
|---|---|---|
onOpen(handle) |
After the drawer is appended, focused, and the enter animation is queued. | Receives the same handle Drawer.open() returns. |
onClose(result) |
As soon as the close begins (before the ~0.2 s exit animation ends). | result is the closing button's close value, or the argument given to close(result); it is undefined for Esc, backdrop, and the ✕ button. |
button onClick(handle) |
On footer-button click, before closing. | Return false to keep the drawer open; otherwise it closes and onClose receives the button's close value. |
<button data-vdr-open="#cart-panel">Cart</button>
<div id="cart-panel" hidden data-vdr-title="Your cart"
data-vdr-side="right" data-vdr-size="380px">
… <button data-vdr-close>Done</button>
</div>
| Attribute | On | Meaning |
|---|---|---|
data-vdr-open="#sel" | trigger | Click opens the referenced element as a drawer. The trigger becomes the opener, so focus returns to it on close. |
data-vdr-close | anything inside a drawer | Click closes the containing drawer (works in every drawer, not just declarative ones). |
data-vdr-title="…" | drawer element | Drawer title. Also honored by instance.open() when no title option is set. |
data-vdr-side="…" | drawer element | right | left | top | bottom. |
data-vdr-size="…" | drawer element | Any CSS length — width for left/right, height for top/bottom. |
data-vdr-dismissible="…" | drawer element | "false" or "0" disables Esc/backdrop/✕; any other value (or bare presence) keeps them enabled. |
Triggers present at load are wired automatically on
DOMContentLoaded; call Drawer.autoInit(root?) for
triggers added later. A trigger is only bound once, and one bad target does
not abort init for the rest of the page.
Resolution order — a per-call theme or
Drawer.defaults.theme of 'light'/'dark'
pins the theme. Otherwise: the shared VC engine when the family core is
loaded; else <html data-theme> /
data-bs-theme → a .dark/.light
class on <html> →
prefers-color-scheme. Open drawers re-resolve live — a
MutationObserver watches those attributes/classes and a media
query listener watches the OS scheme.
All colors are CSS custom properties, defined unsalted on
.vdr (dark values on .vdr[data-theme=dark]) so page
overrides keep working:
| Property | Light default | Role |
|---|---|---|
--vdr-accent | #5b5bd6 | Primary button, focus rings. |
--vdr-on-accent | #ffffff | Text on accent/danger buttons. |
--vdr-danger | #e5484d | danger button variant. |
--vdr-bg | #ffffff | Panel background. |
--vdr-surface | #f2f2f5 | Neutral button background. |
--vdr-text | #1c1d21 | Body text. |
--vdr-muted | #72747e | Secondary text, ✕ button. |
--vdr-faint | #e7e7ec | Borders, hover fills. |
--vdr-backdrop | rgba(20,21,26,.45) | Scrim behind the panel. |
--vdr-shadow | 0 10px 28px rgba(24,25,32,.14), 0 2px 8px rgba(24,25,32,.08) | Panel shadow. |
--vdr-radius | 14px | Inner-edge panel radius. |
--vdr-font | system-ui stack | Panel font family. |
CSS isolation — drawers render as
class="vdr vc1 vdr-right" and every structural rule ships salted
(.vdr.vc1 .vdr-panel { … }), so host-page design systems
can't override them, while the unsalted --vdr-* variable
definitions above stay overridable. Set a custom token with
Drawer.salt = 'acme' before the first drawer, or disable salting
with Drawer.salt = false. With the VC core loaded,
VC.config({ accent: '#b45309' }) themes drawers and the rest of
the family in one call.
Headless — Drawer.defaults.styles = false
(or per-call styles: false) never injects CSS; you keep the full
behavior (focus trap, scroll lock, stacking, ARIA) and style the markup
contract yourself. Our stylesheet is a starting point:
Drawer.css (string, rendered with the current salt) or
dist/drawer.css (file).
dialog.vdr.vdr-right.vdr-has-title.vdr-in[data-theme="dark"] ← .vdr-out while leaving
.vdr-panel ← slides; inline width/height from `size`
.vdr-head
.vdr-title ← only when `title` given (else .vdr-head-spacer)
.vdr-x ← only when dismissible
.vdr-body
.vdr-msg ← string content (adopted elements go here raw)
.vdr-foot ← only when `buttons` given
.vdr-btn.vdr-btn-primary
The root is a native <dialog> opened with
showModal() where supported (an identical fallback overlay
otherwise, which gets an explicit role="dialog"). Either way it
carries aria-modal="true" and aria-labelledby
pointing at the title — or aria-label from the
ariaLabel option / labels.dialog when there is no
title.
Initial focus: [autofocus] → first focusable in the body
→ primary footer button → any footer button → the panel itself
(tabindex="-1"). Tab is trapped inside — by the native top layer,
or by a manual cycle on the fallback path — and focus returns to the opener
once the exit animation finishes. Body scroll is locked while any drawer is
open, with scrollbar-width compensation so the page never shifts. Drawers
stack; Esc closes only the top-most. Animations honor
prefers-reduced-motion.
| Key | Action |
|---|---|
| Esc | Closes the top-most drawer (via the native cancel event, or the fallback keydown handler). Ignored when dismissible: false. |
| Tab | Next focusable element inside the drawer; wraps from the last back to the first. |
| Shift+Tab | Previous focusable element; wraps from the first (or the panel) to the last. |
styles: false injects nothing — you keep the full behavior
(focus trap, scroll lock, stacking), the ARIA wiring and keyboard handling,
and the stable .vdr-* class hooks; you bring your own CSS. Each
demo runs in its own <iframe>: the styled examples above
already injected the drawer stylesheet into this page, so only a clean
document can show truly unstyled output.
styles: false)The raw markup contract — a native <dialog> with
semantic children and class hooks, zero CSS injected.
Drawer.open({
styles: false, // zero CSS injected
title: 'Headless drawer',
content: 'Bring your own styles.',
buttons: [{ label: 'Close',
variant: 'primary', close: true }]
})
The same headless drawer mapped to a different design with Tailwind
utilities — loaded on demand from cdn.tailwindcss.com.
<style type="text/tailwindcss">
.vdr { @apply fixed inset-0 m-0 h-full max-h-none w-full
max-w-none border-0 p-0 opacity-0 transition-opacity
duration-150; background: rgb(15 23 42 / 0.55); }
.vdr::backdrop { background: transparent; }
.vdr.vdr-in { @apply opacity-100; }
.vdr.vdr-out { @apply opacity-0; }
.vdr-panel { @apply absolute inset-y-0 right-0 flex
translate-x-full flex-col bg-slate-900 text-slate-100
shadow-2xl transition-transform duration-200; }
.vdr-in .vdr-panel { @apply translate-x-0; }
.vdr-out .vdr-panel { @apply translate-x-full; }
.vdr-head { @apply flex items-center justify-between
border-b border-slate-700/60 px-5 py-4; }
.vdr-title { @apply m-0 text-base font-semibold; }
.vdr-x { @apply cursor-pointer border-0 bg-transparent p-0
text-xl leading-none text-slate-400 hover:text-white; }
.vdr-body { @apply flex-1 overflow-y-auto px-5 py-4; }
.vdr-msg { @apply m-0 text-sm leading-6 text-slate-300; }
.vdr-foot { @apply flex justify-end gap-2 border-t
border-slate-700/60 px-5 py-4; }
.vdr-btn { @apply cursor-pointer rounded-full border-0
bg-slate-800 px-4 py-2 text-sm font-medium
text-slate-200 hover:bg-slate-700; }
.vdr-btn-primary { @apply bg-emerald-400 text-slate-950
hover:bg-emerald-300; }
</style>