Date Range Picker
stimeo--date-range-picker
A calendar where two clicks pick a range, showing the range as you choose it.
Picks a period on a calendar with two clicks. The first sets the start, and the range to the day you are pointing at is shaded as a preview. The second sets the end, and choosing a day before the start simply swaps them. Both ends of the range are announced, while the days in between are marked for painting only. Shortcuts such as today, the last seven days or this month can sit alongside it. Pressing Esc part-way through drops the preview and starts over. The finished range is announced and handed to the form. Keyboard movement works as it does in the calendar.
This demo marks three and four days from today as unavailable, as an example of closed days. A range may span them, but neither can be the start or the end.
Keyboard
| Key | Action |
|---|---|
| ← | Moves focus to the previous day, transitioning to the previous month automatically if crossing boundaries. Under RTL this is ArrowRight. |
| → | Moves focus to the next day, transitioning to the next month automatically if crossing boundaries. Under RTL this is ArrowLeft. |
| ↑ | Moves focus to the same day of the previous week, transitioning to the previous month if necessary. |
| ↓ | Moves focus to the same day of the next week, transitioning to the next month if necessary. |
| PageUp | Moves focus to the same day of the previous month. |
| PageDown | Moves focus to the same day of the next month. |
| Shift+PageUp / Shift+PageDown | Moves focus to the same day of the previous / next year, clamping missing dates to the end of the month. |
| Home | Moves focus to the first day of the current week. |
| End | Moves focus to the last day of the current week. |
| Enter / Space | Confirms the focused day as the start, then the end of the range. |
| Escape | Discards an in-progress selection (the confirmed range is kept). |
<%# Markup for the date-range-picker (range-selection calendar) demo.
stimeo--date-range-picker derives from calendar and handles two-point start/end
selection, a provisional range preview mid-selection, marking in-range cells,
auto-swapping when start > end, applying presets, and grid keyboard movement
(roving). Range ends are shown with aria-selected and the inside with data-in-range;
placement and styling live in demo.css.
Two unavailable days are declared as data rather than written onto cells. The
42 cells are reused for a different date every month, so availability has to
be keyed by the date; the controller derives each cell's aria-disabled from
this array on every paint. A range may still span such a day — it just cannot
end on one. %>
<% blackout = [3.days.from_now, 4.days.from_now].map { |day| day.strftime("%Y-%m-%d") } %>
<div class="drp demo-card" data-controller="stimeo--date-range-picker"
data-stimeo--date-range-picker-disabled-dates-value="<%= blackout.to_json %>">
<div class="drp__header">
<button class="drp__nav" type="button"
data-action="click->stimeo--date-range-picker#prev"
aria-label="<%= t("components.date_range_picker.demo.prev_month") %>">‹</button>
<span class="drp__label" id="drp-month" aria-live="polite"
data-stimeo--date-range-picker-target="monthLabel"></span>
<button class="drp__nav" type="button"
data-action="click->stimeo--date-range-picker#next"
aria-label="<%= t("components.date_range_picker.demo.next_month") %>">›</button>
</div>
<div class="drp__weekdays" aria-hidden="true">
<% t("components.date_range_picker.demo.weekdays").each do |day| %>
<span class="drp__weekday"><%= day %></span>
<% end %>
</div>
<div class="drp__grid" role="grid" aria-labelledby="drp-month"
data-stimeo--date-range-picker-target="grid">
<% 6.times do %>
<div class="drp__row" role="row">
<% 7.times do %>
<button class="drp__cell" type="button" role="gridcell" tabindex="-1"
data-stimeo--date-range-picker-target="cell"
data-action="click->stimeo--date-range-picker#selectDate
mouseenter->stimeo--date-range-picker#previewTo
focus->stimeo--date-range-picker#previewTo
keydown->stimeo--date-range-picker#onKeydown"></button>
<% end %>
</div>
<% end %>
</div>
<div class="drp__presets" role="group"
aria-label="<%= t("components.date_range_picker.demo.presets_label") %>">
<button class="drp__preset" type="button" data-range="today"
data-action="click->stimeo--date-range-picker#applyPreset">
<%= t("components.date_range_picker.demo.preset_today") %>
</button>
<button class="drp__preset" type="button" data-range="last7"
data-action="click->stimeo--date-range-picker#applyPreset">
<%= t("components.date_range_picker.demo.preset_last7") %>
</button>
<button class="drp__preset" type="button" data-range="thisMonth"
data-action="click->stimeo--date-range-picker#applyPreset">
<%= t("components.date_range_picker.demo.preset_this_month") %>
</button>
</div>
<span class="drp__status" role="status" aria-live="polite"
data-stimeo--date-range-picker-target="status"></span>
<%# Says on screen why two days are struck out. The dates move with today, so
without this the exclusion reads as a glitch rather than a demonstration. %>
<p class="drp__note"><%= t("components.date_range_picker.demo.blackout_note") %></p>
<input type="hidden" name="start_date" data-stimeo--date-range-picker-target="startField" />
<input type="hidden" name="end_date" data-stimeo--date-range-picker-target="endField" />
</div>
/*
* Presentation-only styles for the date-range-picker demo.
* Range shading reacts to the state hooks the library sets on each cell:
* - [data-range-start] / [data-range-end] … the range ends
* - [data-in-range] … inside start–end (incl. the preview range)
* - [aria-selected="true"] … the confirmed ends (AT only; not styled here)
* - [aria-disabled="true"] … outside min/max or in disabled-dates
*/
/* Card surface comes from the shared .demo-card primitive (base/demo-primitives.css),
the same one the calendar demo uses; only layout-specific bits stay here. */
.drp {
width: min(20rem, 100%);
font-variant-numeric: tabular-nums;
}
.drp__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.drp__label {
font-weight: 600;
}
.drp__nav {
width: 2rem;
height: 2rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
background: var(--surface, var(--surface-card));
cursor: pointer;
font-size: 1.1rem;
line-height: 1;
}
.drp__weekdays,
.drp__row {
display: grid;
grid-template-columns: repeat(7, 1fr);
}
.drp__weekday {
padding: 0.25rem 0;
text-align: center;
font-size: 0.75rem;
color: var(--text-muted, var(--color-text-muted));
}
.drp__cell {
aspect-ratio: 1;
border: 0;
background: transparent;
cursor: pointer;
font: inherit;
border-radius: 0;
}
.drp__cell[data-outside="true"] {
color: var(--color-text-muted);
}
.drp__cell[data-today="true"] {
font-weight: 700;
text-decoration: underline;
}
.drp__cell[data-in-range] {
background: color-mix(in srgb, var(--accent) 18%, transparent);
}
/* Ends are styled from the data-* trio only. `aria-selected` marks the last
confirmed range, which stays put until the second click of the next one — so
reading it here would keep painting the previous ends through a new
selection. */
.drp__cell[data-range-start],
.drp__cell[data-range-end] {
background: var(--accent);
color: var(--white);
}
.drp__cell[data-range-start] {
border-top-left-radius: 999px;
border-bottom-left-radius: 999px;
}
.drp__cell[data-range-end] {
border-top-right-radius: 999px;
border-bottom-right-radius: 999px;
}
/* The on-screen explanation of the two declared unavailable days. */
.drp__note {
margin: 0;
color: var(--color-text-muted);
font-size: 0.8125rem;
line-height: 1.5;
}
.drp__cell[aria-disabled="true"] {
color: var(--color-text-muted);
cursor: not-allowed;
}
.drp__cell:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.drp__presets {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
}
.drp__preset {
padding: 0.375rem 0.625rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
background: var(--surface, var(--surface-card));
cursor: pointer;
font-size: 0.85rem;
}
.drp__status {
display: block;
margin-top: 0.625rem;
min-height: 1.25rem;
color: var(--text-muted, var(--color-text-muted));
}
// Demo that subscribes to the date-range-picker change event to inspect the confirmed
// range (ISO dates). The library also announces the confirmed range in a status live
// region, but the consumer can use the detail too.
document.querySelectorAll('[data-controller~="stimeo--date-range-picker"]').forEach((root) => {
root.addEventListener('stimeo--date-range-picker:change', (e) => {
// In real use, e.detail.start / e.detail.end (ISO date strings) feed submission or filtering.
console.log('range:', e.detail.start, '→', e.detail.end);
});
});
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--date-range-picker"
Targets
| Name | Description | Attribute |
|---|---|---|
grid
required
|
The six-week month-grid container. | data-stimeo--date-range-picker-target="grid" |
monthLabel
|
Optional live span showing the localized displayed month and year. | data-stimeo--date-range-picker-target="monthLabel" |
cell
required
|
One of the 42 gridcells; the controller binds date, range, roving-tabindex, and disabled state to each. |
data-stimeo--date-range-picker-target="cell" |
status
|
Optional live status region that announces the confirmed range. | data-stimeo--date-range-picker-target="status" |
startField
|
Optional hidden input mirroring the confirmed range start date. | data-stimeo--date-range-picker-target="startField" |
endField
|
Optional hidden input mirroring the confirmed range end date. | data-stimeo--date-range-picker-target="endField" |
Values
| Name | Description | Attribute |
|---|---|---|
min
|
Earliest selectable date (YYYY-MM-DD); earlier cells get aria-disabled (default empty). |
data-stimeo--date-range-picker-min-value |
max
|
Latest selectable date (YYYY-MM-DD); later cells get aria-disabled (default empty). |
data-stimeo--date-range-picker-max-value |
disabledDates
|
JSON array of individual ISO dates to exclude inside the bounds (default []). Point exclusions, not intervals: such a day cannot be an endpoint, but a range may span it. Swapping the array at runtime repaints. |
data-stimeo--date-range-picker-disabled-dates-value |
Actions
| Name | Description | Action |
|---|---|---|
applyPreset
|
Applies a named preset range (today/last7/last30/thisMonth) from the clicked button's data-range. |
stimeo--date-range-picker#applyPreset |
next
|
Navigates to the next month. | stimeo--date-range-picker#next |
onKeydown
|
Handles grid keyboard navigation (arrows, Home/End, PageUp/Down, Shift+PageUp/Down), selection via Enter/Space, and Escape to cancel an in-progress range. | stimeo--date-range-picker#onKeydown |
prev
|
Navigates to the previous month. | stimeo--date-range-picker#prev |
previewTo
|
Previews the range up to the hovered/focused cell while a selection is in progress. | stimeo--date-range-picker#previewTo |
selectDate
|
Confirms a range endpoint from the clicked cell (first sets the start, second the end). | stimeo--date-range-picker#selectDate |
Events
| Name | Description | Event |
|---|---|---|
change
|
Fires when a range is confirmed; detail { start, end } (ISO dates, start ≤ end). |
stimeo--date-range-picker:change |
monthchange
|
Fires after a different month has been painted; detail { month } (YYYY-MM). A notification, not a hook the paint waits on — feed a fetched result back through disabled-dates. |
stimeo--date-range-picker:monthchange |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
aria-selected |
Cell | "true" on the confirmed start and end dates only. The in-progress preview (the day under the pointer) is not announced as selected; it uses the data-* hooks instead. |
data-range-start / data-range-end |
Cell | Visual hooks for the range ends. |
data-in-range |
Cell | Inner cells between start and end (including the preview). |
aria-disabled |
Cell | Not selectable — outside min / max, or listed in disabled-dates. Controller-owned and derived on every paint, so it always matches what a click does. |
tabindex |
Cell | Roving focus — exactly one cell is focusable. |
text |
Status region | The confirmed range, announced via a live region. |