Tucano v0.37.2

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.

This <dialog> was already on the page. It is the way to go for a form rendered by the server.

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();
}
OptionDefaultWhat 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.

KeyDefaultWhat for
text—Button label
variant'outline'Button variant: primary, outline, ghost, danger…
onClick—Receives the modal instance
closestruefalse 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>
AttributeWhereWhat for
data-tuc-modal="#id"TriggerOpens the <dialog> matching the selector on click
data-tuc-modal-closeButton inside the dialogCloses 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.

toneClassGlow color
defaultis-default--tuc-accent — changing the accent changes the glow
dangeris-danger--tuc-danger-fill
successis-success--tuc-success
warningis-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.

sizeMaximum widthWhat for
sm22remShort confirmation
md30remThe default: notice, form with a few fields
lg44remTable or wide form
fullthe screen, with a 1rem marginFull 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.

KeyAction
Tab Shift+TabMoves between the dialog's controls, without going back to the page
Enter SpaceActivates the focused button
EscCloses 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 onCloseWhen
'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.

Markup
dialog.tuc-modal [data-tuc-modal-close] [data-tuc-modal]
In JS
new Tucano.Modal(opcoes) Tucano.modal() Tucano.confirm()
Attributes
data-backdrop data-closable data-tuc-modal
Methods
open close content

Options

The notes in this table come from comments in the source code, which are written in Portuguese.

OptionDefaultWhat for
titlenull
text''
size'md'sm | md | lg | full
tone'default'default | danger | success | warning
sheetfalseno celular sobe do rodape em vez de surgir no centro
closabletruebotao X e Escape
closeOnBackdroptrue
actionsnull[{ text, variant, onClick, closes }] — closes:false mantem aberto
onClosenull
className''