Date picker
Turns an <input type="text"> into a date field with a calendar, ranges and time.
On screen it shows the format of the page's language; the form sends ISO, which is what Django
expects. Masked typing still works, for people who type faster than they click.
Examples
The same <input> with different attributes. Below each field is the ISO value,
which is what reaches the server.
Date and time
data-time="true" adds the hour and minute columns, in steps of 5.
Range with time
With time the panel doesn't close by itself: the Apply button confirms.
Every minute, with seconds
data-minute-step="1" lists 00 to 59; data-seconds="true" adds the third column.
Limits
data-min and data-max in yyyy-mm-dd. Outside them the day is disabled.
Weekdays only, from today
disabledDates takes a function and doesn't exist as an attribute — it goes in JavaScript.
Range presets
data-presets="true" turns on Today, Last 7 days, This month and the others.
Custom presets
A list of { label, value() }, in JavaScript.
Week number
data-week-numbers="true" shows the ISO-8601 week on the left.
A specific locale
data-locale="en-US": month before day, week starting on Sunday, names in English — whatever the page language.
Custom format
data-format="dd.MM.yyyy". The mask follows the format by itself.
With an icon
The form's .tuc-input-group, with an SVG before the field.
With an error
aria-invalid="true" on the field — Django 5 already writes it.
The date must be after today.
How to use
Mark the <input> and it initializes by itself on load and on every
htmx:afterSwap. The component puts the .tuc-input class on the field, so you don't need to write it.
<input type="text" name="date" data-tuc-datepicker>
<input type="text" name="when" data-tuc-datepicker data-time="true">
<input type="text" name="period" data-tuc-datepicker data-mode="range">
<input type="text" name="window" data-tuc-datepicker data-mode="range" data-time="true">| Attribute | Default | What for |
|---|---|---|
data-mode | single | range for a date range |
data-time | false | Adds the time picker |
data-minute-step | 5 | Step of the minutes column; 1 lists them all |
data-seconds | false | Seconds column |
data-min data-max | — | Limits, in yyyy-mm-dd |
data-months | 1, or 2 for a range | How many months side by side |
data-presets | false | true turns on the presets — range mode only |
data-week-numbers | false | Column with the ISO week |
data-locale | <html lang> | Names, day and month order, first day of the week and 12- or 24-hour clock |
data-format | from the locale | Display format, such as dd/MM/yyyy |
data-iso-name | the name | name of the hidden field with the ISO value |
data-placement | bottom-center | Side and alignment of the panel |
data-native | false | true uses the system picker |
In the Django form
class EventForm(forms.ModelForm):
class Meta:
model = Event
fields = ["date", "start", "period"]
widgets = {
"date": forms.TextInput(attrs={"data-tuc-datepicker": ""}),
"start": forms.TextInput(attrs={"data-tuc-datepicker": "", "data-time": "true"}),
"period": forms.TextInput(attrs={"data-tuc-datepicker": "", "data-mode": "range"}),
}Don't use type="date" on the widget: it would turn on the browser's calendar on top of ours. The initial value can come
in ISO or in the locale's format — the component reads both.
In JavaScript
For what doesn't fit in an attribute — disabledDates, custom presets, firstDayOfWeek,
autoApply, clearable, onChange — and to read and write the value.
const dp = new Tucano.DatePicker('#delivery', {
mode: 'range',
time: true,
minuteStep: 15,
min: '2026-01-01',
disabledDates: (d) => d.getDay() === 0, // closed on Sundays
onChange: (value, { iso }) => console.log(value, iso),
});
dp.setValue({ start: '2026-03-01', end: '2026-03-15' });
dp.getValue(); // { start: Date, end: Date } — in single mode, a Date
dp.clear();
dp.open();
dp.destroy();// Custom presets: value() returns { start, end } and is called every time the panel opens.
new Tucano.DatePicker('#period', {
mode: 'range',
presets: [
{ label: 'Next 7 days', value: () => {
const today = Tucano.dates.startOfDay(new Date());
return { start: today, end: Tucano.dates.addDays(today, 6) };
} },
],
});Value and form
The visible field shows the formatted date, such as 25/12/2026 in pt-BR; an <input type="hidden"> created next to it carries the
name and the value in ISO. The server receives ISO and never the formatted text, which would change with the language of whoever filled it in.
POST date = 2026-12-25
POST start = 2026-12-25T09:30 # DateTimeField reads it directly
POST exact = 2026-12-25T09:30:15 # with data-seconds
POST period = 2026-03-01,2026-03-15 # start and end, separated by a commaDateField and DateTimeField parse this with no configuration. The range arrives in a single field; split it in clean_:
class BookingForm(forms.Form):
period = forms.CharField()
def clean_period(self):
start, _, end = self.cleaned_data["period"].partition(",")
return date.fromisoformat(start), date.fromisoformat(end)To edit, send the value back in ISO in value — the component reads ISO and also the locale's format. With
data-iso-name the visible field keeps its own name and the hidden one gets another, and both are posted.
A field with no name and no data-iso-name gets no hidden field at all.
Every choice fires tucano:change on the visible field, and also the native change, so validation and
hx-trigger="change" see the value.
document.querySelector('#due_date').addEventListener('tucano:change', (e) => {
e.detail.value; // Date, or { start, end } for a range
e.detail.iso; // '2026-12-25' — the same text as the hidden field
e.detail.instance; // the DatePicker
});Here e.target.name is empty
The event comes from the visible field, and the name moved to the hidden one. Use e.detail.iso, or read the hidden field, which sits right after the visible one.
Typing
The field has a mask taken from the display format itself, so it follows the locale with no configuration. You type only the digits; slash, colon and the range dash go in by themselves.
| Mode | Mask in pt-BR |
|---|---|
| Date | dd/mm/aaaa |
| Date and time | dd/mm/aaaa hh:mm |
| Range | dd/mm/aaaa — dd/mm/aaaa |
| Range with time | dd/mm/aaaa hh:mm — dd/mm/aaaa hh:mm |
Once the mask is complete the calendar jumps to the typed date, without closing and without changing the value. The value is confirmed when leaving the field
or with Enter while the panel is open, which also closes it; Esc discards what was typed. Text that doesn't become a date reverts to the previous value, and a date outside
min, max or disabledDates is not accepted — nor pulled to the limit. A range without a valid end is refused whole. Deleting over a separator removes the neighboring
digit, instead of getting stuck.
A format with a month name (MMMM) or AM/PM can't be masked; there the field stays free and the
tolerant parser applies, accepting 25/12/26, 25-12-2026, 25122026, 2512 (current year) and
12/25/2026 2:05 pm. Typing only the date in a field with time keeps the time that was already chosen.
Clicking the month in the header switches to the month grid, and clicking the year, to the year grid — so a date of birth doesn't cost thirty clicks on the arrow.
On mobile
The panel is the same as on desktop, adapted to touch: 16px text, larger cells, one month below the other for ranges and the presets in a strip that scrolls horizontally.
The 16px aren't about looks: iOS Safari zooms the whole page when focusing a field with a smaller font. For the same reason, on narrow touch screens the field gets neither focus nor mask — tapping opens the panel, and the system keyboard doesn't rise to cover the calendar.
The system's own picker is optional:
native: false // default: the panel everywhere
native: 'auto' // the system picker where the pointer is touch
native: true // always the system picker — as an attribute, data-native="true"Native mode doesn't change your field's type
Switching to type="date" made every CSS rule written as input[type=text] stop matching, and the field turned into a raw browser input. Now a transparent native input sits on top, the size of the field, and the POST is still the hidden field's. Ranges never use native mode: there is no range picker in HTML.
Keyboard and accessibility
The field announces that it opens a dialog, the grid is role="grid" and each day has
aria-selected and its full name — "Friday, December 25, 2026". Arriving with Tab doesn't open the panel.
| Key | Where | Action |
|---|---|---|
↓ | Field | Opens the calendar |
Space | Empty field | Opens the calendar |
Enter | Field, panel open | Confirms what was typed and closes |
← → | Grid | Previous and next day |
↑ ↓ | Grid | Same day in the previous and next week |
PageUp PageDown | Grid | Previous and next month; with Shift, year |
Home End | Grid | Start and end of the week |
Enter Space | Grid | Chooses 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 | Chooses the value |
Tab | Field, panel open | Moves on to the next field and closes the panel |
Tab | Inside the panel | Moves between controls without leaving the panel; each time column is a single stop |
Esc | Field or panel | Closes, discards what wasn't confirmed and returns focus to the field |
Why Tab doesn't open it, and neither does Enter
Someone tabbing through a form to the save button shouldn't get a calendar in their face at every field, covering the next one — that's what stacked panels. And this is a text field inside a <form>: Enter there submits the form, which is what you expect after typing the date. That's why Space is what opens it, and only with the field empty, because with time you type 07/09/2026 14:30.
Clicking outside closes it without stealing focus from where you clicked; a half-chosen range is discarded on close, because there is
no such thing as half an interval, and the range that was already chosen comes back. With the Apply button — the default with time, or
autoApply: false —, day, time and preset stay pending: only Apply confirms and fires tucano:change,
once, and closing with Esc or a click outside discards the choice. Without it, each choice takes effect right away. With prefers-reduced-motion the panel only fades, without sliding.
API
Generated from the code on every build — if something is not here, it does not exist.
[data-tuc-datepicker]new Tucano.DatePicker(alvo, opcoes)data-format data-iso-name data-locale data-max data-min data-minute-step data-mode data-months data-native data-placement data-presets data-seconds data-time data-week-numbersgetValue setValue clear open close toggle destroytucano:changeOptions
The notes in this table come from comments in the source code, which are written in Portuguese.
| Option | Default | What for |
|---|---|---|
mode | 'single' | 'single' | 'range' |
time | false | true habilita seletor de hora |
seconds | false | |
minuteStep | 5 | |
locale | undefined | default: locale do documento/navegador |
format | undefined | default: padrao numerico do locale |
firstDayOfWeek | undefined | |
months | undefined | default: 2 em range, 1 em single |
min | null | |
max | null | |
disabledDates | null | (date) => boolean |
presets | false | atalhos de periodo (Hoje, Ultimos 7 dias...): opt-in |
autoApply | undefined | default: true sem hora, false com hora. Sem autoApply, a escolha so vale no Aplicar |
clearable | true | |
weekNumbers | false | |
placement | 'bottom-center' | centralizado no campo; as bordas da tela ainda mandam |
appendTo | undefined | |
isoName | undefined | name do input hidden com o valor ISO |
native | false | |
onChange | null | |
onOpen | null | |
onClose | null |