Skip to content
Star on GitHub
DatesDate picker
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Date 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.

$npx designme add date-picker
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add date-picker

React

$npx designme add date-picker --react

The 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.

02

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.
03

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>
  );
}
04

Props

Everything DatePicker takes.

PropTypeWhat it does
valueDate | nullThe picked day (a Date at local midnight), or null before anything is picked
defaultValueDate | null
onValueChange(date: Date | null) => void
monthDateThe month on show, as its first day. Defaults to the picked day's month, or today's
defaultMonthDate
onMonthChange(month: Date) => void
openbooleanWhether the calendar is open. Leave it out to let the field toggle it
defaultOpenboolean
onOpenChange(open: boolean) => void
querystringWhat is typed in the box at the top, like "next friday". A date it reads is picked straight away
defaultQuerystring
onQueryChange(text: string) => void
parse(text: string, today: Date) => Date | nullTurns typed text into a day. Defaults to plain English: weekdays, "tomorrow", "in 2 weeks"
todayDateThe day marked with a dot. Defaults to the real today; pass one when rendering on a server
placeholderstringShown in the field before a day is picked
queryPlaceholderstringThe hint in the text box
presetsDatePickerPreset[]Quick picks under the text box. Defaults to Today, Tomorrow and In a week
format(date: Date) => stringHow the picked day reads in the field. Defaults to "Fri, Oct 23, 2026"
localestringUsed for the month name, the weekday letters and the default format
weekStartsOnWeekStart1 starts the weeks on Monday (the default), 0 on Sunday
classNamestring
05

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 }) }); }); });
});
06

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 today when rendering on the server?
The today dot and the presets are based on the current date. Passing today keeps server and client output the same and makes demos and tests repeatable.
Can I change the quick picks?
Yes. Pass presets as a list of a label and a number of days after today, like { label: 'Next sprint', days: 14 }.
07

More in Dates

Pickers for single dates and date ranges.

Segmented controlSwitch between views of the same thing, with a sliding indicator.
Date range pickerPick a start and end across two months, or use a preset.
Event calendarA week view where the agent finds a free slot for you.
AgendaToday's schedule with a live now line and agent prep notes.