Drawer
A panel that enters from an edge and takes that edge's whole axis: list filters, mobile menu, cart,
record details. It shares with the modal the mechanics of <dialog> — what changes is
the geometry and the motion: in the center the dialog grows, at the edge it slides, because that is how the drawer tells you where it came from.
Examples
Every example below is the same Tucano.drawer() with different options.
The four sides
side picks the edge; right is the default.
Column width
size on the sides: 18, 24 or 34rem.
Tones
tone changes the glow, which starts from the edge the drawer sits against.
No closing from outside
closeOnBackdrop: false and closable: false, as in the modal.
Free content
content(node) fills the body, which scrolls when the content gets taller than the screen.
App menu
A drawer from the left with the navigation list inside — the full example is on the side menu page.
How to use
The same two ways as the modal. From JavaScript the drawer is built on the spot and leaves the DOM when it closes; from the template it is already on the page, and the component only opens, closes and animates it.
In JavaScript
Tucano.drawer({
title: 'Filters',
text: 'Narrow down the contract list.',
side: 'right', // left | right | top | bottom
size: 'md', // sm | md | lg — on the sides, column width
tone: 'default', // default | danger | success | warning
actions: [
{ text: 'Clear', variant: 'ghost' },
{ text: 'Apply', variant: 'primary', onClick: (drawer) => apply() },
],
}).content(form);
// Built first, opened later
const drawer = new Tucano.Drawer({ title: 'Cart', side: 'right' });
drawer.content(list).open();
drawer.close();actions follows the same rules as the modal: text, variant (default outline),
onClick, which receives the instance, and closes: false to keep it open. onClose(reason, drawer)
receives 'button', 'action', 'escape', 'backdrop' or 'api'.
In the Django template
The typical case is a list's filter form: it is rendered by the server, with the values that came in
request.GET, and the drawer just hides it until someone asks for it.
<dialog class="tuc-drawer is-right is-md" id="filters" aria-labelledby="filters-title">
<form class="tuc-drawer__panel" method="get">
<div class="tuc-drawer__top">
<div class="tuc-drawer__header">
<h2 class="tuc-drawer__title" id="filters-title">Filters</h2>
</div>
<button type="button" class="tuc-btn is-ghost is-icon is-sm tuc-drawer__close" aria-label="Close" data-tuc-drawer-close>…</button>
</div>
<div class="tuc-drawer__body">
{{ form.as_div }}
</div>
<div class="tuc-drawer__footer">
<a class="tuc-btn is-ghost" href="?">Clear</a>
<button class="tuc-btn is-primary">Apply</button>
</div>
</form>
</dialog>
<button class="tuc-btn is-outline" data-tuc-drawer="#filters">Filters</button>| Attribute | Where | What for |
|---|---|---|
data-tuc-drawer="#id" | Trigger | Opens the drawer matching the selector on click |
data-tuc-drawer-close | Button inside the drawer | Closes it, with the reason 'button' |
data-closable="false" | <dialog> | Esc no longer closes it |
data-backdrop="false" | <dialog> | Clicking the backdrop no longer closes it |
Every dialog.tuc-drawer is adopted on load and on every htmx:afterSwap. The inner classes
follow the component name: __panel, __top, __header, __title,
__text, __close, __body and __footer. In the template, write the X and
aria-labelledby: from JavaScript both come ready-made, by hand they don't.
Sides and sizes
The box stretches along the axis of the edge it sits against — on the sides it takes the full height, at the top and bottom the full width — and the box's border appears only on the side that touches the page.
side | Class | Enters from | What size controls |
|---|---|---|---|
right | is-right | Right — the default | Column width |
left | is-left | Left | Column width |
top | is-top | Top | Nothing: the width is the screen's and the height, the content's |
bottom | is-bottom | Bottom | Nothing: the width is the screen's and the height, the content's |
size | Width on the sides |
|---|---|
sm | 18rem |
md | 24rem — the default |
lg | 34rem |
A side drawer is a column, not a card, so its scale differs from the modal's. On mobile (below 40rem)
it stops at min(20rem, 85vw) whatever the size: a narrow column is unreadable there, and the strip left over
shows that the page is still behind it. The footer buttons take the full row, as in the modal.
The glow starts from the edge
In the modal the light comes from the center of the screen. In the drawer it comes from the side the panel sits against, and the ellipse is larger: starting at the edge, only half of it stays on screen. Centered, the light would point at the middle of the page while the panel slides in from the side.
Keyboard and accessibility
It is a <dialog> opened with showModal(), just like the modal: the page behind becomes inert,
focus can't escape and it returns to whoever opened it on close.
| Key | Action |
|---|---|
Tab Shift+Tab | Moves between the drawer's controls, without going back to the page |
Enter Space | Activates the focused button |
Esc | Closes with animation and returns focus; does nothing with closable: false |
Shares the engine with the modal
Top layer, trapped focus, Esc and returned focus live in a single base, not in two similar implementations. That is what keeps a fix made in one of them from failing to apply to the other.
With title, the drawer gets aria-labelledby. The X is a .tuc-btn with
aria-label="Fechar". With prefers-reduced-motion, the entrance lasts 100 ms and doesn't slide.
API
Generated from the code on every build — if something is not here, it does not exist.
dialog.tuc-drawer [data-tuc-drawer-close] [data-tuc-drawer]new Tucano.Drawer(opcoes) Tucano.drawer()data-backdrop data-closable data-tuc-draweropen close contentOptions
The notes in this table come from comments in the source code, which are written in Portuguese.
| Option | Default | What for |
|---|---|---|
title | null | |
text | '' | |
side | 'right' | left | right | top | bottom |
size | 'md' | sm | md | lg — nas laterais, largura da coluna |
tone | 'default' | default | danger | success | warning |
closable | true | |
closeOnBackdrop | true | |
actions | null | [{ text, variant, onClick, closes }] — closes:false mantem aberto |
onClose | null | |
className | '' |