Keyboard and accessibility
Everything works without a mouse, and the keys are those of the native element and the ARIA APG — anyone who
already knows how to use a <select> has nothing new to learn. This page gathers in one place what each
component does with the keyboard and what it announces to screen reader users. The accessible labels the library
writes on its own are in Portuguese by default, and Tucano.setTexts() replaces them; they are quoted below with their meaning.
Tab doesn't open panels
Reaching a field with Tab never opens a calendar, list or color picker. Opening is always
explicit: ↓, a click, and in the select also Enter and Space.
Opening on focus looked convenient and was the source of two bugs. Panels piled up while tabbing — anyone going
through a form to the save button got a calendar in their face at every field, with the panel covering
the next field. And in the color field the panel covered the very field of someone who wanted to type the hex. It's the rule of
the native <select>, and it's the ARIA APG's.
| Component | Opens with |
|---|---|
| Date picker | ↓, click; Space when the field is empty |
| Select | ↓, ↑, Enter, Space, click |
| Color picker | ↓ in the field or on the swatch; Enter and Space on the swatch, which is a <button> |
| Dropdown menu | ↓, ↑, Enter, Space or a click on the trigger |
In the date field, Enter belongs to the form
The date picker is a text field inside a <form>, and Enter in a text field submits the form — which is what anyone expects after typing the date. Hijacking the key would silently break submission in every existing form, so there it's Space that opens. And only when the field is empty: in time mode you type 07/09/2026 14:30, and the space is part of the typing. With the panel open, Enter confirms what was typed.
Any panel that opened closes when focus leaves it, so tabbing through the page never leaves menus open behind you.
Date picker
| Key | Where | Action |
|---|---|---|
↓ | Field, closed | Opens the calendar |
Space | Empty field, closed | Opens the calendar |
Enter | Field, open | Confirms the typed date and closes; when closed, submits the form |
← → | Day | Previous and next day |
↑ ↓ | Day | Same day in the previous and next week |
PageUp PageDown | Day | Previous and next month; with Shift, year |
Home End | Day | Start and end of the week, respecting the locale's first day |
Enter Space | Day | Picks the day |
↑ ↓ | Time column | Previous and next value in the column |
Home End | Time column | First and last value in the column |
Enter Space | Time column | Picks the value |
Tab | Panel | Cycles inside the panel, without escaping to the page; each time column is a single stop |
Esc | Panel | Closes, discards what wasn't confirmed and returns focus to the field |
The field is a combobox with aria-haspopup="dialog" and aria-expanded, and gets
aria-controls pointing to the panel while it's open. The panel is role="dialog"
labeled "Selecionar data" (select date) or "Selecionar período" (select period); each month is a role="grid" with the month's name,
each week a row and each day a gridcell with aria-selected and the full label — "Saturday, September 12,
2026". A day outside the limits has aria-disabled: it receives focus but can't be picked. Changing months is
announced by an aria-live="polite" region that stays fixed in the panel. Each hour, minute and second column is a
group of buttons with a single Tab stop, and the chosen value has aria-pressed. Closing by clicking outside doesn't return focus to the field, so it
isn't stolen from where the person clicked.
Select
| Key | Action |
|---|---|
↓ ↑ Enter Space | When closed, opens the panel |
↑ ↓ | When open, moves between options, skipping disabled ones and wrapping around |
Home End | First and last option, skipping disabled ones |
Enter | Picks the active option; in multiple mode, checks or unchecks it |
| Letters | With search on, they filter the list ignoring accents. Search turns on by itself from 6 options, or with data-search="true" |
Backspace | In multiple mode, with an empty search, removes the last tag |
Backspace Delete | In single mode, with an empty search, clears the value, like the X — without the X (clearable: false), it does not clear |
Esc | Closes and returns focus to the field |
With the native <select> disabled, the field leaves the Tab order, is dimmed,
doesn't open, doesn't accept text and hides the clear X and the tag X. An option inside
<optgroup disabled> also counts as disabled.
The control is a combobox with aria-haspopup="listbox", aria-expanded and
aria-controls pointing to the list while it's open — when closed, the list leaves the DOM and the attribute
goes with it. The list is a listbox, with
aria-multiselectable in multiple mode; each option is an option with aria-selected and
aria-disabled. The active option reaches the screen reader through aria-activedescendant, without taking
focus away from the search field. Each tag's X is called "Remover" (remove) followed by the option's name. The
native <select> stays in the form with aria-hidden, so it isn't read twice.
Color picker
| Key | Where | Action |
|---|---|---|
↓ | Field or swatch | Opens the panel with focus on the color area |
Enter Space | Swatch | Opens the panel with focus on the color area, and closes it |
← → | Color area | Saturation, in steps of 2%; with Shift, steps of 10 |
↑ ↓ | Color area | Brightness, in the same steps |
← ↓ / → ↑ | Hue and opacity sliders | Decreases and increases by 1; with Shift, by 10 |
Home End | Hue and opacity sliders | Minimum and maximum |
Esc | Panel | Closes and returns focus to the swatch |
The swatch is a <button> "Escolher cor" (choose color) with aria-haspopup="dialog" and
aria-expanded; the panel is a role="dialog" "Seletor de cor" (color picker). The sliders are
role="slider" with aria-valuenow, and each palette color is a button labeled with its own
value. The text field is still the <input> where you type the hex.
Dropdown menu
| Key | Where | Action |
|---|---|---|
↓ | Trigger | Opens and moves to the first item |
↑ | Trigger | Opens and moves to the last item |
Enter Space | Trigger | Opens and closes |
↑ ↓ | Menu | Previous and next item, wrapping around and skipping disabled ones |
Home End | Menu | First and last item |
Enter | Item | Activates it; an item without href is a <button> and also accepts Space |
Esc | Menu | Closes and returns focus to the trigger |
Tab | Menu | Closes and moves on to the next element on the page |
The trigger gets aria-haspopup="menu" and aria-expanded; the panel is role="menu", each
item a menuitem, the separator a separator and the disabled item aria-disabled. Items
have tabindex="-1" on purpose: inside a menu the arrows navigate, and tabbable items would make
Tab leave the menu one item at a time.
Tabs
| Key | Action |
|---|---|
Tab | Enters the open tab and leaves the list straight to the panel — the list is a single stop |
← → | Previous and next tab, wrapping around and skipping disabled ones |
Home End | First and last tab |
Enter Space | With data-manual="true", switches the panel; without it, the arrow already switches |
The script adds tablist, tab and tabpanel, links each tab to its panel through
aria-controls and aria-labelledby, and keeps aria-selected and
tabindex up to date. Manual mode exists for panels that load through HTMX: with automatic activation,
crossing four tabs with the arrow would fire four requests.
Modal and drawer
| Key | Action |
|---|---|
Tab Shift+Tab | Cycle inside the dialog, without escaping to the page |
Esc | Closes with animation and returns focus to whatever opened it — unless closable: false or data-closable="false" |
Both are a <dialog> opened with showModal(), and that's where the accessibility comes from: the
page behind becomes inert, focus is trapped and returns to the trigger on close, all done by the browser and not by
our JavaScript. The native Escape would close it instantly, without animation; the library intercepts
cancel to close the same way the X does. With a title, the dialog is labeled by it through
aria-labelledby, and the X is called "Fechar" (close). In Tucano.confirm(), closing with
Esc is a refusal: the promise resolves to false.
Tooltip
| Key or gesture | Action |
|---|---|
Tab to the element | Shows the tip right away, without the pointer delay |
Tab away | Hides the tip |
Esc | Hides it, even with the pointer resting on top |
| Tap | On a touchscreen, tapping opens and tapping again closes |
The bubble is role="tooltip" and the element points to it through aria-describedby, so the screen
reader reads the tip along with the button's name. An element that doesn't receive focus — a <span>, for
example — gets tabindex="0", otherwise keyboard users would never see the tip. Esc with the
pointer on top is what WCAG 1.4.13 asks for, for people who use screen magnification and need to dismiss a tip
that covers the content.
Table
| Key | Action |
|---|---|
Tab | Goes through the sortable headers, the selection checkboxes and the row buttons |
Enter | On a header, sorts — in server mode, follows the link |
Space | Checks the checkbox; in client mode, also sorts through the header button |
The sorted header has aria-sort="ascending" or "descending" and the others
"none". In server mode the trigger is an <a>, announced as a link; in client mode, a
<button>. The header checkbox is "Selecionar todas as linhas desta página" (select all rows on this page), each row's is
"Selecionar linha" (select row), and the mixed state is the native indeterminate.
The other components
These have no keyboard handling of their own: the native element underneath is what responds. Even so, each one has something to tell the screen reader.
| Component | Keyboard | What it announces |
|---|---|---|
| Accordion | Enter and Space on the <summary>, from the browser | Open and closed, through <details>; the arrow is aria-hidden |
| Upload | Enter and Space on the drop zone open the file dialog | The zone is role="button" described by the hint about types and size; a rejected file shows up in a role="alert" notice |
| Mask with eye | Enter and Space on the eye button | "Mostrar" (show) and "Ocultar" (hide), with aria-pressed; with data-validate, a field that fails gets aria-invalid |
| Text editor | Ctrl/⌘ + B, I, U, K; Tab in a table moves between cells and, in the last one, creates a row | The area is a multiline role="textbox"; the bars are role="toolbar" and the formatting buttons have aria-pressed |
| Toast | The timer pauses when something inside it receives focus | Two live regions created before the first message: polite for normal ones and assertive for errors; each toast is status or alert |
| Pagination | Native links | <nav> "Paginação" (pagination), aria-current="page" on the current one; a disabled end is a <span>, out of the Tab path |
| Checkbox, radio and switch | Those of the native <input> | The browser's; the switch needs role="switch" in the template, because CSS can't add a role |
| Button group | Those of the native <input>: the radio group is a single Tab stop, and the arrow keys move through it | The group needs a name — role="group" with aria-label, or <fieldset> with <legend>; the <input> stays invisible, but focusable and announced |
What applies to all of them
The decisions that repeat from component to component.
| Rule | Why |
|---|---|
| The native element stays in the form | Name, required state, mixed state and the screen reader announcement are still the browser's |
A field with an error is aria-invalid="true", not a class | It's the attribute the screen reader announces, and Django 5 writes it on its own. In components that replace the field with their own control, the attribute stays on the native element and the CSS reaches the control |
| A panel that opened closes when focus leaves | Without this, tabbing through the page left a trail of open panels |
Esc closes and returns focus to whatever opened it | Closing and dropping focus at the start of the page would make keyboard users start over from the top |
Drawings get aria-hidden | Arrows, icons and the pagination ellipsis are not read aloud |
prefers-reduced-motion turns off animations | Applies to panels, dialogs, toasts, tabs, accordion, table and buttons |