Inputs

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { CalendarRange, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react"
import { cn } from "@/lib/utils"

/** Panel entrance + page swap. React 19 hoisted <style> — no Tailwind config edits. */
const KEYFRAMES = `@keyframes pp-panel-in{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}
@keyframes pp-page-in{from{opacity:0}to{opacity:1}}`

const MS_PER_WEEK = 7 * 24 * 60 * 60 * 1000
/** A week page is always six rows, so paging a month never resizes the panel. */
const WEEK_ROWS = 6
/** Years are paged in aligned blocks, the way a decade grid pages. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/period-picker.json

Prompt

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 max of 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-invalid plus 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.

On This Page