Vanilla UI Kit Drawer v1.0.0 · modal → toast →

Side sheets, one file.

Zero-dependency drawers for filters, carts and mobile nav — focus-trapped, scroll-locked, theme-aware. Click anything below — every card is live.

Four sides

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

Enhance existing markup

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()

Buttons footer

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()
})

Non-dismissible

No ✕, and Esc / backdrop clicks are ignored — only the button closes it.

Drawer.open({ dismissible: false,
  buttons: [{ label: 'Done',
    variant: 'primary', close: true }] })

Nested / stacked

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

Family theming

With the VC core loaded, one call themes every component.

VC.config({ accent: '#b45309' })

API reference

Constructor & usage

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.

Options

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.

OptionTypeDefaultDescription
sidestring'right' 'right' | 'left' | 'top' | 'bottom'. Unknown values fall back to 'right'.
sizestring'360px' The drawer's one dimension — width for left/right, height for top/bottom (the other axis is 100%). Any CSS length.
dismissiblebooleantrue Esc, backdrop click, and the ✕ button. false removes the ✕ and ignores Esc/backdrop.
stylesbooleantrue false = headless: no CSS is ever injected. See Theming.
themestring'auto' 'auto' | 'light' | 'dark'. Auto follows the page; see Theming.
labelsobject{ 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.
titlestring— Heading text (always rendered as text). Sets aria-labelledby.
contentstring | Element— String (text by default, markup with html: true) or a DOM element, which is adopted into the drawer and restored on close.
htmlbooleanfalse Opt-in: render a string content as trusted markup instead of text.
buttonsarray— Optional footer buttons; entry shape below. Omit for no footer.
onOpenfunction— onOpen(handle), called after the drawer is in the DOM and focused.
onClosefunction— onClose(result), called when the close begins. See Events.
ariaLabelstring— Accessible name used when there is no title (falls back to labels.dialog).
openerElement— Element to return focus to on close. Defaults to document.activeElement at open time; declarative triggers pass themselves.

buttons entry shape:

PropertyTypeDescription
labelstringButton text (rendered as text).
variantstring'primary' or 'danger' for accent styling; anything else is the neutral default.
closeanyValue passed to onClose(result) when this button closes the drawer.
onClickfunctiononClick(handle). Return false to keep the drawer open (validation etc.); otherwise the drawer closes with close as the result.

Instance & handle methods

Instances come from new Drawer(target, opts) / Drawer.create(); handles come from Drawer.open() (and handle.el is the root <dialog>/<div> element).

SignatureReturnsDescription
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.elElement The root element (a native <dialog> where supported).

Statics & helpers

MemberDescription
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.

Events & callbacks

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.)

CallbackWhenNotes
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.

Declarative init

<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>
AttributeOnMeaning
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-closeanything 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.

Theming

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:

PropertyLight defaultRole
--vdr-accent#5b5bd6Primary button, focus rings.
--vdr-on-accent#ffffffText on accent/danger buttons.
--vdr-danger#e5484ddanger button variant.
--vdr-bg#ffffffPanel background.
--vdr-surface#f2f2f5Neutral button background.
--vdr-text#1c1d21Body text.
--vdr-muted#72747eSecondary text, ✕ button.
--vdr-faint#e7e7ecBorders, hover fills.
--vdr-backdroprgba(20,21,26,.45)Scrim behind the panel.
--vdr-shadow0 10px 28px rgba(24,25,32,.14), 0 2px 8px rgba(24,25,32,.08)Panel shadow.
--vdr-radius14pxInner-edge panel radius.
--vdr-fontsystem-ui stackPanel 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

Accessibility

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.

KeyAction
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.

Headless mode

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.

Unstyled (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 }]
})

Bring your own design (Tailwind)

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>