Date Range
import { useState } from 'react';
const css = `
.field-date-range { width: 228px; }
.field-date-range__head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 6px; }
.field-date-range__month { color: light-dark(#373a40, #f1f2f3); font-size: 13px; font-weight: 600; }
.field-date-range__sum { color: light-dark(#5f626a, #a7abb2); font-size: 11px; font-variant-numeric: tabular-nums; }
.field-date-range__dow, .field-date-range__row { display: grid; grid-template-columns: repeat(7, 1fr); }
.field-date-range__dow span { color: light-dark(#5f626a, #a7abb2); font-size: 10px; line-height: 18px; text-align: center; }
.field-date-range__row { position: relative; height: 26px; }
/* One band per week row, gliding to cover the part of the range in that week. */
.field-date-range__band {
position: absolute;
top: 2px;
bottom: 2px;
border-radius: 11px;
background: light-dark(#ebedf0, #232529);
transition: left 0.3s cubic-bezier(0.2, 0.8, 0.3, 1), width 0.3s cubic-bezier(0.2, 0.8, 0.3, 1), opacity 0.2s;
}
.field-date-range__band[data-preview] { background: color-mix(in oklab, light-dark(#ebedf0, #232529) 60%, transparent); }
.field-date-range__day {
position: relative;
margin: 2px auto;
width: 22px;
height: 22px;
padding: 0;
border: 0;
border-radius: 50%;
background: none;
color: light-dark(#42454c, #e5e6e9);
font-family: inherit; font-size: 12px; line-height: 22px;
font-variant-numeric: tabular-nums;
cursor: pointer;
transition: background-color 0.2s, color 0.2s, transform 0.2s;
}
.field-date-range__day:hover { color: light-dark(#373a40, #f1f2f3); }
.field-date-range__day:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 1px; }
.field-date-range__day[data-end] { background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); font-weight: 600; transform: scale(1.08); }
@media (prefers-reduced-motion: reduce) { .field-date-range__band, .field-date-range__day { transition-duration: 0.01s; } }
`;
const YEAR = 2026;
const MONTH = 10; // November
const DAYS = new Date(YEAR, MONTH + 1, 0).getDate();
const LEAD = (new Date(YEAR, MONTH, 1).getDay() + 6) % 7; // Monday-first offset
const ROWS = Math.ceil((LEAD + DAYS) / 7);
const name = (d: number) => new Date(YEAR, MONTH, d).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long' });
export function DateRangeInput() {
const [start, setStart] = useState<number | null>(12);
const [end, setEnd] = useState<number | null>(18);
const [hover, setHover] = useState<number | null>(null);
const pick = (d: number) => {
if (start === null || end !== null) {
setStart(d);
setEnd(null);
} else if (d < start) {
setEnd(start);
setStart(d);
} else setEnd(d);
};
// While choosing the end, the band previews up to the hovered day.
const a = start;
const b = end ?? (start !== null && hover !== null ? hover : null);
const [lo, hi] = a !== null && b !== null ? [Math.min(a, b), Math.max(a, b)] : [a, a];
const nights = end !== null && start !== null ? end - start : 0;
return (
<>
<style>
{css}
</style>
<div className="field-date-range" role="group" aria-label="Stay dates">
<div className="field-date-range__head">
<span className="field-date-range__month">November 2026</span>
<span className="field-date-range__sum" aria-live="polite">
{start !== null && end !== null ? `${start} to ${end} Nov · ${nights} night${nights === 1 ? '' : 's'}` : 'Pick the last night'}
</span>
</div>
<div className="field-date-range__dow" aria-hidden="true">
{['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((d, i) => (
<span key={i}>{d}</span>
))}
</div>
{Array.from({ length: ROWS }, (_, r) => {
const first = r * 7 - LEAD + 1;
const from = lo === null ? null : Math.max(lo, first);
const to = hi === null ? null : Math.min(hi, first + 6);
const show = from !== null && to !== null && from <= to;
const col = (d: number) => d - first;
return (
<div key={r} className="field-date-range__row">
<span
className="field-date-range__band"
data-preview={end === null || undefined}
style={{
left: `calc(${show ? col(from!) : 0} * 100% / 7)`,
width: `calc(${show ? col(to!) - col(from!) + 1 : 0} * 100% / 7)`,
opacity: show ? 1 : 0,
}}
/>
{Array.from({ length: 7 }, (_, c) => {
const d = first + c;
if (d < 1 || d > DAYS) return <span key={c} />;
return (
<button
key={c}
type="button"
className="field-date-range__day"
data-end={d === start || d === end || undefined}
aria-pressed={d === start || d === end}
aria-label={name(d)}
onClick={() => pick(d)}
onPointerEnter={() => setHover(d)}
onPointerLeave={() => setHover(null)}
>
{d}
</button>
);
})}
</div>
);
})}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldDateRange(el) on the mounted element instead.
<div class="field-date-range" role="group" aria-label="Stay dates">
<div class="field-date-range__head">
<span class="field-date-range__month">November 2026</span>
<span class="field-date-range__sum" aria-live="polite">12 to 18 Nov · 6 nights</span>
</div>
<div class="field-date-range__dow" aria-hidden="true">
<span>M</span>
<span>T</span>
<span>W</span>
<span>T</span>
<span>F</span>
<span>S</span>
<span>S</span>
</div>
<div class="field-date-range__row">
<span class="field-date-range__band" style="left:calc(0 * 100% / 7);width:calc(0 * 100% / 7);opacity:0"></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<button type="button" class="field-date-range__day" aria-pressed="false" aria-label="Sunday 1 November">1</button>
</div>
<div class="field-date-range__row">
<span class="field-date-range__band" style="left:calc(0 * 100% / 7);width:calc(0 * 100% / 7);opacity:0"></span>
<button type="button" class="field-date-range__day" aria-pressed="false" aria-label="Monday 2 November">2</button>
<button type="button" class="field-date-range__day" aria-pressed="false" aria-label="Tuesday 3 November">3</button>
<button type="button" class="field-date-range__day" aria-pressed="false" aria-label="Wednesday 4 November">4</button>
<button type="button" class="field-date-range__day" aria-pressed="false" aria-label="Thursday 5 November">5</button>
<button type="button" class="field-date-range__day" aria-pressed="false" aria-label="Friday 6 November">6</button>
<button type="button" class="field-date-range__day" aria-pressed="false" aria-label="Saturday 7 November">7</button>
<button type="button" class="field-date-range__day" aria-pressed="false" aria-label="Sunday 8 November">8</button>
</div>
<div class="field-date-range__row">
<span class="field-date-range__band" style="left:calc(3 * 100% / 7);width:calc(4 * 100% / 7);opacity:1"></span>
<button type="button" class="field-date-range__day" aria-pressed="false" aria-label="Monday 9 November">9</button>
<button type="button" class="field-date-range__day" aria-pressed="false" aria-label="Tuesday 10 November">10</button>
<button type="button" class="field-date-range__day" aria-pressed="false" aria-label="Wednesday 11 November">11</button>
<button type="button" class="field-date-range__day" data-end="true" aria-pressed="true" aria-label="Thursday 12 November">12</button>
<button type="button" class="field-date-range__day" aria-pressed="false" aria-label="Friday 13 November">13</button>
<button type="button" class="field-date-range__day" aria-pressed="false" aria-label="Saturday 14 November">14</button>
<button type="button" class="field-date-range__day" aria-pressed="false" aria-label="Sunday 15 November">15</button>
</div>
<div class="field-date-range__row">
<span class="field-date-range__band" style="left:calc(0 * 100% / 7);width:calc(3 * 100% / 7);opacity:1"></span>
<button type="button" class="field-date-range__day" aria-pressed="false" aria-label="Monday 16 November">16</button>
<button type="button" class="field-date-range__day" aria-pressed="false" aria-label="Tuesday 17 November">17</button>
<button type="button" class="field-date-range__day" data-end="true" aria-pressed="true" aria-label="Wednesday 18 November">18</button>
<button type="button" class="field-date-range__day" aria-pressed="false" aria-label="Thursday 19 November">19</button>
<button type="button" class="field-date-range__day" aria-pressed="false" aria-label="Friday 20 November">20</button>
<button type="button" class="field-date-range__day" aria-pressed="false" aria-label="Saturday 21 November">21</button>
<button type="button" class="field-date-range__day" aria-pressed="false" aria-label="Sunday 22 November">22</button>
</div>
<div class="field-date-range__row">
<span class="field-date-range__band" style="left:calc(0 * 100% / 7);width:calc(0 * 100% / 7);opacity:0"></span>
<button type="button" class="field-date-range__day" aria-pressed="false" aria-label="Monday 23 November">23</button>
<button type="button" class="field-date-range__day" aria-pressed="false" aria-label="Tuesday 24 November">24</button>
<button type="button" class="field-date-range__day" aria-pressed="false" aria-label="Wednesday 25 November">25</button>
<button type="button" class="field-date-range__day" aria-pressed="false" aria-label="Thursday 26 November">26</button>
<button type="button" class="field-date-range__day" aria-pressed="false" aria-label="Friday 27 November">27</button>
<button type="button" class="field-date-range__day" aria-pressed="false" aria-label="Saturday 28 November">28</button>
<button type="button" class="field-date-range__day" aria-pressed="false" aria-label="Sunday 29 November">29</button>
</div>
<div class="field-date-range__row">
<span class="field-date-range__band" style="left:calc(0 * 100% / 7);width:calc(0 * 100% / 7);opacity:0"></span>
<button type="button" class="field-date-range__day" aria-pressed="false" aria-label="Monday 30 November">30</button>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
</div>.field-date-range { width: 228px; }
.field-date-range__head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 6px; }
.field-date-range__month { color: light-dark(#373a40, #f1f2f3); font-size: 13px; font-weight: 600; }
.field-date-range__sum { color: light-dark(#5f626a, #a7abb2); font-size: 11px; font-variant-numeric: tabular-nums; }
.field-date-range__dow, .field-date-range__row { display: grid; grid-template-columns: repeat(7, 1fr); }
.field-date-range__dow span { color: light-dark(#5f626a, #a7abb2); font-size: 10px; line-height: 18px; text-align: center; }
.field-date-range__row { position: relative; height: 26px; }
/* One band per week row, gliding to cover the part of the range in that week. */
.field-date-range__band {
position: absolute;
top: 2px;
bottom: 2px;
border-radius: 11px;
background: light-dark(#ebedf0, #232529);
transition: left 0.3s cubic-bezier(0.2, 0.8, 0.3, 1), width 0.3s cubic-bezier(0.2, 0.8, 0.3, 1), opacity 0.2s;
}
.field-date-range__band[data-preview] { background: color-mix(in oklab, light-dark(#ebedf0, #232529) 60%, transparent); }
.field-date-range__day {
position: relative;
margin: 2px auto;
width: 22px;
height: 22px;
padding: 0;
border: 0;
border-radius: 50%;
background: none;
color: light-dark(#42454c, #e5e6e9);
font-family: inherit; font-size: 12px; line-height: 22px;
font-variant-numeric: tabular-nums;
cursor: pointer;
transition: background-color 0.2s, color 0.2s, transform 0.2s;
}
.field-date-range__day:hover { color: light-dark(#373a40, #f1f2f3); }
.field-date-range__day:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 1px; }
.field-date-range__day[data-end] { background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); font-weight: 600; transform: scale(1.08); }
@media (prefers-reduced-motion: reduce) { .field-date-range__band, .field-date-range__day { transition-duration: 0.01s; } }function initFieldDateRange(root) {
var rows = root.querySelectorAll('.field-date-range__row');
var sum = root.querySelector('.field-date-range__sum');
if (!rows.length || !sum) return;
var YEAR = 2026;
var MONTH = 10; // November
var LEAD = (new Date(YEAR, MONTH, 1).getDay() + 6) % 7; // Monday-first offset
var start = 12;
var end = 18;
var hover = null;
function render() {
// While choosing the end, the band previews up to the hovered day.
var b = end !== null ? end : start !== null && hover !== null ? hover : null;
var lo = start === null ? null : b === null ? start : Math.min(start, b);
var hi = start === null ? null : b === null ? start : Math.max(start, b);
rows.forEach(function (row, r) {
var first = r * 7 - LEAD + 1;
var band = row.querySelector('.field-date-range__band');
var from = lo === null ? null : Math.max(lo, first);
var to = hi === null ? null : Math.min(hi, first + 6);
var show = from !== null && to !== null && from <= to;
band.toggleAttribute('data-preview', end === null);
band.style.left = 'calc(' + (show ? from - first : 0) + ' * 100% / 7)';
band.style.width = 'calc(' + (show ? to - from + 1 : 0) + ' * 100% / 7)';
band.style.opacity = show ? '1' : '0';
});
root.querySelectorAll('.field-date-range__day').forEach(function (btn) {
var d = Number(btn.textContent);
var isEnd = d === start || d === end;
btn.toggleAttribute('data-end', isEnd);
btn.setAttribute('aria-pressed', String(isEnd));
});
var nights = end !== null && start !== null ? end - start : 0;
sum.textContent = start !== null && end !== null ? start + ' to ' + end + ' Nov · ' + nights + ' night' + (nights === 1 ? '' : 's') : 'Pick the last night';
}
function pick(d) {
if (start === null || end !== null) {
start = d;
end = null;
} else if (d < start) {
end = start;
start = d;
} else end = d;
render();
}
function click(e) {
var btn = e.target.closest('.field-date-range__day');
if (btn) pick(Number(btn.textContent));
}
function over(e) {
var btn = e.target.closest('.field-date-range__day');
hover = btn ? Number(btn.textContent) : null;
if (end === null) render();
}
function out() {
hover = null;
if (end === null) render();
}
root.addEventListener('click', click);
root.addEventListener('pointerover', over);
root.addEventListener('pointerleave', out);
render();
return function () {
root.removeEventListener('click', click);
root.removeEventListener('pointerover', over);
root.removeEventListener('pointerleave', out);
};
}
document.querySelectorAll('.field-date-range').forEach(initFieldDateRange);Goal: Create a "Date Range" input.
Visual behavior: A stay-dates calendar: pick a first night and a band previews the stay as you hover, then pick the last night and the band settles, gliding across each week row to join the two days.
Technique:
- November 2026 is laid out Monday-first from the real weekday of the 1st; each week row has one band element positioned by the part of the range that falls in that week.
- Bands transition left and width, so changing the range makes them glide; while choosing the end, the band follows the hovered day in a lighter tone.
- Choosing an end before the start swaps them; the header shows the range and the number of nights.
Accessibility: Every day is a real button with an aria-label naming the full date and aria-pressed for the chosen ends; the summary is aria-live. Under prefers-reduced-motion the band moves without gliding.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Date Range" input.
Visual behavior: A stay-dates calendar: pick a first night and a band previews the stay as you hover, then pick the last night and the band settles, gliding across each week row to join the two days.
Technique:
- November 2026 is laid out Monday-first from the real weekday of the 1st; each week row has one band element positioned by the part of the range that falls in that week.
- Bands transition left and width, so changing the range makes them glide; while choosing the end, the band follows the hovered day in a lighter tone.
- Choosing an end before the start swaps them; the header shows the range and the number of nights.
Accessibility: Every day is a real button with an aria-label naming the full date and aria-pressed for the chosen ends; the summary is aria-live. Under prefers-reduced-motion the band moves without gliding.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A stay-dates calendar: pick a first night and a band previews the stay as you hover, then pick the last night and the band settles, gliding across each week row to join the two days.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, hover, state change, fill
- Techniques
- css transition, state machine
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
Every day is a real button with an aria-label naming the full date and aria-pressed for the chosen ends; the summary is aria-live. Under prefers-reduced-motion the band moves without gliding.