Every booking site, analytics dashboard, and invoice filter has the same widget: two months side by side, click a start date, click an end date, and a highlighted band flows between them — even across the month boundary. It looks fiddly. Underneath, it's just calendar arithmetic plus a tiny state machine, and you can build the whole thing in one file with no <input type="date"> and no library.
The calendar math
A month grid rests on two facts: how many days the month has, and which weekday day 1 falls on. Days-per-month is a fixed table, except February in a leap year:
function isLeap(y){
return (y % 4 === 0 && y % 100 !== 0) || (y % 400 === 0);
}
function daysInMonth(y, m){ // m is 0-based: 0 = Jan
return [31, isLeap(y) ? 29 : 28, 31,30,31,30,31,31,30,31,30,31][m];
}
Day 1 doesn't sit in the top-left cell — it sits under its real weekday. new Date(y, m, 1).getDay() gives that column (0 = Sunday), which is exactly how many blank cells to emit before the first number so every date lands in the right column.
One integer key to compare any two days
Comparing Date objects is awkward. Instead, fold each day into a single monotonic number:
function keyOf(y, m, d){ return y*10000 + (m+1)*100 + d; }
keyOf(2024,2,31) < keyOf(2024,3,1); // true (Mar 31 < Apr 1)
Because no component overflows its slot, one < comparison works cleanly across a month boundary. This single trick is what makes a range spanning March into April trivial to reason about.
The two-click state machine
The entire interaction is two variables: rangeStart and rangeEnd. If there's no start (or a full range already exists), a click begins fresh. Otherwise it sets the end — and if the click lands before the start, the two swap so the range always reads left-to-right:
function pick(y, m, d){
const p = {y, m, d};
if (!rangeStart || rangeEnd){ // start a new range
rangeStart = p; rangeEnd = null;
} else { // set the end...
if (keyOf(y,m,d) < keyOf(rangeStart.y, rangeStart.m, rangeStart.d)){
rangeEnd = rangeStart; rangeStart = p; // ...swapping if earlier
} else {
rangeEnd = p;
}
}
render();
}
Painting reduces to a lo/hi pair
Rendering the band never inspects individual clicks. It collapses the state to a lo and a hi key — from the committed range, or from start + the hovered day during preview — then asks every cell three cheap questions: am I the start, the end, or strictly between?
function paint(){
let lo = null, hi = null;
if (rangeStart && rangeEnd){ lo = key2(rangeStart); hi = key2(rangeEnd); }
else if (rangeStart && hover){ lo = Math.min(...); hi = Math.max(...); }
else if (rangeStart){ lo = hi = key2(rangeStart); } // lone start
dayCells.forEach(c => {
const k = +c.dataset.key;
c.classList.toggle("range-start", lo !== null && k === lo && lo !== hi);
c.classList.toggle("range-end", lo !== null && k === hi && lo !== hi);
c.classList.toggle("single", lo !== null && k === lo && lo === hi);
c.classList.toggle("in-range", lo !== null && k > lo && k < hi);
});
}
CSS does the visual work: a half-band gradient on each endpoint plus a solid circle, and a continuous fill on the in-range cells.
Presets are just date arithmetic
"Last 7 days" isn't special — it computes two dates and writes the same two variables. "This month" is the 1st to day 0 of next month (the last day of this one). Because presets, clicks, and arrow keys all write only rangeStart and rangeEnd, a read-out card that displays them can never drift from the highlight.
Keep the state to two dates and one integer key, and a range across sixty cells stays simple. Try the live picker — keyboard nav, hover preview, presets, and the full ten-step build — here: https://dev48v.infy.uk/design/day55-date-range-picker.html
Top comments (0)