DatesDate range picker
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callDate range picker
Pick a start and end across two months, or use a preset. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add date-range-pickerReact
npx designme add date-range-picker --reactThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.
About
What it is for and what it does.
A range picker with two months side by side and a list of ready-made ranges such as Last 7 days and This quarter. People click a start day and an end day, see the range fill in as they hover, and confirm with Apply. It suits reports and dashboards where an agent's results are read over a period.
- When a report, chart or agent summary needs a start and end date.
- When most people want a common range, like the last 30 days, in one click.
- When a range tied to your product, such as "Since pricing launch", should sit next to the standard ones.
- The first click sets the start, the second sets the end; clicking a day before the start, or clicking again after a full range, starts over.
- While only the start is set, hovering a later day previews the range.
- From and To boxes show the picked days, and a counter shows how many days the range covers.
- Presets in the side list fill the range from
today; the preset that matches the current range lights up, and its days fill in one after another. - Apply is enabled once both days are set and calls
onApply; Clear empties the range. locale,weekStartsOnandformatcontrol the month names, week start and how days read.
- Arrow keys move focus a day or a week, Home and End go to the start and end of the week, and Page Up and Page Down move a month, or a year with Shift; the months follow the focus.
- Only one day is a tab stop: the one the arrows reached, the end, the start, today or the first day on show.
- Moving focus with the keyboard while only the start is set previews the range the same way hovering does.
- Each day button has its full date as aria-label and aria-pressed on the start and end days.
- Preset buttons use aria-pressed to show which one matches the range.
Usage
Import it and drive it from your own state.
Import
import { DateRangePicker } from '@/components/designme/date-range-picker/DateRangePicker';Example
The demo above as a React component, 20 lines you can copy.
'use client';
// The preview from designme.dev, rebuilt from the React component: the range since the pricing launch fills in.
import { useState } from 'react';
import { DateRangePicker, DateRangePickerPresets, type DateRangePickerRange } from './DateRangePicker';
import { useScript } from '../lib/useScript';
const TODAY = new Date(2026, 9, 19), LAUNCH = new Date(2026, 8, 18);
const PRESETS = [...DateRangePickerPresets, { key: 'launch', label: 'Since pricing launch', range: (t: Date): [Date, Date] => [LAUNCH, t] }];
/* ---------- Date range picker: ready-made ranges, two months, a hover preview, then Apply ---------- */
export default function DateRangePickerDemo() {
const [range, setRange] = useState<DateRangePickerRange>({ start: null, end: null });
useScript(async ({ wait }) => { await wait(900); setRange({ start: LAUNCH, end: TODAY }); });
return (
<div style={{ width: '100%' }}>
<DateRangePicker today={TODAY} defaultMonth={new Date(2026, 8, 1)} presets={PRESETS} value={range} onValueChange={setRange} />
</div>
);
}
Props
Everything DateRangePicker takes.
| Prop | Type | What it does |
|---|---|---|
value | DateRangePickerRange | The picked range. Days are Dates at local midnight |
defaultValue | DateRangePickerRange | |
onValueChange | (range: DateRangePickerRange) => void | |
month | Date | The month in the left calendar, as its first day; the right one shows the month after. Defaults to last month |
defaultMonth | Date | |
onMonthChange | (month: Date) => void | |
presets | DateRangePickerPreset[] | The side list. Defaults to the last 7 and 30 days and this quarter. The one matching the range lights up |
today | Date | Ranges end today in the presets. Defaults to the real today; pass one when rendering on a server |
onApply | (range: { start: Date; end: Date }) => void | Called with the range when Apply is pressed |
format | (date: Date) => string | How a day reads in the From and To boxes. Defaults to "Sep 18, 2026" |
locale | string | Used for the month names, the weekday letters and the default format |
weekStartsOn | WeekStart | 1 starts the weeks on Monday (the default), 0 on Sunday |
className | string |
Code
The full source, as the CLI installs it.
DateRangePicker.tsxReact · 175 lines
'use client';
import { useEffect, useRef, useState, type KeyboardEvent } from 'react';
import { addDays, addMonths, daysBetween, dayKey, formatMonth, fromIsoDay, isoDay, monthDays, moveDay, sameMonth, startOfDay, startOfMonth, startOfQuarter, weekdayIndex, weekdayNames, type WeekStart } from '../lib/calendar';
import { cx } from '../lib/cx';
import { useControllable, useIsoLayoutEffect } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Swap } from '../lib/motion';
import '../designme.css';
import './date-range-picker.css';
/** A range of days. `end` is null while the second day is still to be picked */
export interface DateRangePickerRange {
start: Date | null;
end: Date | null;
}
/** A ready-made range in the side list, worked out from today */
export interface DateRangePickerPreset {
key: string;
label: string;
range: (today: Date) => [Date, Date];
}
export interface DateRangePickerProps {
/** The picked range. Days are Dates at local midnight */
value?: DateRangePickerRange;
defaultValue?: DateRangePickerRange;
onValueChange?: (range: DateRangePickerRange) => void;
/** The month in the left calendar, as its first day; the right one shows the month after. Defaults to last month */
month?: Date;
defaultMonth?: Date;
onMonthChange?: (month: Date) => void;
/** The side list. Defaults to the last 7 and 30 days and this quarter. The one matching the range lights up */
presets?: DateRangePickerPreset[];
/** Ranges end today in the presets. Defaults to the real today; pass one when rendering on a server */
today?: Date;
/** Called with the range when Apply is pressed */
onApply?: (range: { start: Date; end: Date }) => void;
/** How a day reads in the From and To boxes. Defaults to "Sep 18, 2026" */
format?: (date: Date) => string;
/** Used for the month names, the weekday letters and the default format */
locale?: string;
/** 1 starts the weeks on Monday (the default), 0 on Sunday */
weekStartsOn?: WeekStart;
className?: string;
}
/** The side list designme.dev starts from: the last 7 days, the last 30 days and this quarter */
export const DateRangePickerPresets: DateRangePickerPreset[] = [
{ key: '7', label: 'Last 7 days', range: t => [addDays(t, -6), t] },
{ key: '30', label: 'Last 30 days', range: t => [addDays(t, -29), t] },
{ key: 'q', label: 'This quarter', range: t => [startOfQuarter(t), t] }
];
const EMPTY: DateRangePickerRange = { start: null, end: null };
/** Two months side by side with ready-made ranges, a live preview of the range as you hover, and Apply. */
export function DateRangePicker({
value, defaultValue = EMPTY, onValueChange, month, defaultMonth, onMonthChange, presets = DateRangePickerPresets, today: todayProp,
onApply, format, locale = 'en-US', weekStartsOn = 1, className
}: DateRangePickerProps) {
const [today] = useState(() => startOfDay(todayProp ?? new Date()));
const now = todayProp ? startOfDay(todayProp) : today;
const [range, setRange] = useControllable(value, defaultValue, onValueChange);
const [view, setView] = useControllable(month, startOfMonth(defaultMonth ?? addMonths(defaultValue.end ?? now, -1)), onMonthChange);
const [hover, setHover] = useState<Date | null>(null);
const show = format ?? ((d: Date) => d.toLocaleDateString(locale, { month: 'short', day: 'numeric', year: 'numeric' }));
const { start, end } = range;
const ka = start ? dayKey(start) : 0, kb = end ? dayKey(end) : 0;
const preset = start && end ? presets.find(p => { const [a, b] = p.range(now); return dayKey(a) === ka && dayKey(b) === kb; }) : undefined;
// a range that matches a preset fills in day by day, the way picking the preset does on designme.dev
const rangeKey = `${ka}-${kb}`;
const [seen, setSeen] = useState(rangeKey);
const [pass, setPass] = useState(0);
const [flash, setFlash] = useState(false);
if (seen !== rangeKey) { setSeen(rangeKey); setFlash(!!preset); if (preset) setPass(n => n + 1); }
// when the range is set from outside, bring its last day into view
const visible = (d: Date) => sameMonth(d, view) || sameMonth(d, addMonths(startOfMonth(view), 1));
useEffect(() => { if (end && !visible(end)) setView(startOfMonth(addMonths(end, -1))); }, [kb]); // eslint-disable-line react-hooks/exhaustive-deps
const click = (d: Date) => {
setHover(null);
setRange(!start || end || dayKey(d) < ka ? { start: d, end: null } : { start, end: d });
};
const choose = (p: DateRangePickerPreset) => {
const [a, b] = p.range(now);
setHover(null); setRange({ start: a, end: b });
const base = startOfMonth(addMonths(b, -1));
if (dayKey(base) !== dayKey(startOfMonth(view))) setView(base);
};
const [applied, setApplied] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(timer.current), []);
const apply = () => {
if (!start || !end) return;
onApply?.({ start, end });
setApplied(true); clearTimeout(timer.current); timer.current = setTimeout(() => setApplied(false), 1200);
};
// arrow keys move the focus a day or a week, and Page Up / Page Down a month
const cals = useRef<HTMLDivElement>(null);
const [focus, setFocus] = useState<string | null>(null);
useIsoLayoutEffect(() => {
if (!focus) return;
cals.current?.querySelector<HTMLButtonElement>(`[data-date="${focus}"]`)?.focus();
setFocus(null);
}, [focus, view]);
// one day takes Tab: the one the arrows last reached, or the end, the start, today or the first day on show
const [cursor, setCursor] = useState<Date | null>(null);
const tabDay = [cursor, end, start, now].find(d => d && visible(d)) ?? startOfMonth(view);
const keys = (e: KeyboardEvent<HTMLDivElement>) => {
const at = (e.target as HTMLElement).dataset.date; if (!at) return;
const next = moveDay(fromIsoDay(at), e.key, weekStartsOn, e.shiftKey); if (!next) return;
e.preventDefault();
if (!visible(next)) setView(startOfMonth(dayKey(next) < dayKey(view) ? next : addMonths(next, -1)));
setCursor(next); setFocus(isoDay(next));
if (start && !end) setHover(next);
};
// the days that light up when a preset fills in, in page order, for their stagger
const reach = end ?? hover, kr = reach ? dayKey(reach) : 0;
const months = [startOfMonth(view), startOfMonth(addMonths(startOfMonth(view), 1))];
let lit = 0;
const heads = weekdayNames(locale, weekStartsOn);
return (
<div data-dm="" className={cx('dm-drx dm-panel', className)}>
<div className="dm-drx-side">
{presets.map(p => <button type="button" key={p.key} className={cx('dm-drx-p', preset === p && 'dm-on')} aria-pressed={preset === p} onClick={() => choose(p)}>{p.label}</button>)}
</div>
<div className="dm-drx-main">
<div className="dm-drx-top">
<div className={cx('dm-drx-in', !start && 'dm-on')}><span className="dm-muted">From</span><b className="dm-drx-a"><Swap>{start ? show(start) : '–'}</Swap></b></div>
<Icon name="right" size="xs" />
<div className={cx('dm-drx-in', !!start && !end && 'dm-on')}><span className="dm-muted">To</span><b className="dm-drx-b"><Swap>{end ? show(end) : '–'}</Swap></b></div>
<span className="dm-drx-n dm-muted">{start && end ? `${daysBetween(start, end) + 1} ${daysBetween(start, end) ? 'days' : 'day'}` : start ? 'Pick an end date' : 'Pick a start date'}</span>
</div>
<div ref={cals} className="dm-drx-cals" onMouseLeave={() => setHover(null)} onKeyDown={keys}>
{months.map((mo, o) => (
<div key={o} className="dm-drx-cal" data-o={o}>
<h5>{formatMonth(mo, locale)}</h5>
<div key={`${isoDay(mo)}-${pass}`} className="dm-drx-g">
{heads.map((w, i) => <span key={'w' + i} className="dm-w" aria-hidden="true">{w}</span>)}
{Array.from({ length: weekdayIndex(mo, weekStartsOn) }, (_, i) => <span key={'e' + i} className="dm-drx-d dm-e" />)}
{monthDays(mo).map(d => {
const k = dayKey(d), isA = !!start && k === ka, isB = !!end && k === kb;
const between = !!start && !!reach && k > ka && k < kr;
const on = isA || isB || (between && !!end);
const stagger = flash && on ? { className: 'dm-enter', style: { animationDelay: lit++ * 8 + 'ms' } } : null;
return (
<button type="button" key={k} data-date={isoDay(d)} style={stagger?.style}
className={cx('dm-drx-d', isA && 'dm-a', isA && reach && kr > k && 'dm-ra', isB && 'dm-b dm-rb', between && (end ? 'dm-in' : 'dm-hv'),
!end && hover && start && k === kr && k > ka && 'dm-hv', stagger?.className)}
aria-pressed={isA || isB} aria-label={show(d)} tabIndex={k === dayKey(tabDay) ? 0 : -1}
onClick={() => click(d)} onMouseEnter={() => { if (start && !end) setHover(d); }}>{d.getDate()}</button>
);
})}
</div>
</div>
))}
</div>
<div className="dm-drx-f">
<button type="button" className="dm-pill dm-ghost dm-sm dm-drx-clr" onClick={() => { setHover(null); setRange(EMPTY); }}>Clear</button>
<button type="button" className="dm-pill dm-primary dm-sm dm-drx-ok" disabled={!start || !end} onClick={apply}>
{applied ? <><Icon name="check" size="xs" />Applied</> : 'Apply'}
</button>
</div>
</div>
</div>
);
}
date-range-picker.cssReact · 31 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Date range picker ---------- */
.dm-drx{display:grid;grid-template-columns:160px 1fr;overflow:hidden}
@media (max-width:720px){.dm-drx{grid-template-columns:1fr}.dm-drx-side{flex-direction:row!important;flex-wrap:wrap;border-right:0!important;border-bottom:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}}
.dm-drx-side{display:flex;flex-direction:column;gap:2px;padding:10px;border-right:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-drx-p{text-align:left;padding:7px 10px;border-radius:9px;font-size:12.5px;color:var(--dm-fg2);transition:background .2s,color .2s;white-space:nowrap}
@media (hover:hover) and (pointer:fine){.dm-drx-p:hover{background:var(--dm-row);color:var(--dm-fg)}}
.dm-drx-p.dm-on{background:var(--dm-sel);color:var(--dm-accent-fg,var(--dm-accent));font-weight:500}
.dm-drx-main{padding:var(--dm-s-3) 14px;display:flex;flex-direction:column;gap:10px;min-width:0}
.dm-drx-top{display:flex;align-items:center;gap:var(--dm-s-2);flex-wrap:wrap;color:var(--dm-muted)}
.dm-drx-in{display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 var(--dm-s-3);border-radius:var(--dm-r-field);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line) 50%,transparent);font-size:12px;white-space:nowrap;transition:background-color var(--dm-t) var(--dm-ease),box-shadow var(--dm-t) var(--dm-ease)}
.dm-drx-in b{font-weight:500;font-size:12.5px;color:var(--dm-fg)}
/* the end that the next click sets */
.dm-drx-in.dm-on{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
@supports (corner-shape:squircle){.dm-drx-in{corner-shape:squircle;border-radius:var(--dm-r-field-sq)}}
.dm-drx-n{margin-left:auto;font-size:12px}
.dm-drx-cals{display:grid;grid-template-columns:1fr 1fr;gap:18px}
@media (max-width:560px){.dm-drx-cals{grid-template-columns:1fr}.dm-drx-cal[data-o="1"]{display:none}}
.dm-drx-cal h5{margin:0 0 6px;font-size:12.5px;font-weight:500;text-align:center}
.dm-drx-g{display:grid;grid-template-columns:repeat(7,1fr);row-gap:2px}
.dm-drx-g .dm-w{font-size:10.5px;color:var(--dm-muted);text-align:center;height:22px}
.dm-drx-d{height:30px;font-size:12px;color:var(--dm-fg);position:relative;transition:background .15s,color .15s}
.dm-drx-d:empty{pointer-events:none}
/* the start and end keep their accent under the pointer, so their day stays readable */
@media (hover:hover) and (pointer:fine){.dm-drx-d:hover:not(.dm-e):not(.dm-a):not(.dm-b){background:var(--dm-chip);border-radius:var(--dm-r-sm)}}
.dm-drx-d.dm-in{background:var(--dm-sel);border-radius:0}
.dm-drx-d.dm-a,.dm-drx-d.dm-b{background:var(--dm-accent);color:var(--dm-on-accent);border-radius:var(--dm-r-sm);z-index:1}
.dm-drx-d.dm-a.dm-ra{border-radius:var(--dm-r-sm) 0 0 var(--dm-r-sm)}.dm-drx-d.dm-b.dm-rb{border-radius:0 var(--dm-r-sm) var(--dm-r-sm) 0}
.dm-drx-d.dm-hv{background:color-mix(in srgb,var(--dm-accent) 7%,transparent);border-radius:0}
.dm-drx-f{display:flex;justify-content:flex-end;gap:6px}
date-range-picker.htmlHTML · 13 lines
<div class="card wide" data-c="date-range-picker">
<div class="stage-in">
<div class="drx panel">
<div class="drx-side"><button class="drx-p" data-p="7">Last 7 days</button><button class="drx-p" data-p="30">Last 30 days</button><button class="drx-p" data-p="q">This quarter</button><button class="drx-p" data-p="sp">Since pricing launch</button></div>
<div class="drx-main">
<div class="drx-top"><div class="drx-in"><span class="muted">From</span><b class="drx-a">–</b></div><svg class="i xs"><use href="#i-right"/></svg><div class="drx-in"><span class="muted">To</span><b class="drx-b">–</b></div><span class="drx-n muted"></span></div>
<div class="drx-cals"><div class="drx-cal" data-o="0"></div><div class="drx-cal" data-o="1"></div></div>
<div class="drx-f"><button class="pill ghost sm drx-clr">Clear</button><button class="pill primary sm drx-ok" disabled>Apply</button></div>
</div>
</div>
</div>
</div>
date-range-picker.cssHTML · 30 lines
/* ---------- Date range picker ---------- */
.drx{display:grid;grid-template-columns:160px 1fr;overflow:hidden}
@media (max-width:720px){.drx{grid-template-columns:1fr}.drx-side{flex-direction:row!important;flex-wrap:wrap;border-right:0!important;border-bottom:1px solid color-mix(in srgb,var(--line) 50%,transparent)}}
.drx-side{display:flex;flex-direction:column;gap:2px;padding:10px;border-right:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
.drx-p{text-align:left;padding:7px 10px;border-radius:9px;font-size:12.5px;color:var(--fg2);transition:background .2s,color .2s;white-space:nowrap}
@media (hover:hover) and (pointer:fine){.drx-p:hover{background:var(--row);color:var(--fg)}}
.drx-p.on{background:var(--sel);color:var(--accent-fg,var(--accent));font-weight:500}
.drx-main{padding:var(--s-3) 14px;display:flex;flex-direction:column;gap:10px;min-width:0}
.drx-top{display:flex;align-items:center;gap:var(--s-2);flex-wrap:wrap;color:var(--muted)}
.drx-in{display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 var(--s-3);border-radius:var(--r-field);background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line) 50%,transparent);font-size:12px;white-space:nowrap;transition:background-color var(--t) var(--ease),box-shadow var(--t) var(--ease)}
.drx-in b{font-weight:500;font-size:12.5px;color:var(--fg)}
/* the end that the next click sets */
.drx-in.on{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line-strong) 50%,transparent)}
@supports (corner-shape:squircle){.drx-in{corner-shape:squircle;border-radius:var(--r-field-sq)}}
.drx-n{margin-left:auto;font-size:12px}
.drx-cals{display:grid;grid-template-columns:1fr 1fr;gap:18px}
@media (max-width:560px){.drx-cals{grid-template-columns:1fr}.drx-cal[data-o="1"]{display:none}}
.drx-cal h5{margin:0 0 6px;font-size:12.5px;font-weight:500;text-align:center}
.drx-g{display:grid;grid-template-columns:repeat(7,1fr);row-gap:2px}
.drx-g .w{font-size:10.5px;color:var(--muted);text-align:center;height:22px}
.drx-d{height:30px;font-size:12px;color:var(--fg);position:relative;transition:background .15s,color .15s}
.drx-d:empty{pointer-events:none}
/* the start and end keep their accent under the pointer, so their day stays readable */
@media (hover:hover) and (pointer:fine){.drx-d:hover:not(.e):not(.a):not(.b){background:var(--chip);border-radius:var(--r-sm)}}
.drx-d.in{background:var(--sel);border-radius:0}
.drx-d.a,.drx-d.b{background:var(--accent);color:var(--on-accent);border-radius:var(--r-sm);z-index:1}
.drx-d.a.ra{border-radius:var(--r-sm) 0 0 var(--r-sm)}.drx-d.b.rb{border-radius:0 var(--r-sm) var(--r-sm) 0}
.drx-d.hv{background:color-mix(in srgb,var(--accent) 7%,transparent);border-radius:0}
.drx-f{display:flex;justify-content:flex-end;gap:6px}
date-range-picker.jsHTML · 64 lines
DM.def('date-range-picker', (r, c) => {
r.innerHTML = r.innerHTML.replace('{right}', I('right','xs'));
const TODAY = new Date(2026, 9, 19); let base = new Date(2026, 8, 1), A = null, B = null, hov = null, cursor = null;
const cals = $$('.drx-cal', r), ok = $('.drx-ok', r);
const key = d => d.getFullYear() * 1e4 + d.getMonth() * 100 + d.getDate();
const fmt = d => d ? d.toLocaleDateString('en-US', { month:'short', day:'numeric', year:'numeric' }) : '–';
/* From and To crossfade to the days picked, and always end on the latest ones: a pick that lands while the last one
is still fading in (picking the same end again, two quick clicks, Enter twice) writes its day when the fade does */
const put = (el, t) => { el._to = t; if (el._t || el.textContent === t) return; el.classList.add('sw', 'out');
el._t = setTimeout(() => { el._t = 0; el.textContent = el._to; el.classList.remove('out'); }, REDUCE ? 0 : 170); };
/* the days are built once per month view; selection and hover only change their classes */
function build(){
cals.forEach(cal => { const mo = new Date(base.getFullYear(), base.getMonth() + +cal.dataset.o, 1); const pad = (mo.getDay() + 6) % 7, days = new Date(mo.getFullYear(), mo.getMonth() + 1, 0).getDate();
let h = `<h5>${mo.toLocaleDateString('en-US', { month:'long', year:'numeric' })}</h5><div class="drx-g">${['M','T','W','T','F','S','S'].map(w => `<span class="w">${w}</span>`).join('')}`;
for (let i = 0; i < pad; i++) h += '<span class="drx-d e"></span>';
for (let d = 1; d <= days; d++){ const dt = new Date(mo.getFullYear(), mo.getMonth(), d); h += `<button class="drx-d" data-t="${dt.getTime()}" data-k="${key(dt)}">${d}</button>`; }
cal.innerHTML = h + '</div>'; });
$$('.drx-d[data-t]', r).forEach(b => { b.onclick = () => click(new Date(+b.dataset.t)); b.onmouseenter = () => { if (A && !B){ hov = new Date(+b.dataset.t); deco(); } }; });
deco();
}
function deco(){
const end = B || hov, ka = A && key(A), kb = B && key(B), ke = end && key(end);
$$('.drx-d[data-t]', r).forEach(b => { const k = +b.dataset.k; let cl = 'drx-d';
if (A && k === ka) cl += ' a' + (end && ke > k ? ' ra' : '');
if (B && k === kb) cl += ' b rb';
if (A && end && k > ka && k < ke) cl += B ? ' in' : ' hv';
if (!B && hov && A && k === ke && k > ka) cl += ' hv';
if (b.className !== cl) b.className = cl; });
// one day takes Tab: the one the arrows last reached, or the end, the start, today or the first day on show
const days = $$('.drx-d[data-t]', r), on = [cursor, B, A, TODAY].map(d => d && days.find(b => +b.dataset.k === key(d))).find(Boolean) || days[0];
days.forEach(b => { const t = b === on ? '0' : '-1'; if (b.getAttribute('tabindex') !== t) b.setAttribute('tabindex', t); });
put($('.drx-a', r), fmt(A)); put($('.drx-b', r), fmt(B));
const ins = $$('.drx-in', r); ins[0].classList.toggle('on', !A); ins[1].classList.toggle('on', !!A && !B);
const n = A && B ? Math.round((B - A) / 864e5) + 1 : 0;
$('.drx-n', r).textContent = n ? n + (n === 1 ? ' day' : ' days') : A ? 'Pick an end date' : 'Pick a start date';
ok.disabled = !(A && B);
}
function click(d){ $$('.drx-p', r).forEach(x => x.classList.remove('on')); if (!A || B || d < A){ A = d; B = null; hov = null; } else { B = d; hov = null; } deco(); }
$$('.drx-p', r).forEach(p => p.onclick = () => { $$('.drx-p', r).forEach(x => x.classList.toggle('on', x === p)); const v = p.dataset.p; B = new Date(TODAY);
A = new Date(TODAY); if (v === '7') A.setDate(A.getDate() - 6); else if (v === '30') A.setDate(A.getDate() - 29); else if (v === 'q') A = new Date(2026, 9, 1); else A = new Date(2026, 8, 18);
const nb = new Date(B.getFullYear(), B.getMonth() - 1, 1); hov = null; if (+nb !== +base){ base = nb; build(); } else deco(); stagger($$('.drx-d.in, .drx-d.a, .drx-d.b', r), 8); });
$('.drx-clr', r).onclick = () => { A = B = hov = null; $$('.drx-p', r).forEach(x => x.classList.remove('on')); deco(); };
ok.onclick = () => { ok.innerHTML = I('check','xs') + 'Applied'; c.later(1200, () => ok.textContent = 'Apply'); };
c.on($('.drx-cals', r), 'mouseleave', () => { if (hov){ hov = null; deco(); } });
// the keyboard in the calendars: the arrows move a day or a week, Home and End go to the start and end of the week, Page Up
// and Page Down move a month, or a year with Shift, and the months on show follow; Enter or Space picks the day. While the
// end is open, the range follows the focus as it does the pointer
c.on($('.drx-cals', r), 'keydown', e => {
const b = e.target.closest('.drx-d[data-t]'); if (!b) return;
const d = new Date(+b.dataset.t), wd = (d.getDay() + 6) % 7, at = n => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
const mon = (y, mo) => new Date(y, mo, Math.min(d.getDate(), new Date(y, mo + 1, 0).getDate()));
const n = { ArrowLeft:at(-1), ArrowRight:at(1), ArrowUp:at(-7), ArrowDown:at(7), Home:at(-wd), End:at(6 - wd),
PageUp:e.shiftKey ? mon(d.getFullYear() - 1, d.getMonth()) : mon(d.getFullYear(), d.getMonth() - 1),
PageDown:e.shiftKey ? mon(d.getFullYear() + 1, d.getMonth()) : mon(d.getFullYear(), d.getMonth() + 1) }[e.key];
if (!n) return; e.preventDefault(); cursor = n;
const m0 = new Date(n.getFullYear(), n.getMonth(), 1), last = new Date(base.getFullYear(), base.getMonth() + 1, 1);
if (A && !B && n > A) hov = n;
if (m0 < base){ base = m0; build(); } else if (m0 > last){ base = new Date(m0.getFullYear(), m0.getMonth() - 1, 1); build(); } else deco();
const to = $(`.drx-d[data-k="${key(n)}"]`, r); if (to) to.focus({ preventScroll:true });
});
build();
c.later(900, () => $('.drx-p[data-p="sp"]', r).click());
});
Questions
What people ask about it.
- How do I add a custom preset like "Since launch"?
- Spread the exported
DateRangePickerPresetsinto a new array and add your own entry with akey, alabeland arangefunction that takes today and returns [start, end]. - How do people get to other months?
- There are no previous and next buttons. The view moves when a preset is picked, when the keyboard focus passes the edge of the two months (Page Up and Page Down move a month), or when you set
monthyourself. - What is the difference between
onValueChangeandonApply? onValueChangefires on every click, including when only the start is set andendis null.onApplyfires only when the person presses Apply with both days set.- Which months show first?
- By default the left calendar shows the month before the range's end, or before today, and the right one shows the month after it. Pass
defaultMonthormonthto choose.
More in Dates
Pickers for single dates and date ranges.