Drawer
A drawer slides a panel in from the edge of the viewport. <cinq-drawer> wraps
your overlay and panel markup, handles focus trap, scroll lock, and open/close
events. <cinq-drawer-button> connects triggers through aria-controls.
Install
Section titled “Install”pnpm add @agencecinq/drawerImport once:
import "@agencecinq/drawer";Then write the markup: overlay, panel (role="dialog"), and buttons with
matching aria-controls. The component adds interactivity only.
HTML is the source of truth. The component will not auto-set
role, auto-migrate attributes, or warn about missing labels. Run an a11y linter (axe, Lighthouse) to catch invalid markup.
<cinq-drawer-button class="block"> <button aria-controls="demo-drawer">Open drawer</button></cinq-drawer-button>
<cinq-drawer id="demo-drawer" class="group"> <div data-dom="overlay" class="fixed inset-0 z-[2147483646] bg-black/50 opacity-0 pointer-events-none group-[[open]]:opacity-100 group-[[open]]:pointer-events-auto"></div> <div role="dialog" class="fixed right-0 top-0 z-[2147483647] h-full w-full max-w-md bg-white shadow-2xl translate-x-full transition-transform group-[[open]]:translate-x-0"> <div class="p-8 flex flex-col gap-4"> <h2 class="text-2xl font-bold">Bestiary drawer</h2> <p class="text-gray-600"> Use the drawer as a sliding panel listing creatures from your AD&D bestiary: trolls, dracoliches, or galeb duhrs guarding the dungeon entrance. </p> <cinq-drawer-button class="block"> <button aria-controls="demo-drawer">Close</button> </cinq-drawer-button> </div> </div></cinq-drawer>Attributes
Section titled “Attributes”| Attribute | Required | Description |
|---|---|---|
id |
Yes | Drawer identifier (must match button aria-controls. |
open |
No | Reflected state attribute (useful for styling). |
Methods
Section titled “Methods”| Method | Description |
|---|---|
open() |
Opens the drawer. Returns false if already open, aborted, or deferred. |
close() |
Closes the drawer. Returns false if already closed, aborted, or deferred. |
toggle() |
Toggles between open and closed. |
Events
Section titled “Events”Prefer the constants from @agencecinq/utils (EVENTS.DRAWER_*). Events are
dispatched on document.documentElement.
| Event | Cancelable | Detail payload | Description |
|---|---|---|---|
drawer:toggle |
No | { drawer, trigger, trap } |
Request open/close from a button. |
drawer:before-open |
Yes | { drawer, instance, trigger, resolve } |
Fired before open is set. Cancel to defer, then call resolve() to commit. |
drawer:before-close |
Yes | { drawer, instance, resolve } |
Fired before open is removed. Cancel to defer, then call resolve() to commit. |
drawer:open |
No | { drawer, trigger? } |
Fired after open is set (trap, scroll lock, side effects). |
drawer:close |
No | { drawer } |
Fired after open is removed (scroll unlock, focus restore, side effects). |
Deferring open or close
Section titled “Deferring open or close”Every open path (toggle or open()) dispatches cancelable drawer:before-open
first. Every close path (toggle, overlay click, Escape, or close()) dispatches
cancelable drawer:before-close first. Without a listener (or without
preventDefault()), the drawer opens or closes immediately. To run async work,
cancel the event and call detail.resolve() when ready:
import { EVENTS } from "@agencecinq/utils";
document.documentElement.addEventListener(EVENTS.DRAWER_BEFORE_OPEN, (event) => { if (event.detail.drawer !== "my-drawer") return;
event.preventDefault();
void doAsyncWork().then(() => { event.detail.resolve(); });});
document.documentElement.addEventListener(EVENTS.DRAWER_BEFORE_CLOSE, (event) => { if (event.detail.drawer !== "my-drawer") return;
event.preventDefault();
void doAsyncWork().then(() => { event.detail.resolve(); });});resolve() is idempotent: safe to call more than once. drawer:open and
drawer:close fire only after the matching resolve(), when side effects run.
UX: defer open with before-open when the fetch is short (roughly under
500 ms) and the panel would feel broken if empty: show loading on the trigger
while you wait, then call resolve(). For slower or unpredictable loads, skip
the defer: let the drawer open immediately and fetch on drawer:open with a
skeleton or spinner inside the panel. Defer close with before-close when
you need to save, archive, or run an exit animation before dismissing.
TypeScript: import BeforeOpenDetail and BeforeCloseDetail from
@agencecinq/drawer to type listeners.
Shopify (Liquid snippet)
Section titled “Shopify (Liquid snippet)”If you are integrating in a Shopify theme, you can use the provided snippet and Vite plugin. The plugin requires Node.js 18+.
1) Copy the snippet automatically (Vite)
Section titled “1) Copy the snippet automatically (Vite)”import { defineConfig } from 'vite';import { cinqDrawerPlugin } from '@agencecinq/drawer/plugin';
export default defineConfig({ plugins: [cinqDrawerPlugin()],});This copies the package snippet to your theme:
snippets/cinq-drawer.html.liquid
2) Render the snippet
Section titled “2) Render the snippet”{% render 'cinq-drawer.html', id: 'bestiary-drawer', content: '<div class="p-8"><p class="mb-2">The galeb duhr looks like a boulder until it moves.</p><p class="mb-0">Trolls and jermlaines skulk deeper in the dungeon corridors.</p></div>'%}
<cinq-drawer-button class="block"> <button aria-controls="bestiary-drawer" aria-expanded="false">Open bestiary</button></cinq-drawer-button>Interactive example
Section titled “Interactive example”CINQ drawer: encounter prep
Keep quick notes on the next encounter: a troll ambush, a cloaker in the rafters, or a nest of jermlaines sabotaging the party.
Playground (live)
Section titled “Playground (live)”Bestiary drawer
A slide-in bestiary panel: overlay, scroll lock, and a tally of creatures catalogued from your AD&D campaign.
Bestiary
Troll
Regenerates quickly. Best handled with fire or acid.
Dracolich skull
A trophy from an undead dragon. Radiates faint necromancy.
Galeb duhr statuette
Looks like a boulder, watches like a sentry.
Exclusive drawers
Section titled “Exclusive drawers”Only one drawer stays open at a time. A shared return-focus target remembers the page control that started the chain (triggers inside a drawer are ignored). Open notes, switch to bestiary to close: focus lands back on Open left.
Exclusive drawers
Opening one drawer closes any other that is already open. Focus return is shared: only a trigger outside any cinq-drawer is remembered, so closing the chain returns to the page button that started it.
Encounter notes
Party enters from the west corridor. Two trolls wait behind the collapsed arch.
This control targets both drawers. Closing the bestiary returns focus to Open left, not this button.
Bestiary
Troll. Regenerates unless hit with fire or acid.
Multiple buttons, one drawer
Section titled “Multiple buttons, one drawer”Several triggers can share the same aria-controls id. Each
<cinq-drawer-button> listens for drawer:open / drawer:close and updates its
own aria-expanded. Open or close from any control and they all stay in sync
(red ring when collapsed, green when expanded).
Shared drawer, many buttons
Every cinq-drawer-button that points at the same id updates its nested aria-expanded from drawer:open / drawer:close. Not from the click itself. Open or close from any control. All of them stay in sync.
Red ring = aria-expanded="false", green = aria-expanded="true".
Shared bestiary
Close from here or from any of the three triggers outside. All four buttons flip aria-expanded together.
Async bestiary (before-open / before-close)
Section titled “Async bestiary (before-open / before-close)”Try opening and closing the drawer below. Each transition is deferred until async
work finishes: the creature loads before open is set, then archives before open
is removed. The overlay shows the wait state while the hook is pending.
Bestiary drawer
Open the drawer to load a creature. Close it to archive the entry.
Monster entry
Creature
Pick a creature and open the drawer.