Calendar
A month grid for the choice of one day.
<calendar.Calendar id="cal-chosen" name="day" value="2026-10-14" label="Day" />Installation
Run the command in the app module.
gx add calendarThe command writes these files.
| File | Path in the app |
|---|---|
Calendar.gx |
ui/calendar/Calendar.gx |
Calendar.fixtures.go |
ui/calendar/Calendar.fixtures.go |
CalendarGrid.ts |
ui/calendar/CalendarGrid.ts |
styles.go |
ui/calendar/styles.go |
Copy each file to its path in the app. Change each import of a registry package to the path of that package in the app.
package calendar
props {
// Id is the id of the date input. A label points at it.
Id string
// Name is the form field name. The form sends the day as YYYY-MM-DD.
Name string = ""
// Value is the chosen day of the first render, as YYYY-MM-DD.
Value string = ""
// Month is the month that the grid shows first, as YYYY-MM. Empty means the month of Value, or the month of today.
Month string = ""
// Min is the first day that a user can choose, as YYYY-MM-DD.
Min string = ""
// Max is the last day that a user can choose, as YYYY-MM-DD.
Max string = ""
// Label is the accessible name of the date input.
Label string = "Date"
// WeekStart is the first day of the week: Sunday or Monday.
WeekStart WeekStart = Sunday
// Locale is the language of the month and day names, as a BCP 47 tag. Empty means the language of the page.
Locale string = ""
// Display is the id of an element that shows the chosen day as text. A date picker sets it.
Display string = ""
// Popover is the id of a popover that closes when the user chooses a day. A date picker sets it.
Popover string = ""
// Class adds classes to the root element.
Class string = ""
// Attrs adds HTML attributes to the date input.
Attrs gx.Attrs = nil
}
<div data-slot="calendar" class={gx.Cx(rootClass, p.Class)}>
<input id={p.Id} name={p.Name} type="date" value={p.Value} min={p.Min} max={p.Max} aria-label={p.label()} class={inputClass} {...p.Attrs} />
<CalendarGrid input={p.Id} month={p.month()} weekStart={p.weekStart()} locale={p.Locale} display={p.Display} popover={p.Popover} previousLabel="Previous month" nextLabel="Next month" classes={gridClasses} load="eager" />
</div>package calendar
import "github.com/alternayte/gx"
var CalendarFixtures = gx.Fixtures[CalendarProps]{
"Chosen": {Id: "cal-chosen", Name: "day", Value: "2026-10-14", Label: "Day"},
"Empty": {Id: "cal-empty", Name: "day", Month: "2026-02", Label: "Day"},
"Monday": {Id: "cal-monday", Name: "day", Value: "2026-10-14", WeekStart: Monday, Label: "Day"},
"Limited": {Id: "cal-limited", Name: "day", Value: "2026-10-14", Min: "2026-10-10", Max: "2026-10-20", Label: "Day"},
"German": {Id: "cal-german", Name: "day", Value: "2026-10-14", WeekStart: Monday, Locale: "de", Label: "Tag"},
}import type { Mount, Props } from "./CalendarGrid.props";
// A day as the count of days from 1970-01-01, in UTC. A calendar date has no
// time zone, so every step here is whole days.
type Day = number;
const dayMs = 86400000;
const toDay = (iso: string): Day | null => {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
if (!m) return null;
const ms = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
const date = new Date(ms);
// 2026-02-31 is not a date.
return date.getUTCDate() === Number(m[3]) ? ms / dayMs : null;
};
const toISO = (day: Day): string => new Date(day * dayMs).toISOString().slice(0, 10);
const parts = (day: Day): { year: number; month: number; date: number } => {
const d = new Date(day * dayMs);
return { year: d.getUTCFullYear(), month: d.getUTCMonth(), date: d.getUTCDate() };
};
const fromParts = (year: number, month: number, date: number): Day => Date.UTC(year, month, date) / dayMs;
const daysIn = (year: number, month: number): number => new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
// addMonths moves a day by whole months and keeps the day of the month
// where the new month has it: 31 January plus one month is 28 February.
const addMonths = (day: Day, count: number): Day => {
const { year, month, date } = parts(day);
const first = new Date(Date.UTC(year, month + count, 1));
const y = first.getUTCFullYear();
const m = first.getUTCMonth();
return fromParts(y, m, Math.min(date, daysIn(y, m)));
};
const today = (): Day => {
const now = new Date();
return fromParts(now.getFullYear(), now.getMonth(), now.getDate());
};
const el = <K extends keyof HTMLElementTagNameMap>(tag: K, className: string, parent?: Element): HTMLElementTagNameMap[K] => {
const node = document.createElement(tag);
if (className) node.className = className;
parent?.appendChild(node);
return node;
};
// The island draws one month as a grid of days. The native date input with
// the id props.input stays the form control: the island writes the chosen
// day into it and sends its change event. With no script the page shows the
// date input of the browser.
const mount: Mount = (root, props: Props, ctx) => {
const input = document.getElementById(props.input);
if (!(input instanceof HTMLInputElement)) {
throw new Error(`no input with the id ${props.input}`);
}
const c = props.classes;
const locale = props.locale || document.documentElement.lang || undefined;
const min = toDay(input.min);
const max = toDay(input.max);
const allowed = (day: Day): boolean => (min === null || day >= min) && (max === null || day <= max);
let selected = toDay(input.value);
// focus is the day that takes the Tab key: the chosen day, or the first
// day of the month in the props, or today.
let focus: Day = selected ?? toDay(props.month + "-01") ?? today();
const hadClass = input.className;
input.className = c.hidden;
input.tabIndex = -1;
root.className = c.root;
const header = el("div", c.header, root);
const prev = el("button", c.nav, header);
prev.type = "button";
prev.setAttribute("aria-label", props.previousLabel);
prev.textContent = "‹";
const title = el("div", c.title, header);
title.id = props.input + "-month";
title.setAttribute("aria-live", "polite");
const next = el("button", c.nav, header);
next.type = "button";
next.setAttribute("aria-label", props.nextLabel);
next.textContent = "›";
const table = el("table", c.table, root);
table.setAttribute("role", "grid");
table.setAttribute("aria-labelledby", title.id);
const head = el("tr", "", el("thead", "", table));
const body = el("tbody", "", table);
const monthName = new Intl.DateTimeFormat(locale, { month: "long", year: "numeric", timeZone: "UTC" });
const dayName = new Intl.DateTimeFormat(locale, { dateStyle: "full", timeZone: "UTC" });
const weekdayShort = new Intl.DateTimeFormat(locale, { weekday: "short", timeZone: "UTC" });
const weekdayLong = new Intl.DateTimeFormat(locale, { weekday: "long", timeZone: "UTC" });
// 4 January 1970 is a Sunday.
for (let i = 0; i < 7; i++) {
const date = new Date((3 + props.weekStart + i) * dayMs);
const th = el("th", c.weekday, head);
th.scope = "col";
th.abbr = weekdayLong.format(date);
th.textContent = weekdayShort.format(date);
}
const render = (moveFocus: boolean): void => {
const { year, month } = parts(focus);
const first = fromParts(year, month, 1);
title.textContent = monthName.format(new Date(first * dayMs));
// The weekday of the first day, counted from the first day of the week.
const offset = (new Date(first * dayMs).getUTCDay() - props.weekStart + 7) % 7;
const count = daysIn(year, month);
const now = today();
body.replaceChildren();
let row: HTMLTableRowElement | null = null;
for (let cell = 0; cell < Math.ceil((offset + count) / 7) * 7; cell++) {
if (cell % 7 === 0) row = el("tr", "", body);
const td = el("td", c.cell, row!);
const date = cell - offset + 1;
if (date < 1 || date > count) continue;
const day = first + date - 1;
const button = el("button", c.day, td);
button.type = "button";
button.textContent = String(date);
button.dataset.day = toISO(day);
button.setAttribute("aria-label", dayName.format(new Date(day * dayMs)));
button.tabIndex = day === focus ? 0 : -1;
button.disabled = !allowed(day);
// The cell of a grid holds the selected state.
td.setAttribute("aria-selected", String(day === selected));
if (day === selected) button.className = c.day + " " + c.selected;
if (day === now) {
button.setAttribute("aria-current", "date");
if (day !== selected) button.className = c.day + " " + c.today;
}
}
if (moveFocus) body.querySelector<HTMLButtonElement>('button[tabindex="0"]')?.focus();
};
const select = (day: Day): void => {
if (!allowed(day)) return;
selected = day;
focus = day;
input.value = toISO(day);
render(true);
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
// A date picker shows the day in its trigger and closes its popover.
show();
if (popover?.matches(":popover-open")) popover.hidePopover();
};
// show writes the chosen day into the display element of a date picker.
const display = props.display ? document.getElementById(props.display) : null;
const popover = props.popover ? document.getElementById(props.popover) : null;
const show = (): void => {
if (!display || selected === null) return;
display.textContent = dayName.format(new Date(selected * dayMs));
display.setAttribute("data-empty", "false");
};
const options = { signal: ctx.abort };
prev.addEventListener("click", () => { focus = addMonths(focus, -1); render(false); }, options);
next.addEventListener("click", () => { focus = addMonths(focus, 1); render(false); }, options);
body.addEventListener(
"click",
(event) => {
const button = (event.target as Element).closest<HTMLButtonElement>("button[data-day]");
const day = button ? toDay(button.dataset.day!) : null;
if (day !== null) select(day);
},
options,
);
body.addEventListener(
"keydown",
(event) => {
const weekday = (new Date(focus * dayMs).getUTCDay() - props.weekStart + 7) % 7;
let target: Day;
switch (event.key) {
case "ArrowLeft": target = focus - 1; break;
case "ArrowRight": target = focus + 1; break;
case "ArrowUp": target = focus - 7; break;
case "ArrowDown": target = focus + 7; break;
case "Home": target = focus - weekday; break;
case "End": target = focus + 6 - weekday; break;
case "PageUp": target = addMonths(focus, event.shiftKey ? -12 : -1); break;
case "PageDown": target = addMonths(focus, event.shiftKey ? 12 : 1); break;
default: return;
}
event.preventDefault();
focus = target;
render(true);
},
options,
);
// A reset of the form, or code of the page, can change the input.
input.addEventListener(
"change",
() => {
const day = toDay(input.value);
if (day === selected) return;
selected = day;
if (day !== null) focus = day;
render(false);
},
options,
);
// The focus goes to the grid when the popover of a date picker opens.
popover?.addEventListener(
"toggle",
(event) => {
if ((event as ToggleEvent).newState === "open") render(true);
},
options,
);
render(false);
show();
return () => {
input.className = hadClass;
input.removeAttribute("tabindex");
root.replaceChildren();
root.className = "";
};
};
export default mount;// Package calendar is a month grid for one day: a native date input that
// the CalendarGrid island shows as a grid of days.
package calendar
// WeekStart is the first day of the week of a calendar.
type WeekStart string
// The first days of the week of calendar.Calendar.
const (
Sunday WeekStart = "sunday"
Monday WeekStart = "monday"
)
// CalendarGridProps are the props of the island that draws the grid. The
// island reads and writes the date input with the id Input.
type CalendarGridProps struct {
// Input is the id of the date input.
Input string `json:"input"`
// Month is the first month of the grid, as YYYY-MM, or "".
Month string `json:"month"`
// WeekStart is the first day of the week: 0 is Sunday, 1 is Monday.
WeekStart int `json:"weekStart"`
// Locale is the language of the names, or "".
Locale string `json:"locale"`
// Display is the id of an element that shows the chosen day, or "".
Display string `json:"display"`
// Popover is the id of a popover to close after a choice, or "".
Popover string `json:"popover"`
// PreviousLabel and NextLabel name the two month buttons.
PreviousLabel string `json:"previousLabel"`
NextLabel string `json:"nextLabel"`
// Classes holds the classes of the parts. They are Go constants, so
// the stylesheet build sees them.
Classes GridClasses `json:"classes"`
}
// GridClasses are the classes of the parts of the grid.
type GridClasses struct {
Root string `json:"root"`
Header string `json:"header"`
Nav string `json:"nav"`
Title string `json:"title"`
Table string `json:"table"`
Weekday string `json:"weekday"`
Cell string `json:"cell"`
Day string `json:"day"`
Selected string `json:"selected"`
Today string `json:"today"`
Hidden string `json:"hidden"`
}
// rootClass is the classes of the root.
const rootClass = "inline-block rounded-md border border-border bg-background p-3 text-foreground"
// inputClass is the classes of the date input with no script.
const inputClass = "h-9 rounded-md border border-input bg-transparent px-3 text-base shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 md:text-sm dark:bg-input/30"
// gridClasses are the classes that the island gives to its parts.
var gridClasses = GridClasses{
Root: "flex w-fit flex-col gap-3",
Header: "flex items-center justify-between gap-2",
Nav: "inline-flex size-8 items-center justify-center rounded-md border border-border bg-background text-lg leading-none shadow-xs outline-none hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
Title: "text-sm font-medium",
Table: "border-collapse",
Weekday: "size-8 p-0 text-center text-[0.8rem] font-normal text-muted-foreground",
Cell: "size-8 p-0 text-center",
Day: "inline-flex size-8 items-center justify-center rounded-md text-sm outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-40",
Selected: "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground",
Today: "bg-accent text-accent-foreground",
// The island keeps the date input as the form control and takes it out
// of view.
Hidden: "sr-only",
}
// label returns the accessible name of the date input; a zero value is
// "Date".
func (p CalendarProps) label() string {
if p.Label == "" {
return "Date"
}
return p.Label
}
// month returns the first month of the grid: Month, or the month of Value.
func (p CalendarProps) month() string {
if p.Month != "" {
return p.Month
}
if len(p.Value) >= 7 {
return p.Value[:7]
}
return ""
}
// weekStart returns the first day of the week as the island reads it.
func (p CalendarProps) weekStart() int {
if p.WeekStart == Monday {
return 1
}
return 0
}The theme must define these tokens: --background, --foreground, --border, --input, --ring, --primary, --primary-foreground, --accent, --accent-foreground, --muted-foreground.
Usage
<calendar.Calendar id="day" name="day" value={p.Day} />
<calendar.Calendar id="start" name="start" min="2026-10-10" max="2026-10-20" weekStart={calendar.Monday} />The component is a native <input type="date"> and an island that shows a month as a grid.
The island writes the chosen day into the input, so a form sends it as YYYY-MM-DD.
With no script the page shows the date input of the browser.
The month and day names follow Locale, or the language of the page.
Examples
Chosen
<calendar.Calendar id="cal-chosen" name="day" value="2026-10-14" label="Day" />Empty
<calendar.Calendar id="cal-empty" name="day" month="2026-02" label="Day" />Monday
<calendar.Calendar id="cal-monday" name="day" value="2026-10-14" weekStart={calendar.Monday} label="Day" />Limited
<calendar.Calendar id="cal-limited" name="day" value="2026-10-14" min="2026-10-10" max="2026-10-20" label="Day" />German
<calendar.Calendar id="cal-german" name="day" value="2026-10-14" weekStart={calendar.Monday} locale="de" label="Tag" />API reference
A tag sets a prop by its name with a lower-case first letter: Class is class.
calendar.Calendar
| Prop | Type | Default | Description |
|---|---|---|---|
Id |
string |
Required | Id is the id of the date input. A label points at it. |
Name |
string |
"" |
Name is the form field name. The form sends the day as YYYY-MM-DD. |
Value |
string |
"" |
Value is the chosen day of the first render, as YYYY-MM-DD. |
Month |
string |
"" |
Month is the month that the grid shows first, as YYYY-MM. Empty means the month of Value, or the month of today. |
Min |
string |
"" |
Min is the first day that a user can choose, as YYYY-MM-DD. |
Max |
string |
"" |
Max is the last day that a user can choose, as YYYY-MM-DD. |
Label |
string |
"Date" |
Label is the accessible name of the date input. |
WeekStart |
WeekStart |
Sunday |
WeekStart is the first day of the week: Sunday or Monday. |
Locale |
string |
"" |
Locale is the language of the month and day names, as a BCP 47 tag. Empty means the language of the page. |
Display |
string |
"" |
Display is the id of an element that shows the chosen day as text. A date picker sets it. |
Popover |
string |
"" |
Popover is the id of a popover that closes when the user chooses a day. A date picker sets it. |
Class |
string |
"" |
Class adds classes to the root element. |
Attrs |
gx.Attrs |
nil |
Attrs adds HTML attributes to the date input. |
Do and do not
Do
- Give the calendar a
Label, or a<label>for itsId. - Set
MinandMaxfor a range of valid days. The server checks the value too. - Pass
Valuefrom the loader for a day that depends on the time.
Don't
- Do not use a calendar for a date of birth. Use three inputs or one text input.
- Do not use two calendars with one
Id.
Keyboard
| Key | Action |
|---|---|
| Tab | Moves focus to the previous-month button, the next-month button, then the grid. |
| Arrow Left, Arrow Right | Moves one day back or forward. |
| Arrow Up, Arrow Down | Moves one week back or forward. |
| Home, End | Moves to the first or the last day of the week. |
| Page Up, Page Down | Moves one month back or forward. |
| Shift + Page Up, Shift + Page Down | Moves one year back or forward. |
| Enter, Space | Chooses the day that has the focus. |