Skip to content
txbrendan/ui

Calendar

beta

A month grid for picking one date or a range, with keyboard moves, date limits, and names in the user's language.

Preview

today

SunMonTueWedThuFriSat

range

October 2026
SunMonTueWedThuFriSat
123
45678910
11121314151617
18192021222324
25262728293031

limits

October 2026
SunMonTueWedThuFriSat
123
45678910
11121314151617
18192021222324
25262728293031

locale

Oktober 2026
MoDiMiDoFrSaSo
1234
567891011
12131415161718
19202122232425
262728293031
أكتوبر ٢٠٢٦
سحنثرخج
١٢
٣٤٥٦٧٨٩
١٠١١١٢١٣١٤١٥١٦
١٧١٨١٩٢٠٢١٢٢٢٣
٢٤٢٥٢٦٢٧٢٨٢٩٣٠
٣١

size

October 2026
SunMonTueWedThuFriSat
123
45678910
11121314151617
18192021222324
25262728293031
October 2026
SunMonTueWedThuFriSat
123
45678910
11121314151617
18192021222324
25262728293031
October 2026
SunMonTueWedThuFriSat
123
45678910
11121314151617
18192021222324
25262728293031

disabled

October 2026
SunMonTueWedThuFriSat
123
45678910
11121314151617
18192021222324
25262728293031

Install

  1. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. Add the component.

    npx shadcn@latest add @txbrendan/calendar

Use it for

  • Picking a date or a range when people need to see the days around it: a booking, a deadline, a trip.
  • Give the calendar a name: aria-label, like "Check-in", or aria-labelledby with the id of a visible heading.
  • Values are date strings: "2026-10-20" for one date, and { start, end } of them for a range. A date string has no time and no time zone, so it never moves a day on its way between the browser, the server, and the database. A Date object carries both, which causes off-by-one-day bugs.
  • It works uncontrolled with defaultValue, even from a server component. Pass value with onValueChange to control it.
  • In range mode, the first pick sends a one-day range, and the second pick sends the full range. The next pick starts over.
  • A range people pick never covers a disabled date. After the first pick, days past the nearest one on each side cannot be picked. To start over, pick the first day again.
  • Set name to send the value with a form: "2026-10-20", or "2026-10-12/2026-10-16" for a range.
  • The server cannot know the user's date or language. It renders with no today mark, in en-US unless you pass locale, and the browser fills both in after hydration. Pass locale to skip that switch.
  • With no value, defaultMonth, or month, the server sends a blank grid of the same size, and the browser shows the current month.

Avoid it for

  • Dates far from today, like a birth date. Typing is faster there, so use an input.
  • Picking a time. This calendar picks days only.
  • Ranges that may cover a disabled date, like a stay over a holiday. After the first pick, a range stops at the nearest one.
  • Picking many separate dates. It picks one date or one range.

Props

  • mode

    Pick one date, or a start and an end date.

    "single" | "range" · default "single"

  • defaultValue

    The picked date at first render, as "YYYY-MM-DD". A { start, end } range in range mode.

    string | { start: string; end: string } | null

  • value

    Controlled value. Use it with onValueChange.

    string | { start: string; end: string } | null

  • onValueChange

    Called with the new value on each pick.

    (value: string | { start: string; end: string }) => void

  • min

    The earliest date people can pick, as "YYYY-MM-DD". Earlier days are disabled, and focus stops there.

    string

  • max

    The latest date people can pick. Later days are disabled, and focus stops there.

    string

  • disabledDates

    Days people cannot pick. They can still get focus, and a range people pick cannot cross one. A function only works from a client component.

    string[] | ((date: string) => boolean)

  • disabled

    Blocks picking and moving, and takes the calendar out of the tab order.

    boolean · default false

  • weekStartsOn

    The first column: 0 is Sunday, 1 is Monday. Defaults to the locale's custom.

    0 | 1 | 2 | 3 | 4 | 5 | 6

  • locale

    A language tag for month and day names, like "de-DE". Defaults to the browser's language.

    string

  • defaultMonth

    The month on screen at first render, as "YYYY-MM". Defaults to the picked date's month, or today's.

    string

  • month

    Controlled month, as "YYYY-MM". Use it with onMonthChange.

    string

  • onMonthChange

    Called with the new "YYYY-MM" when the month on screen changes.

    (month: string) => void

  • size

    Day size and text size. Days are as tall as controls of the same size. Density scales them.

    "sm" | "md" | "lg" · default "md"

  • tone

    Color of the picked days.

    "neutral" | "brand" | "danger" | "success" | "warning" · default "neutral"

  • name

    Form field name. Sends "YYYY-MM-DD", or "YYYY-MM-DD/YYYY-MM-DD" for a range.

    string

  • aria-label

    Names the calendar, like "Check-in". TypeScript requires this or aria-labelledby. The grid is already named by the month.

    string

  • aria-labelledby

    The id of a visible heading that names the calendar. Use it in place of aria-label.

    string

  • className

    Classes for the outer box. Later classes win, so className='p-0' drops the padding.

    string

Accessibility

  • TypeScript requires aria-label or aria-labelledby, so screen readers say what the date is for.
  • The days form a grid (role="grid") named by the month title. Each day is a gridcell named with its full date, like "Tuesday, October 20, 2026", in the calendar's language.
  • Weekday headers show short names, or one letter in languages whose short names are long, like Arabic. Each header keeps the full day name in its abbr attribute.
  • One day is in the tab order: the last focused day, else the picked day, else today, else the first day of the month.
  • Arrow keys move by day and by week. Home and End go to the start and end of the week. Page Up and Page Down move by month, and with Shift by year. Enter or Space picks the day.
  • Left and Right Arrow swap in right-to-left text, when a parent has dir="rtl".
  • Picked days have aria-selected, today has aria-current="date", and days people cannot pick have aria-disabled.
  • Disabled dates can still get focus, so people can hear them, but they cannot be picked. Days outside min and max cannot get focus.
  • In range mode, after the first pick, days past the nearest disabled date get aria-disabled until the second pick. They can still get focus, but Enter and clicks do nothing there.
  • The month buttons are named by the month they show, like "November 2026", so their names follow the locale. At min or max they set aria-disabled and keep focus.
  • The month title is a polite live region, so screen readers hear the new month.
  • Picked days use the tone's solid pair, and days inside a range use its soft pair. The theme tests hold both to 4.5:1.
  • Hovered days use the accent pair, and weekday names use muted text. The theme tests hold both to 4.5:1. The focus ring uses --ring, held to 3:1 on the page and cards.
  • Days people cannot pick are dimmed to half strength, and days from disabledDates are also struck through.
  • On a screen narrower than the calendar, the days get narrower to fit, so the page never scrolls sideways.

Examples

Pick a date

<Calendar aria-label="Due date" name="due" defaultValue="2026-10-20" />

A range between two limits

<Calendar
  aria-label="Trip"
  mode="range"
  tone="brand"
  min="2026-10-01"
  max="2026-12-31"
  defaultValue={{ start: "2026-10-12", end: "2026-10-16" }}
/>

Days people cannot pick

<Calendar
  aria-label="Delivery day"
  defaultMonth="2026-12"
  disabledDates={["2026-12-25", "2026-12-26"]}
/>

Controlled, in one language

"use client";
import { useState } from "react";

function DueDate() {
  const [date, setDate] = useState<string | null>(null);
  return <Calendar aria-label="Fällig am" locale="de-DE" value={date} onValueChange={setDate} />;
}

No weekends, from a client component

"use client";

// A "YYYY-MM-DD" string parses as midnight UTC, so getUTCDay gives its weekday.
const weekend = (date: string) => [0, 6].includes(new Date(date).getUTCDay());

function MeetingDay() {
  return <Calendar aria-label="Meeting day" disabledDates={weekend} />;
}

Show a picked date

// Format in UTC, the zone a date string parses in, so the day never moves.
const label = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" }).format(
  new Date("2026-10-20"),
);
// "Oct 20, 2026"