diff --git a/website/content/docs/accessibility.md b/website/content/docs/accessibility.md
new file mode 100644
index 0000000..2778637
--- /dev/null
+++ b/website/content/docs/accessibility.md
@@ -0,0 +1,101 @@
+---
+title: Accessibility
+description: Keyboard interactions, ARIA semantics, and localization built into $projectName.
+order: 3
+section: Overview
+---
+
+{% $projectName %} implements the
+[ARIA grid pattern](https://www.w3.org/WAI/ARIA/apg/patterns/grid/) for calendars, so the
+markup you compose is accessible before you write a line of ARIA yourself. This page
+describes what the library does for you — and the few things left in your hands.
+
+## Keyboard interactions
+
+Focus the grid, then:
+
+| Key | Action |
+| ------------------------------------- | ------------------------------------------------------------------------ |
+| `ArrowRight` / `ArrowLeft` | Move focus one day forward / back |
+| `ArrowDown` / `ArrowUp` | Move focus one week forward / back |
+| `Home` / `End` | Move focus to the first / last day of the week (respects `weekStartDay`) |
+| `PageDown` / `PageUp` | Move focus one month forward / back |
+| `Shift + PageDown` / `Shift + PageUp` | Move focus one year forward / back |
+| `Enter` / `Space` | Select the focused day |
+
+Movement is clamped to your `min`/`max` bounds — focus stops at the boundary instead of
+escaping the selectable window. When focus crosses a month boundary, the view follows
+automatically (MonthView pages; WeeksView scrolls its window).
+
+In a `readOnly` calendar, navigation still works but `Enter`/`Space` do nothing. In a
+`disabled` calendar, keyboard interaction is off entirely.
+
+## Focus management
+
+The grid uses a **roving tab index**: exactly one day cell is in the tab order at a time,
+so the calendar occupies a single tab stop in the page. The tab target is the selected
+day when there is one, otherwise today, otherwise the nearest sensible day in view.
+
+Pass `autoFocus` to `Grid` to move DOM focus into the grid when it mounts — useful when
+the calendar opens inside a popover:
+
+```tsx
+{/* … */}
+```
+
+## Semantics and labelling
+
+- `Grid` renders a `
`; `DayCellTemplate` renders
+ `` cells with `aria-selected` and `aria-disabled` reflecting state.
+- Each `DayButton` gets a full-date `aria-label` (e.g. "Saturday, June 20, 2026"),
+ localized with your `locale`.
+- `GridHeaderCell` renders abbreviated weekday text with the full weekday name as its
+ `aria-label`.
+- When you render a `MonthYearString`, the grid is automatically linked to it with
+ `aria-labelledby`, giving screen-reader users the "June 2026" context. Without one, the
+ grid falls back to `aria-label="Calendar"`.
+- `MonthYearString`, `DateString`, and `TimeString` render with `aria-live="polite"`, so
+ month navigation and selection changes are announced without stealing focus.
+- `PrevMonthButton` / `NextMonthButton` (and the WeeksView equivalents) carry descriptive
+ `aria-label`s and use the native `disabled` attribute at bounds, so assistive tech sees
+ real button semantics.
+
+## Outside and hidden days
+
+`outsideDays="hidden"` keeps the grid shape intact for assistive tech: hidden cells stay in
+the DOM as empty ` ` elements with `aria-hidden` (and a `data-hidden` styling hook)
+rather than being removed, so row and column counts remain consistent.
+
+## Range drag handles
+
+`RangeStartDragHandle` / `RangeEndDragHandle` are pointer affordances layered on top of the
+keyboard-accessible selection model. They expose `aria-roledescription="drag handle"`, a
+label for the boundary they control, and `aria-valuetext` with the current date — and they
+are `aria-hidden` while inactive. Because every range operation can also be performed by
+clicking or with the keyboard, dragging is an enhancement, not a requirement.
+
+## Localization
+
+- **`locale`** (BCP 47 string, default `"en-US"`) drives every formatted string: weekday
+ headers, month/year labels, and day `aria-label`s, all via `Intl.DateTimeFormat`.
+- **`weekStartDay`** (0 = Sunday … 6 = Saturday) reorders the grid _and_ the `Home`/`End`
+ keyboard behavior together, so visual and keyboard order never disagree.
+- **`timeZone`** (IANA identifier) controls which day is "today" and how values convert.
+ See [Dates & formats](/docs/dates-and-formats).
+
+```tsx
+
+ {/* Mo Di Mi Do Fr Sa So */}
+
+```
+
+## Your responsibilities
+
+Headless means a few things remain yours:
+
+- **Visible focus.** Style `:focus-visible` on `DayButton` (and the nav buttons) with a
+ clearly visible ring; the library manages _where_ focus goes, you make it _seen_.
+- **Color contrast.** Selected, in-range, disabled, and outside-month states are your
+ colors — keep them WCAG-compliant, and don't rely on color alone to convey selection.
+- **Hit targets.** Keep day buttons comfortably tappable (44×44 px is a good floor) if you
+ target touch devices.
diff --git a/website/content/docs/calendar-provider.md b/website/content/docs/calendar-provider.md
index 5247baf..624435b 100644
--- a/website/content/docs/calendar-provider.md
+++ b/website/content/docs/calendar-provider.md
@@ -1,18 +1,98 @@
---
title: CalendarProvider
description: Manages shared state across calendar views — selection, bounds, locale, and more.
-order: 2
+order: 20
section: Components
---
-## Props
+`CalendarProvider` is the state root of every calendar. It owns the selection (in all
+[three modes](/docs/selection-modes)), resolves configuration — bounds, time zone, locale,
+week start, the [Temporal implementation](/docs/dates-and-formats) — and shares everything
+with its descendants through context. It renders no DOM of its own.
+
+```tsx
+import { Temporal } from "@js-temporal/polyfill";
+import { CalendarProvider, MonthView } from "@klinking/colander";
+
+ console.log(range)}
+>
+ {/* navigation + grid */}
+ ;
+```
+
+## When you need it explicitly
+
+The `MonthView` and `WeeksView` wrappers already include a `CalendarProvider`, so most
+calendars never mention it. Reach for the explicit form when:
+
+- **Components outside the view need calendar state.** Anything using
+ `useCalendarStable()` / `useCalendarState()` must live under the provider — a selection
+ summary, preset-range buttons, a clear button.
+- **Two views should share one selection.** Render both roots under a single provider —
+ for example a month grid next to a scrolling weeks strip, always in sync:
+
+```tsx
+
+ {/* … */}
+ {/* … */}
+
+```
+
+- **You're building a custom view** from the grid primitives and hooks.
+
+{% callout type="warning" %}
+Don't nest a `MonthView`/`WeeksView` _wrapper_ inside a `CalendarProvider` — the wrapper
+creates its own provider, which would shadow yours. Inside an explicit provider, always use
+`MonthView.Root` / `WeeksView.Root`.
+{% /callout %}
+
+## What it manages
+
+- **Selection** — single / range / multiple, controlled or uncontrolled, exposed in your
+ configured value [format](/docs/dates-and-formats).
+- **Constraints** — `min`, `max`, `isDateDisabled`, `disabled`, `readOnly`.
+- **Range behavior** — `rangeMode`, `preventRangeReversal`, the hover preview and
+ `previewRange` override.
+- **Environment** — `temporal`, `timeZone`, `locale`, `weekStartDay`.
+
+What it does _not_ manage: focus, keyboard navigation, and which month/weeks are visible —
+those belong to the view roots ([MonthView](/docs/month-view), [WeeksView](/docs/weeks-view)),
+which is why the provider alone renders nothing interactive.
+
+## Reading state with hooks
+
+Two hooks expose the provider's context, split by volatility so components can subscribe
+to only what they need:
+
+- `useCalendarStable()` — configuration and stable callbacks (`onSelect`, `setRange`,
+ `selectionMode`, `minValue`, `maxValue`, `temporal`, `locale`, `timeZone`, …). Doesn't
+ change during normal interaction.
+- `useCalendarState()` — the live values (`selected`, `selectedDates`, `rangeStart`,
+ `rangeEnd`, `hoveredDate`, `previewStart`, `previewEnd`).
+
+```tsx
+function ClearButton() {
+ const { setRange, readOnly } = useCalendarStable();
+ const { rangeStart, rangeEnd } = useCalendarState();
+ if (!rangeStart || readOnly) return null;
+ // …
+}
+```
+
+## API reference
+
+### Props
{% api symbol="CalendarProviderProps" /%}
-## Stable Context
+### Stable context
{% api symbol="CalendarStableContextValue" /%}
-## State Context
+### State context
{% api symbol="CalendarStateContextValue" /%}
diff --git a/website/content/docs/composition.md b/website/content/docs/composition.md
new file mode 100644
index 0000000..58aebee
--- /dev/null
+++ b/website/content/docs/composition.md
@@ -0,0 +1,156 @@
+---
+title: Composition
+description: How $projectName components fit together — templates, the render prop, and building your own parts.
+order: 10
+section: Guides
+---
+
+{% $projectName %} is a set of compound components: small parts that you arrange in JSX to
+form a calendar. This page explains the component tree, the _template_ components that
+repeat themselves, the `render` prop, and how to drop down to hooks when you need a part
+the library doesn't ship.
+
+## The component tree
+
+```text
+CalendarProvider selection state, bounds, locale, time zone
+└─ MonthView.Root │ WeeksView.Root view state: visible month(s) / week window, focus
+ ├─ PrevMonthButton / NextMonthButton (or PrevWeeksButton / NextWeeksButton)
+ ├─ MonthYearString localized label, labels the grid
+ └─ Grid
+ ├─ GridHeader
+ │ └─ GridHeaderCell weekday labels (×7)
+ └─ GridBody
+ └─ WeekTemplate — repeated per visible week
+ ├─ WeekNumberCell optional week number
+ ├─ RangeSelected optional range overlay
+ ├─ RangePreview optional hover-preview overlay
+ └─ DayCellTemplate — repeated per day
+ └─ DayButton — the interactive day
+ ├─ RangeStartDragHandle optional
+ └─ RangeEndDragHandle optional
+```
+
+Two rules make the tree work:
+
+1. **State flows down through context.** `CalendarProvider` owns the selection;
+ the view root owns navigation and focus; grid parts read both. No prop drilling.
+2. **You write structure once; templates repeat it.**
+
+## Templates
+
+`WeekTemplate`, `DayCellTemplate`, and `GridHeaderCell` are _templates_: the single element
+you write is instantiated for every week, day, and weekday in view. This keeps a full
+calendar's JSX to a dozen lines while letting you customize the one repeated unit:
+
+```tsx
+
+
+
+
+
+
+
+```
+
+Each instance receives its own state — `DayCellTemplate` and `DayButton` know their `date`,
+`columnIndex`, and every selection flag for that specific day. You can also opt out of
+iteration: `DayCellTemplate date={someDate}` renders a single explicit cell, and
+`GridHeaderCell index={0}` renders just one weekday header.
+
+## Convenience wrappers vs. explicit provider
+
+`MonthView` and `WeeksView` fold a `CalendarProvider` and the corresponding `.Root` into
+one component — ideal for the common case:
+
+```tsx
+
+ {/* navigation + grid */}
+
+```
+
+Use the explicit form when you need to place the provider yourself — for example, to share
+one selection between two views, or to read calendar state from components that live
+outside the view:
+
+```tsx
+
+ {/* grids */}
+ {/* reads state via hooks, outside the view */}
+
+```
+
+Both forms accept the same props; the wrapper simply forwards view props to `.Root` and
+everything else to the provider.
+
+## The render prop
+
+Every component accepts a `render` prop (the same pattern as
+[Base UI](https://base-ui.com/react/handbook/composition)) for when `className` and CSS
+aren't enough. Pass a function that receives the merged DOM props and a typed `state`
+object, and return the element you want:
+
+```tsx
+ (
+
+ )}
+/>
+```
+
+Rules of thumb:
+
+- **Always spread `props`** onto your element — they carry the event handlers, ARIA
+ attributes, and `data-*` state the library manages.
+- **`state` is read-only and fully typed** per component (`DayButtonState`,
+ `WeekTemplateState`, `RangeSelectedState`, …). Every state also includes `state.root`
+ (a `RootState`) with calendar-wide values: `selected`, `rangeStart`/`rangeEnd`,
+ `viewing`, `focused`, `locale`, `timeZone`, and more.
+- Keep the element type semantically equivalent (a `` for cell parts, a `` for
+ interactive parts) so the grid semantics survive.
+
+## Building your own parts
+
+When the shipped parts aren't enough, use the same hooks and contexts they're built from:
+
+- `useCalendarStable()` — stable config and actions: `onSelect(date)`,
+ `setRange(start, end)`, `selectionMode`, `minValue`/`maxValue`, `temporal`, `locale`,
+ `timeZone`, `weekStartDay`.
+- `useCalendarState()` — live selection state: `selected`, `selectedDates`,
+ `rangeStart`/`rangeEnd`, `hoveredDate`, `previewStart`/`previewEnd`.
+- `useMonthViewState()` / `useWeeksViewState()` — view state: the visible month data
+ (`allMonths`) or the weeks window (`windowInfo`).
+- `DayCellDataContext` / `GridContext` — inside a cell, the cell's `date` and the grid's
+ `orientation`.
+
+For example, a footer that shows the current selection:
+
+```tsx
+function SelectionSummary() {
+ const { locale } = useCalendarStable();
+ const { rangeStart, rangeEnd } = useCalendarState();
+ if (!rangeStart) return Select a start date
;
+ return (
+
+ {rangeStart.toLocaleString(locale)}
+ {rangeEnd ? ` – ${rangeEnd.toLocaleString(locale)}` : " – …"}
+
+ );
+}
+```
+
+Any component using these hooks must be rendered inside the provider (and inside the view
+root for the view hooks).
+
+## Multiple months
+
+One `MonthView.Root` can display several consecutive months. Set `numberOfMonths`, then
+render one `Grid` per month with `monthIndex`:
+
+{% example file="multiple-months.tsx" /%}
+
+`MonthYearString monthIndex={i}` labels each grid; navigation buttons page the whole
+window. See [MonthView](/docs/month-view) for details.
diff --git a/website/content/docs/dates-and-formats.md b/website/content/docs/dates-and-formats.md
new file mode 100644
index 0000000..f0e18f2
--- /dev/null
+++ b/website/content/docs/dates-and-formats.md
@@ -0,0 +1,150 @@
+---
+title: Dates & formats
+description: Temporal, the temporal prop, value formats, subpath entry points, time zones, and locales.
+order: 13
+section: Guides
+---
+
+{% $projectName %} does its date math with the
+[Temporal API](https://tc39.es/proposal-temporal/docs/) — JavaScript's modern, immutable,
+time-zone-explicit date library. This page covers how to supply a Temporal implementation,
+how to choose what type your selected _values_ are, and how time zones and locales flow
+through the calendar.
+
+## Providing Temporal
+
+The library deliberately doesn't bundle a Temporal implementation. Pass one through the
+`temporal` prop on `CalendarProvider` (or the `MonthView`/`WeeksView` wrappers):
+
+```tsx
+import { Temporal } from "@js-temporal/polyfill";
+
+{/* … */} ;
+```
+
+Two well-supported polyfills:
+
+- [`temporal-polyfill`](https://github.com/fullcalendar/temporal-polyfill) — smaller
+ (~20 kB min+gzip), production-oriented.
+- [`@js-temporal/polyfill`](https://github.com/js-temporal/temporal-polyfill) — the
+ champions' reference-quality polyfill.
+
+If `temporal` is omitted, the library falls back to the native `globalThis.Temporal` and
+throws a descriptive error when neither exists. As runtimes ship Temporal natively, you
+delete the polyfill and the prop — nothing else changes.
+
+{% callout type="info" %}
+Whichever polyfill you choose, import it in **one place** and pass the same namespace to
+every calendar. Temporal objects from different implementations shouldn't be mixed.
+{% /callout %}
+
+## The `format` prop
+
+Internally the calendar always works in Temporal types. The `format` prop chooses the type
+of the values that cross the boundary to _your_ code — `value`, `defaultValue`, `min`,
+`max`, and the payloads of `onValueChange`:
+
+| `format` | Value type | Use when |
+| ------------------------- | ------------------------- | --------------------------------------------------------------------------------- |
+| `"PlainDate"` _(default)_ | `Temporal.PlainDate` | You need a calendar date, nothing more. The right default. |
+| `"PlainDateTime"` | `Temporal.PlainDateTime` | A date with a wall-clock time (existing time is preserved when the date changes). |
+| `"ZonedDateTime"` | `Temporal.ZonedDateTime` | A precise instant in a time zone. |
+| `"PlainYearMonth"` | `Temporal.PlainYearMonth` | Month-granularity values. |
+| `"PlainMonthDay"` | `Temporal.PlainMonthDay` | Recurring dates like birthdays. |
+| `"object"` | `{ year, month, day, … }` | Framework-agnostic plain objects (serialization, form state). |
+| `"Date"` | `Date` | Interop with legacy code that speaks `Date`. |
+
+```tsx
+ {
+ // value: Temporal.ZonedDateTime | null
+ }}
+>
+ {/* … */}
+
+```
+
+The `format` only shapes the _props and callbacks_. Render-prop `state` objects and hook
+values always expose plain Temporal types (`Temporal.PlainDate` days, etc.) regardless of
+format, so component code stays uniform.
+
+## Format-narrowed entry points
+
+The main entry point types values with a generic parameter. If you'd rather have the types
+pre-narrowed — and skip passing `format` — import from a format subpath:
+
+```tsx
+import {
+ MonthView,
+ type DateRange, // already DateRange<"PlainDate">
+} from "@klinking/colander/plain-date";
+```
+
+Available subpaths: `/plain-date`, `/plain-date-time`, `/plain-month-day`,
+`/plain-year-month`, `/zoned-date-time`, `/object`, and `/date`. Each re-exports the whole
+API with `CalendarProvider`, `MonthView`, `WeeksView`, and the value types bound to that
+format.
+
+## Bounds and disabled dates
+
+`min` and `max` (in your value format) disable everything outside them, and keyboard focus
+is clamped to the bounds. `isDateDisabled` handles irregular rules and always receives a
+`Temporal.PlainDate`:
+
+```tsx
+ holidays.has(date.toString())}
+>
+ {/* … */}
+
+```
+
+Bounds restrict _selection_. Whether users can still scroll the view past them is the
+per-view `outOfRangeBehavior` prop — see [MonthView](/docs/month-view) and
+[WeeksView](/docs/weeks-view).
+
+## Time zones
+
+`timeZone` (an IANA identifier, defaulting to the system zone) determines:
+
+- which day is highlighted as **today**,
+- how partial or zoned values convert to grid days,
+- the zone of emitted `ZonedDateTime` values.
+
+Plain formats like `PlainDate` are zone-independent by nature — selecting June 20 means
+June 20, no matter where the user is. That's most of the reason to prefer them.
+
+## Locale and week start
+
+`locale` (BCP 47, default `"en-US"`) localizes weekday headers, month/year labels, and day
+`aria-label`s via `Intl.DateTimeFormat`. It does **not** change the week's first day —
+that's explicit, so it never surprises you:
+
+```tsx
+
+ {/* lun. mar. mer. jeu. ven. sam. dim. */}
+
+```
+
+`weekStartDay` takes `0` (Sunday, default) through `6` (Saturday) and consistently drives
+the grid column order, week-number calculations, and `Home`/`End` keyboard navigation.
+
+## Displaying values
+
+For formatted output inside the calendar, use the built-in string components — they read
+the calendar's `locale` and accept `Intl.DateTimeFormat` options:
+
+```tsx
+
+
+
+```
+
+Outside the calendar, Temporal values format themselves:
+`date.toLocaleString("de-DE", { dateStyle: "long" })`.
diff --git a/website/content/docs/getting-started.md b/website/content/docs/getting-started.md
deleted file mode 100644
index e49960c..0000000
--- a/website/content/docs/getting-started.md
+++ /dev/null
@@ -1,21 +0,0 @@
----
-title: Getting Started
-description: Install $projectName and start building accessible calendar components.
-order: 1
-section: Guides
----
-
-## Installation
-
-{% install-cmd /%}
-
-## Basic Usage
-
-{% $projectName %} provides two calendar views that share state via `CalendarProvider`:
-
-- **MonthView** — Traditional month grid
-- **WeeksView** — Continuous scrolling weeks
-
-## Quick Example
-
-{% example file="basic-calendar.tsx" /%}
diff --git a/website/content/docs/introduction.md b/website/content/docs/introduction.md
new file mode 100644
index 0000000..343d593
--- /dev/null
+++ b/website/content/docs/introduction.md
@@ -0,0 +1,124 @@
+---
+title: Introduction
+description: What Colander is, the ideas behind it, and how it compares to other React date pickers.
+order: 1
+section: Overview
+---
+
+{% $projectName %} is a library of unstyled React components for building calendars and
+date pickers. Instead of shipping a finished date picker with a theme to override, it gives
+you the primitives — grids, day cells, navigation buttons, range overlays — that you compose
+and style yourself. You own every pixel; {% $projectName %} owns the date math, keyboard
+navigation, selection logic, and accessibility semantics.
+
+```tsx
+
+ ‹
+
+ ›
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+## Core ideas
+
+### Headless by design
+
+Every component renders a plain semantic element (``, ``, ``, …) with no
+CSS attached. You style with any tool you already use — plain CSS, Tailwind, CSS-in-JS —
+through three hooks the library exposes:
+
+- **`className`** — every component accepts one, like any React element.
+- **`data-*` attributes** — interaction state is stamped onto the DOM
+ (`data-selected`, `data-today`, `data-in-range`, `data-disabled`, …), so most styling is
+ just CSS attribute selectors.
+- **The `render` prop** — swap out the rendered element entirely and receive a fully typed
+ `state` object, following the same pattern as [Base UI](https://base-ui.com).
+
+See the [Styling guide](/docs/styling) for the full picture.
+
+### Temporal-first
+
+{% $projectName %} is built on the [Temporal API](https://tc39.es/proposal-temporal/docs/) —
+the modern replacement for JavaScript's `Date`. Selected values are precise, immutable
+Temporal objects (`Temporal.PlainDate` by default) instead of `Date` instances that secretly
+carry a time and a time zone. That eliminates the classic date picker bug class: off-by-one
+days caused by implicit UTC/local conversions.
+
+You choose the value type per calendar with the `format` prop — `PlainDate`,
+`PlainDateTime`, `ZonedDateTime`, a plain object, or even a legacy `Date` if you're
+integrating with existing code. The library doesn't bundle a Temporal implementation; you
+pass one in (a ~20 kB polyfill today, the built-in `Temporal` as runtimes ship it). See
+[Dates & formats](/docs/dates-and-formats).
+
+### Two views, one state model
+
+- **[MonthView](/docs/month-view)** — the traditional paged month grid, including
+ multi-month layouts (up to 12 side by side).
+- **[WeeksView](/docs/weeks-view)** — a continuously scrolling window of week rows that
+ spans month boundaries, like the mini-calendars in Google Calendar or Fantastical. You
+ control how many weeks are visible and scroll by row or by page.
+
+Both views plug into the same [CalendarProvider](/docs/calendar-provider) state: selection
+mode, bounds, locale, and time zone are shared, so you can even render both views of the
+same selection at once.
+
+### Selection built for real products
+
+Single, multiple, and range selection are all first-class, each with controlled and
+uncontrolled modes. Range selection goes well beyond click-twice: a live hover preview,
+six configurable policies for what a click inside an existing range means (`rangeMode`),
+and draggable range-boundary handles. See [Selection modes](/docs/selection-modes).
+
+### Accessible by default
+
+The grid follows the ARIA grid pattern: roving tab index, arrow-key navigation, `Home` /
+`End` / `PageUp` / `PageDown`, `aria-selected` and `aria-disabled` on day cells, and month
+labels announced via a live region. Details in [Accessibility](/docs/accessibility).
+
+## Why not another date picker?
+
+Plenty of good date pickers exist. {% $projectName %} makes a different set of trade-offs:
+
+| If you're considering… | How {% $projectName %} differs |
+| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **react-day-picker** | Similar spirit (composable, customizable), but react-day-picker works in `Date` objects and ships default styles to override. {% $projectName %} is Temporal-typed end-to-end, fully unstyled, and adds the continuously scrolling WeeksView, range drag handles, and hover previews as primitives. |
+| **react-datepicker** | A batteries-included widget: fast to drop in, hard to restyle deeply. {% $projectName %} is the opposite — more assembly, unlimited control over markup and design. |
+| **MUI X Date Pickers** | Excellent inside a Material UI app; heavy outside one. {% $projectName %} has no design-system dependency and pairs with any styling stack. |
+| **React Aria (hooks)** | Comparable headless philosophy and strong a11y. React Aria uses its own `@internationalized/date` objects and a hooks-first API; {% $projectName %} uses standard Temporal types and a component/compound API, so markup composition stays in JSX. |
+
+Choose {% $projectName %} when you're building a **design-system-grade calendar** — a
+component you'll style precisely, extend with custom cells or overlays, and keep for years —
+and you want date values that are actually dates.
+
+## When _not_ to use it
+
+Honesty saves you an afternoon:
+
+- **You want a finished picker in ten minutes.** {% $projectName %} has no default
+ stylesheet. If you don't want to write styles, use a styled library.
+- **You can't add a Temporal polyfill.** Until `Temporal` lands natively in the runtimes you
+ target, you'll ship a small polyfill (see the [Quick start](/docs/quick-start)).
+
+{% callout type="warning" %}
+{% $projectName %} is **pre-stable**. The API is still evolving and releases are published
+to the `alpha` npm dist-tag. Pin your version and read release notes when upgrading. The
+first stable release will be `3.0.0`.
+{% /callout %}
+
+## Next steps
+
+- [Quick start](/docs/quick-start) — install and build your first calendar.
+- [Composition](/docs/composition) — how the pieces fit together.
+- [Styling](/docs/styling) — the data-attribute and render-prop styling model.
diff --git a/website/content/docs/month-view.md b/website/content/docs/month-view.md
index ab2cb95..dc5252d 100644
--- a/website/content/docs/month-view.md
+++ b/website/content/docs/month-view.md
@@ -1,15 +1,117 @@
---
title: MonthView
-description: Displays a traditional calendar grid with month-level navigation.
-order: 3
+description: The traditional paged month grid, with multi-month layouts and month-level navigation.
+order: 21
section: Components
---
-## Props
+`MonthView` displays one or more month grids and pages between them — the classic date
+picker layout. It manages the visible month(s), keyboard focus, and navigation; selection
+state comes from its [CalendarProvider](/docs/calendar-provider).
+
+Use `MonthView` directly for the common case (it wraps a provider for you), or
+`CalendarProvider` + `MonthView.Root` when you compose the provider yourself:
+
+{% example file="basic-calendar.tsx" /%}
+
+## Navigation
+
+- `PrevMonthButton` / `NextMonthButton` page the view one month at a time and disable
+ themselves at the bounds when `outOfRangeBehavior="stop"`.
+- `MonthYearString` renders the localized current month label (and labels the grid for
+ assistive tech).
+- Keyboard: `PageUp`/`PageDown` page months, `Shift+PageUp`/`Shift+PageDown` page years,
+ and arrowing past the grid edge moves the view automatically
+ ([full list](/docs/accessibility)).
+
+### Controlled month
+
+By default the view manages the visible month itself, starting at the selection or today
+(`defaultMonth` overrides the start). To control it — syncing with a URL, an agenda pane,
+or a "jump to date" input — pass `month` and update it in `onMonthChange`:
+
+```tsx
+const [month, setMonth] = useState(() =>
+ Temporal.PlainYearMonth.from("2026-06"),
+);
+
+
+ {/* … */}
+ ;
+```
+
+`month` is an ISO `Temporal.PlainYearMonth` and round-trips with `onMonthChange` exactly;
+`onMonthChange` also fires for keyboard-driven month crossings, but never on mount.
+
+## Multiple months
+
+Set `numberOfMonths` (1–12) and render one `Grid monthIndex={i}` per month. Navigation
+still moves by single months, revealing one new month per click:
+
+{% example file="multiple-months.tsx" /%}
+
+Give each grid its own label with `MonthYearString monthIndex={i}`. Range selection spans
+grids naturally — a range can start in one month and end in another.
+
+## Grid shape
+
+- **`fixedWeeks`** — always render 6 week rows, padding with adjacent-month days.
+ February 2026 (4 rows) and August 2026 (6 rows) take the same height, so nothing below
+ the calendar jumps as users page.
+- **`outsideDays`** — what to do with the adjacent-month days that pad the first and last
+ weeks:
+
+| Value | Behavior |
+| ----------------------- | ------------------------------------------------------------------------ |
+| `"enabled"` _(default)_ | Fully interactive; clicking one selects it |
+| `"readOnly"` | Visible but not selectable; range highlighting still paints through |
+| `"disabled"` | Visible but not selectable; no range highlighting |
+| `"hidden"` | Blank cells (kept in the DOM with `aria-hidden` for a stable grid shape) |
+
+Outside days carry `data-outside-month` (and `data-hidden` when hidden) for styling.
+
+## Bounds
+
+`min`/`max` disable out-of-range days everywhere. `outOfRangeBehavior` additionally decides
+whether the user can still _page_ past them:
+
+- `"unbounded"` _(default)_ — page freely; out-of-range days simply render disabled.
+- `"stop"` — `PrevMonthButton`/`NextMonthButton` disable once the destination month
+ crosses a bound.
+
+## Week numbers
+
+Add a `WeekNumberHeader` to the header row and a `WeekNumberCell` at the start of the week
+template to show ISO week numbers (determined by each row's Thursday, per ISO 8601):
+
+```tsx
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+## API reference
+
+### Props
+
+`MonthView` accepts all [CalendarProvider](/docs/calendar-provider) props plus the
+view props below.
{% api symbol="MonthViewRootProps" /%}
-## Hooks
+### Hooks
+
+Inside a `MonthView`, `useMonthViewStable()` and `useMonthViewState()` expose the view's
+context for custom components:
{% api symbol="MonthViewStableContextValue" /%}
diff --git a/website/content/docs/quick-start.md b/website/content/docs/quick-start.md
new file mode 100644
index 0000000..0d12619
--- /dev/null
+++ b/website/content/docs/quick-start.md
@@ -0,0 +1,173 @@
+---
+title: Quick start
+description: Install $projectName and build a working, styled calendar in a few minutes.
+order: 2
+section: Overview
+---
+
+This walkthrough takes you from an empty file to a working, styled month calendar.
+
+## Install the package
+
+Install {% $packageName %} and a Temporal polyfill in your React project:
+
+{% install-cmd /%}
+
+{% $projectName %} has three peer dependencies — `react`, `react-dom` (18+), and
+`@base-ui/react` — which your package manager installs automatically. The Temporal polyfill
+is explicit on purpose: the library doesn't bundle one, so you control which implementation
+you ship (see [Dates & formats](/docs/dates-and-formats) for the options).
+
+{% callout type="info" %}
+Releases are currently published to the **`alpha`** dist-tag while the API stabilizes —
+`@klinking/colander@alpha` installs the latest prerelease.
+{% /callout %}
+
+## Provide Temporal
+
+Every calendar needs a `Temporal` implementation. Import it once and pass it via the
+`temporal` prop:
+
+```tsx
+import { Temporal } from "@js-temporal/polyfill";
+
+{/* … */} ;
+```
+
+If the runtime already exposes the native `Temporal` global, the prop can be omitted.
+
+## Assemble the calendar
+
+{% $projectName %} components are compound parts you compose in JSX, so your markup mirrors
+the calendar's actual structure. Here is a complete single-month calendar with navigation:
+
+{% example file="basic-calendar.tsx" /%}
+
+A quick tour of the parts:
+
+- **`MonthView`** — the all-in-one root. It owns selection state and month navigation, and
+ reports selection through `onValueChange`. (Under the hood it composes a
+ [CalendarProvider](/docs/calendar-provider) with a `MonthView.Root` — you can also use
+ those two directly when you need more control.)
+- **`PrevMonthButton` / `NextMonthButton`** — ``s that page the visible month and
+ disable themselves at your `min`/`max` bounds.
+- **`MonthYearString`** — a localized "June 2026" label, wired to the grid via
+ `aria-labelledby` and announced politely when the month changes.
+- **`Grid`, `GridHeader`, `GridHeaderCell`, `GridBody`** — the calendar table. A single
+ `GridHeaderCell` with no `index` renders all seven localized weekday headers.
+- **`WeekTemplate` / `DayCellTemplate` / `DayButton`** — _templates_: you write one row,
+ one cell, and one button, and the library stamps them out for every week and day in view.
+
+## Read the selection
+
+`onValueChange` receives the new value in your configured format — a
+`Temporal.PlainDate | null` by default:
+
+```tsx
+function BookingForm() {
+ const [date, setDate] = useState(null);
+
+ return (
+ <>
+
+ {date ? date.toLocaleString() : "Pick a date"}
+ >
+ );
+}
+```
+
+The example above is _uncontrolled_ (the calendar keeps its own state). To control it, pass
+`value` and update it from `onValueChange` — see
+[Selection modes](/docs/selection-modes) for the full rules, plus range and multi-select.
+
+## Style it
+
+Nothing you've rendered so far has any appearance — that's yours. Components accept
+`className` like any element, and they expose their interaction state as `data-*`
+attributes, so most styling is plain CSS:
+
+```css
+.calendar {
+ inline-size: 20rem;
+ font: inherit;
+}
+
+.calendar-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-block-end: 0.5rem;
+}
+
+.calendar-nav {
+ inline-size: 2rem;
+ block-size: 2rem;
+ border: none;
+ border-radius: 0.375rem;
+ background: transparent;
+ cursor: pointer;
+}
+
+.calendar-nav:hover {
+ background: #f1f0ef;
+}
+
+.calendar-nav:disabled {
+ opacity: 0.4;
+ cursor: default;
+}
+
+.calendar-grid {
+ inline-size: 100%;
+ border-collapse: collapse;
+}
+
+.calendar-weekday {
+ padding-block: 0.25rem;
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: #6f6d66;
+}
+
+.calendar-day {
+ inline-size: 2.25rem;
+ block-size: 2.25rem;
+ border: none;
+ border-radius: 0.375rem;
+ background: transparent;
+ cursor: pointer;
+}
+
+.calendar-day:hover {
+ background: #f1f0ef;
+}
+
+.calendar-day[data-today] {
+ font-weight: 700;
+}
+
+.calendar-day[data-outside-month] {
+ color: #b5b3ad;
+}
+
+.calendar-day[data-selected] {
+ background: #1a1a17;
+ color: #fff;
+}
+
+.calendar-day[data-disabled] {
+ opacity: 0.4;
+ cursor: default;
+}
+```
+
+The [Styling guide](/docs/styling) covers the complete data-attribute reference, Tailwind
+usage, and the `render` prop for cases where CSS alone isn't enough.
+
+## Next steps
+
+- [Composition](/docs/composition) — the component tree, templates, and the `render` prop.
+- [Selection modes](/docs/selection-modes) — single, multiple, and range selection.
+- [Dates & formats](/docs/dates-and-formats) — value formats, time zones, and locales.
+- [MonthView](/docs/month-view) and [WeeksView](/docs/weeks-view) — everything each view
+ can do.
diff --git a/website/content/docs/selection-modes.md b/website/content/docs/selection-modes.md
new file mode 100644
index 0000000..d023ffb
--- /dev/null
+++ b/website/content/docs/selection-modes.md
@@ -0,0 +1,183 @@
+---
+title: Selection modes
+description: Single, multiple, and range selection — controlled and uncontrolled — plus range previews and drag handles.
+order: 12
+section: Guides
+---
+
+Selection is configured on the [CalendarProvider](/docs/calendar-provider) (or on the
+`MonthView` / `WeeksView` wrappers, which forward to it) with the `selectionMode` prop:
+`"single"` (default), `"multiple"`, or `"range"`. The mode determines the shape of
+`value`, `defaultValue`, and the argument to `onValueChange`.
+
+## Controlled vs. uncontrolled
+
+Every mode supports both patterns, with the usual React rules:
+
+- **Uncontrolled** — _omit_ `value` entirely and optionally seed with `defaultValue`. The
+ calendar manages its own state; `onValueChange` still reports every change.
+- **Controlled** — pass `value` and keep it updated from `onValueChange`. Pass
+ `value={null}` (or `[]` in multiple mode) to clear the selection — don't pass
+ `value={undefined}`, which means "uncontrolled".
+
+`onValueChange(value, meta)` receives the new value in your configured
+[format](/docs/dates-and-formats), plus a `meta` object with the clicked `date`
+(a `Temporal.PlainDate`, or `undefined` for non-click changes) and the `previous` value.
+
+## Single
+
+One date or `null`. Clicking the selected date again keeps it selected; clicking another
+date moves the selection.
+
+```tsx
+const [date, setDate] = useState(null);
+
+
+ {/* … */}
+ ;
+```
+
+## Multiple
+
+An array of dates, kept sorted oldest-first. Clicking an unselected date adds it; clicking
+a selected date removes it.
+
+```tsx
+const [dates, setDates] = useState([]);
+
+
+ {/* … */}
+ ;
+```
+
+## Range
+
+A `{ start, end }` pair (`DateRange`), either boundary possibly `null` while the range is
+in progress. The first click sets the start; hovering shows a live preview; the second
+click commits the end.
+
+{% example file="range-calendar.tsx" /%}
+
+Style ranges with the `data-in-range` / `data-range-start` / `data-range-end` attributes
+on day cells, or with the `RangeSelected` overlay — both covered in
+[Styling](/docs/styling).
+
+### Clicking inside an existing range: `rangeMode`
+
+Once a full range exists, what should the next click do? `rangeMode` makes that policy
+explicit:
+
+| `rangeMode` | Behavior on click |
+| --------------------------- | ------------------------------------------------------------------------- |
+| `"nearest-end"` _(default)_ | Moves whichever boundary is closer to the clicked date; ties move the end |
+| `"nearest-start"` | Same, but ties move the start |
+| `"adjust-end"` | Always moves the end to the clicked date |
+| `"adjust-start"` | Always moves the start to the clicked date |
+| `"start-end"` | Two-step: the click starts a fresh range (click again to set its end) |
+| `"reset"` | Collapses to a single-day range on the clicked date |
+
+### Reversed selections
+
+If the user picks an end date _before_ the start date, the range is auto-sorted by default
+(select June 20 then June 10 → June 10–20). Set `preventRangeReversal` to instead collapse
+reversed picks to a single-day range — useful when "backwards" selection is likely a
+mistake in your UX.
+
+### The hover preview
+
+While a range is in progress (and when hovering with `rangeMode` policies that would move
+a boundary), the provider computes a **preview range** — the range that _would_ be
+committed if the user clicked the hovered day. It surfaces in three places:
+
+- `data-range-preview-*` attributes on day cells,
+- the `RangePreview` overlay component,
+- `onHoveredDateChange`, if you want to react to hovering yourself.
+
+You can also take the preview over entirely with the `previewRange` prop — pass a
+`DateRange` to display, or `null` to hide it. This is how you'd preview a range from an
+external input (e.g. "next weekend" buttons above the calendar).
+
+### Setting a range programmatically
+
+Components inside the provider can commit a range directly with `setRange` from
+`useCalendarStable()`:
+
+```tsx
+function NextWeekButton() {
+ const { setRange, temporal } = useCalendarStable();
+ return (
+ {
+ const start = temporal.Now.plainDateISO().add({ days: 7 });
+ setRange(start, start.add({ days: 6 }));
+ }}
+ >
+ Next week
+
+ );
+}
+```
+
+(For external control, the controlled `value` prop does the same job.)
+
+### Drag handles
+
+`RangeStartDragHandle` and `RangeEndDragHandle` render grab affordances inside day buttons
+at the range boundaries, letting users drag either end of a committed range. Place them
+inside `DayButton` (via children or `render`); they position themselves only on the
+boundary days and stay `aria-hidden` elsewhere:
+
+```tsx
+
+ (
+
+ {state.date.day}
+
+
+
+ )}
+ />
+
+```
+
+Dragging is a pointer enhancement — every range edit remains possible by click and
+keyboard (see [Accessibility](/docs/accessibility)).
+
+## Restricting selectable dates
+
+Three props combine to disable dates in every mode:
+
+- `min` / `max` — bounds in your configured value format; days outside are disabled and
+ keyboard focus is clamped to them.
+- `isDateDisabled` — a predicate for irregular rules (weekends, blackout dates):
+
+```tsx
+ date.dayOfWeek >= 6}
+>
+ {/* … */}
+
+```
+
+- `disabled` / `readOnly` — disable the whole calendar, or allow browsing but not
+ selecting.
+
+Note that `min`/`max` restrict _selection_; whether the user can still _navigate_ past
+them is a per-view choice via `outOfRangeBehavior` — see [MonthView](/docs/month-view)
+and [WeeksView](/docs/weeks-view).
+
+## Switching modes at runtime
+
+If `selectionMode` changes while the calendar is mounted (e.g. a "range" toggle in your
+UI), an uncontrolled calendar truncates its state sensibly (range → its start date, etc.)
+and reports the change through `onValueChange`. A controlled calendar leaves reconciling
+`value` to you.
diff --git a/website/content/docs/styling.md b/website/content/docs/styling.md
new file mode 100644
index 0000000..25e376e
--- /dev/null
+++ b/website/content/docs/styling.md
@@ -0,0 +1,184 @@
+---
+title: Styling
+description: Style $projectName with plain CSS, Tailwind, or any tool — via data attributes and the render prop.
+order: 11
+section: Guides
+---
+
+{% $projectName %} ships zero CSS. Every component renders a semantic element you style
+yourself, and exposes its interaction state on the DOM so your styles can react to it.
+There are three layers, from simplest to most powerful:
+
+1. **`className`** — every part accepts one.
+2. **`data-*` attributes** — state stamped onto the element; target it with CSS attribute
+ selectors.
+3. **The `render` prop** — replace the element and read the typed `state` object directly
+ (see [Composition](/docs/composition)).
+
+## Styling with data attributes
+
+State becomes presence-style attributes: `data-selected` is present when a day is selected
+and absent otherwise. In CSS:
+
+```css
+.day[data-selected] {
+ background: black;
+ color: white;
+}
+
+.day[data-today] {
+ font-weight: 700;
+}
+
+.day[data-outside-month] {
+ opacity: 0.45;
+}
+
+.day[data-disabled] {
+ opacity: 0.35;
+ pointer-events: none;
+}
+```
+
+With Tailwind, use the `data-*` variants directly on `className`:
+
+```tsx
+
+```
+
+## Data attribute reference
+
+### `DayCellTemplate` and `DayButton`
+
+| Attribute | Present when |
+| ------------------------ | --------------------------------------------------------- |
+| `data-date="2026-06-20"` | Always — the cell's ISO date (a value, not a flag) |
+| `data-selected` | The day is selected |
+| `data-today` | The day is today (in the calendar's `timeZone`) |
+| `data-disabled` | Disabled via `min`/`max`, `isDateDisabled`, or `disabled` |
+| `data-focused` | The day is the grid's logically focused cell |
+| `data-outside-month` | The day belongs to an adjacent month |
+| `data-hidden` | The cell is blanked by `outsideDays="hidden"` |
+| `data-orientation` | `"horizontal"` or `"vertical"` (a value) |
+
+Range selection adds:
+
+| Attribute | Present when |
+| --------------------------------------------- | -------------------------------------------- |
+| `data-in-range` | The day is inside the committed range |
+| `data-range-start` / `data-range-end` | The day is the range's first / last day |
+| `data-range-boundary` | Either of the above |
+| `data-range-index` / `data-range-length` | Position within / size of the range (values) |
+| `data-range-has-start` / `data-range-has-end` | The range's boundary is defined |
+
+The hover **preview** range (see [Selection modes](/docs/selection-modes)) mirrors the same
+set with a `data-range-preview-*` prefix: `data-range-preview-in-range`,
+`data-range-preview-start`, `data-range-preview-end`, `data-range-preview-boundary`, and so
+on — so you can render "what would happen if you clicked here" more subtly than the
+committed range.
+
+### Other parts
+
+| Component | Attributes |
+| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `Grid` | `data-orientation`, `data-days-per-week`, `data-weeks-in-month` |
+| `PrevMonthButton` / `NextMonthButton` | `data-direction="prev" / "next"`, native `disabled` at bounds |
+| `PrevWeeksButton` / `NextWeeksButton` | `data-direction`, native `disabled` at bounds |
+| `RangeSelected` / `RangePreview` | `data-active`, `data-week-index`, `data-start-index`, `data-end-index`, `data-start-date`, `data-end-date`, `data-extends-before`, `data-extends-after`, `data-has-start`, `data-has-end`, `data-orientation` |
+| `RangeStartDragHandle` / `RangeEndDragHandle` | `data-active`, `data-dragging`, `data-edge="start" / "end"`, `data-orientation` |
+| `WeekNumberCell` | `data-week-number` |
+| `MonthSeparator` parts | `data-month`, `data-year`, `data-first-of-year`, `data-first-visible`, `data-first-day-column`, `data-grid-row-start`, … |
+
+The generated API pages (e.g. [DayButtonState](/docs/api/DayButtonState)) list the state
+each component exposes; every boolean state maps to a presence attribute of the same
+kebab-cased name.
+
+## Styling day states: a range example
+
+Range styling composes from the day attributes alone — no extra components needed:
+
+```css
+.day[data-in-range] {
+ background: #e8e6e1;
+ border-radius: 0;
+}
+
+.day[data-range-start] {
+ border-start-start-radius: 0.375rem;
+ border-end-start-radius: 0.375rem;
+}
+
+.day[data-range-end] {
+ border-start-end-radius: 0.375rem;
+ border-end-end-radius: 0.375rem;
+}
+
+.day[data-range-boundary] {
+ background: #1a1a17;
+ color: #fff;
+}
+
+/* subtler hover preview */
+.day[data-range-preview-in-range] {
+ outline: 1px dashed #b5b3ad;
+}
+```
+
+## Range overlays: `RangeSelected` and `RangePreview`
+
+For pill-shaped range highlights that render _behind_ a whole week's days as one element
+(rather than per-cell backgrounds), add `RangeSelected` / `RangePreview` inside your
+`WeekTemplate`. They're `` overlays that report where the range intersects the week:
+`startIndex` / `endIndex` (column positions), `extendsBefore` / `extendsAfter` (whether the
+range continues into adjacent weeks — flatten the pill's corners on that side), and
+`active` (whether the range touches this week at all).
+
+Because positioning an overlay across table columns requires a CSS-grid layout, these are
+typically used with the `render` prop:
+
+```tsx
+
+
+ state.active ? (
+
+ ) : (
+
+ )
+ }
+ />
+ {/* … */}
+
+```
+
+This pairs with laying out `Grid` as `display: grid` — see the interactive
+[demo](/demo) source for a complete Tailwind implementation, including vertical
+orientation and week-number offsets.
+
+## Grid layout tips
+
+- `Grid` renders a ``, which styles fine for simple calendars. For overlays,
+ subgrid tricks, or vertical orientation, restyle it with `display: grid` — the library
+ sets the CSS custom properties `--calendar-days-per-week` and
+ `--calendar-weeks-in-month` on the grid element so your template can use
+ `grid-template-columns: repeat(var(--calendar-days-per-week), 1fr)`.
+- `WeekTemplate` exposes `state.gridRowIndex` (WeeksView only) and `DayCellTemplate`
+ exposes `state.columnIndex` + `state.orientation` for explicit grid placement via
+ `render`.
+- Don't forget `:focus-visible` styles on `DayButton` — see
+ [Accessibility](/docs/accessibility).
diff --git a/website/content/docs/weeks-view.md b/website/content/docs/weeks-view.md
index 1f59eb9..e411d97 100644
--- a/website/content/docs/weeks-view.md
+++ b/website/content/docs/weeks-view.md
@@ -1,18 +1,156 @@
---
title: WeeksView
-description: Displays a configurable window of continuous week rows that span month boundaries.
-order: 4
+description: A continuously scrolling window of week rows that spans month boundaries.
+order: 22
section: Components
---
-## Props
+`WeeksView` renders a fixed-height **window of week rows** that scrolls continuously
+through the calendar — no page flips, no month boundaries. It's the layout behind
+agenda-style mini calendars (think Google Calendar's sidebar): June's last week and July's
+first week can sit next to each other in the same view.
+
+Compared to [MonthView](/docs/month-view):
+
+| | MonthView | WeeksView |
+| --------------- | -------------------------------- | -------------------------------------- |
+| Unit of display | Whole months | Any consecutive weeks |
+| Navigation | Page by month | Scroll by week row or page |
+| Window height | Rows per month (or `fixedWeeks`) | Always exactly `weekCount` rows |
+| Month labels | One per grid | `MonthSeparator` parts inside the flow |
+
+Both views share the same grid parts, selection model, and provider — switching between
+them is mostly a matter of swapping the root and navigation.
+
+{% example file="weeks-view-basic.tsx" /%}
+
+## The window
+
+- **`weekCount`** (required) — how many week rows are visible.
+- **`firstWeek` / `defaultFirstWeek` / `onFirstWeekChange`** — the controlled /
+ uncontrolled first visible week. Any date-like value works — a `FirstWeekSpec` is
+ resolved to the containing week and snapped to `weekStartDay`:
+
+```tsx
+
+// also accepted:
+// Temporal.PlainDate.from("2026-06-15")
+// new Date(2026, 5, 15)
+// { isoWeek: 25, isoYear: 2026 }
+// { week: 25, year: 2026 } (relative to weekStartDay)
+```
+
+- **`onWindowChange`** — fires with a `WindowInfo` snapshot whenever the window moves:
+ `windowStart`/`windowEnd`, day and week counts, how many of those are enabled, and the
+ `visibleMonths` list — handy for rendering a "Jun – Jul 2026" heading (see the example
+ above, which reads the same data from `useWeeksViewState().windowInfo`).
+
+## Scrolling
+
+- **`PrevWeeksButton` / `NextWeeksButton`** shift the window; **`scrollBy`** decides the
+ step — `"row"` (one week, default) or `"page"` (a full `weekCount`).
+- **`WeekCount`** renders the number of visible weeks, if you want it in your UI.
+- Keyboard: arrowing or paging focus past the window edge scrolls it automatically.
+- **Imperative scrolling** — `WeeksView` (and `WeeksView.Root`) forwards a ref with a
+ `scrollToWeek(target, { snap })` handle:
+
+```tsx
+const ref = useRef(null);
+
+
+ {/* … */}
+ ;
+
+// later:
+ref.current?.scrollToWeek(Temporal.PlainDate.from("2026-09-01"), {
+ snap: "center",
+});
+```
+
+`snap` positions the target within the window: `"start"` (default), `"center"`, `"end"`,
+or `"nearest"` — which scrolls only if the target is outside the window, choosing the
+closer edge.
+
+## Behavior at the bounds
+
+With `min`/`max` set, `outOfRangeBehavior` controls how the _window_ treats the bounds
+(selection is always restricted regardless):
+
+| Value | Behavior |
+| ------------------------- | ------------------------------------------------------------------------------------------ |
+| `"unbounded"` _(default)_ | Scroll freely; out-of-range days render disabled |
+| `"stop"` | Nav buttons disable once the next step would show no in-range day |
+| `"stop-shrink"` | Like `"stop"`, but the window shrinks near the edge instead of showing fully-disabled rows |
+| `"snap"` | Overshooting jumps snap the window edge to the first/last in-range week |
+| `"snap-shrink"` | Snap, then trim any remaining fully-disabled rows |
+
+`"snap"` and `"snap-shrink"` only differ when the selectable range spans _fewer_ weeks
+than `weekCount`: snapping can pin only one edge, so the window overhangs the other —
+`"snap"` keeps the full height (padding with disabled rows) while `"snap-shrink"` trims to
+just the in-range weeks. With `weekCount: 6` and bounds spanning 2 weeks, `"snap"` shows
+6 rows (4 disabled), `"snap-shrink"` shows 2.
+
+## Month separators
+
+Because months flow into each other, `MonthSeparator` parts let you mark where a new month
+begins inside the grid — a border above its first week, a rotated month label in a side
+column, however you like. `MonthSeparatorRow` repeats for each month whose first day is in
+view and exposes layout state (`firstDayColumn`, `firstDayVisible`, `gridRowStart`,
+`fullWeeksVisibleAfter`) via its `render` prop, with `MonthSeparatorMonth` /
+`MonthSeparatorYear` for localized labels:
+
+```tsx
+
+ (
+
+
+ {state.firstDayVisible && (
+
+
+
+ )}
+
+
+ )}
+ />
+ {/* … */}
+
+```
+
+Like the range overlays, separators assume a CSS-grid layout on `Grid` — see
+[Styling](/docs/styling) and the [demo](/demo) source for a complete implementation.
+
+## Week numbers
+
+`WeekNumberHeader` / `WeekNumberCell` work exactly as in
+[MonthView](/docs/month-view#week-numbers).
+
+## API reference
+
+### Props
+
+`WeeksView` accepts all [CalendarProvider](/docs/calendar-provider) props plus the view
+props below.
{% api symbol="WeeksViewRootProps" /%}
-## Window Info
+### Window info
{% api symbol="WindowInfo" /%}
-## First Week Spec
+### First week spec
{% api symbol="FirstWeekSpec" /%}
+
+### Hooks
+
+Inside a `WeeksView`, `useWeeksViewStable()` and `useWeeksViewState()` expose the view's
+context — including `windowInfo` and the `scrollToWeek`/`goNext`/`goPrev` actions — for
+custom components.
diff --git a/website/src/docs-data/nav.gen.ts b/website/src/docs-data/nav.gen.ts
index 736cf22..cfbc275 100644
--- a/website/src/docs-data/nav.gen.ts
+++ b/website/src/docs-data/nav.gen.ts
@@ -4,12 +4,72 @@ import type { DocsNavEntry } from "#/components/DocsNav";
export const docEntries = [
{
- slug: "getting-started",
+ slug: "introduction",
frontmatter: {
- title: "Getting Started",
+ title: "Introduction",
description:
- "Install Colander and start building accessible calendar components.",
+ "What Colander is, the ideas behind it, and how it compares to other React date pickers.",
order: 1,
+ section: "Overview",
+ },
+ },
+ {
+ slug: "quick-start",
+ frontmatter: {
+ title: "Quick start",
+ description:
+ "Install Colander and build a working, styled calendar in a few minutes.",
+ order: 2,
+ section: "Overview",
+ },
+ },
+ {
+ slug: "accessibility",
+ frontmatter: {
+ title: "Accessibility",
+ description:
+ "Keyboard interactions, ARIA semantics, and localization built into Colander.",
+ order: 3,
+ section: "Overview",
+ },
+ },
+ {
+ slug: "composition",
+ frontmatter: {
+ title: "Composition",
+ description:
+ "How Colander components fit together — templates, the render prop, and building your own parts.",
+ order: 10,
+ section: "Guides",
+ },
+ },
+ {
+ slug: "styling",
+ frontmatter: {
+ title: "Styling",
+ description:
+ "Style Colander with plain CSS, Tailwind, or any tool — via data attributes and the render prop.",
+ order: 11,
+ section: "Guides",
+ },
+ },
+ {
+ slug: "selection-modes",
+ frontmatter: {
+ title: "Selection modes",
+ description:
+ "Single, multiple, and range selection — controlled and uncontrolled — plus range previews and drag handles.",
+ order: 12,
+ section: "Guides",
+ },
+ },
+ {
+ slug: "dates-and-formats",
+ frontmatter: {
+ title: "Dates & formats",
+ description:
+ "Temporal, the temporal prop, value formats, subpath entry points, time zones, and locales.",
+ order: 13,
section: "Guides",
},
},
@@ -19,7 +79,7 @@ export const docEntries = [
title: "CalendarProvider",
description:
"Manages shared state across calendar views — selection, bounds, locale, and more.",
- order: 2,
+ order: 20,
section: "Components",
},
},
@@ -28,8 +88,8 @@ export const docEntries = [
frontmatter: {
title: "MonthView",
description:
- "Displays a traditional calendar grid with month-level navigation.",
- order: 3,
+ "The traditional paged month grid, with multi-month layouts and month-level navigation.",
+ order: 21,
section: "Components",
},
},
@@ -38,8 +98,8 @@ export const docEntries = [
frontmatter: {
title: "WeeksView",
description:
- "Displays a configurable window of continuous week rows that span month boundaries.",
- order: 4,
+ "A continuously scrolling window of week rows that spans month boundaries.",
+ order: 22,
section: "Components",
},
},
diff --git a/website/src/examples/basic-calendar.tsx b/website/src/examples/basic-calendar.tsx
index e9893a6..449ea17 100644
--- a/website/src/examples/basic-calendar.tsx
+++ b/website/src/examples/basic-calendar.tsx
@@ -1,7 +1,9 @@
-import type { Temporal } from "@js-temporal/polyfill";
+import { Temporal } from "@js-temporal/polyfill";
import {
- CalendarProvider,
MonthView,
+ PrevMonthButton,
+ MonthYearString,
+ NextMonthButton,
Grid,
GridHeader,
GridHeaderCell,
@@ -17,21 +19,26 @@ export function BasicCalendar({
onSelect?: (value: Temporal.PlainDate | null) => void;
}) {
return (
-
-
-
+
+
+
);
}
diff --git a/website/src/examples/multiple-months.tsx b/website/src/examples/multiple-months.tsx
new file mode 100644
index 0000000..72ee954
--- /dev/null
+++ b/website/src/examples/multiple-months.tsx
@@ -0,0 +1,51 @@
+import { Temporal } from "@js-temporal/polyfill";
+import {
+ MonthView,
+ PrevMonthButton,
+ MonthYearString,
+ NextMonthButton,
+ Grid,
+ GridHeader,
+ GridHeaderCell,
+ GridBody,
+ WeekTemplate,
+ DayCellTemplate,
+ DayButton,
+} from "@klinking/colander";
+
+function MonthGrid({ monthIndex }: { monthIndex: number }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function TwoMonthCalendar() {
+ return (
+
+
+
+ );
+}
diff --git a/website/src/examples/range-calendar.tsx b/website/src/examples/range-calendar.tsx
new file mode 100644
index 0000000..5d724a1
--- /dev/null
+++ b/website/src/examples/range-calendar.tsx
@@ -0,0 +1,52 @@
+import { Temporal } from "@js-temporal/polyfill";
+import {
+ MonthView,
+ PrevMonthButton,
+ MonthYearString,
+ NextMonthButton,
+ Grid,
+ GridHeader,
+ GridHeaderCell,
+ GridBody,
+ WeekTemplate,
+ DayCellTemplate,
+ DayButton,
+ type DateRange,
+} from "@klinking/colander";
+import { useState } from "react";
+
+export function RangeCalendar() {
+ const [range, setRange] = useState | null>(null);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {/* Style boundaries and the interior with data attributes:
+ [data-range-start], [data-range-end], [data-in-range],
+ and the hover preview via [data-range-preview-in-range]. */}
+
+
+
+
+
+
+
+ );
+}
diff --git a/website/src/examples/weeks-view-basic.tsx b/website/src/examples/weeks-view-basic.tsx
new file mode 100644
index 0000000..c0ac46b
--- /dev/null
+++ b/website/src/examples/weeks-view-basic.tsx
@@ -0,0 +1,58 @@
+import { Temporal } from "@js-temporal/polyfill";
+import {
+ WeeksView,
+ PrevWeeksButton,
+ NextWeeksButton,
+ Grid,
+ GridHeader,
+ GridHeaderCell,
+ GridBody,
+ WeekTemplate,
+ DayCellTemplate,
+ DayButton,
+ useWeeksViewState,
+} from "@klinking/colander";
+
+function VisibleMonthsLabel() {
+ const { windowInfo } = useWeeksViewState();
+ const label = windowInfo.visibleMonths
+ .map(({ month, year }) =>
+ new Date(year, month - 1).toLocaleDateString("en-US", {
+ month: "short",
+ year: "numeric",
+ }),
+ )
+ .join(" – ");
+ return {label} ;
+}
+
+export function BasicWeeksView() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/website/src/routeTree.gen.ts b/website/src/routeTree.gen.ts
index 46bc29b..16f5d61 100644
--- a/website/src/routeTree.gen.ts
+++ b/website/src/routeTree.gen.ts
@@ -14,9 +14,15 @@ import { Route as DemoRouteImport } from './routes/demo'
import { Route as IndexRouteImport } from './routes/index'
import { Route as DocsIndexRouteImport } from './routes/docs/index'
import { Route as DocsWeeksViewRouteImport } from './routes/docs/weeks-view'
+import { Route as DocsStylingRouteImport } from './routes/docs/styling'
+import { Route as DocsSelectionModesRouteImport } from './routes/docs/selection-modes'
+import { Route as DocsQuickStartRouteImport } from './routes/docs/quick-start'
import { Route as DocsMonthViewRouteImport } from './routes/docs/month-view'
-import { Route as DocsGettingStartedRouteImport } from './routes/docs/getting-started'
+import { Route as DocsIntroductionRouteImport } from './routes/docs/introduction'
+import { Route as DocsDatesAndFormatsRouteImport } from './routes/docs/dates-and-formats'
+import { Route as DocsCompositionRouteImport } from './routes/docs/composition'
import { Route as DocsCalendarProviderRouteImport } from './routes/docs/calendar-provider'
+import { Route as DocsAccessibilityRouteImport } from './routes/docs/accessibility'
import { Route as DocsApiSymbolRouteImport } from './routes/docs/api/$symbol'
const DocsRoute = DocsRouteImport.update({
@@ -44,14 +50,39 @@ const DocsWeeksViewRoute = DocsWeeksViewRouteImport.update({
path: '/weeks-view',
getParentRoute: () => DocsRoute,
} as any)
+const DocsStylingRoute = DocsStylingRouteImport.update({
+ id: '/styling',
+ path: '/styling',
+ getParentRoute: () => DocsRoute,
+} as any)
+const DocsSelectionModesRoute = DocsSelectionModesRouteImport.update({
+ id: '/selection-modes',
+ path: '/selection-modes',
+ getParentRoute: () => DocsRoute,
+} as any)
+const DocsQuickStartRoute = DocsQuickStartRouteImport.update({
+ id: '/quick-start',
+ path: '/quick-start',
+ getParentRoute: () => DocsRoute,
+} as any)
const DocsMonthViewRoute = DocsMonthViewRouteImport.update({
id: '/month-view',
path: '/month-view',
getParentRoute: () => DocsRoute,
} as any)
-const DocsGettingStartedRoute = DocsGettingStartedRouteImport.update({
- id: '/getting-started',
- path: '/getting-started',
+const DocsIntroductionRoute = DocsIntroductionRouteImport.update({
+ id: '/introduction',
+ path: '/introduction',
+ getParentRoute: () => DocsRoute,
+} as any)
+const DocsDatesAndFormatsRoute = DocsDatesAndFormatsRouteImport.update({
+ id: '/dates-and-formats',
+ path: '/dates-and-formats',
+ getParentRoute: () => DocsRoute,
+} as any)
+const DocsCompositionRoute = DocsCompositionRouteImport.update({
+ id: '/composition',
+ path: '/composition',
getParentRoute: () => DocsRoute,
} as any)
const DocsCalendarProviderRoute = DocsCalendarProviderRouteImport.update({
@@ -59,6 +90,11 @@ const DocsCalendarProviderRoute = DocsCalendarProviderRouteImport.update({
path: '/calendar-provider',
getParentRoute: () => DocsRoute,
} as any)
+const DocsAccessibilityRoute = DocsAccessibilityRouteImport.update({
+ id: '/accessibility',
+ path: '/accessibility',
+ getParentRoute: () => DocsRoute,
+} as any)
const DocsApiSymbolRoute = DocsApiSymbolRouteImport.update({
id: '/api/$symbol',
path: '/api/$symbol',
@@ -69,9 +105,15 @@ export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/demo': typeof DemoRoute
'/docs': typeof DocsRouteWithChildren
+ '/docs/accessibility': typeof DocsAccessibilityRoute
'/docs/calendar-provider': typeof DocsCalendarProviderRoute
- '/docs/getting-started': typeof DocsGettingStartedRoute
+ '/docs/composition': typeof DocsCompositionRoute
+ '/docs/dates-and-formats': typeof DocsDatesAndFormatsRoute
+ '/docs/introduction': typeof DocsIntroductionRoute
'/docs/month-view': typeof DocsMonthViewRoute
+ '/docs/quick-start': typeof DocsQuickStartRoute
+ '/docs/selection-modes': typeof DocsSelectionModesRoute
+ '/docs/styling': typeof DocsStylingRoute
'/docs/weeks-view': typeof DocsWeeksViewRoute
'/docs/': typeof DocsIndexRoute
'/docs/api/$symbol': typeof DocsApiSymbolRoute
@@ -79,9 +121,15 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/demo': typeof DemoRoute
+ '/docs/accessibility': typeof DocsAccessibilityRoute
'/docs/calendar-provider': typeof DocsCalendarProviderRoute
- '/docs/getting-started': typeof DocsGettingStartedRoute
+ '/docs/composition': typeof DocsCompositionRoute
+ '/docs/dates-and-formats': typeof DocsDatesAndFormatsRoute
+ '/docs/introduction': typeof DocsIntroductionRoute
'/docs/month-view': typeof DocsMonthViewRoute
+ '/docs/quick-start': typeof DocsQuickStartRoute
+ '/docs/selection-modes': typeof DocsSelectionModesRoute
+ '/docs/styling': typeof DocsStylingRoute
'/docs/weeks-view': typeof DocsWeeksViewRoute
'/docs': typeof DocsIndexRoute
'/docs/api/$symbol': typeof DocsApiSymbolRoute
@@ -91,9 +139,15 @@ export interface FileRoutesById {
'/': typeof IndexRoute
'/demo': typeof DemoRoute
'/docs': typeof DocsRouteWithChildren
+ '/docs/accessibility': typeof DocsAccessibilityRoute
'/docs/calendar-provider': typeof DocsCalendarProviderRoute
- '/docs/getting-started': typeof DocsGettingStartedRoute
+ '/docs/composition': typeof DocsCompositionRoute
+ '/docs/dates-and-formats': typeof DocsDatesAndFormatsRoute
+ '/docs/introduction': typeof DocsIntroductionRoute
'/docs/month-view': typeof DocsMonthViewRoute
+ '/docs/quick-start': typeof DocsQuickStartRoute
+ '/docs/selection-modes': typeof DocsSelectionModesRoute
+ '/docs/styling': typeof DocsStylingRoute
'/docs/weeks-view': typeof DocsWeeksViewRoute
'/docs/': typeof DocsIndexRoute
'/docs/api/$symbol': typeof DocsApiSymbolRoute
@@ -104,9 +158,15 @@ export interface FileRouteTypes {
| '/'
| '/demo'
| '/docs'
+ | '/docs/accessibility'
| '/docs/calendar-provider'
- | '/docs/getting-started'
+ | '/docs/composition'
+ | '/docs/dates-and-formats'
+ | '/docs/introduction'
| '/docs/month-view'
+ | '/docs/quick-start'
+ | '/docs/selection-modes'
+ | '/docs/styling'
| '/docs/weeks-view'
| '/docs/'
| '/docs/api/$symbol'
@@ -114,9 +174,15 @@ export interface FileRouteTypes {
to:
| '/'
| '/demo'
+ | '/docs/accessibility'
| '/docs/calendar-provider'
- | '/docs/getting-started'
+ | '/docs/composition'
+ | '/docs/dates-and-formats'
+ | '/docs/introduction'
| '/docs/month-view'
+ | '/docs/quick-start'
+ | '/docs/selection-modes'
+ | '/docs/styling'
| '/docs/weeks-view'
| '/docs'
| '/docs/api/$symbol'
@@ -125,9 +191,15 @@ export interface FileRouteTypes {
| '/'
| '/demo'
| '/docs'
+ | '/docs/accessibility'
| '/docs/calendar-provider'
- | '/docs/getting-started'
+ | '/docs/composition'
+ | '/docs/dates-and-formats'
+ | '/docs/introduction'
| '/docs/month-view'
+ | '/docs/quick-start'
+ | '/docs/selection-modes'
+ | '/docs/styling'
| '/docs/weeks-view'
| '/docs/'
| '/docs/api/$symbol'
@@ -176,6 +248,27 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DocsWeeksViewRouteImport
parentRoute: typeof DocsRoute
}
+ '/docs/styling': {
+ id: '/docs/styling'
+ path: '/styling'
+ fullPath: '/docs/styling'
+ preLoaderRoute: typeof DocsStylingRouteImport
+ parentRoute: typeof DocsRoute
+ }
+ '/docs/selection-modes': {
+ id: '/docs/selection-modes'
+ path: '/selection-modes'
+ fullPath: '/docs/selection-modes'
+ preLoaderRoute: typeof DocsSelectionModesRouteImport
+ parentRoute: typeof DocsRoute
+ }
+ '/docs/quick-start': {
+ id: '/docs/quick-start'
+ path: '/quick-start'
+ fullPath: '/docs/quick-start'
+ preLoaderRoute: typeof DocsQuickStartRouteImport
+ parentRoute: typeof DocsRoute
+ }
'/docs/month-view': {
id: '/docs/month-view'
path: '/month-view'
@@ -183,11 +276,25 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DocsMonthViewRouteImport
parentRoute: typeof DocsRoute
}
- '/docs/getting-started': {
- id: '/docs/getting-started'
- path: '/getting-started'
- fullPath: '/docs/getting-started'
- preLoaderRoute: typeof DocsGettingStartedRouteImport
+ '/docs/introduction': {
+ id: '/docs/introduction'
+ path: '/introduction'
+ fullPath: '/docs/introduction'
+ preLoaderRoute: typeof DocsIntroductionRouteImport
+ parentRoute: typeof DocsRoute
+ }
+ '/docs/dates-and-formats': {
+ id: '/docs/dates-and-formats'
+ path: '/dates-and-formats'
+ fullPath: '/docs/dates-and-formats'
+ preLoaderRoute: typeof DocsDatesAndFormatsRouteImport
+ parentRoute: typeof DocsRoute
+ }
+ '/docs/composition': {
+ id: '/docs/composition'
+ path: '/composition'
+ fullPath: '/docs/composition'
+ preLoaderRoute: typeof DocsCompositionRouteImport
parentRoute: typeof DocsRoute
}
'/docs/calendar-provider': {
@@ -197,6 +304,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DocsCalendarProviderRouteImport
parentRoute: typeof DocsRoute
}
+ '/docs/accessibility': {
+ id: '/docs/accessibility'
+ path: '/accessibility'
+ fullPath: '/docs/accessibility'
+ preLoaderRoute: typeof DocsAccessibilityRouteImport
+ parentRoute: typeof DocsRoute
+ }
'/docs/api/$symbol': {
id: '/docs/api/$symbol'
path: '/api/$symbol'
@@ -208,18 +322,30 @@ declare module '@tanstack/react-router' {
}
interface DocsRouteChildren {
+ DocsAccessibilityRoute: typeof DocsAccessibilityRoute
DocsCalendarProviderRoute: typeof DocsCalendarProviderRoute
- DocsGettingStartedRoute: typeof DocsGettingStartedRoute
+ DocsCompositionRoute: typeof DocsCompositionRoute
+ DocsDatesAndFormatsRoute: typeof DocsDatesAndFormatsRoute
+ DocsIntroductionRoute: typeof DocsIntroductionRoute
DocsMonthViewRoute: typeof DocsMonthViewRoute
+ DocsQuickStartRoute: typeof DocsQuickStartRoute
+ DocsSelectionModesRoute: typeof DocsSelectionModesRoute
+ DocsStylingRoute: typeof DocsStylingRoute
DocsWeeksViewRoute: typeof DocsWeeksViewRoute
DocsIndexRoute: typeof DocsIndexRoute
DocsApiSymbolRoute: typeof DocsApiSymbolRoute
}
const DocsRouteChildren: DocsRouteChildren = {
+ DocsAccessibilityRoute: DocsAccessibilityRoute,
DocsCalendarProviderRoute: DocsCalendarProviderRoute,
- DocsGettingStartedRoute: DocsGettingStartedRoute,
+ DocsCompositionRoute: DocsCompositionRoute,
+ DocsDatesAndFormatsRoute: DocsDatesAndFormatsRoute,
+ DocsIntroductionRoute: DocsIntroductionRoute,
DocsMonthViewRoute: DocsMonthViewRoute,
+ DocsQuickStartRoute: DocsQuickStartRoute,
+ DocsSelectionModesRoute: DocsSelectionModesRoute,
+ DocsStylingRoute: DocsStylingRoute,
DocsWeeksViewRoute: DocsWeeksViewRoute,
DocsIndexRoute: DocsIndexRoute,
DocsApiSymbolRoute: DocsApiSymbolRoute,
diff --git a/website/src/routes/docs/accessibility.tsx b/website/src/routes/docs/accessibility.tsx
new file mode 100644
index 0000000..9a05a56
--- /dev/null
+++ b/website/src/routes/docs/accessibility.tsx
@@ -0,0 +1,238 @@
+// Auto-generated from accessibility.md — do not edit
+import { createFileRoute } from "@tanstack/react-router";
+import * as Tags from "#/components/markdoc";
+import { PROJECT_NAME } from "#/config";
+
+const frontmatter = {
+ title: "Accessibility",
+ description:
+ "Keyboard interactions, ARIA semantics, and localization built into Colander.",
+ order: 3,
+ section: "Overview",
+};
+
+export const Route = createFileRoute("/docs/accessibility")({
+ loader: () => ({ frontmatter }),
+ head: () => ({
+ meta: [
+ { title: `${frontmatter.title} - ${PROJECT_NAME}` },
+ ...(frontmatter.description
+ ? [{ name: "description", content: frontmatter.description }]
+ : []),
+ ],
+ }),
+ component: DocContent,
+});
+
+function DocContent() {
+ return (
+
+
+ Colander implements the{" "}
+
+ ARIA grid pattern
+ {" "}
+ for calendars, so the markup you compose is accessible before you write
+ a line of ARIA yourself. This page describes what the library does for
+ you — and the few things left in your hands.
+
+
+ Keyboard interactions
+
+ Focus the grid, then:
+
+
+
+ Key
+ Action
+
+
+
+
+
+ ArrowRight / ArrowLeft
+
+ Move focus one day forward / back
+
+
+
+ ArrowDown / ArrowUp
+
+ Move focus one week forward / back
+
+
+
+ Home / End
+
+
+ Move focus to the first / last day of the week (respects{" "}
+ weekStartDay)
+
+
+
+
+ PageDown / PageUp
+
+ Move focus one month forward / back
+
+
+
+ Shift + PageDown / Shift + PageUp
+
+ Move focus one year forward / back
+
+
+
+ Enter / Space
+
+ Select the focused day
+
+
+
+
+ Movement is clamped to your min/max bounds —
+ focus stops at the boundary instead of escaping the selectable window.
+ When focus crosses a month boundary, the view follows automatically
+ (MonthView pages; WeeksView scrolls its window).
+
+
+ In a readOnly calendar, navigation still works but{" "}
+ Enter/Space do nothing. In a{" "}
+ disabled calendar, keyboard interaction is off entirely.
+
+
+ Focus management
+
+
+ The grid uses a roving tab index : exactly one day cell
+ is in the tab order at a time, so the calendar occupies a single tab
+ stop in the page. The tab target is the selected day when there is one,
+ otherwise today, otherwise the nearest sensible day in view.
+
+
+ Pass autoFocus to Grid to move DOM focus into
+ the grid when it mounts — useful when the calendar opens inside a
+ popover:
+
+
+ {"{/* … */} \n"}
+
+
+ Semantics and labelling
+
+
+
+ Grid renders a {''};{" "}
+ DayCellTemplate renders{" "}
+ {''} cells with{" "}
+ aria-selected and aria-disabled reflecting
+ state.
+
+
+ Each DayButton gets a full-date aria-label{" "}
+ (e.g. "Saturday, June 20, 2026"), localized with your{" "}
+ locale.
+
+
+ GridHeaderCell renders abbreviated weekday text with the
+ full weekday name as its aria-label.
+
+
+ When you render a MonthYearString, the grid is
+ automatically linked to it with aria-labelledby, giving
+ screen-reader users the "June 2026" context. Without one, the grid
+ falls back to aria-label="Calendar".
+
+
+ MonthYearString, DateString, and{" "}
+ TimeString render with aria-live="polite",
+ so month navigation and selection changes are announced without
+ stealing focus.
+
+
+ PrevMonthButton / NextMonthButton (and the
+ WeeksView equivalents) carry descriptive aria-labels and
+ use the native disabled attribute at bounds, so assistive
+ tech sees real button semantics.
+
+
+
+ Outside and hidden days
+
+
+ outsideDays="hidden" keeps the grid shape intact for
+ assistive tech: hidden cells stay in the DOM as empty{" "}
+ {""} elements with aria-hidden (and a{" "}
+ data-hidden styling hook) rather than being removed, so row
+ and column counts remain consistent.
+
+
+ Range drag handles
+
+
+ RangeStartDragHandle / RangeEndDragHandle are
+ pointer affordances layered on top of the keyboard-accessible selection
+ model. They expose aria-roledescription="drag handle", a
+ label for the boundary they control, and aria-valuetext{" "}
+ with the current date — and they are aria-hidden while
+ inactive. Because every range operation can also be performed by
+ clicking or with the keyboard, dragging is an enhancement, not a
+ requirement.
+
+
+ Localization
+
+
+
+
+ locale
+ {" "}
+ (BCP 47 string, default "en-US") drives every formatted
+ string: weekday headers, month/year labels, and day{" "}
+ aria-labels, all via Intl.DateTimeFormat.
+
+
+
+ weekStartDay
+ {" "}
+ (0 = Sunday … 6 = Saturday) reorders the grid and the{" "}
+ Home/End keyboard behavior together, so
+ visual and keyboard order never disagree.
+
+
+
+ timeZone
+ {" "}
+ (IANA identifier) controls which day is "today" and how values
+ convert. See Dates & formats .
+
+
+
+ {
+ '\n {/* Mo Di Mi Do Fr Sa So */}\n \n'
+ }
+
+
+ Your responsibilities
+
+ Headless means a few things remain yours:
+
+
+ Visible focus. Style :focus-visible on{" "}
+ DayButton (and the nav buttons) with a clearly visible
+ ring; the library manages where focus goes, you make it{" "}
+ seen .
+
+
+ Color contrast. Selected, in-range, disabled, and
+ outside-month states are your colors — keep them WCAG-compliant, and
+ don't rely on color alone to convey selection.
+
+
+ Hit targets. Keep day buttons comfortably tappable
+ (44×44 px is a good floor) if you target touch devices.
+
+
+
+ );
+}
diff --git a/website/src/routes/docs/calendar-provider.tsx b/website/src/routes/docs/calendar-provider.tsx
index d08094e..52d7fdc 100644
--- a/website/src/routes/docs/calendar-provider.tsx
+++ b/website/src/routes/docs/calendar-provider.tsx
@@ -7,7 +7,7 @@ const frontmatter = {
title: "CalendarProvider",
description:
"Manages shared state across calendar views — selection, bounds, locale, and more.",
- order: 2,
+ order: 20,
section: "Components",
};
@@ -27,16 +27,133 @@ export const Route = createFileRoute("/docs/calendar-provider")({
function DocContent() {
return (
-
+
+ CalendarProvider is the state root of every calendar. It
+ owns the selection (in all{" "}
+ three modes ), resolves configuration
+ — bounds, time zone, locale, week start, the{" "}
+ Temporal implementation — and
+ shares everything with its descendants through context. It renders no
+ DOM of its own.
+
+
+ {
+ 'import { Temporal } from "@js-temporal/polyfill";\nimport { CalendarProvider, MonthView } from "@klinking/colander";\n\n console.log(range)}\n>\n {/* navigation + grid */} \n ;\n'
+ }
+
+
+ When you need it explicitly
+
+
+ The MonthView and WeeksView wrappers already
+ include a CalendarProvider, so most calendars never mention
+ it. Reach for the explicit form when:
+
+
+
+ Components outside the view need calendar state. {" "}
+ Anything using useCalendarStable() /{" "}
+ useCalendarState() must live under the provider — a
+ selection summary, preset-range buttons, a clear button.
+
+
+ Two views should share one selection. Render both
+ roots under a single provider — for example a month grid next to a
+ scrolling weeks strip, always in sync:
+
+
+
+ {
+ '\n {/* … */} \n {/* … */} \n \n'
+ }
+
+
+
+ You're building a custom view from the grid
+ primitives and hooks.
+
+
+
+
+ Don't nest a MonthView/WeeksView{" "}
+ wrapper inside a CalendarProvider — the wrapper
+ creates its own provider, which would shadow yours. Inside an explicit
+ provider, always use MonthView.Root /{" "}
+ WeeksView.Root.
+
+
+
+ What it manages
+
+
+
+ Selection — single / range / multiple, controlled or
+ uncontrolled, exposed in your configured value{" "}
+ format .
+
+
+ Constraints — min, max,{" "}
+ isDateDisabled, disabled,{" "}
+ readOnly.
+
+
+ Range behavior — rangeMode,{" "}
+ preventRangeReversal, the hover preview and{" "}
+ previewRange override.
+
+
+ Environment — temporal,{" "}
+ timeZone, locale, weekStartDay.
+
+
+
+ What it does not manage: focus, keyboard navigation, and which
+ month/weeks are visible — those belong to the view roots (
+ MonthView ,{" "}
+ WeeksView ), which is why the provider
+ alone renders nothing interactive.
+
+
+ Reading state with hooks
+
+
+ Two hooks expose the provider's context, split by volatility so
+ components can subscribe to only what they need:
+
+
+
+ useCalendarStable() — configuration and stable callbacks
+ (onSelect, setRange,{" "}
+ selectionMode, minValue,{" "}
+ maxValue, temporal, locale,{" "}
+ timeZone, …). Doesn't change during normal interaction.
+
+
+ useCalendarState() — the live values (
+ selected, selectedDates,{" "}
+ rangeStart, rangeEnd,{" "}
+ hoveredDate, previewStart,{" "}
+ previewEnd).
+
+
+
+ {
+ "function ClearButton() {\n const { setRange, readOnly } = useCalendarStable();\n const { rangeStart, rangeEnd } = useCalendarState();\n if (!rangeStart || readOnly) return null;\n // …\n}\n"
+ }
+
+
+ API reference
+
+
Props
-
- Stable Context
+
+ Stable context
-
- State Context
+
+ State context
diff --git a/website/src/routes/docs/composition.tsx b/website/src/routes/docs/composition.tsx
new file mode 100644
index 0000000..0909b43
--- /dev/null
+++ b/website/src/routes/docs/composition.tsx
@@ -0,0 +1,210 @@
+// Auto-generated from composition.md — do not edit
+import { createFileRoute } from "@tanstack/react-router";
+import * as Tags from "#/components/markdoc";
+import { PROJECT_NAME } from "#/config";
+
+const frontmatter = {
+ title: "Composition",
+ description:
+ "How Colander components fit together — templates, the render prop, and building your own parts.",
+ order: 10,
+ section: "Guides",
+};
+
+export const Route = createFileRoute("/docs/composition")({
+ loader: () => ({ frontmatter }),
+ head: () => ({
+ meta: [
+ { title: `${frontmatter.title} - ${PROJECT_NAME}` },
+ ...(frontmatter.description
+ ? [{ name: "description", content: frontmatter.description }]
+ : []),
+ ],
+ }),
+ component: DocContent,
+});
+
+function DocContent() {
+ return (
+
+
+ Colander is a set of compound components: small parts that you arrange
+ in JSX to form a calendar. This page explains the component tree, the{" "}
+ template components that repeat themselves, the{" "}
+ render prop, and how to drop down to hooks when you need a
+ part the library doesn't ship.
+
+
+ The component tree
+
+
+ {
+ 'CalendarProvider selection state, bounds, locale, time zone\n└─ MonthView.Root │ WeeksView.Root view state: visible month(s) / week window, focus\n ├─ PrevMonthButton / NextMonthButton (or PrevWeeksButton / NextWeeksButton)\n ├─ MonthYearString localized label, labels the grid\n └─ Grid \n ├─ GridHeader \n │ └─ GridHeaderCell weekday labels (×7)\n └─ GridBody \n └─ WeekTemplate — repeated per visible week\n ├─ WeekNumberCell optional week number\n ├─ RangeSelected optional range overlay \n ├─ RangePreview optional hover-preview overlay \n └─ DayCellTemplate — repeated per day\n └─ DayButton — the interactive day\n ├─ RangeStartDragHandle optional\n └─ RangeEndDragHandle optional\n'
+ }
+
+ Two rules make the tree work:
+
+
+ State flows down through context. {" "}
+ CalendarProvider owns the selection; the view root owns
+ navigation and focus; grid parts read both. No prop drilling.
+
+
+ You write structure once; templates repeat it.
+
+
+
+ Templates
+
+
+ WeekTemplate, DayCellTemplate, and{" "}
+ GridHeaderCell are templates : the single element
+ you write is instantiated for every week, day, and weekday in view. This
+ keeps a full calendar's JSX to a dozen lines while letting you customize
+ the one repeated unit:
+
+
+ {
+ '\n \n \n \n \n \n \n'
+ }
+
+
+ Each instance receives its own state — DayCellTemplate and{" "}
+ DayButton know their date,{" "}
+ columnIndex, and every selection flag for that specific
+ day. You can also opt out of iteration:{" "}
+ {"DayCellTemplate date={someDate}"} renders a single
+ explicit cell, and {"GridHeaderCell index={0}"} renders
+ just one weekday header.
+
+
+ Convenience wrappers vs. explicit provider
+
+
+ MonthView and WeeksView fold a{" "}
+ CalendarProvider and the corresponding .Root{" "}
+ into one component — ideal for the common case:
+
+
+ {
+ '\n {/* navigation + grid */}\n \n'
+ }
+
+
+ Use the explicit form when you need to place the provider yourself — for
+ example, to share one selection between two views, or to read calendar
+ state from components that live outside the view:
+
+
+ {
+ '\n {/* grids */} \n {/* reads state via hooks, outside the view */}\n \n'
+ }
+
+
+ Both forms accept the same props; the wrapper simply forwards view props
+ to .Root and everything else to the provider.
+
+
+ The render prop
+
+
+ Every component accepts a render prop (the same pattern as{" "}
+ Base UI )
+ for when className and CSS aren't enough. Pass a function
+ that receives the merged DOM props and a typed state{" "}
+ object, and return the element you want:
+
+
+ {
+ " (\n \n )}\n/>\n"
+ }
+
+ Rules of thumb:
+
+
+
+ Always spread props
+ {" "}
+ onto your element — they carry the event handlers, ARIA attributes,
+ and data-* state the library manages.
+
+
+
+ state is read-only and fully typed
+ {" "}
+ per component (DayButtonState,{" "}
+ WeekTemplateState, RangeSelectedState, …).
+ Every state also includes state.root (a{" "}
+ RootState) with calendar-wide values:{" "}
+ selected, rangeStart/rangeEnd,{" "}
+ viewing, focused, locale,{" "}
+ timeZone, and more.
+
+
+ Keep the element type semantically equivalent (a {""}{" "}
+ for cell parts, a {""} for interactive parts) so
+ the grid semantics survive.
+
+
+
+ Building your own parts
+
+
+ When the shipped parts aren't enough, use the same hooks and contexts
+ they're built from:
+
+
+
+ useCalendarStable() — stable config and actions:{" "}
+ onSelect(date), setRange(start, end),{" "}
+ selectionMode, minValue/
+ maxValue, temporal, locale,{" "}
+ timeZone, weekStartDay.
+
+
+ useCalendarState() — live selection state:{" "}
+ selected, selectedDates,{" "}
+ rangeStart/rangeEnd,{" "}
+ hoveredDate, previewStart/
+ previewEnd.
+
+
+ useMonthViewState() / useWeeksViewState() —
+ view state: the visible month data (allMonths) or the
+ weeks window (windowInfo).
+
+
+ DayCellDataContext / GridContext — inside a
+ cell, the cell's date and the grid's{" "}
+ orientation.
+
+
+
+ For example, a footer that shows the current selection:
+
+
+ {
+ 'function SelectionSummary() {\n const { locale } = useCalendarStable();\n const { rangeStart, rangeEnd } = useCalendarState();\n if (!rangeStart) return Select a start date
;\n return (\n \n {rangeStart.toLocaleString(locale)}\n {rangeEnd ? ` – ${rangeEnd.toLocaleString(locale)}` : " – …"}\n
\n );\n}\n'
+ }
+
+
+ Any component using these hooks must be rendered inside the provider
+ (and inside the view root for the view hooks).
+
+
+ Multiple months
+
+
+ One MonthView.Root can display several consecutive months.
+ Set numberOfMonths, then render one Grid per
+ month with monthIndex:
+
+
+
+ {"MonthYearString monthIndex={i}"} labels each grid;
+ navigation buttons page the whole window. See{" "}
+ MonthView for details.
+
+
+ );
+}
diff --git a/website/src/routes/docs/dates-and-formats.tsx b/website/src/routes/docs/dates-and-formats.tsx
new file mode 100644
index 0000000..99d14b5
--- /dev/null
+++ b/website/src/routes/docs/dates-and-formats.tsx
@@ -0,0 +1,285 @@
+// Auto-generated from dates-and-formats.md — do not edit
+import { createFileRoute } from "@tanstack/react-router";
+import * as Tags from "#/components/markdoc";
+import { PROJECT_NAME } from "#/config";
+
+const frontmatter = {
+ title: "Dates & formats",
+ description:
+ "Temporal, the temporal prop, value formats, subpath entry points, time zones, and locales.",
+ order: 13,
+ section: "Guides",
+};
+
+export const Route = createFileRoute("/docs/dates-and-formats")({
+ loader: () => ({ frontmatter }),
+ head: () => ({
+ meta: [
+ { title: `${frontmatter.title} - ${PROJECT_NAME}` },
+ ...(frontmatter.description
+ ? [{ name: "description", content: frontmatter.description }]
+ : []),
+ ],
+ }),
+ component: DocContent,
+});
+
+function DocContent() {
+ return (
+
+
+ Colander does its date math with the{" "}
+ Temporal API —
+ JavaScript's modern, immutable, time-zone-explicit date library. This
+ page covers how to supply a Temporal implementation, how to choose what
+ type your selected values are, and how time zones and locales
+ flow through the calendar.
+
+
+ Providing Temporal
+
+
+ The library deliberately doesn't bundle a Temporal implementation. Pass
+ one through the temporal prop on{" "}
+ CalendarProvider (or the MonthView/
+ WeeksView wrappers):
+
+
+ {
+ 'import { Temporal } from "@js-temporal/polyfill";\n\n{/* … */} ;\n'
+ }
+
+ Two well-supported polyfills:
+
+
+ If temporal is omitted, the library falls back to the
+ native globalThis.Temporal and throws a descriptive error
+ when neither exists. As runtimes ship Temporal natively, you delete the
+ polyfill and the prop — nothing else changes.
+
+
+
+ Whichever polyfill you choose, import it in one place {" "}
+ and pass the same namespace to every calendar. Temporal objects from
+ different implementations shouldn't be mixed.
+
+
+
+ The format prop
+
+
+ Internally the calendar always works in Temporal types. The{" "}
+ format prop chooses the type of the values that cross the
+ boundary to your code — value,{" "}
+ defaultValue, min, max, and the
+ payloads of onValueChange:
+
+
+
+
+
+ format
+
+ Value type
+ Use when
+
+
+
+
+
+ "PlainDate" (default)
+
+
+ Temporal.PlainDate
+
+ You need a calendar date, nothing more. The right default.
+
+
+
+ "PlainDateTime"
+
+
+ Temporal.PlainDateTime
+
+
+ A date with a wall-clock time (existing time is preserved when the
+ date changes).
+
+
+
+
+ "ZonedDateTime"
+
+
+ Temporal.ZonedDateTime
+
+ A precise instant in a time zone.
+
+
+
+ "PlainYearMonth"
+
+
+ Temporal.PlainYearMonth
+
+ Month-granularity values.
+
+
+
+ "PlainMonthDay"
+
+
+ Temporal.PlainMonthDay
+
+ Recurring dates like birthdays.
+
+
+
+ "object"
+
+
+ {"{ year, month, day, … }"}
+
+
+ Framework-agnostic plain objects (serialization, form state).
+
+
+
+
+ "Date"
+
+
+ Date
+
+
+ Interop with legacy code that speaks Date.
+
+
+
+
+
+ {
+ ' {\n // value: Temporal.ZonedDateTime | null\n }}\n>\n {/* … */}\n \n'
+ }
+
+
+ The format only shapes the props and callbacks .
+ Render-prop state objects and hook values always expose
+ plain Temporal types (Temporal.PlainDate days, etc.)
+ regardless of format, so component code stays uniform.
+
+
+ Format-narrowed entry points
+
+
+ The main entry point types values with a generic parameter. If you'd
+ rather have the types pre-narrowed — and skip passing{" "}
+ format — import from a format subpath:
+
+
+ {
+ 'import {\n MonthView,\n type DateRange, // already DateRange<"PlainDate">\n} from "@klinking/colander/plain-date";\n'
+ }
+
+
+ Available subpaths: /plain-date,{" "}
+ /plain-date-time, /plain-month-day,{" "}
+ /plain-year-month, /zoned-date-time,{" "}
+ /object, and /date. Each re-exports the whole
+ API with CalendarProvider, MonthView,{" "}
+ WeeksView, and the value types bound to that format.
+
+
+ Bounds and disabled dates
+
+
+ min and max (in your value format) disable
+ everything outside them, and keyboard focus is clamped to the bounds.{" "}
+ isDateDisabled handles irregular rules and always receives
+ a Temporal.PlainDate:
+
+
+ {
+ ' holidays.has(date.toString())}\n>\n {/* … */}\n \n'
+ }
+
+
+ Bounds restrict selection . Whether users can still scroll the
+ view past them is the per-view outOfRangeBehavior prop —
+ see MonthView and{" "}
+ WeeksView .
+
+
+ Time zones
+
+
+ timeZone (an IANA identifier, defaulting to the system
+ zone) determines:
+
+
+
+ which day is highlighted as today ,
+
+ how partial or zoned values convert to grid days,
+
+ the zone of emitted ZonedDateTime values.
+
+
+
+ Plain formats like PlainDate are zone-independent by nature
+ — selecting June 20 means June 20, no matter where the user is. That's
+ most of the reason to prefer them.
+
+
+ Locale and week start
+
+
+ locale (BCP 47, default "en-US") localizes
+ weekday headers, month/year labels, and day aria-labels via{" "}
+ Intl.DateTimeFormat. It does not change
+ the week's first day — that's explicit, so it never surprises you:
+
+
+ {
+ '\n {/* lun. mar. mer. jeu. ven. sam. dim. */}\n \n'
+ }
+
+
+ weekStartDay takes 0 (Sunday, default) through{" "}
+ 6 (Saturday) and consistently drives the grid column order,
+ week-number calculations, and Home/End{" "}
+ keyboard navigation.
+
+
+ Displaying values
+
+
+ For formatted output inside the calendar, use the built-in string
+ components — they read the calendar's locale and accept{" "}
+ Intl.DateTimeFormat options:
+
+
+ {
+ ' \n \n \n'
+ }
+
+
+ Outside the calendar, Temporal values format themselves:{" "}
+ {'date.toLocaleString("de-DE", { dateStyle: "long" })'}.
+
+
+ );
+}
diff --git a/website/src/routes/docs/getting-started.tsx b/website/src/routes/docs/getting-started.tsx
deleted file mode 100644
index 99dbaf3..0000000
--- a/website/src/routes/docs/getting-started.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-// Auto-generated from getting-started.md — do not edit
-import { createFileRoute } from "@tanstack/react-router";
-import * as Tags from "#/components/markdoc";
-import { PROJECT_NAME } from "#/config";
-
-const frontmatter = {
- title: "Getting Started",
- description:
- "Install Colander and start building accessible calendar components.",
- order: 1,
- section: "Guides",
-};
-
-export const Route = createFileRoute("/docs/getting-started")({
- loader: () => ({ frontmatter }),
- head: () => ({
- meta: [
- { title: `${frontmatter.title} - ${PROJECT_NAME}` },
- ...(frontmatter.description
- ? [{ name: "description", content: frontmatter.description }]
- : []),
- ],
- }),
- component: DocContent,
-});
-
-function DocContent() {
- return (
-
-
- Installation
-
-
-
- Basic Usage
-
-
- Colander provides two calendar views that share state via{" "}
- CalendarProvider:
-
-
-
- MonthView — Traditional month grid
-
-
- WeeksView — Continuous scrolling weeks
-
-
-
- Quick Example
-
-
-
- );
-}
diff --git a/website/src/routes/docs/introduction.tsx b/website/src/routes/docs/introduction.tsx
new file mode 100644
index 0000000..b86731a
--- /dev/null
+++ b/website/src/routes/docs/introduction.tsx
@@ -0,0 +1,262 @@
+// Auto-generated from introduction.md — do not edit
+import { createFileRoute } from "@tanstack/react-router";
+import * as Tags from "#/components/markdoc";
+import { PROJECT_NAME } from "#/config";
+
+const frontmatter = {
+ title: "Introduction",
+ description:
+ "What Colander is, the ideas behind it, and how it compares to other React date pickers.",
+ order: 1,
+ section: "Overview",
+};
+
+export const Route = createFileRoute("/docs/introduction")({
+ loader: () => ({ frontmatter }),
+ head: () => ({
+ meta: [
+ { title: `${frontmatter.title} - ${PROJECT_NAME}` },
+ ...(frontmatter.description
+ ? [{ name: "description", content: frontmatter.description }]
+ : []),
+ ],
+ }),
+ component: DocContent,
+});
+
+function DocContent() {
+ return (
+
+
+ Colander is a library of unstyled React components for building
+ calendars and date pickers. Instead of shipping a finished date picker
+ with a theme to override, it gives you the primitives — grids, day
+ cells, navigation buttons, range overlays — that you compose and style
+ yourself. You own every pixel; Colander owns the date math, keyboard
+ navigation, selection logic, and accessibility semantics.
+
+
+ {
+ "\n ‹ \n \n › \n \n \n \n \n \n \n \n \n \n \n \n \n \n"
+ }
+
+
+ Core ideas
+
+
+ Headless by design
+
+
+ Every component renders a plain semantic element (
+ {""}, {""},{" "}
+ {""} , …) with no CSS attached. You style with any
+ tool you already use — plain CSS, Tailwind, CSS-in-JS — through three
+ hooks the library exposes:
+
+
+
+
+ className
+ {" "}
+ — every component accepts one, like any React element.
+
+
+
+ data-* attributes
+ {" "}
+ — interaction state is stamped onto the DOM (
+ data-selected, data-today,{" "}
+ data-in-range, data-disabled, …), so most
+ styling is just CSS attribute selectors.
+
+
+
+ The render prop
+ {" "}
+ — swap out the rendered element entirely and receive a fully typed{" "}
+ state object, following the same pattern as{" "}
+ Base UI .
+
+
+
+ See the Styling guide for the full picture.
+
+
+ Temporal-first
+
+
+ Colander is built on the{" "}
+ Temporal API — the
+ modern replacement for JavaScript's Date. Selected values
+ are precise, immutable Temporal objects (Temporal.PlainDate{" "}
+ by default) instead of Date instances that secretly carry a
+ time and a time zone. That eliminates the classic date picker bug class:
+ off-by-one days caused by implicit UTC/local conversions.
+
+
+ You choose the value type per calendar with the format prop
+ — PlainDate, PlainDateTime,{" "}
+ ZonedDateTime, a plain object, or even a legacy{" "}
+ Date if you're integrating with existing code. The library
+ doesn't bundle a Temporal implementation; you pass one in (a ~20 kB
+ polyfill today, the built-in Temporal as runtimes ship it).
+ See Dates & formats .
+
+
+ Two views, one state model
+
+
+
+
+ MonthView
+ {" "}
+ — the traditional paged month grid, including multi-month layouts (up
+ to 12 side by side).
+
+
+
+ WeeksView
+ {" "}
+ — a continuously scrolling window of week rows that spans month
+ boundaries, like the mini-calendars in Google Calendar or Fantastical.
+ You control how many weeks are visible and scroll by row or by page.
+
+
+
+ Both views plug into the same{" "}
+ CalendarProvider state: selection
+ mode, bounds, locale, and time zone are shared, so you can even render
+ both views of the same selection at once.
+
+
+ Selection built for real products
+
+
+ Single, multiple, and range selection are all first-class, each with
+ controlled and uncontrolled modes. Range selection goes well beyond
+ click-twice: a live hover preview, six configurable policies for what a
+ click inside an existing range means (rangeMode), and
+ draggable range-boundary handles. See{" "}
+ Selection modes .
+
+
+ Accessible by default
+
+
+ The grid follows the ARIA grid pattern: roving tab index, arrow-key
+ navigation, Home / End / PageUp /{" "}
+ PageDown, aria-selected and{" "}
+ aria-disabled on day cells, and month labels announced via
+ a live region. Details in{" "}
+ Accessibility .
+
+
+ Why not another date picker?
+
+
+ Plenty of good date pickers exist. Colander makes a different set of
+ trade-offs:
+
+
+
+
+ If you're considering…
+ How Colander differs
+
+
+
+
+
+ react-day-picker
+
+
+ Similar spirit (composable, customizable), but react-day-picker
+ works in Date objects and ships default styles to
+ override. Colander is Temporal-typed end-to-end, fully unstyled,
+ and adds the continuously scrolling WeeksView, range drag handles,
+ and hover previews as primitives.
+
+
+
+
+ react-datepicker
+
+
+ A batteries-included widget: fast to drop in, hard to restyle
+ deeply. Colander is the opposite — more assembly, unlimited
+ control over markup and design.
+
+
+
+
+ MUI X Date Pickers
+
+
+ Excellent inside a Material UI app; heavy outside one. Colander
+ has no design-system dependency and pairs with any styling stack.
+
+
+
+
+ React Aria (hooks)
+
+
+ Comparable headless philosophy and strong a11y. React Aria uses
+ its own @internationalized/date objects and a
+ hooks-first API; Colander uses standard Temporal types and a
+ component/compound API, so markup composition stays in JSX.
+
+
+
+
+
+ Choose Colander when you're building a{" "}
+ design-system-grade calendar — a component you'll style
+ precisely, extend with custom cells or overlays, and keep for years —
+ and you want date values that are actually dates.
+
+
+ When not to use it
+
+ Honesty saves you an afternoon:
+
+
+ You want a finished picker in ten minutes. Colander
+ has no default stylesheet. If you don't want to write styles, use a
+ styled library.
+
+
+ You can't add a Temporal polyfill. Until{" "}
+ Temporal lands natively in the runtimes you target,
+ you'll ship a small polyfill (see the{" "}
+ Quick start ).
+
+
+
+
+ Colander is pre-stable . The API is still evolving and
+ releases are published to the alpha npm dist-tag. Pin
+ your version and read release notes when upgrading. The first stable
+ release will be 3.0.0.
+
+
+
+ Next steps
+
+
+
+ Quick start — install and build your
+ first calendar.
+
+
+ Composition — how the pieces fit
+ together.
+
+
+ Styling — the data-attribute and
+ render-prop styling model.
+
+
+
+ );
+}
diff --git a/website/src/routes/docs/month-view.tsx b/website/src/routes/docs/month-view.tsx
index 76a45f1..b14ae1f 100644
--- a/website/src/routes/docs/month-view.tsx
+++ b/website/src/routes/docs/month-view.tsx
@@ -6,8 +6,8 @@ import { PROJECT_NAME } from "#/config";
const frontmatter = {
title: "MonthView",
description:
- "Displays a traditional calendar grid with month-level navigation.",
- order: 3,
+ "The traditional paged month grid, with multi-month layouts and month-level navigation.",
+ order: 21,
section: "Components",
};
@@ -27,13 +27,189 @@ export const Route = createFileRoute("/docs/month-view")({
function DocContent() {
return (
-
+
+ MonthView displays one or more month grids and pages
+ between them — the classic date picker layout. It manages the visible
+ month(s), keyboard focus, and navigation; selection state comes from its{" "}
+ CalendarProvider .
+
+
+ Use MonthView directly for the common case (it wraps a
+ provider for you), or CalendarProvider +{" "}
+ MonthView.Root when you compose the provider yourself:
+
+
+
+ Navigation
+
+
+
+ PrevMonthButton / NextMonthButton page the
+ view one month at a time and disable themselves at the bounds when{" "}
+ outOfRangeBehavior="stop".
+
+
+ MonthYearString renders the localized current month label
+ (and labels the grid for assistive tech).
+
+
+ Keyboard: PageUp/PageDown page months,{" "}
+ Shift+PageUp/Shift+PageDown page years, and
+ arrowing past the grid edge moves the view automatically (
+ full list ).
+
+
+
+ Controlled month
+
+
+ By default the view manages the visible month itself, starting at the
+ selection or today (defaultMonth overrides the start). To
+ control it — syncing with a URL, an agenda pane, or a "jump to date"
+ input — pass month and update it in{" "}
+ onMonthChange:
+
+
+ {
+ 'const [month, setMonth] = useState(() =>\n Temporal.PlainYearMonth.from("2026-06"),\n);\n\n\n {/* … */}\n ;\n'
+ }
+
+
+ month is an ISO Temporal.PlainYearMonth and
+ round-trips with onMonthChange exactly;{" "}
+ onMonthChange also fires for keyboard-driven month
+ crossings, but never on mount.
+
+
+ Multiple months
+
+
+ Set numberOfMonths (1–12) and render one{" "}
+ {"Grid monthIndex={i}"} per month. Navigation still moves
+ by single months, revealing one new month per click:
+
+
+
+ Give each grid its own label with{" "}
+ {"MonthYearString monthIndex={i}"}. Range selection spans
+ grids naturally — a range can start in one month and end in another.
+
+
+ Grid shape
+
+
+
+
+ fixedWeeks
+ {" "}
+ — always render 6 week rows, padding with adjacent-month days.
+ February 2026 (4 rows) and August 2026 (6 rows) take the same height,
+ so nothing below the calendar jumps as users page.
+
+
+
+ outsideDays
+ {" "}
+ — what to do with the adjacent-month days that pad the first and last
+ weeks:
+
+
+
+
+
+ Value
+ Behavior
+
+
+
+
+
+ "enabled" (default)
+
+ Fully interactive; clicking one selects it
+
+
+
+ "readOnly"
+
+
+ Visible but not selectable; range highlighting still paints
+ through
+
+
+
+
+ "disabled"
+
+ Visible but not selectable; no range highlighting
+
+
+
+ "hidden"
+
+
+ Blank cells (kept in the DOM with aria-hidden for a
+ stable grid shape)
+
+
+
+
+
+ Outside days carry data-outside-month (and{" "}
+ data-hidden when hidden) for styling.
+
+
+ Bounds
+
+
+ min/max disable out-of-range days everywhere.{" "}
+ outOfRangeBehavior additionally decides whether the user
+ can still page past them:
+
+
+
+ "unbounded" (default) — page freely;
+ out-of-range days simply render disabled.
+
+
+ "stop" — PrevMonthButton/
+ NextMonthButton disable once the destination month
+ crosses a bound.
+
+
+
+ Week numbers
+
+
+ Add a WeekNumberHeader to the header row and a{" "}
+ WeekNumberCell at the start of the week template to show
+ ISO week numbers (determined by each row's Thursday, per ISO 8601):
+
+
+ {
+ '\n \n \n \n\n \n \n \n \n \n \n \n'
+ }
+
+
+ API reference
+
+
Props
+
+ MonthView accepts all{" "}
+ CalendarProvider props plus the
+ view props below.
+
-
+
Hooks
+
+ Inside a MonthView, useMonthViewStable() and{" "}
+ useMonthViewState() expose the view's context for custom
+ components:
+
diff --git a/website/src/routes/docs/quick-start.tsx b/website/src/routes/docs/quick-start.tsx
new file mode 100644
index 0000000..e4e2194
--- /dev/null
+++ b/website/src/routes/docs/quick-start.tsx
@@ -0,0 +1,192 @@
+// Auto-generated from quick-start.md — do not edit
+import { createFileRoute } from "@tanstack/react-router";
+import * as Tags from "#/components/markdoc";
+import { PROJECT_NAME } from "#/config";
+
+const frontmatter = {
+ title: "Quick start",
+ description:
+ "Install Colander and build a working, styled calendar in a few minutes.",
+ order: 2,
+ section: "Overview",
+};
+
+export const Route = createFileRoute("/docs/quick-start")({
+ loader: () => ({ frontmatter }),
+ head: () => ({
+ meta: [
+ { title: `${frontmatter.title} - ${PROJECT_NAME}` },
+ ...(frontmatter.description
+ ? [{ name: "description", content: frontmatter.description }]
+ : []),
+ ],
+ }),
+ component: DocContent,
+});
+
+function DocContent() {
+ return (
+
+
+ This walkthrough takes you from an empty file to a working, styled month
+ calendar.
+
+
+ Install the package
+
+
+ Install @klinking/colander and a Temporal polyfill in your React
+ project:
+
+
+
+ Colander has three peer dependencies — react,{" "}
+ react-dom (18+), and @base-ui/react — which
+ your package manager installs automatically. The Temporal polyfill is
+ explicit on purpose: the library doesn't bundle one, so you control
+ which implementation you ship (see{" "}
+ Dates & formats for the options).
+
+
+
+ Releases are currently published to the{" "}
+
+ alpha
+ {" "}
+ dist-tag while the API stabilizes —{" "}
+ @klinking/colander@alpha installs the latest prerelease.
+
+
+
+ Provide Temporal
+
+
+ Every calendar needs a Temporal implementation. Import it
+ once and pass it via the temporal prop:
+
+
+ {
+ 'import { Temporal } from "@js-temporal/polyfill";\n\n{/* … */} ;\n'
+ }
+
+
+ If the runtime already exposes the native Temporal global,
+ the prop can be omitted.
+
+
+ Assemble the calendar
+
+
+ Colander components are compound parts you compose in JSX, so your
+ markup mirrors the calendar's actual structure. Here is a complete
+ single-month calendar with navigation:
+
+
+ A quick tour of the parts:
+
+
+
+ MonthView
+ {" "}
+ — the all-in-one root. It owns selection state and month navigation,
+ and reports selection through onValueChange. (Under the
+ hood it composes a{" "}
+ CalendarProvider with a{" "}
+ MonthView.Root — you can also use those two directly when
+ you need more control.)
+
+
+
+ PrevMonthButton / NextMonthButton
+ {" "}
+ — {""} s that page the visible month and disable
+ themselves at your min/max bounds.
+
+
+
+ MonthYearString
+ {" "}
+ — a localized "June 2026" label, wired to the grid via{" "}
+ aria-labelledby and announced politely when the month
+ changes.
+
+
+
+ Grid, GridHeader,{" "}
+ GridHeaderCell, GridBody
+ {" "}
+ — the calendar table. A single GridHeaderCell with no{" "}
+ index renders all seven localized weekday headers.
+
+
+
+ WeekTemplate / DayCellTemplate /{" "}
+ DayButton
+ {" "}
+ — templates : you write one row, one cell, and one button, and
+ the library stamps them out for every week and day in view.
+
+
+
+ Read the selection
+
+
+ onValueChange receives the new value in your configured
+ format — a Temporal.PlainDate | null by default:
+
+
+ {
+ 'function BookingForm() {\n const [date, setDate] = useState(null);\n\n return (\n <>\n \n {date ? date.toLocaleString() : "Pick a date"}
\n >\n );\n}\n'
+ }
+
+
+ The example above is uncontrolled (the calendar keeps its own
+ state). To control it, pass value and update it from{" "}
+ onValueChange — see{" "}
+ Selection modes for the full rules,
+ plus range and multi-select.
+
+
+ Style it
+
+
+ Nothing you've rendered so far has any appearance — that's yours.
+ Components accept className like any element, and they
+ expose their interaction state as data-* attributes, so
+ most styling is plain CSS:
+
+
+ {
+ ".calendar {\n inline-size: 20rem;\n font: inherit;\n}\n\n.calendar-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n margin-block-end: 0.5rem;\n}\n\n.calendar-nav {\n inline-size: 2rem;\n block-size: 2rem;\n border: none;\n border-radius: 0.375rem;\n background: transparent;\n cursor: pointer;\n}\n\n.calendar-nav:hover {\n background: #f1f0ef;\n}\n\n.calendar-nav:disabled {\n opacity: 0.4;\n cursor: default;\n}\n\n.calendar-grid {\n inline-size: 100%;\n border-collapse: collapse;\n}\n\n.calendar-weekday {\n padding-block: 0.25rem;\n font-size: 0.75rem;\n font-weight: 500;\n color: #6f6d66;\n}\n\n.calendar-day {\n inline-size: 2.25rem;\n block-size: 2.25rem;\n border: none;\n border-radius: 0.375rem;\n background: transparent;\n cursor: pointer;\n}\n\n.calendar-day:hover {\n background: #f1f0ef;\n}\n\n.calendar-day[data-today] {\n font-weight: 700;\n}\n\n.calendar-day[data-outside-month] {\n color: #b5b3ad;\n}\n\n.calendar-day[data-selected] {\n background: #1a1a17;\n color: #fff;\n}\n\n.calendar-day[data-disabled] {\n opacity: 0.4;\n cursor: default;\n}\n"
+ }
+
+
+ The Styling guide covers the complete
+ data-attribute reference, Tailwind usage, and the render{" "}
+ prop for cases where CSS alone isn't enough.
+
+
+ Next steps
+
+
+
+ );
+}
diff --git a/website/src/routes/docs/selection-modes.tsx b/website/src/routes/docs/selection-modes.tsx
new file mode 100644
index 0000000..fcd728f
--- /dev/null
+++ b/website/src/routes/docs/selection-modes.tsx
@@ -0,0 +1,291 @@
+// Auto-generated from selection-modes.md — do not edit
+import { createFileRoute } from "@tanstack/react-router";
+import * as Tags from "#/components/markdoc";
+import { PROJECT_NAME } from "#/config";
+
+const frontmatter = {
+ title: "Selection modes",
+ description:
+ "Single, multiple, and range selection — controlled and uncontrolled — plus range previews and drag handles.",
+ order: 12,
+ section: "Guides",
+};
+
+export const Route = createFileRoute("/docs/selection-modes")({
+ loader: () => ({ frontmatter }),
+ head: () => ({
+ meta: [
+ { title: `${frontmatter.title} - ${PROJECT_NAME}` },
+ ...(frontmatter.description
+ ? [{ name: "description", content: frontmatter.description }]
+ : []),
+ ],
+ }),
+ component: DocContent,
+});
+
+function DocContent() {
+ return (
+
+
+ Selection is configured on the{" "}
+ CalendarProvider (or on the{" "}
+ MonthView / WeeksView wrappers, which forward
+ to it) with the selectionMode prop: "single"{" "}
+ (default), "multiple", or "range". The mode
+ determines the shape of value, defaultValue,
+ and the argument to onValueChange.
+
+
+ Controlled vs. uncontrolled
+
+
+ Every mode supports both patterns, with the usual React rules:
+
+
+
+ Uncontrolled — omit value{" "}
+ entirely and optionally seed with defaultValue. The
+ calendar manages its own state; onValueChange still
+ reports every change.
+
+
+ Controlled — pass value and keep it
+ updated from onValueChange. Pass{" "}
+ {"value={null}"} (or [] in multiple mode) to
+ clear the selection — don't pass {"value={undefined}"},
+ which means "uncontrolled".
+
+
+
+ onValueChange(value, meta) receives the new value in your
+ configured format , plus a{" "}
+ meta object with the clicked date (a{" "}
+ Temporal.PlainDate, or undefined for non-click
+ changes) and the previous value.
+
+
+ Single
+
+
+ One date or null. Clicking the selected date again keeps it
+ selected; clicking another date moves the selection.
+
+
+ {
+ "const [date, setDate] = useState(null);\n\n\n {/* … */}\n ;\n"
+ }
+
+
+ Multiple
+
+
+ An array of dates, kept sorted oldest-first. Clicking an unselected date
+ adds it; clicking a selected date removes it.
+
+
+ {
+ 'const [dates, setDates] = useState([]);\n\n\n {/* … */}\n ;\n'
+ }
+
+
+ Range
+
+
+ A {"{ start, end }"} pair (DateRange), either
+ boundary possibly null while the range is in progress. The
+ first click sets the start; hovering shows a live preview; the second
+ click commits the end.
+
+
+
+ Style ranges with the data-in-range /{" "}
+ data-range-start / data-range-end attributes
+ on day cells, or with the RangeSelected overlay — both
+ covered in Styling .
+
+
+ Clicking inside an existing range: rangeMode
+
+
+ Once a full range exists, what should the next click do?{" "}
+ rangeMode makes that policy explicit:
+
+
+
+
+
+ rangeMode
+
+ Behavior on click
+
+
+
+
+
+ "nearest-end" (default)
+
+
+ Moves whichever boundary is closer to the clicked date; ties move
+ the end
+
+
+
+
+ "nearest-start"
+
+ Same, but ties move the start
+
+
+
+ "adjust-end"
+
+ Always moves the end to the clicked date
+
+
+
+ "adjust-start"
+
+ Always moves the start to the clicked date
+
+
+
+ "start-end"
+
+
+ Two-step: the click starts a fresh range (click again to set its
+ end)
+
+
+
+
+ "reset"
+
+ Collapses to a single-day range on the clicked date
+
+
+
+
+ Reversed selections
+
+
+ If the user picks an end date before the start date, the range
+ is auto-sorted by default (select June 20 then June 10 → June 10–20).
+ Set preventRangeReversal to instead collapse reversed picks
+ to a single-day range — useful when "backwards" selection is likely a
+ mistake in your UX.
+
+
+ The hover preview
+
+
+ While a range is in progress (and when hovering with{" "}
+ rangeMode policies that would move a boundary), the
+ provider computes a preview range — the range that{" "}
+ would be committed if the user clicked the hovered day. It
+ surfaces in three places:
+
+
+
+ data-range-preview-* attributes on day cells,
+
+
+ the RangePreview overlay component,
+
+
+ onHoveredDateChange, if you want to react to hovering
+ yourself.
+
+
+
+ You can also take the preview over entirely with the{" "}
+ previewRange prop — pass a DateRange to
+ display, or null to hide it. This is how you'd preview a
+ range from an external input (e.g. "next weekend" buttons above the
+ calendar).
+
+
+ Setting a range programmatically
+
+
+ Components inside the provider can commit a range directly with{" "}
+ setRange from useCalendarStable():
+
+
+ {
+ "function NextWeekButton() {\n const { setRange, temporal } = useCalendarStable();\n return (\n {\n const start = temporal.Now.plainDateISO().add({ days: 7 });\n setRange(start, start.add({ days: 6 }));\n }}\n >\n Next week\n \n );\n}\n"
+ }
+
+
+ (For external control, the controlled value prop does the
+ same job.)
+
+
+ Drag handles
+
+
+ RangeStartDragHandle and RangeEndDragHandle{" "}
+ render grab affordances inside day buttons at the range boundaries,
+ letting users drag either end of a committed range. Place them inside{" "}
+ DayButton (via children or render); they
+ position themselves only on the boundary days and stay{" "}
+ aria-hidden elsewhere:
+
+
+ {
+ '\n (\n \n {state.date.day}\n \n \n \n )}\n />\n \n'
+ }
+
+
+ Dragging is a pointer enhancement — every range edit remains possible by
+ click and keyboard (see Accessibility
+ ).
+
+
+ Restricting selectable dates
+
+
+ Three props combine to disable dates in every mode:
+
+
+
+ min / max — bounds in your configured value
+ format; days outside are disabled and keyboard focus is clamped to
+ them.
+
+
+ isDateDisabled — a predicate for irregular rules
+ (weekends, blackout dates):
+
+
+
+ {
+ ' date.dayOfWeek >= 6}\n>\n {/* … */}\n \n'
+ }
+
+
+
+ disabled / readOnly — disable the whole
+ calendar, or allow browsing but not selecting.
+
+
+
+ Note that min/max restrict selection ;
+ whether the user can still navigate past them is a per-view
+ choice via outOfRangeBehavior — see{" "}
+ MonthView and{" "}
+ WeeksView .
+
+
+ Switching modes at runtime
+
+
+ If selectionMode changes while the calendar is mounted
+ (e.g. a "range" toggle in your UI), an uncontrolled calendar truncates
+ its state sensibly (range → its start date, etc.) and reports the change
+ through onValueChange. A controlled calendar leaves
+ reconciling value to you.
+
+
+ );
+}
diff --git a/website/src/routes/docs/styling.tsx b/website/src/routes/docs/styling.tsx
new file mode 100644
index 0000000..3e2ecd4
--- /dev/null
+++ b/website/src/routes/docs/styling.tsx
@@ -0,0 +1,365 @@
+// Auto-generated from styling.md — do not edit
+import { createFileRoute } from "@tanstack/react-router";
+import * as Tags from "#/components/markdoc";
+import { PROJECT_NAME } from "#/config";
+
+const frontmatter = {
+ title: "Styling",
+ description:
+ "Style Colander with plain CSS, Tailwind, or any tool — via data attributes and the render prop.",
+ order: 11,
+ section: "Guides",
+};
+
+export const Route = createFileRoute("/docs/styling")({
+ loader: () => ({ frontmatter }),
+ head: () => ({
+ meta: [
+ { title: `${frontmatter.title} - ${PROJECT_NAME}` },
+ ...(frontmatter.description
+ ? [{ name: "description", content: frontmatter.description }]
+ : []),
+ ],
+ }),
+ component: DocContent,
+});
+
+function DocContent() {
+ return (
+
+
+ Colander ships zero CSS. Every component renders a semantic element you
+ style yourself, and exposes its interaction state on the DOM so your
+ styles can react to it. There are three layers, from simplest to most
+ powerful:
+
+
+
+
+ className
+ {" "}
+ — every part accepts one.
+
+
+
+ data-* attributes
+ {" "}
+ — state stamped onto the element; target it with CSS attribute
+ selectors.
+
+
+
+ The render prop
+ {" "}
+ — replace the element and read the typed state object
+ directly (see Composition ).
+
+
+
+ Styling with data attributes
+
+
+ State becomes presence-style attributes: data-selected is
+ present when a day is selected and absent otherwise. In CSS:
+
+
+ {
+ ".day[data-selected] {\n background: black;\n color: white;\n}\n\n.day[data-today] {\n font-weight: 700;\n}\n\n.day[data-outside-month] {\n opacity: 0.45;\n}\n\n.day[data-disabled] {\n opacity: 0.35;\n pointer-events: none;\n}\n"
+ }
+
+
+ With Tailwind, use the data-* variants directly on{" "}
+ className:
+
+
+ {
+ ' \n'
+ }
+
+
+ Data attribute reference
+
+
+ DayCellTemplate and DayButton
+
+
+
+
+ Attribute
+ Present when
+
+
+
+
+
+ data-date="2026-06-20"
+
+ Always — the cell's ISO date (a value, not a flag)
+
+
+
+ data-selected
+
+ The day is selected
+
+
+
+ data-today
+
+
+ The day is today (in the calendar's timeZone)
+
+
+
+
+ data-disabled
+
+
+ Disabled via min/max,{" "}
+ isDateDisabled, or disabled
+
+
+
+
+ data-focused
+
+ The day is the grid's logically focused cell
+
+
+
+ data-outside-month
+
+ The day belongs to an adjacent month
+
+
+
+ data-hidden
+
+
+ The cell is blanked by outsideDays="hidden"
+
+
+
+
+ data-orientation
+
+
+ "horizontal" or "vertical" (a value)
+
+
+
+
+ Range selection adds:
+
+
+
+ Attribute
+ Present when
+
+
+
+
+
+ data-in-range
+
+ The day is inside the committed range
+
+
+
+ data-range-start / data-range-end
+
+ The day is the range's first / last day
+
+
+
+ data-range-boundary
+
+ Either of the above
+
+
+
+ data-range-index / data-range-length
+
+ Position within / size of the range (values)
+
+
+
+ data-range-has-start /{" "}
+ data-range-has-end
+
+ The range's boundary is defined
+
+
+
+
+ The hover preview range (see{" "}
+ Selection modes ) mirrors the same
+ set with a data-range-preview-* prefix:{" "}
+ data-range-preview-in-range,{" "}
+ data-range-preview-start,{" "}
+ data-range-preview-end,{" "}
+ data-range-preview-boundary, and so on — so you can render
+ "what would happen if you clicked here" more subtly than the committed
+ range.
+
+
+ Other parts
+
+
+
+
+ Component
+ Attributes
+
+
+
+
+
+ Grid
+
+
+ data-orientation, data-days-per-week,{" "}
+ data-weeks-in-month
+
+
+
+
+ PrevMonthButton / NextMonthButton
+
+
+ data-direction="prev" / "next", native{" "}
+ disabled at bounds
+
+
+
+
+ PrevWeeksButton / NextWeeksButton
+
+
+ data-direction, native disabled at
+ bounds
+
+
+
+
+ RangeSelected / RangePreview
+
+
+ data-active, data-week-index,{" "}
+ data-start-index, data-end-index,{" "}
+ data-start-date, data-end-date,{" "}
+ data-extends-before, data-extends-after,{" "}
+ data-has-start, data-has-end,{" "}
+ data-orientation
+
+
+
+
+ RangeStartDragHandle /{" "}
+ RangeEndDragHandle
+
+
+ data-active, data-dragging,{" "}
+ data-edge="start" / "end",{" "}
+ data-orientation
+
+
+
+
+ WeekNumberCell
+
+
+ data-week-number
+
+
+
+
+ MonthSeparator parts
+
+
+ data-month, data-year,{" "}
+ data-first-of-year, data-first-visible,{" "}
+ data-first-day-column,{" "}
+ data-grid-row-start, …
+
+
+
+
+
+ The generated API pages (e.g.{" "}
+ DayButtonState ) list the state
+ each component exposes; every boolean state maps to a presence attribute
+ of the same kebab-cased name.
+
+
+ Styling day states: a range example
+
+
+ Range styling composes from the day attributes alone — no extra
+ components needed:
+
+
+ {
+ ".day[data-in-range] {\n background: #e8e6e1;\n border-radius: 0;\n}\n\n.day[data-range-start] {\n border-start-start-radius: 0.375rem;\n border-end-start-radius: 0.375rem;\n}\n\n.day[data-range-end] {\n border-start-end-radius: 0.375rem;\n border-end-end-radius: 0.375rem;\n}\n\n.day[data-range-boundary] {\n background: #1a1a17;\n color: #fff;\n}\n\n/* subtler hover preview */\n.day[data-range-preview-in-range] {\n outline: 1px dashed #b5b3ad;\n}\n"
+ }
+
+
+ Range overlays: RangeSelected and RangePreview
+
+
+ For pill-shaped range highlights that render behind a whole
+ week's days as one element (rather than per-cell backgrounds), add{" "}
+ RangeSelected / RangePreview inside your{" "}
+ WeekTemplate. They're {""} overlays that
+ report where the range intersects the week: startIndex /{" "}
+ endIndex (column positions), extendsBefore /{" "}
+ extendsAfter (whether the range continues into adjacent
+ weeks — flatten the pill's corners on that side), and{" "}
+ active (whether the range touches this week at all).
+
+
+ Because positioning an overlay across table columns requires a CSS-grid
+ layout, these are typically used with the render prop:
+
+
+ {
+ '\n \n state.active ? (\n \n ) : (\n \n )\n }\n />\n {/* … */} \n \n'
+ }
+
+
+ This pairs with laying out Grid as{" "}
+ display: grid — see the interactive{" "}
+ demo source for a complete Tailwind implementation,
+ including vertical orientation and week-number offsets.
+
+
+ Grid layout tips
+
+
+
+ Grid renders a {""}, which styles
+ fine for simple calendars. For overlays, subgrid tricks, or vertical
+ orientation, restyle it with display: grid — the library
+ sets the CSS custom properties --calendar-days-per-week{" "}
+ and --calendar-weeks-in-month on the grid element so your
+ template can use{" "}
+
+ grid-template-columns: repeat(var(--calendar-days-per-week), 1fr)
+
+ .
+
+
+ WeekTemplate exposes state.gridRowIndex{" "}
+ (WeeksView only) and DayCellTemplate exposes{" "}
+ state.columnIndex + state.orientation for
+ explicit grid placement via render.
+
+
+ Don't forget :focus-visible styles on{" "}
+ DayButton — see{" "}
+ Accessibility .
+
+
+
+ );
+}
diff --git a/website/src/routes/docs/weeks-view.tsx b/website/src/routes/docs/weeks-view.tsx
index 2914b81..2838c37 100644
--- a/website/src/routes/docs/weeks-view.tsx
+++ b/website/src/routes/docs/weeks-view.tsx
@@ -6,8 +6,8 @@ import { PROJECT_NAME } from "#/config";
const frontmatter = {
title: "WeeksView",
description:
- "Displays a configurable window of continuous week rows that span month boundaries.",
- order: 4,
+ "A continuously scrolling window of week rows that spans month boundaries.",
+ order: 22,
section: "Components",
};
@@ -27,18 +27,266 @@ export const Route = createFileRoute("/docs/weeks-view")({
function DocContent() {
return (
-
+
+ WeeksView renders a fixed-height{" "}
+ window of week rows that scrolls continuously through
+ the calendar — no page flips, no month boundaries. It's the layout
+ behind agenda-style mini calendars (think Google Calendar's sidebar):
+ June's last week and July's first week can sit next to each other in the
+ same view.
+
+
+ Compared to MonthView :
+
+
+
+
+
+ MonthView
+ WeeksView
+
+
+
+
+ Unit of display
+ Whole months
+ Any consecutive weeks
+
+
+ Navigation
+ Page by month
+ Scroll by week row or page
+
+
+ Window height
+
+ Rows per month (or fixedWeeks)
+
+
+ Always exactly weekCount rows
+
+
+
+ Month labels
+ One per grid
+
+ MonthSeparator parts inside the flow
+
+
+
+
+
+ Both views share the same grid parts, selection model, and provider —
+ switching between them is mostly a matter of swapping the root and
+ navigation.
+
+
+
+ The window
+
+
+
+
+ weekCount
+ {" "}
+ (required) — how many week rows are visible.
+
+
+
+ firstWeek / defaultFirstWeek /{" "}
+ onFirstWeekChange
+ {" "}
+ — the controlled / uncontrolled first visible week. Any date-like
+ value works — a FirstWeekSpec is resolved to the
+ containing week and snapped to weekStartDay:
+
+
+
+ {
+ ' \n// also accepted:\n// Temporal.PlainDate.from("2026-06-15")\n// new Date(2026, 5, 15)\n// { isoWeek: 25, isoYear: 2026 }\n// { week: 25, year: 2026 } (relative to weekStartDay)\n'
+ }
+
+
+
+
+ onWindowChange
+ {" "}
+ — fires with a WindowInfo snapshot whenever the window
+ moves: windowStart/windowEnd, day and week
+ counts, how many of those are enabled, and the{" "}
+ visibleMonths list — handy for rendering a "Jun – Jul
+ 2026" heading (see the example above, which reads the same data from{" "}
+ useWeeksViewState().windowInfo).
+
+
+
+ Scrolling
+
+
+
+
+ PrevWeeksButton / NextWeeksButton
+ {" "}
+ shift the window;{" "}
+
+ scrollBy
+ {" "}
+ decides the step — "row" (one week, default) or{" "}
+ "page" (a full weekCount).
+
+
+
+ WeekCount
+ {" "}
+ renders the number of visible weeks, if you want it in your UI.
+
+
+ Keyboard: arrowing or paging focus past the window edge scrolls it
+ automatically.
+
+
+ Imperative scrolling — WeeksView (and{" "}
+ WeeksView.Root) forwards a ref with a{" "}
+ {"scrollToWeek(target, { snap })"} handle:
+
+
+
+ {
+ 'const ref = useRef(null);\n\n\n {/* … */}\n ;\n\n// later:\nref.current?.scrollToWeek(Temporal.PlainDate.from("2026-09-01"), {\n snap: "center",\n});\n'
+ }
+
+
+ snap positions the target within the window:{" "}
+ "start" (default), "center",{" "}
+ "end", or "nearest" — which scrolls only if
+ the target is outside the window, choosing the closer edge.
+
+
+ Behavior at the bounds
+
+
+ With min/max set,{" "}
+ outOfRangeBehavior controls how the window treats
+ the bounds (selection is always restricted regardless):
+
+
+
+
+ Value
+ Behavior
+
+
+
+
+
+ "unbounded" (default)
+
+ Scroll freely; out-of-range days render disabled
+
+
+
+ "stop"
+
+
+ Nav buttons disable once the next step would show no in-range day
+
+
+
+
+ "stop-shrink"
+
+
+ Like "stop", but the window shrinks near the edge
+ instead of showing fully-disabled rows
+
+
+
+
+ "snap"
+
+
+ Overshooting jumps snap the window edge to the first/last in-range
+ week
+
+
+
+
+ "snap-shrink"
+
+ Snap, then trim any remaining fully-disabled rows
+
+
+
+
+ "snap" and "snap-shrink" only differ when the
+ selectable range spans fewer weeks than weekCount:
+ snapping can pin only one edge, so the window overhangs the other —{" "}
+ "snap" keeps the full height (padding with disabled rows)
+ while "snap-shrink" trims to just the in-range weeks. With{" "}
+ weekCount: 6 and bounds spanning 2 weeks,{" "}
+ "snap" shows 6 rows (4 disabled),{" "}
+ "snap-shrink" shows 2.
+
+
+ Month separators
+
+
+ Because months flow into each other, MonthSeparator parts
+ let you mark where a new month begins inside the grid — a border above
+ its first week, a rotated month label in a side column, however you
+ like. MonthSeparatorRow repeats for each month whose first
+ day is in view and exposes layout state (firstDayColumn,{" "}
+ firstDayVisible, gridRowStart,{" "}
+ fullWeeksVisibleAfter) via its render prop,
+ with MonthSeparatorMonth / MonthSeparatorYear{" "}
+ for localized labels:
+
+
+ {
+ '\n (\n \n \n {state.firstDayVisible && (\n \n \n
\n )}\n \n \n )}\n />\n {/* … */} \n \n'
+ }
+
+
+ Like the range overlays, separators assume a CSS-grid layout on{" "}
+ Grid — see Styling and the{" "}
+ demo source for a complete implementation.
+
+
+ Week numbers
+
+
+ WeekNumberHeader / WeekNumberCell work exactly
+ as in MonthView .
+
+
+ API reference
+
+
Props
+
+ WeeksView accepts all{" "}
+ CalendarProvider props plus the
+ view props below.
+
-
- Window Info
+
+ Window info
-
- First Week Spec
+
+ First week spec
+
+ Hooks
+
+
+ Inside a WeeksView, useWeeksViewStable() and{" "}
+ useWeeksViewState() expose the view's context — including{" "}
+ windowInfo and the scrollToWeek/
+ goNext/goPrev actions — for custom components.
+
);
}
diff --git a/website/src/routes/index.tsx b/website/src/routes/index.tsx
index 9c80e2e..7ae4852 100644
--- a/website/src/routes/index.tsx
+++ b/website/src/routes/index.tsx
@@ -43,7 +43,7 @@ function App() {
}
+ render={ }
>
Get Started