DatesDate picker
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callDate picker
A field that opens a calendar, with quick picks and natural language. 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-pickerReact
npx designme add date-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 date field that opens a small calendar, with a text box at the top that reads plain English like "next friday" or "in 2 weeks". Quick picks such as Today and Tomorrow sit under the box, and the month grid below works with mouse or keyboard. It suits agent products where people set a due date or launch date the way they would say it.
- When people pick one date, such as a deadline, launch or reminder.
- When typing "tomorrow" or "in 3 days" is quicker than clicking through months.
- When the same few dates come up often and deserve one-click quick picks.
- The field shows the picked day, like "Fri, Oct 23, 2026", or
placeholderbefore a day is picked;formatchanges how it reads. - Typed text is passed to
parseand picks the day as soon as it reads as a date, while the calendar stays open; Enter then closes it. - The default parser understands "today", "tomorrow", weekday names and "in N days", "in N weeks" or "in N months".
- Quick picks from
presets(Today, Tomorrow and In a week by default) pick a day a set number of days after today. - Previous and next month buttons slide the grid in from the side it comes from, and clicking a day picks it and closes the calendar.
localesets the month and weekday names,weekStartsOnstarts weeks on Monday or Sunday, andtodaysets the day marked with a dot.
- The field button has aria-haspopup="dialog" and aria-expanded, and the calendar is a role="dialog" labelled "Choose a date"; Escape closes it and returns focus to the field.
- In the day grid, arrow keys move 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.
- Opened from the keyboard, the calendar takes the focus on the picked day, or today. Only one day is a tab stop: the one the arrows reached, the picked day, today or the 1st.
- Each day button has its full date as aria-label, aria-pressed on the picked day and aria-current="date" on today.
- The month name is an aria-live="polite" region, so changing month is announced.
Usage
Import it and drive it from your own state.
Import
import { DatePicker } from '@/components/designme/date-picker/DatePicker';Example
The demo above as a React component, 29 lines you can copy.
'use client';
// The preview from designme.dev, rebuilt from the React component: the calendar opens and someone types "next friday".
import { useRef, useState } from 'react';
import { DatePicker } from './DatePicker';
import { useStory } from '../lib/useScript';
/* ---------- Date picker: a field that opens a calendar and reads plain English ---------- */
// today is the real today, so the calendar opens on this month and "next friday" is the coming Friday
export default function DatePickerDemo() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
// the preview opens it and types a date on its own; clicking, focusing or typing in it takes over, and what it had typed goes
const ref = useRef<HTMLDivElement>(null);
useStory(ref, async ({ wait, taken }) => {
await wait(800); setOpen(true);
await wait(500);
const s = 'next friday';
let typing = true;
taken(() => { if (typing) setQuery(''); });
for (let k = 1; k <= s.length; k++) { setQuery(s.slice(0, k)); await wait(70); }
typing = false;
});
return (
<div ref={ref} style={{ width: '100%' }}>
<DatePicker placeholder="Pick a launch date" open={open} onOpenChange={setOpen} query={query} onQueryChange={setQuery} />
</div>
);
}
Props
Everything DatePicker takes.
| Prop | Type | What it does |
|---|---|---|
value | Date | null | The picked day (a Date at local midnight), or null before anything is picked |
defaultValue | Date | null | |
onValueChange | (date: Date | null) => void | |
month | Date | The month on show, as its first day. Defaults to the picked day's month, or today's |
defaultMonth | Date | |
onMonthChange | (month: Date) => void | |
open | boolean | Whether the calendar is open. Leave it out to let the field toggle it |
defaultOpen | boolean | |
onOpenChange | (open: boolean) => void | |
query | string | What is typed in the box at the top, like "next friday". A date it reads is picked straight away |
defaultQuery | string | |
onQueryChange | (text: string) => void | |
parse | (text: string, today: Date) => Date | null | Turns typed text into a day. Defaults to plain English: weekdays, "tomorrow", "in 2 weeks" |
today | Date | The day marked with a dot. Defaults to the real today; pass one when rendering on a server |
placeholder | string | Shown in the field before a day is picked |
queryPlaceholder | string | The hint in the text box |
presets | DatePickerPreset[] | Quick picks under the text box. Defaults to Today, Tomorrow and In a week |
format | (date: Date) => string | How the picked day reads in the field. Defaults to "Fri, Oct 23, 2026" |
locale | string | Used for the month name, 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.
DatePicker.tsxReact · 148 lines
'use client';
import { useEffect, useRef, useState, type KeyboardEvent } from 'react';
import { addDays, formatMonth, fromIsoDay, isoDay, monthGrid, moveDay, parseRelativeDay, sameDay, sameMonth, startOfDay, startOfMonth, 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 { usePopover } from '../lib/popover';
import '../designme.css';
import './date-picker.css';
/** A quick pick under the text box, like Tomorrow: `days` after today */
export interface DatePickerPreset {
label: string;
days: number;
}
export interface DatePickerProps {
/** The picked day (a Date at local midnight), or null before anything is picked */
value?: Date | null;
defaultValue?: Date | null;
onValueChange?: (date: Date | null) => void;
/** The month on show, as its first day. Defaults to the picked day's month, or today's */
month?: Date;
defaultMonth?: Date;
onMonthChange?: (month: Date) => void;
/** Whether the calendar is open. Leave it out to let the field toggle it */
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** What is typed in the box at the top, like "next friday". A date it reads is picked straight away */
query?: string;
defaultQuery?: string;
onQueryChange?: (text: string) => void;
/** Turns typed text into a day. Defaults to plain English: weekdays, "tomorrow", "in 2 weeks" */
parse?: (text: string, today: Date) => Date | null;
/** The day marked with a dot. Defaults to the real today; pass one when rendering on a server */
today?: Date;
/** Shown in the field before a day is picked */
placeholder?: string;
/** The hint in the text box */
queryPlaceholder?: string;
/** Quick picks under the text box. Defaults to Today, Tomorrow and In a week */
presets?: DatePickerPreset[];
/** How the picked day reads in the field. Defaults to "Fri, Oct 23, 2026" */
format?: (date: Date) => string;
/** Used for the month name, the weekday letters and the default format */
locale?: string;
/** 1 starts the weeks on Monday (the default), 0 on Sunday */
weekStartsOn?: WeekStart;
className?: string;
}
const PRESETS: DatePickerPreset[] = [{ label: 'Today', days: 0 }, { label: 'Tomorrow', days: 1 }, { label: 'In a week', days: 7 }];
/** A date field that opens a calendar, with quick picks and a box that understands "next friday". */
export function DatePicker({
value, defaultValue = null, onValueChange, month, defaultMonth, onMonthChange, open, defaultOpen = false, onOpenChange,
query, defaultQuery = '', onQueryChange, parse = parseRelativeDay, today: todayProp, placeholder = 'Pick a date',
queryPlaceholder = 'Try “next friday” or “in 2 weeks”', presets = PRESETS, format, locale = 'en-US', weekStartsOn = 1, className
}: DatePickerProps) {
const [today] = useState(() => startOfDay(todayProp ?? new Date()));
const now = todayProp ? startOfDay(todayProp) : today;
const [picked, setPicked] = useControllable(value, defaultValue, onValueChange);
const [view, setView] = useControllable(month, startOfMonth(defaultMonth ?? defaultValue ?? now), onMonthChange);
const [text, setText] = useControllable(query, defaultQuery, onQueryChange);
const menu = usePopover({ side: 'down', open, defaultOpen, onOpenChange });
const show = format ?? ((d: Date) => d.toLocaleDateString(locale, { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }));
// a new month slides in from the side it comes from
const viewKey = isoDay(startOfMonth(view));
const [shown, setShown] = useState(viewKey);
const [dir, setDir] = useState(0);
if (shown !== viewKey) { setDir(viewKey > shown ? 1 : -1); setShown(viewKey); }
const closing = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(closing.current), []);
const pick = (d: Date, keep = false) => {
setPicked(d);
if (!sameMonth(d, view)) setView(startOfMonth(d));
if (!keep) { clearTimeout(closing.current); closing.current = setTimeout(() => menu.setOpen(false), 260); }
};
// typed text picks a day as soon as it reads as one, and the calendar stays open
const read = useRef(text);
useEffect(() => {
if (read.current === text) return;
read.current = text;
const d = parse(text, now);
if (d) pick(d, true);
}, [text]); // eslint-disable-line react-hooks/exhaustive-deps
// arrow keys move the focus a day or a week, across months too
const grid = useRef<HTMLDivElement>(null);
const [focus, setFocus] = useState<string | null>(null);
useIsoLayoutEffect(() => {
if (!focus) return;
grid.current?.querySelector<HTMLButtonElement>(`[data-date="${focus}"]`)?.focus();
setFocus(null);
}, [focus, viewKey]);
// one day takes Tab: the one the arrows last reached, or the picked day, today or the 1st
const [cursor, setCursor] = useState<Date | null>(null);
const tabDay = [cursor, picked, now].find(d => d && sameMonth(d, view)) ?? 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 (!sameMonth(next, view)) setView(startOfMonth(next));
setCursor(next); setFocus(isoDay(next));
};
// opened with Enter or Space, the calendar takes the focus on the picked day, or today
const keyOpen = useRef(false);
useIsoLayoutEffect(() => { if (menu.open && keyOpen.current) { keyOpen.current = false; setCursor(null); grid.current?.querySelector<HTMLButtonElement>('[tabindex="0"]')?.focus({ preventScroll: true }); } }, [menu.open]);
return (
<div data-dm="" className={cx('dm-dpk', className)}>
<button type="button" className="dm-dpk-f" {...menu.trigger} aria-haspopup="dialog" onClick={e => { keyOpen.current = !menu.open && e.detail === 0; menu.trigger.onClick(); }}>
<Icon name="cal" size="xs" />
<Swap className={cx('dm-dpk-val', !picked && 'dm-ph')}>{picked ? show(picked) : placeholder}</Swap>
<Icon name="chev-d" size="xs" />
</button>
<div {...menu.pop} className={cx(menu.pop.className, 'dm-dpk-pop')} role="dialog" aria-label="Choose a date">
<input className="dm-dpk-nl" placeholder={queryPlaceholder} spellCheck={false} aria-label="Type a date" value={text}
onChange={e => setText(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && picked) menu.setOpen(false); }} />
<div className="dm-dpk-qk">
{presets.map(p => <button type="button" key={p.label} className="dm-chip dm-sm" onClick={() => pick(addDays(now, p.days))}>{p.label}</button>)}
</div>
<div className="dm-dpk-mh">
<button type="button" className="dm-ib dm-dpk-pv" aria-label="Previous month" onClick={() => setView(startOfMonth(new Date(view.getFullYear(), view.getMonth() - 1, 1)))}><Icon name="chev-l" size="xs" /></button>
<b className="dm-dpk-m" aria-live="polite">{formatMonth(view, locale)}</b>
<button type="button" className="dm-ib dm-dpk-nx" aria-label="Next month" onClick={() => setView(startOfMonth(new Date(view.getFullYear(), view.getMonth() + 1, 1)))}><Icon name="chev-r" size="xs" /></button>
</div>
<div className="dm-dpk-wd dm-muted" aria-hidden="true">{weekdayNames(locale, weekStartsOn).map((w, i) => <span key={i}>{w}</span>)}</div>
<div className="dm-dpk-vp">
<div key={viewKey} ref={grid} className={cx('dm-dpk-g', dir < 0 && 'dm-in-l', dir > 0 && 'dm-in-r')} onKeyDown={keys}>
{monthGrid(view, weekStartsOn).map(d => (
<button type="button" key={isoDay(d)} data-date={isoDay(d)}
className={cx('dm-dpk-d', !sameMonth(d, view) && 'dm-o', sameDay(d, now) && 'dm-t', sameDay(d, picked) && 'dm-s')}
aria-pressed={sameDay(d, picked)} aria-current={sameDay(d, now) ? 'date' : undefined} aria-label={show(d)} tabIndex={sameDay(d, tabDay) ? 0 : -1}
onClick={() => pick(d)}>{d.getDate()}</button>
))}
</div>
</div>
</div>
</div>
);
}
date-picker.cssReact · 41 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Date picker ---------- */
/* one field, as wide as the other single fields; its script keeps it centred in its stage and glides it up as the calendar opens */
.dm-dpk{position:relative;width:100%;max-width:var(--dm-field-w);margin:0 auto}
.dm-dpk-f{display:flex;align-items:center;gap:10px;width:100%;height:var(--dm-field-h);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:13.5px;transition:box-shadow var(--dm-t-fast)}
@supports (corner-shape:squircle){.dm-dpk-f{corner-shape:squircle;border-radius:var(--dm-r-field-sq)}}
/* a dropdown's field steps up its edge while it has the focus or its calendar is open; it takes no ring */
.dm-dpk-f:focus-visible{outline:0;box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
.dm-dpk-f>svg:first-child{color:var(--dm-muted)}
.dm-dpk-val{flex:1;text-align:left;color:var(--dm-fg)}
.dm-dpk-val.dm-ph{color:var(--dm-muted)}
.dm-dpk-f>svg:last-child{color:var(--dm-muted)}
.dm-dpk-pop{width:100%;padding:10px;background:var(--dm-surface)}
.dm-dpk-nl{width:100%;height:32px;border:0;outline:0;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);padding:0 10px;font:inherit;font-size:13px;color:var(--dm-fg);transition:box-shadow var(--dm-t-fast)}
@supports (corner-shape:squircle){.dm-dpk-nl{corner-shape:squircle;border-radius:var(--dm-r-field-sq)}}
.dm-dpk-nl::placeholder{color:var(--dm-muted)}
.dm-dpk-nl:focus{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
.dm-dpk-qk{display:flex;gap:var(--dm-s-1);margin:var(--dm-s-2) 0}
.dm-dpk-mh{display:flex;align-items:center;justify-content:space-between;margin:var(--dm-s-1) 0}
.dm-dpk-mh b{font-weight:500;font-size:13px}
.dm-dpk-mh .dm-ib{width:26px;height:26px}
.dm-dpk-wd,.dm-dpk-g{display:grid;grid-template-columns:repeat(7,1fr);text-align:center}
.dm-dpk-wd{font-size:11px;margin-bottom:2px}
.dm-dpk-vp{overflow:hidden}
.dm-dpk-g{gap:2px 0}
.dm-dpk-g.dm-in-l{animation:dm-dpkl .4s var(--dm-ease) both}.dm-dpk-g.dm-in-r{animation:dm-dpkr .4s var(--dm-ease) both}
@keyframes dm-dpkl{from{opacity:0;transform:translateX(-24px);filter:blur(3px)}}
@keyframes dm-dpkr{from{opacity:0;transform:translateX(24px);filter:blur(3px)}}
.dm-dpk-d{height:32px;border-radius:9px;font-size:12.5px;color:var(--dm-fg);transition:background .2s,color .2s;position:relative}
@media (hover:hover) and (pointer:fine){.dm-dpk-d:hover{background:var(--dm-row)}}
/* the days fill the month's sliding view, which would cut an outside ring: theirs is drawn inside */
.dm-dpk-d:focus-visible{outline-offset:-2px}
.dm-dpk-d.dm-o{color:var(--dm-muted)}
.dm-dpk-d.dm-t::after{content:"";position:absolute;left:50%;bottom:4px;width:4px;height:4px;margin-left:-2px;border-radius:50%;background:var(--dm-accent-mark,var(--dm-accent))}
.dm-dpk-d.dm-s{background:var(--dm-accent);color:var(--dm-on-accent)}
.dm-dpk-d.dm-s::after{background:var(--dm-on-accent)}
.dm-dpk-d.dm-s{animation:dm-dpks .35s var(--dm-ease)}
/* on the picked day the inside ring would sit on the accent fill: a surface gap inside it keeps it seen */
.dm-dpk-d.dm-s:focus-visible{box-shadow:inset 0 0 0 3.5px var(--dm-surface)}
@keyframes dm-dpks{from{transform:scale(.8)}}
date-picker.htmlHTML · 15 lines
<div class="card" data-c="date-picker">
<div class="stage-in">
<div class="dpk">
<button class="dpk-f" data-pop=".dpk-pop" data-pop-pos="down"><svg class="i xs"><use href="#i-cal"/></svg><span class="dpk-val">Pick a launch date</span><svg class="i xs"><use href="#i-chev-d"/></svg></button>
<div class="pop dpk-pop" role="dialog" aria-label="Choose a date">
<input class="dpk-nl" placeholder="Try “next friday” or “in 2 weeks”" spellcheck="false">
<div class="dpk-qk"><button class="chip sm" data-d="0">Today</button><button class="chip sm" data-d="1">Tomorrow</button><button class="chip sm" data-d="7">In a week</button></div>
<div class="dpk-mh"><button class="ib dpk-pv" aria-label="Previous month"><svg class="i xs"><use href="#i-chev-l"/></svg></button><b class="dpk-m"></b><button class="ib dpk-nx" aria-label="Next month"><svg class="i xs"><use href="#i-chev-r"/></svg></button></div>
<div class="dpk-wd muted"><span>M</span><span>T</span><span>W</span><span>T</span><span>F</span><span>S</span><span>S</span></div>
<div class="dpk-vp"><div class="dpk-g"></div></div>
</div>
</div>
</div>
</div>
date-picker.cssHTML · 40 lines
/* ---------- Date picker ---------- */
/* one field, as wide as the other single fields; its script keeps it centred in its stage and glides it up as the calendar opens */
.dpk{position:relative;width:100%;max-width:var(--field-w);margin:0 auto}
.dpk-f{display:flex;align-items:center;gap:10px;width:100%;height:var(--field-h);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:13.5px;transition:box-shadow var(--t-fast)}
@supports (corner-shape:squircle){.dpk-f{corner-shape:squircle;border-radius:var(--r-field-sq)}}
/* a dropdown's field steps up its edge while it has the focus or its calendar is open; it takes no ring */
.dpk-f:focus-visible{outline:0;box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line-strong) 50%,transparent)}
.dpk-f>svg:first-child{color:var(--muted)}
.dpk-val{flex:1;text-align:left;color:var(--fg)}
.dpk-val.ph{color:var(--muted)}
.dpk-f>svg:last-child{color:var(--muted)}
.dpk-pop{width:100%;padding:10px;background:var(--surface)}
.dpk-nl{width:100%;height:32px;border:0;outline:0;border-radius:var(--r-field);background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line) 50%,transparent);padding:0 10px;font:inherit;font-size:13px;color:var(--fg);transition:box-shadow var(--t-fast)}
@supports (corner-shape:squircle){.dpk-nl{corner-shape:squircle;border-radius:var(--r-field-sq)}}
.dpk-nl::placeholder{color:var(--muted)}
.dpk-nl:focus{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line-strong) 50%,transparent)}
.dpk-qk{display:flex;gap:var(--s-1);margin:var(--s-2) 0}
.dpk-mh{display:flex;align-items:center;justify-content:space-between;margin:var(--s-1) 0}
.dpk-mh b{font-weight:500;font-size:13px}
.dpk-mh .ib{width:26px;height:26px}
.dpk-wd,.dpk-g{display:grid;grid-template-columns:repeat(7,1fr);text-align:center}
.dpk-wd{font-size:11px;margin-bottom:2px}
.dpk-vp{overflow:hidden}
.dpk-g{gap:2px 0}
.dpk-g.in-l{animation:dpkl .4s var(--ease) both}.dpk-g.in-r{animation:dpkr .4s var(--ease) both}
@keyframes dpkl{from{opacity:0;transform:translateX(-24px);filter:blur(3px)}}
@keyframes dpkr{from{opacity:0;transform:translateX(24px);filter:blur(3px)}}
.dpk-d{height:32px;border-radius:9px;font-size:12.5px;color:var(--fg);transition:background .2s,color .2s;position:relative}
@media (hover:hover) and (pointer:fine){.dpk-d:hover{background:var(--row)}}
/* the days fill the month's sliding view, which would cut an outside ring: theirs is drawn inside */
.dpk-d:focus-visible{outline-offset:-2px}
.dpk-d.o{color:var(--muted)}
.dpk-d.t::after{content:"";position:absolute;left:50%;bottom:4px;width:4px;height:4px;margin-left:-2px;border-radius:50%;background:var(--accent-mark,var(--accent))}
.dpk-d.s{background:var(--accent);color:var(--on-accent)}
.dpk-d.s::after{background:var(--on-accent)}
.dpk-d.s{animation:dpks .35s var(--ease)}
/* on the picked day the inside ring would sit on the accent fill: a surface gap inside it keeps it seen */
.dpk-d.s:focus-visible{box-shadow:inset 0 0 0 3.5px var(--surface)}
@keyframes dpks{from{transform:scale(.8)}}
date-picker.jsHTML · 59 lines
DM.def('date-picker', (r, c) => {
r.innerHTML = r.innerHTML.replace('{cal}', I('cal','xs')).replace('{chev}', I('chev-d','xs')).replace('{l}', I('chev-l','xs')).replace('{r}', I('chev-r','xs'));
// today is the real today: the calendar opens on its month and the quick picks and typed dates count from it
const now = new Date(), TODAY = new Date(now.getFullYear(), now.getMonth(), now.getDate()); let view = new Date(TODAY.getFullYear(), TODAY.getMonth(), 1), sel = null;
const g = $('.dpk-g', r), m = $('.dpk-m', r), val = $('.dpk-val', r), f = $('.dpk-f', r); let cursor = null;
val.classList.add('ph');
const same = (a, b) => a && b && a.toDateString() === b.toDateString();
const fmt = d => d.toLocaleDateString('en-US', { weekday:'short', month:'short', day:'numeric', year:'numeric' });
function paint(dir){
m.textContent = view.toLocaleDateString('en-US', { month:'long', year:'numeric' });
const first = new Date(view.getFullYear(), view.getMonth(), 1), start = new Date(first); start.setDate(1 - ((first.getDay() + 6) % 7));
// one day takes Tab, the one the arrows last reached, or the picked day, today or the 1st; the arrows move from it
const inView = d => d && d.getMonth() === view.getMonth() && d.getFullYear() === view.getFullYear(), tab = [cursor, sel, TODAY].find(inView) || first;
let h = ''; for (let i = 0; i < 42; i++){ const d = new Date(start); d.setDate(start.getDate() + i); h += `<button class="dpk-d${d.getMonth() !== view.getMonth() ? ' o' : ''}${same(d, TODAY) ? ' t' : ''}${same(d, sel) ? ' s' : ''}" data-t="${d.getTime()}" tabindex="${same(d, tab) ? 0 : -1}" aria-label="${fmt(d)}" aria-pressed="${!!same(d, sel)}"${same(d, TODAY) ? ' aria-current="date"' : ''}>${d.getDate()}</button>`; }
g.innerHTML = h; if (dir){ g.classList.remove('in-l','in-r'); void g.offsetWidth; g.classList.add(dir < 0 ? 'in-l' : 'in-r'); }
$$('.dpk-d', g).forEach(b => b.onclick = () => pick(new Date(+b.dataset.t)));
}
function pick(d, keep){ sel = d; val.classList.remove('ph'); swap(val, fmt(d)); if (d.getMonth() !== view.getMonth() || d.getFullYear() !== view.getFullYear()){ const dir = d > view ? 1 : -1; view = new Date(d.getFullYear(), d.getMonth(), 1); paint(dir); } else paint(); if (!keep) c.later(260, () => closePops()); }
$('.dpk-pv', r).onclick = () => { view = new Date(view.getFullYear(), view.getMonth() - 1, 1); paint(-1); };
$('.dpk-nx', r).onclick = () => { view = new Date(view.getFullYear(), view.getMonth() + 1, 1); paint(1); };
$$('.dpk-qk .chip', r).forEach(b => b.onclick = () => { const d = new Date(TODAY); d.setDate(d.getDate() + +b.dataset.d); pick(d); });
const W = ['sunday','monday','tuesday','wednesday','thursday','friday','saturday'];
$('.dpk-nl', r).oninput = e => { const s = e.target.value.toLowerCase().trim(); let d = null;
const wd = W.findIndex(w => s.includes(w)); const n = s.match(/in (\d+) (day|week|month)s?/);
if (n){ d = new Date(TODAY); if (n[2] === 'day') d.setDate(d.getDate() + +n[1]); if (n[2] === 'week') d.setDate(d.getDate() + n[1] * 7); if (n[2] === 'month') d.setMonth(d.getMonth() + +n[1]); }
else if (wd >= 0){ d = new Date(TODAY); let add = (wd - d.getDay() + 7) % 7 || 7; if (s.includes('next') && add < 7) add += 0; d.setDate(d.getDate() + add); }
else if (s === 'tomorrow'){ d = new Date(TODAY); d.setDate(d.getDate() + 1); }
if (d) pick(d, true); };
$('.dpk-nl', r).onkeydown = e => { if (e.key === 'Enter' && sel) closePops(); };
// the keyboard in the calendar: the arrows move a day or a week, across months too; 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; Enter or Space picks the day
const dayBtn = d => $$('.dpk-d', g).find(b => same(new Date(+b.dataset.t), d));
function goDay(d){
cursor = d;
if (d.getMonth() !== view.getMonth() || d.getFullYear() !== view.getFullYear()){ const dir = d > view ? 1 : -1; view = new Date(d.getFullYear(), d.getMonth(), 1); paint(dir); } else paint();
const b = dayBtn(d); if (b) b.focus({ preventScroll:true });
}
c.on(g, 'keydown', e => {
const b = e.target.closest('.dpk-d'); 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(); goDay(n);
});
// opened with the keyboard, the calendar takes the focus on the picked day, or today
c.on(f, 'click', e => { if (e.isTrusted && !e.detail) requestAnimationFrame(() => { if (f.getAttribute('aria-expanded') !== 'true') return; cursor = null; paint(); const b = $('.dpk-d[tabindex="0"]', g); if (b) b.focus({ preventScroll:true }); }); });
paint();
// the field sits centred on its own, and glides up as the calendar opens under it, so the two sit centred together; on
// closing, it waits for the calendar to fade before it glides back to the middle
const box = $('.dpk', r), pop = $('.dpk-pop', r), isOpen = () => f.getAttribute('aria-expanded') === 'true';
const centred = centre(c, box, () => isOpen() ? Math.max(box.offsetHeight, pop.offsetTop + pop.offsetHeight) : box.offsetHeight);
const mo = new MutationObserver(() => isOpen() ? centred() : c.later(REDUCE ? 0 : 180, centred));
mo.observe(f, { attributes:true, attributeFilter:['aria-expanded'] }); c.offs.push(() => mo.disconnect());
// the preview opens it and types a date on its own; clicking, focusing or typing in it takes over, and what it had typed goes
c.auto(800, () => { f.click(); c.run(async () => { const i = $('.dpk-nl', r); await c.step(500); await c.type(i, 'next friday', { ms:70, each:() => i.oninput({ target:i }) }); }); });
});
Questions
What people ask about it.
- What does "friday" pick if today is Friday?
- The default parser always picks the next one, so it returns the Friday a week later, never today. "next friday" works the same way.
- Can I use my own natural language parser?
- Yes. Pass
parse, a function that takes the typed text and today's date and returns a Date or null. You could call your agent or a library like chrono from there, as long as it returns synchronously. - Why should I pass
todaywhen rendering on the server? - The today dot and the presets are based on the current date. Passing
todaykeeps server and client output the same and makes demos and tests repeatable. - Can I change the quick picks?
- Yes. Pass
presetsas a list of alabeland a number ofdaysafter today, like { label: 'Next sprint', days: 14 }.
More in Dates
Pickers for single dates and date ranges.