Skip to content
Gx
GitHub

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.

Terminal
gx add calendar

The 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.

ui/calendar/Calendar.gx
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>
ui/calendar/Calendar.fixtures.go
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"},
}
ui/calendar/CalendarGrid.ts
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;
ui/calendar/styles.go
// 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 its Id.
  • Set Min and Max for a range of valid days. The server checks the value too.
  • Pass Value from 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.