Calendar
A month-grid calendar over a model you supply — days, ranges and per-day states, named by an absolute day serial.
import { Calendar } from "@glint/components/calendar.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 380px; height: 360px; background: Tokens.color-background;
// 14 May 2026, on this host's own day scale — chrono's // `num_days_from_ce()` for that day. in-out property <int> chosen: 739750;
VerticalLayout { padding: 16px; alignment: center;
HorizontalLayout { alignment: center;
Rectangle { border-width: 1px; border-color: Tokens.color-border-hairline; border-radius: Tokens.radius-lg; background: Tokens.color-card;
VerticalLayout { // 1 May 2026 is a Friday, so the grid starts five cells in. Calendar { months: [{ label: "May 2026", day-count: 31, first-day-offset: 5, first-serial: 739737, }]; selected-serial <=> root.chosen; } } } } }}Usage
import { Calendar, CalendarMonth } from "@glint/components/calendar.slint";
export component AppWindow inherits Window { // Everything about a month is your date library's arithmetic: how long it // is, what weekday it opens on, and the serial of its first day. in property <[CalendarMonth]> months; in-out property <int> chosen;
callback page-back(); callback page-forward();
Calendar { months: root.months; selected-serial <=> root.chosen; day-selected(serial) => { root.chosen = serial; } // The component holds no date model, so paging is rolling yours. prev-month => { root.page-back(); } next-month => { root.page-forward(); } // If your range is bounded, say where it ends. can-prev-month: true; can-next-month: true; }}Slint 1.17 has no Date type and no date arithmetic, so the dates belong to
you. You describe each month you want drawn and the calendar renders the
grid, walks it, and reports what was picked. It formats nothing and assumes no
calendar system, which is what makes a Persian or Hijri month cost exactly
what a Gregorian one does.
A day is a serial. It is named by an absolute day number of your own,
counted on whatever scale your date library gives you: chrono’s
num_days_from_ce(), an epoch day count, anything monotonic and positive. A month carries the
serial of its first day and every cell after it counts on from there.
That one number is what lets a single comparison answer “is this day inside the range?” across a month boundary, across two months drawn side by side, and for a range end that is not on screen at all (ADR-0031). A day-of-month cannot: 3 June is not “after” 28 May by it.
Serial 0 is never a day, so it is the empty selection — and what a blank
leading or trailing cell carries, which is what makes those cells refuse a
click that would otherwise land in the next month.
CalendarMonth is one month of the grid:
| Field | What it is |
|---|---|
label | The caption, e.g. “May 2026” — it also names the month’s grid to assistive technology |
day-count | How many days the month has |
first-day-offset | Weekday of day 1, 0 = Sunday — the leading blanks |
first-serial | The serial of day 1, one past the last serial of the month before it |
week-numbers | ISO week numbers for the six rows; empty draws no column |
CalendarDay is what you know about one day that the grid cannot work out:
| Field | What it is |
|---|---|
disabled | An unavailable day: it dims, refuses every activation and reports itself disabled, while keeping its cell and its place in the month |
note | What is special about this day — “Booked”, “Public holiday”. It draws a dot under the number and rides the cell’s description |
day-states is a flat array indexed from the first rendered day, not a
set the cells search: Slint’s expression language has no loop, so membership
can only be answered by position (ADR-0031). It degrades well — a read past
the end is the default CalendarDay, so supplying fewer states than the grid
draws leaves the rest ordinary days.
Examples
Picking a day
CalendarMode.single — the default — is one selected day: each pick replaces
selected-serial and reports itself through day-selected(serial). Map the
serial back through the model you built it from.
import { Calendar, CalendarMode } from "@glint/components/calendar.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 380px; height: 400px; background: Tokens.color-background;
in-out property <int> chosen: 0;
VerticalLayout { padding: 16px; spacing: 12px; alignment: center;
HorizontalLayout { alignment: center;
Rectangle { border-width: 1px; border-color: Tokens.color-border-hairline; border-radius: Tokens.radius-lg; background: Tokens.color-card;
VerticalLayout { Calendar { mode: CalendarMode.single; months: [{ label: "May 2026", day-count: 31, first-day-offset: 5, first-serial: 739737, }]; selected-serial <=> root.chosen; } } } }
Text { text: root.chosen == 0 ? "No day selected." : "Selected: " + (root.chosen - 739736) + " May 2026 (serial " + root.chosen + ")"; color: Tokens.color-muted-foreground; font-size: Tokens.typography-body-sm-size; horizontal-alignment: center; } }}A range
CalendarMode.range takes two picks: the first opens the interval, the second
closes it and reports the pair through range-selected(from, to). They are
the ends in either order, so a user who draws backwards gets the range they
drew rather than a dead end, and picking again once a range is complete starts
the next one.
While an interval is forming, the day under the pointer is its open end —
preview-serial, which paints the band and is what a trigger shows before the
second pick lands. It is paint rather than state: a preview is something the
pointer is doing, so only a settled range announces itself.
import { Calendar, CalendarMode } from "@glint/components/calendar.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 380px; height: 420px; background: Tokens.color-background;
in-out property <int> from-day: 0; in-out property <int> to-day: 0; property <int> forming: cal.preview-serial;
VerticalLayout { padding: 16px; spacing: 12px; alignment: center;
HorizontalLayout { alignment: center;
Rectangle { border-width: 1px; border-color: Tokens.color-border-hairline; border-radius: Tokens.radius-lg; background: Tokens.color-card;
VerticalLayout { cal := Calendar { mode: CalendarMode.range; months: [{ label: "May 2026", day-count: 31, first-day-offset: 5, first-serial: 739737, }]; range-from <=> root.from-day; range-to <=> root.to-day; } } } }
Text { text: root.from-day == 0 ? "Pick the first day of the range." : (root.to-day != 0 ? "Booked " + (root.from-day - 739736) + " – " + (root.to-day - 739736) + " May" : (root.forming != 0 ? "Ends " + (root.forming - 739736) + " May?" : "Now pick the day it ends on.")); color: Tokens.color-muted-foreground; font-size: Tokens.typography-body-sm-size; horizontal-alignment: center; } }}Two months at once
Give months more than one entry and they are drawn side by side from the one
instance, each grid named for its own month. The serials have to be
consecutive across them — May’s 31 days run out at 739767 and June opens at
739768 — which is what makes a range whose ends sit in two different grids one
interval rather than two.
The chevrons page the whole strip, because you are the one rolling the model.
import { Calendar, CalendarMode } from "@glint/components/calendar.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 660px; height: 380px; background: Tokens.color-background;
VerticalLayout { padding: 16px; alignment: center;
HorizontalLayout { alignment: center;
Rectangle { border-width: 1px; border-color: Tokens.color-border-hairline; border-radius: Tokens.radius-lg; background: Tokens.color-card;
VerticalLayout { Calendar { mode: CalendarMode.range; months: [ { label: "May 2026", day-count: 31, first-day-offset: 5, first-serial: 739737 }, { label: "June 2026", day-count: 30, first-day-offset: 1, first-serial: 739768 }, ]; // 29 May to 5 June — one interval across two grids. range-from: 739765; range-to: 739772; } } } } }}Unavailable days, and days with a note
day-states says what you know about each day. A disabled day dims, refuses
the pointer, Enter and the accessible default action alike, and
still keeps its cell and its index — the way every Glint control refuses. A
note draws a dot under the number and is read after the day’s name, because
the accessibility tree has no property for “this day is special” (ADR-0013).
The array is indexed from the first rendered day, so its first entry is 1 May.
import { Calendar } from "@glint/components/calendar.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 380px; height: 400px; background: Tokens.color-background;
in-out property <int> chosen: 0;
VerticalLayout { padding: 16px; spacing: 12px; alignment: center;
HorizontalLayout { alignment: center;
Rectangle { border-width: 1px; border-color: Tokens.color-border-hairline; border-radius: Tokens.radius-lg; background: Tokens.color-card;
VerticalLayout { Calendar { months: [{ label: "May 2026", day-count: 31, first-day-offset: 5, first-serial: 739737, }]; // Index 0 is 1 May: the first two days are taken, and // the fourth is a holiday nobody is unavailable on. day-states: [ { disabled: true }, { disabled: true }, { disabled: false }, { note: "Public holiday" }, ]; selected-serial <=> root.chosen; } } } }
Text { text: "1 and 2 May refuse every activation path; 4 May carries a note."; color: Tokens.color-muted-foreground; font-size: Tokens.typography-body-sm-size; horizontal-alignment: center; wrap: word-wrap; } }}Paging, and the bounds
The calendar has no notion of a bound of its own — the year/month model is
yours — so a host that restricts a range says so with can-prev-month and
can-next-month. Both default to true, so an unbounded calendar is unchanged.
A chevron at a bound dims, fires nothing and reports itself disabled, the same
contract Pagination’s steps carry at the ends of
their range.
Here the model is three months and the chevrons roll it. Walking off the edge of the grid with the arrow keys pages too, and the focus lands on the day you aimed at once the new month arrives.
import { Calendar, CalendarMonth } from "@glint/components/calendar.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 380px; height: 440px; background: Tokens.color-background;
// Slint arrays do not grow and one cannot be built from another, so a // host picks between the months it prepared rather than assembling one. in-out property <int> month: 1; property <[CalendarMonth]> april: [ { label: "April 2026", day-count: 30, first-day-offset: 3, first-serial: 739707 }, ]; property <[CalendarMonth]> may: [ { label: "May 2026", day-count: 31, first-day-offset: 5, first-serial: 739737 }, ]; property <[CalendarMonth]> june: [ { label: "June 2026", day-count: 30, first-day-offset: 1, first-serial: 739768 }, ];
VerticalLayout { padding: 16px; spacing: 12px; alignment: center;
HorizontalLayout { alignment: center;
Rectangle { border-width: 1px; border-color: Tokens.color-border-hairline; border-radius: Tokens.radius-lg; background: Tokens.color-card;
VerticalLayout { Calendar { months: root.month == 0 ? root.april : (root.month == 1 ? root.may : root.june); // April is the first month this host has, June the last. can-prev-month: root.month > 0; can-next-month: root.month < 2; prev-month => { root.month -= 1; } next-month => { root.month += 1; } } } } }
Text { text: "April is the first month and June the last, so a chevron at either end is disabled."; color: Tokens.color-muted-foreground; font-size: Tokens.typography-body-sm-size; horizontal-alignment: center; wrap: word-wrap; } }}The caption dropdowns
CalendarCaption.label is the month’s own text. CalendarCaption.dropdown
replaces it with a month picker and a year picker, so a distant month is one
pick away instead of forty chevron clicks.
The dropdowns pick nothing: each reports the index it was given through
month-picked(index) / year-picked(index) and you roll months to match —
the same division can-prev-month already draws. Their open list is a card
drawn inside the calendar rather than a popup of its own, because the calendar
may already be inside one (ADR-0020), and closing it hands the keyboard to the
day grid, which is where a user picking a month was going.
import { Calendar, CalendarCaption } from "@glint/components/calendar.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 380px; height: 420px; background: Tokens.color-background;
in-out property <string> picked: "Take a month or a year from the caption.";
VerticalLayout { padding: 16px; spacing: 12px; alignment: center;
HorizontalLayout { alignment: center;
Rectangle { border-width: 1px; border-color: Tokens.color-border-hairline; border-radius: Tokens.radius-lg; background: Tokens.color-card;
VerticalLayout { Calendar { caption: CalendarCaption.dropdown; months: [{ label: "May 2026", day-count: 31, first-day-offset: 5, first-serial: 739737, }]; month-options: [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", ]; year-options: ["2024", "2025", "2026", "2027"]; month-index: 4; year-index: 2; month-picked(index) => { root.picked = "Month " + index + " — roll your model to it."; } year-picked(index) => { root.picked = "Year " + index + " — roll your model to it."; } } } } }
Text { text: root.picked; color: Tokens.color-muted-foreground; font-size: Tokens.typography-body-sm-size; horizontal-alignment: center; } }}Week numbers
A month that carries week-numbers prepends them as a leading column under
week-number-label; a month that carries none draws no column at all. There
are six entries, one per row of the grid, and they are yours for the same
reason the rest of the model is: an ISO week number is arithmetic the
component can no more do than it can name a month.
import { Calendar } from "@glint/components/calendar.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 400px; height: 360px; background: Tokens.color-background;
VerticalLayout { padding: 16px; alignment: center;
HorizontalLayout { alignment: center;
Rectangle { border-width: 1px; border-color: Tokens.color-border-hairline; border-radius: Tokens.radius-lg; background: Tokens.color-card;
VerticalLayout { Calendar { months: [{ label: "May 2026", day-count: 31, first-day-offset: 5, first-serial: 739737, week-numbers: [18, 19, 20, 21, 22, 23], }]; week-number-label: "Wk"; } } } } }}A Monday-first week
weekday-labels is the column header, left to right — seven entries in the
grid’s own order, which the defaults spell Sunday-first. Pass your own to
translate them, to spell them out, or to start the week on Monday: rotate the
labels and shift first-day-offset in your model to match, since the grid
counts the offset in the same order it draws the header.
Every other string the calendar ships is a property too, and they all default
through the CalendarStrings global below.
import { Calendar } from "@glint/components/calendar.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 440px; height: 360px; background: Tokens.color-background;
VerticalLayout { padding: 16px; alignment: center;
HorizontalLayout { alignment: center;
Rectangle { border-width: 1px; border-color: Tokens.color-border-hairline; border-radius: Tokens.radius-lg; background: Tokens.color-card;
VerticalLayout { Calendar { weekday-labels: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; months: [{ label: "May 2026", day-count: 31, // 1 May 2026 is a Friday: the fifth column of a // Sunday-first week, the fourth of this one. first-day-offset: 4, first-serial: 739737, }]; } } } } }}API Reference
Properties
| Property | Type | Default | Description |
|---|---|---|---|
months | in [CalendarMonth] | [ }] | The months to draw, left to right. One is the ordinary calendar; more than one is the two-month range view, paged as a strip. |
day-states | in [CalendarDay] | no default | Per-day states, indexed from the first rendered day — index 0 is months[0].first-serial. Shorter than the grid, or absent, is a grid of plain days: a read past the end is the default CalendarDay. |
weekday-labels | in [string] | CalendarStrings.weekday-initials | Column headers, left to right — seven entries matching the grid's Sunday-first order. Defaults to the English initials (translatable); pass your own for another language or a Monday-first week (rotate the labels and shift first-day-offset in your model to match). |
mode | in CalendarMode | CalendarMode.single | Whether a pick is a day or an end of an interval. |
selected-serial | in-out int | 0 | Two-way; the serial of the selected day, or 0 for none. A range calendar leaves it alone — its selection is the two ends below. |
range-from | in-out int | 0 | Two-way; the ends of the selected interval, or 0 for none. range-to is 0 while the range is still forming, which is what makes the next pick close the interval rather than start another. |
range-to | in-out int | 0 | |
focused-serial | in-out int | 0 | Two-way; where the keyboard stands. 0 falls back to the selection, and then to the first day on screen — so a consumer that never touches it gets a sensible starting cell, and one that does can place the walk. |
can-prev-month | in bool | true | Whether there is a month to page to in each direction. The component has no notion of a bound of its own — the year/month model belongs to the consumer, which is what lets a host page the calendar however it likes — so a host that restricts a range says so here. A chevron at a bound dims, fires nothing and reports itself disabled, the same contract Pagination's steps carry at the ends of their range. Both default to true, so a host that never sets them keeps two live chevrons and behaves exactly as before. |
can-next-month | in bool | true | |
prev-month-label | in string | CalendarStrings.previous-month | Accessible names for the two chevrons. The month is not in them: a screen reader reads the button's name, and "previous month" is what it does regardless of which month is on screen. |
next-month-label | in string | CalendarStrings.next-month | |
caption | in CalendarCaption | CalendarCaption.label | What stands between the chevrons: the months' own captions, or the month and year dropdowns. The dropdowns drive the view rather than one of its grids, so a strip of months has one pair of them and each grid keeps its own name in the accessibility tree. |
month-options | in [string] | no default | What the two dropdowns offer, and which entry each stands on. The component neither builds nor formats these — same division as the rest of the model — and it picks no month either: it reports the index and the host rolls months to match. |
year-options | in [string] | no default | |
month-index | in int | -1 | |
year-index | in int | -1 | |
month-picker-label | in string | CalendarStrings.month | The dropdowns' own names, as against the values they stand on. |
year-picker-label | in string | CalendarStrings.year | |
week-number-label | in string | CalendarStrings.week-number | Heading of the week-number column, for the months that carry one. |
in-range-note | in string | CalendarStrings.in-range | What a day between the two ends of a settled range announces. The tree has no property for "inside the selection", so it rides the cell's description (ADR-0013). |
auto-focus | in bool | false | Take the keyboard as the grid is built. A Panel rider cannot reach in and focus it — focus() does not cross the PopupWindow boundary (panel.slint), and calling a public function on content inlined into one is rejected outright — so the surface asks for it with a property instead. DatePicker sets it, which is what makes the arrows, Home/End, the pages and Enter reachable at all inside its popup. Same shape Command uses for its search field. |
preview-serial | out int | no default | The open end of a forming range: the day under the pointer while range-from is set and range-to is not. 0 the rest of the time — including over a complete range, which has both its ends already. It is what paints the preview, and what a trigger shows before the second pick lands. |
Callbacks
| Callback | Description |
|---|---|
day-selected(int) | Fired when the user picks a day, by pointer, by Enter or through the accessible default action. Carries the day's serial; a day the host disabled fires nothing at all. |
range-selected(int, int) | Fired when the second end of a range lands, with the two ends in order. A range calendar reports both: day-selected for each pick, this once the interval is whole. |
month-picked(int) | Fired with the index of the month or year taken from the caption dropdown. |
year-picked(int) | |
prev-month() | Fired when the previous-month chevron is clicked, unless it is at a bound. |
next-month() | Fired when the next-month chevron is clicked, unless it is at a bound. |
Enums
| Enum | Values |
|---|---|
CalendarMode | single, range |
CalendarCaption | label, dropdown |
CalendarStrings
Every string the calendar ships as a default, in one place. A composite
wrapping a Calendar cannot alias what is inside a PopupWindow, so
DatePicker declares the same properties and
defaults them from here — spelling them twice is how the two would drift
apart.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
weekday-initials | out [string] | no default | |
previous-month | out string | no default | |
next-month | out string | no default | |
week-number | out string | no default | |
month | out string | no default | |
year | out string | no default | |
in-range | out string | no default |
Accessibility
- A month is a table, and a day is one of its cells. Each grid carries the
tablerole under its ownlabel, and every day of the month is alist-itemnaming itself — “14 May 2026” — with its index and the month’s own day count, so assistive technology can say “14 of 31”. - Selecting a day is its default action, which is what lets a screen reader pick one without a pointer. The cell reports itself selected, and in a range both ends do.
- A blank is nothing at all. The leading and trailing cells outside the
month are in no accessibility tree, take no click, and carry serial
0— which the one refusal path turns away, so the six dead cells under May cannot pick June 1–6 by any route. - A disabled day stays in the grid. It keeps its cell and its index, reports itself disabled, and refuses the pointer, Enter and the accessible default action alike.
- What has no property rides the description. A day’s
noteand, inside a settled range,in-range-noteare read after the day’s name, because the tree has no property for either (ADR-0013). - The chevrons are named buttons.
prev-month-labelandnext-month-labelname them; the month is deliberately not in the name, because “previous month” is what the button does whichever month is on screen. At a bound a chevron dims, leaves the tab order and reports itself disabled. - The caption dropdowns are comboboxes, each named for what it is
(“Month”, “Year”) and reporting the value it stands on. Their open list is a
listwhose rows are its items, so a screen reader can say “5 of 12”. - The grid is one tab stop, not forty-two. ← / → move a day, ↑ / ↓ a week, Home / End reach the ends of the focused week without leaving the strip, and Page Up / Page Down are the chevrons under another name. Enter and Space pick whatever the walk landed on.
- A walk off the strip pages. The focus goes to the day you aimed at and the host’s model catches up; at a bound the move is refused outright, because nothing will be drawn there.
- Keyboard-only focus ring, around the day the arrows landed on rather than around the whole grid.