Date, range and time pickers, mini calendars and time displays.
Dates travel as plain ISO strings ("2026-10-21") and are always formatted with Intl in the visitor’s locale, so there are no time-zone off-by-one bugs and no date library.
12 parts
A field that shows the chosen date and opens a month grid in a popover. Values are ISO date strings.
import { useState } from 'react'import { DatePicker } from '@rchursin/product/calendar'
/** The field shows the date in the visitor's words; the value it hands back is a plain ISO string. */export default function DatePickerBasic() { const [shipDate, setShipDate] = useState<string | null>('2026-10-21')
return ( <div className="flex w-full max-w-72 flex-col gap-3"> <DatePicker label="Ship date" hint="The release goes out that morning." value={shipDate} onChange={setShipDate} /> <p className="font-mono text-[11px] text-fg-3">value: {shipDate === null ? 'null' : `"${shipDate}"`}</p> </div> )}A start and end date for report filters, with analytics presets, typed ends and two linked months.
import { useState } from 'react'import { RangePicker, type DateRange } from '@rchursin/product/calendar'
/** A report filter. The range changes only on Apply; Cancel or Esc keeps the one before. */export default function RangePickerBasic() { const [range, setRange] = useState<DateRange | null>({ start: '2026-10-01', end: '2026-10-21' })
return ( <div className="flex w-full max-w-80 flex-col gap-3"> <RangePicker label="Date range" hint="Runs that started in this range." name="range" value={range} onChange={setRange} /> <p className="font-mono text-[11px] text-fg-3">{range ? `start: "${range.start}" · end: "${range.end}"` : 'value: null'}</p> </div> )}A date typed in segments in the locale’s order, with a calendar button beside it. Paste "2026-10-21" to fill it.
import { useState } from 'react'import { DateField } from '@rchursin/product/calendar'
/** * Type the date: digits move on by themselves, ↑ ↓ step, and pasting "2026-10-21" fills all three * parts. The calendar button opens the same panel as the date picker. */export default function DateFieldBasic() { const [start, setStart] = useState<string | null>('2026-10-21') const [end, setEnd] = useState<string | null>(null)
return ( <div className="flex w-full max-w-64 flex-col gap-4"> <DateField label="Start date" value={start} onChange={setStart} /> <DateField label="End date" hint="Leave empty for no end." value={end} onChange={setEnd} /> <p className="font-mono text-[11px] text-fg-3"> start: {start ? `"${start}"` : 'null'} · end: {end ? `"${end}"` : 'null'} </p> </div> )}Day, month and year as three plain fields (the GOV.UK date input) for birthdays and dates people know by heart.
import { useState, type FormEvent } from 'react'import { MemorableDateInput, validateMemorableDate, type MemorableDate } from '@rchursin/product/calendar'import { Button } from '@rchursin/product/ui'
/** * A date people know by heart, as three plain fields. Nothing jumps ahead while you type, and * the month takes a name ("mar", "March") as well as a number. Checked when you continue. */export default function MemorableDateBasic() { const [dob, setDob] = useState<MemorableDate>({ day: '', month: '', year: '' }) const [submitted, setSubmitted] = useState(false) const check = validateMemorableDate(dob, { label: 'Date of birth', past: true })
function submit(e: FormEvent) { e.preventDefault() setSubmitted(true) }
return ( <form onSubmit={submit} noValidate className="flex w-full max-w-80 flex-col gap-5"> <MemorableDateInput legend="What is your date of birth?" value={dob} onChange={setDob} error={submitted ? check.error : undefined} errorFields={check.fields} autoComplete="bday" name="dob" /> <Button type="submit" className="self-start"> Continue </Button> <p role="status" className="-mt-2 min-h-4 font-mono text-[11px] text-fg-3"> {submitted && check.iso && `Reads as ${check.iso}`} </p> </form> )}A time of day from a list in 15-minute steps, in the locale’s hour cycle. End-time lists show each option’s length.
import { useState } from 'react'import { TimeSelect } from '@rchursin/product/calendar'
/** HH:mm in and out. The end list starts after the start time and shows each option's length. */export default function TimeSelectBasic() { const [start, setStart] = useState('10:00') const [end, setEnd] = useState('11:30')
function changeStart(next: string) { setStart(next) // Keep the end after the start: push it to an hour later when it would come first if (end <= next) { const [h = 0, m = 0] = next.split(':').map(Number) const later = Math.min(h * 60 + m + 60, 23 * 60 + 45) setEnd(`${String(Math.floor(later / 60)).padStart(2, '0')}:${String(later % 60).padStart(2, '0')}`) } }
return ( <div className="grid w-full max-w-80 grid-cols-2 gap-3"> <TimeSelect label="Starts" value={start} onChange={changeStart} latest="23:30" /> <TimeSelect label="Ends" value={end} onChange={setEnd} durationFrom={start} /> <p className="col-span-2 font-mono text-[11px] text-fg-3"> start: "{start}" · end: "{end}" </p> </div> )}One month as an ARIA grid with the APG date-picker keys. Every picker in the kit is built on it.
import { useId, useState } from 'react'import { CalendarGrid } from '@rchursin/product/calendar'import { parseISODate, shiftMonths } from '@rchursin/product/calendar/dates'import { formatMonth } from '@rchursin/product/lib/dates'import { IconButton } from '@rchursin/product/ui'
/** * The grid keeps no state. You own the focused date (it also decides the month on show) and the * value, and bring your own header. Click a date, or Tab in and use the arrow keys. */export default function CalendarGridBasic() { const titleId = useId() const [focused, setFocused] = useState('2026-10-21') const [value, setValue] = useState<string | null>('2026-10-21')
return ( <div className="flex flex-col gap-2"> <div className="flex items-center gap-0.5"> <p id={titleId} aria-live="polite" className="flex-1 pl-1 text-[13px] font-semibold tracking-[-0.01em] tabular-nums"> {formatMonth(parseISODate(focused) ?? new Date(2026, 9, 1))} </p> <IconButton icon="chevron-left" label="Previous month" size="sm" onClick={() => setFocused(shiftMonths(focused, -1))} /> <IconButton icon="chevron-right" label="Next month" size="sm" onClick={() => setFocused(shiftMonths(focused, 1))} /> </div> <CalendarGrid month={focused} focusedDate={focused} onFocusChange={setFocused} value={value} onSelect={setValue} labelledBy={titleId} /> <p className="pl-1 font-mono text-[11px] text-fg-3">value: {value ? `"${value}"` : 'null'}</p> </div> )}A small month for sidebars and widgets, with up to three squares under days that have events.
import { useState } from 'react'import { MiniCalendar, parseISODate } from '@rchursin/product/calendar'import { SampleBadge } from '@rchursin/product/shell/layout'
const agenda: Record<string, string[]> = { '2026-10-19': ['Sprint planning'], '2026-10-20': ['Pairing: retry backoff'], '2026-10-21': ['Design review', 'Agent eval run', 'Release notes'], '2026-10-22': ['1:1 with Ana'], '2026-10-27': ['Team demo', 'Cost report'], '2026-10-30': ['Quarterly review'],}const dayName = (iso: string, style: 'long' | 'short' = 'long') => new Intl.DateTimeFormat('en-US', { weekday: style, month: style, day: 'numeric' }).format(parseISODate(iso) ?? new Date(2026, 9, 21))
/** A sidebar month: squares under the days that have events, and the chosen day's list beside it. */export default function MiniCalendarBasic() { const [day, setDay] = useState('2026-10-21') const events = agenda[day] ?? []
Relative due dates for task rows (“Today”, “Fri”, “Oct 30”) with overdue shown by shape, and a picker with quick choices.
import { DueChip, StatusIcon, type StatusId } from '@rchursin/product/tasks'import { SampleBadge } from '@rchursin/product/shell/layout'
/** A fixed "now" (Wednesday, Oct 21, 2026, 10:00 AM), so this sample reads the same on any day */const now = new Date(2026, 9, 21, 10, 0)
const tasks: { id: string; title: string; due: string; status: StatusId }[] = [ { id: 'RC-41', title: 'Fix the flaky retry test', due: '2026-10-19', status: 'in_progress' }, { id: 'RC-44', title: 'Write the release notes', due: '2026-10-21', status: 'todo' }, { id: 'RC-45', title: 'Review the pricing copy', due: '2026-10-22', status: 'in_review' }, { id: 'RC-47', title: 'Ship the eval dashboard', due: '2026-10-23', status: 'todo' }, { id: 'RC-39', title: 'Archive old agent logs', due: '2026-10-20', status: 'done' }, { id: 'RC-52', title: 'Plan the Q1 roadmap', due: '2027-01-08', status: 'backlog' },]
/** * Relative and short in dense rows. Overdue swaps the calendar for an alert sign and a firm * outline (and says "overdue" to screen readers); a closed task's date goes quiet, never overdue. */export default function DueDateChips() { return ( <div className="flex w-full max-w-md flex-col gap-2"> <div className="flex items-center justify-between px-1"> <p className="font-mono text-[11px] tracking-[0.12em] text-fg-3 uppercase">Wed, Oct 21</p> <SampleBadge /> </div> <ul className="lux-card flex flex-col rounded-xl bg-raised"> {tasks.map((t) => ( <li key={t.id} className="flex h-10 min-w-0 items-center gap-2.5 border-b border-line px-3 text-[13px] last:border-0"> <StatusIcon status={t.status} /> <span className="hidden font-mono text-[11px] text-fg-3 sm:inline">{t.id}</span> <span className="min-w-0 flex-1 truncate">{t.title}</span> <DueChip due={t.due} status={t.status} now={now} /> </li> ))} </ul> </div> )}The clock face: Geist Mono digits in fixed cells, lit like the buttons, still or live, with a spoken form for screen readers.
import { MINUTE } from '@rchursin/product/lib/dates'import { TimeDisplay } from '@rchursin/product/timer'
/** One fixed cell per character, so nothing shifts as time passes. Lighter weight as it grows. */export default function TimeDisplaySizes() { return ( <div className="flex flex-col items-center gap-5"> <TimeDisplay ms={25 * MINUTE} size="lg" state="running" label="Focus, 25 minutes left" /> <div className="flex flex-wrap items-baseline justify-center gap-x-6 gap-y-3"> <TimeDisplay ms={25 * MINUTE} size="md" state="running" label="Medium" /> <TimeDisplay ms={25 * MINUTE} size="sm" state="running" label="Small" /> <TimeDisplay ms={25 * MINUTE} size="xs" state="running" label="Inline" /> </div> <TimeDisplay ms={65 * MINUTE + 9_000} size="sm" state="running" label="With hours" /> </div> )}A stopwatch with laps: a square that fills once a minute, hundredths drawn per frame, and Undo after a reset.
import { Stopwatch } from '@rchursin/product/timer'
/** * The whole tool. It keeps its own state in this browser (here under its own key, so it doesn't * share a run with the Timer app). Click its background, then Space starts and pauses, L laps, R resets. */export default function StopwatchApp() { return ( <div className="flex justify-center px-4 py-10 sm:px-8"> <Stopwatch storageKey="library:stopwatch" /> </div> )}A countdown with presets or a typed length, +1 min while it runs, a closing square and an honest finished banner.
import { Countdown } from '@rchursin/product/timer'
/** * The whole tool. Choose a length (or type one), start, add a minute while it runs. It keeps its * state in this browser, so a reload picks up the same deadline; if it ended while you were away, * the banner says how long ago. Click its background, then Space starts and pauses, M adds a minute, R resets. */export default function CountdownApp() { return ( <div className="flex justify-center px-4 py-10 sm:px-8"> <Countdown storageKey="library:countdown" /> </div> )}One field that reads a date, time or repeat out of plain English and shows what it understood before you save.
import { useState } from 'react'import { QuickCapture, type NewReminder } from '@rchursin/product/notes'import { Icon } from '@rchursin/product/icons/Icon'import { formatDay, formatTime } from '@rchursin/product/lib/dates'
/** Every relative word is measured from `now`. A fixed sample moment keeps this demo the same on any day: */const now = new Date(2026, 9, 21, 10, 0) // Wednesday, Oct 21, 2026, 10:00 AM
/** * Type it the way you'd say it. The words it understood are marked in the field, the reading sits * under it, and Enter saves the reminder without the date words. Try "Call Ana tomorrow at 9". */export default function QuickCaptureDemo() { const [added, setAdded] = useState<NewReminder[]>([])
return ( <div className="flex w-full max-w-xl flex-col gap-4"> <p className="font-mono text-[11px] tracking-[0.12em] text-fg-3 uppercase">Sample clock: Wed, Oct 21 · 10:00 AM</p> <QuickCapture now={now} onAdd={(r) => setAdded((list) => [r, ...list].slice(0, 4))} /> {added.length > 0 && ( <ul aria-label="Added in this demo (not saved)" className="flex flex-col gap-1.5 border-t border-line pt-3 text-[13px]"> {added.map((r, i) => ( <li key={i} className="flex min-w-0 items-center gap-2"> <Icon name={r.repeat ? 'repeat' : 'bell'} className="size-3.5 shrink-0 text-fg-3" /> <span className="min-w-0 flex-1 truncate">{r.text}</span> <span className="shrink-0 text-xs text-fg-3 tabular-nums"> {r.repeat ?? formatDay(r.at)} · {formatTime(r.at)} </span> </li> ))} </ul> )} </div> )}The release goes out that morning.
value: "2026-10-21"
Runs that started in this range.
start: "2026-10-01" · end: "2026-10-21"
Leave empty for no end.
start: "2026-10-21" · end: null
start: "10:00" · end: "11:30"
October 2026
| Sunday | Monday | Tuesday | Wednesday | Thursday | Friday | Saturday |
|---|---|---|---|---|---|---|
value: "2026-10-21"
| Sunday | Monday | Tuesday | Wednesday | Thursday | Friday | Saturday |
|---|---|---|---|---|---|---|
Wed, Oct 21
Sample dataWed, Oct 21
Sample data