Period Picker
A whole-period picker — week, month, quarter or year — whose grid changes shape per granularity and whose value carries the resolved start and end instants.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/period-picker.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "PeriodPicker" component: a trigger button
plus a popover panel that picks a whole period — a week, a month, a quarter or a
year — and emits its two edges. lucide-react for icons, Intl.DateTimeFormat for
every string, no date library and no popover library.
Contract
- type Granularity = "week" | "month" | "quarter" | "year".
- interface Period { granularity; start: Date; end: Date; key: string; label:
string }. start is local midnight of the first day, end is 23:59:59.999 local
on the last day, so `start <= t && t <= end` is a complete filter and the
half-open form is `t < end + 1ms`. key is stable and sortable: "2026-W27",
"2026-07", "2026-Q3", "2026".
- Export periodFor(granularity, date, options?) so consumers can seed value /
defaultValue without re-deriving the edges themselves; it takes the same
weekStartsOn and locale the component takes.
- forwardRef<HTMLButtonElement> — the ref and every native prop that is not
listed below land on the trigger; className styles the outer box.
- Props extend Omit<React.ButtonHTMLAttributes<HTMLButtonElement>,
"value" | "defaultValue" | "onChange" | "disabled" | "type"> and add:
value?: Period | null / defaultValue?: Period | null,
onChange?: (period: Period | null) => void,
granularities?: Granularity[] (default all four, in tab order; one entry hides
the tab row), granularityLabels?: Partial<Record<Granularity, string>>,
min?: Date, max?: Date, disabledPeriods?: (period: Period) => boolean,
weekStartsOn?: 0 | 1 (default 1), locale?: string (default "en-US"),
now?: Date, clearable?: boolean (default false), align?: "start" | "end",
disabled?: boolean, name?: string, placeholder?: string.
- name renders three hidden inputs: name (the key), name_start and name_end as
local "YYYY-MM-DD" strings built from getFullYear/getMonth/getDate — never
toISOString(), which shifts the day for anyone east or west of UTC. A form-only
consumer gets the resolved range without any JavaScript of its own.
Behavior — resolving a period
- Every helper builds dates from local year/month/day parts and compares them the
same way; nothing goes through getUTC*, so a period never slides a day.
- month: [1st, last day]. quarter: index = floor(month / 3), so the span is
[index*3, index*3 + 2] and the label is "Q3 2026". year: [Jan 1, Dec 31].
- week: start = the given day walked back to weekStartsOn. Numbering follows the
convention the start day implies, from one formula: take a marker day
(start + 3 = Thursday for ISO, start + 6 = the last day for the US rule); the
marker's year owns the week; week 1 is the week holding Jan 4 (ISO) or Jan 1
(US); number = round((start - week1Start) / 7 days) + 1. Round, not floor: two
local midnights an integer number of weeks apart differ by ±1h across a DST
switch. That formula gives W53 where it exists and files Dec 29 2025 under
W1 2026, which is what an ISO consumer expects.
Behavior — the grid
- One page builder, four shapes: week = 6 rows x 1 column of the weeks starting
at the week that holds the 1st of the anchor month (always six, so paging never
resizes the panel; rows past the month render muted, like a calendar's outside
days); month = 12 cells in 3 columns; quarter = 4 tiles in 2 columns; year = an
aligned block of 12, floor(year / 12) * 12, so the block is stable whatever
year you arrive on.
- Two anchors, not one: the *value* is what was picked, the *view* is which page
is on screen. The view is `paged-to ?? value.start ?? now`, and it is dropped
when the panel closes so the next open follows a value that changed meanwhile.
A week is filed under the month holding its Thursday, so opening on the week of
Jun 29 2026 shows July, not June.
- Switching granularity re-frames the grid and commits nothing — the old value
stays until a cell is picked, so a tab press can never silently rewrite it.
- Availability is overlap, not containment: a period is refused when it ends
before min, starts after max, or disabledPeriods returns true. With max = today
the running quarter stays selectable and the emitted range is still the true
Jul 1 – Sep 30; clamp downstream if your query cannot exceed today.
- Selection is matched by containment (same granularity, value.start inside the
cell), so a hand-built Period whose start sits anywhere inside the period still
lights the right cell.
- The trigger re-derives the label of the current value through its own locale
and weekStartsOn instead of trusting the one baked into the Period, or a German
picker keeps printing the English label it was seeded with.
Behavior — keyboard and ARIA
- Trigger: click or ArrowDown opens and moves focus onto a cell; Escape closes,
stopPropagation() so a surrounding dialog does not also close, and returns
focus to the trigger; picking a cell closes and returns focus the same way.
- Granularity tabs are a radiogroup: Left/Right/Up/Down cycle and select, Home /
End jump to the ends, one tab stop via roving tabindex.
- Grid: Arrow keys move ±1 cell and ±one row (± the column count), Home / End to
the first / last selectable cell of the page, PageUp / PageDown turn the page
(a month, a year, twelve years) and keep the same cell index, Shift + PageUp /
PageDown move exactly one year whatever the page is, Enter / Space select.
Every move hops over refused periods by walking up to ~24 periods in the travel
direction and giving up if nothing is selectable.
- Roving tabindex: exactly one cell is tabbable — the focused period, else the
selection, else the period holding `now`, else the first selectable cell, else
the first cell. The grid is one tab stop; Tab leaves it, arrows move inside.
- ARIA: the panel is role="dialog" with its own label (non-modal, no focus trap);
the trigger carries aria-haspopup="dialog", aria-expanded and aria-controls
(only while open — a dangling id is an ARIA error); the page is role="grid"
labelled with its caption, rows are role="row", cells are role="gridcell"
wrappers with aria-selected around a <button> whose aria-label is the full
"Q3 2026, Jul 1 – Sep 30" plus ", selected" / ", unavailable" — the visible
text is only "Q3". aria-current="date" marks the period holding `now`.
- Refused periods keep aria-disabled and stay in the tree, struck through: a tile
that vanishes cannot be told apart from one that does not exist. Every guard
lives in the handler, so nothing relies on pointer-events for correctness.
- A value that breaks min / max / disabledPeriods is flagged, not swallowed: the
trigger gets aria-invalid plus a message ("Q1 2025 is not available.") wired
through aria-describedby, and the consumer's own aria-invalid /
aria-describedby are OR-ed and concatenated in rather than overwritten.
- Never the native disabled attribute on anything the user may be standing on:
the page arrows die at the min / max edge, Clear dies the instant it clears,
the "current period" jump dies when that period is refused. All of them use
aria-disabled + an early return, because the browser blurs a node to <body> the
moment it becomes disabled. `disabled` on the whole control is the same deal —
the trigger stays focusable and readable, and the panel's open state is derived
as `open && !disabled` so going inert cannot leave a live panel over a dead
trigger.
- A polite live region (role="status", visually hidden, always mounted) announces
the focused period while arrowing and the new caption when the page turns with
no focused cell.
Behavior — focus, pointers and cleanup
- A page swap re-creates every cell, so raise a ref flag in the handler and move
DOM focus in an effect after the commit; when the target cell is already on
screen, focus it synchronously and skip the flag. Every arrow move anchors the
view on its own target, so the cell it wants always exists after the swap.
- On the wrapper holding the trigger and the panel, a pointerdown that did NOT
land on something natively focusable (input/select/textarea/button/a[href]/
[tabindex]) is a press on the control's own surface — the panel's padding, the
gap between two tiles. preventDefault() it, which suppresses the compatibility
mouse event that would otherwise blur to <body>; on the trigger's surface also
focus the trigger. Without this an open panel loses its keyboard owner: Escape
is bound to the root and the arrows to the grid, so neither would see the key
again. Keep the handler off the root itself, or the message under the trigger
stops being selectable.
- Closing: a pointerdown outside the whole control closes it (that listener is
added only while open and removed on close and on unmount); focusout closes on
a real Tab-out only — relatedTarget outside the root — and does not steal focus
back, because relatedTarget is null for a press on bare surface and the
pointerdown handler already owns that case.
- `now` is injected. Without the prop it comes from useSyncExternalStore with a
server snapshot of null, so SSR and the first paint render with no notion of
"now" and React fills it in after hydration; the snapshot is midnight-today so
it stays stable within a render. The only other clock read is inside the open
handler, never on the render path.
Rendering & styling
- Semantic tokens only: trigger border + bg-background, bg-muted when inert,
border-destructive + text-destructive for the invalid state, panel bg-popover /
text-popover-foreground with a border and shadow, tab row bg-muted with the
active tab on bg-background, selected cell bg-primary / text-primary-foreground,
the current period ring-1 ring-primary, hover bg-accent / text-accent-foreground,
muted-foreground for outside and refused cells.
- cn() merges every className; focus-visible:ring-2 ring-ring on the trigger,
every tab, both page arrows, every cell and both footer actions.
- Cell geometry is the only thing that changes per shape: a week is a wide h-9 row
with its number left and its range right, a quarter is an h-14 tile stacking
"Q3" over "Jul – Sep", months and years are plain h-10 cells. tabular-nums
everywhere a number is read as a number.
- Motion is decoration: a 140ms panel fade-in and a 180ms page cross-fade, both
behind motion-reduce:[animation:none], plus motion-reduce:transition-none on
colour transitions and on the chevron flip. With motion off it simply appears.
Customization levers
- Which shapes: granularities picks and orders the tabs, and a single entry drops
the tab row entirely (a quarter-only picker is `["quarter"]`). Adding a shape
means one entry in the page builder plus its cell geometry — the keyboard, the
roving tabindex and the availability rules are shape-agnostic.
- Density: cells are h-9 / h-10 / h-14 inside a w-72 panel; drop a step for a
compact filter bar and nothing else has to move, because nothing is measured
in JS.
- Sub-blocks are independent: delete the footer (the current-period jump and
Clear), or replace the caption + arrows with month / year selects.
- Semantics of the edges: end is inclusive to the millisecond. If your API wants
a half-open range, emit `new Date(end.getTime() + 1)` in the onChange adapter
rather than changing the component — the grid, the labels and the hidden
inputs all read the inclusive form.
- Fiscal years: quarters here are calendar quarters. For a fiscal calendar, shift
the month index by the fiscal offset in the quarter branch of the period
builder and in its label; every consumer keeps working because the contract is
still start / end.
- Placement: align="start" | "end" flips the anchor edge; to escape a clipping
ancestor, mount the same panel in a portal — nothing in the logic assumes it is
a sibling.
- Tokens: recolour the selected cell to var(--chart-1) when the picker sits over
a chart that owns the same range, or switch the current-period ring to a dot.Concepts
- Period as the value — the commit is not a date but a resolved span: granularity, both edges and a sortable key, so the query layer never re-implements "last day of the quarter" or "which Monday starts ISO week 27".
- One engine, four shapes — granularity only decides how many cells sit on a row and how tall they are; the keyboard map, the roving tabindex and the availability rules are written once and inherited by every shape, which is why adding a fiscal half-year is a page-builder entry rather than a second component.
- Two anchors — the value is what was picked, the view is what is on screen; separating them lets you page to 2019 without touching the selection, and dropping the view on close makes the next open follow a value that changed while the panel was shut.
- Overlap, not containment — bounds refuse only periods that lie entirely outside the window, so a
maxof today leaves the running quarter selectable and the emitted range stays the true one instead of being silently clamped. - Refusal is a state, not a silence — an out-of-window value arriving through props lands on
aria-invalidplus a spoken reason, and blocked tiles stay visible and struck through, because a tile that disappears cannot be told apart from one that never existed. - Injected now — "this quarter" is measured from a prop, falling back to a post-hydration clock read with a null server snapshot; a render-time
new Date()would make the server and the visitor disagree about the current period and break hydration.
Datetime Picker
A calendar grid beside an hour/minute/second ladder that emits one Date — min and max bound the combined instant, not the day, so the max day narrows the hours.
Code Input
A monospace code field with an aligned line gutter, indent-aware Tab, bracket auto-close, regex syntax tinting and error markers fed by a diagnostics prop.