Skip to content

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.

Terminal window
pnpm add @agencecinq/drawer

Import 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&amp;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>
Attribute Required Description
id Yes Drawer identifier (must match button aria-controls.
open No Reflected state attribute (useful for styling).
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.

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

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.

If you are integrating in a Shopify theme, you can use the provided snippet and Vite plugin. The plugin requires Node.js 18+.

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
{% 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>

Bestiary drawer

A slide-in bestiary panel: overlay, scroll lock, and a tally of creatures catalogued from your AD&D campaign.

Entries catalogued3 creatures

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.

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

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.