Modal
A dialog that sits on top of everything, with a blurred backdrop and a glow in the tone's color. Built on the
native <dialog>: that is where the layer above any z-index, the trapped focus, the
Esc key and focus returning to whoever opened it come from — the component only draws the box and animates it.
Examples
Every example below is the same Tucano.modal() with different options.
Sizes
size sets the maximum width of the box; md is the default.
Tones
tone changes the backdrop glow and the aura around the box.
Confirmation with a promise
Tucano.confirm() resolves true or false; closing from outside counts as a no.
Sheet on mobile
sheet: true slides up from the bottom on narrow screens. Narrow the window to see it.
No closing from outside
closeOnBackdrop: false ignores clicks on the backdrop; closable: false removes the X and Esc.
Close reason
onClose receives how the person left. Close it in different ways.
Free content
content(node) puts any element in the body, which scrolls when it gets taller than the screen.
Action that doesn't close
closes: false on an action keeps the modal open after the click.
How to use
There are two ways. From JavaScript, the modal is built on the spot, goes into <body> when it
opens and leaves when it closes. From the template, the <dialog> is already on the page and the component only opens, closes and animates it.
In JavaScript
Tucano.modal({
title: 'Delete contract',
text: 'This action cannot be undone.',
tone: 'danger', // default | danger | success | warning
size: 'md', // sm | md | lg | full
sheet: true, // slides up from the bottom on mobile
actions: [
{ text: 'Cancel', variant: 'outline' },
{ text: 'Delete', variant: 'danger', onClick: () => deleteContract() },
],
onClose: (reason, modal) => console.log(reason),
});
// Text only: the shortcut accepts a string
Tucano.modal('File uploaded.');The Tucano.modal() shortcut creates and opens in one step, and returns the instance. To build it first and open it later,
use the class:
const m = new Tucano.Modal({ title: 'New contact', size: 'lg' });
m.content(form); // any node, or an array of nodes
m.open();
m.close(); // animates out and calls onClose(reason)Confirmation
The most common use of a modal in a CRUD app is asking before deleting. Tucano.confirm() returns a promise, and the
code keeps reading top to bottom:
if (await Tucano.confirm({ title: 'Delete contract?', text: 'This action cannot be undone.', confirm: 'Delete' })) {
deleteContract();
}| Option | Default | What for |
|---|---|---|
confirm | 'Confirmar' | Label of the button that resolves true |
cancel | 'Cancelar' | Label of the button that resolves false |
tone | 'danger' | With danger the confirm button is red; with any other tone, primary |
The other modal options work here too, except actions, which confirm() builds on its own. An
onClose you pass is still called.
Closing from outside is a no, not limbo
With the X, Esc or the backdrop, the promise resolves false. Without that it would stay pending forever, and the await would never return.
Actions
Each item in actions becomes a .tuc-btn in the footer, in the order it was written.
| Key | Default | What for |
|---|---|---|
text | — | Button label |
variant | 'outline' | Button variant: primary, outline, ghost, danger… |
onClick | — | Receives the modal instance |
closes | true | false keeps the modal open after the click |
Written in the template
When the content is rendered by the server — a Django form, with errors and csrf_token —,
the <dialog> lives in the template. Every dialog.tuc-modal is adopted on load and on every
htmx:afterSwap; opening doesn't insert it and closing doesn't remove it, because the node belongs to whoever wrote the HTML.
<dialog class="tuc-modal is-md" id="delete" aria-labelledby="delete-title">
<form class="tuc-modal__panel" method="post" action="{% url 'contract-delete' contract.pk %}">
{% csrf_token %}
<div class="tuc-modal__top">
<div class="tuc-modal__header">
<h2 class="tuc-modal__title" id="delete-title">Delete contract?</h2>
<p class="tuc-modal__text">This action cannot be undone.</p>
</div>
<button type="button" class="tuc-btn is-ghost is-icon is-sm tuc-modal__close" aria-label="Close" data-tuc-modal-close>…</button>
</div>
<div class="tuc-modal__footer">
<button type="button" class="tuc-btn is-outline" data-tuc-modal-close>Cancel</button>
<button class="tuc-btn is-danger">Delete</button>
</div>
</form>
</dialog>
<button class="tuc-btn is-outline" data-tuc-modal="#delete">Delete</button>| Attribute | Where | What for |
|---|---|---|
data-tuc-modal="#id" | Trigger | Opens the <dialog> matching the selector on click |
data-tuc-modal-close | Button inside the dialog | 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 |
The classes are the same ones JavaScript builds: __panel is the box, __top holds the
__header (with __title and __text) and the __close, __body is the
scrolling body and __footer aligns the actions to the right. Size and tone go as classes on the
<dialog> itself: is-lg, is-danger, is-sheet.
In the template, the X and the dialog's name are up to you
From JavaScript, the close button and aria-labelledby come ready-made. In a hand-written <dialog>, write both: without the X the person depends on Esc, and without aria-labelledby the screen reader announces just "dialog".
Confirm before submitting a form
For a delete form that is already on the page, confirm() intercepts the submit and lets it through only on a yes:
document.querySelector('#delete-contract').addEventListener('submit', async (e) => {
e.preventDefault();
const ok = await Tucano.confirm({ title: 'Delete contract?', confirm: 'Delete' });
if (ok) e.target.submit();
});A closed <dialog> must go back to display: none — and the library already does that
The modal rule declares display: grid, which beats the browser stylesheet. Without .tuc-modal:not([open]) { display: none }, a <dialog> sitting in the template would stay rendered, fixed and transparent over the whole page: nothing shows, and no click works. That's why it doesn't need hidden.
Tones and glow
The backdrop isn't just dark: a radial glow behind the box separates the dialog from what is underneath, and the blur takes the page out of focus. The tone changes the color of that glow and of the aura around the box — not the color of the buttons, which is still each action's choice.
tone | Class | Glow color |
|---|---|---|
default | is-default | --tuc-accent — changing the accent changes the glow |
danger | is-danger | --tuc-danger-fill |
success | is-success | --tuc-success |
warning | is-warning | --tuc-warning |
Sizes
The box takes the available width up to the size's limit, and the body scrolls when the content gets taller than the screen.
size | Maximum width | What for |
|---|---|---|
sm | 22rem | Short confirmation |
md | 30rem | The default: notice, form with a few fields |
lg | 44rem | Table or wide form |
full | the screen, with a 1rem margin | Full width and height: editor, document preview |
Below 40rem the footer buttons take the full row, so the touch target grows. With sheet, the box
loses its bottom corners, sits against the bottom edge and respects the iPhone safe area. It is optional because not every modal wants
to become a drawer on mobile.
Keyboard and accessibility
showModal() puts the dialog in the top layer and makes the rest of the page inert: focus can't escape
behind it, and on close it goes back to whoever opened it. None of that is our JavaScript — it is the native element.
| Key | Action |
|---|---|
Tab Shift+Tab | Moves between the dialog'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 |
Why <dialog>, and not a div with z-index
The top layer sits above any z-index and is immune to an ancestor with overflow: hidden or transform — the three reasons a hand-made modal shows up clipped or underneath. The native Esc closes instantly, without animation; the component intercepts cancel to close along the same path as the others, which animates and reports the reason.
With title, the dialog gets aria-labelledby pointing to it, and the screen reader announces the title when it
opens. The X is a .tuc-btn with aria-label="Fechar". With prefers-reduced-motion, the entrance
lasts 100 ms and doesn't grow.
Reason in onClose | When |
|---|---|
'button' | The X, or a data-tuc-modal-close in the template |
'action' | A button from actions |
'escape' | The Esc key |
'backdrop' | Click outside the box |
'api' | close() called with no argument |
The modal and the drawer share this mechanism: a fix made in one of them applies to the other.
API
Generated from the code on every build — if something is not here, it does not exist.
dialog.tuc-modal [data-tuc-modal-close] [data-tuc-modal]new Tucano.Modal(opcoes) Tucano.modal() Tucano.confirm()data-backdrop data-closable data-tuc-modalopen 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 | '' | |
size | 'md' | sm | md | lg | full |
tone | 'default' | default | danger | success | warning |
sheet | false | no celular sobe do rodape em vez de surgir no centro |
closable | true | botao X e Escape |
closeOnBackdrop | true | |
actions | null | [{ text, variant, onClick, closes }] — closes:false mantem aberto |
onClose | null | |
className | '' |