Open demo

CSS code

HTML
<div class="dp" data-dp>
  <label class="dp-label" id="dp-label" for="dp-field">Departure date</label>
  <button type="button" class="dp-field" id="dp-field" aria-haspopup="dialog" aria-expanded="false" aria-controls="dp-dialog" aria-labelledby="dp-label dp-value">
    <svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="5" width="16" height="16" rx="2"/><path d="M8 3v4M16 3v4M4 11h16"/></svg>
    <span id="dp-value" data-value data-empty>Select a date</span>
  </button>
  <input type="hidden" name="departure" value="">
  <div class="dp-pop" id="dp-dialog" role="dialog" aria-modal="true" aria-label="Choose departure date" hidden>
    <div class="dp-hd">
      <h2 id="dp-month" data-month aria-live="polite"></h2>
      <span><button type="button" data-move="-1" aria-label="Previous month">‹</button><button type="button" data-move="1" aria-label="Next month">›</button></span>
    </div>
    <table class="dp-grid" role="grid" aria-labelledby="dp-month">
      <thead><tr>
        <th scope="col"><span aria-hidden="true">M</span><span class="sr-only">Monday</span></th>
        <th scope="col"><span aria-hidden="true">T</span><span class="sr-only">Tuesday</span></th>
        <th scope="col"><span aria-hidden="true">W</span><span class="sr-only">Wednesday</span></th>
        <th scope="col"><span aria-hidden="true">T</span><span class="sr-only">Thursday</span></th>
        <th scope="col"><span aria-hidden="true">F</span><span class="sr-only">Friday</span></th>
        <th scope="col"><span aria-hidden="true">S</span><span class="sr-only">Saturday</span></th>
        <th scope="col"><span aria-hidden="true">S</span><span class="sr-only">Sunday</span></th>
      </tr></thead>
      <tbody></tbody>
    </table>
    <div class="dp-acts"><button type="button" class="mute" data-clear>Clear</button><button type="button" data-today>Today</button></div>
  </div>
</div>
CSS
/* Date picker: a field button opens a modal dialog holding a <table role="grid"> month. Selected and today
   states come from aria-pressed / aria-current. A hidden input carries the ISO date for forms. */
.dp{--dp-accent:#6d28d9;--dp-muted:#64748b;position:relative;width:300px;max-width:100%;color:#191e28}
.dp-label{display:block;font-size:.79rem;font-weight:700;margin-bottom:7px;color:#4b5567}
.dp-field{width:100%;display:flex;align-items:center;gap:10px;background:#fff;border:1px solid #8a94a6;border-radius:11px;
  padding:11px 13px;font:inherit;font-size:.9rem;color:#191e28;text-align:left;cursor:pointer;transition:border-color .12s,box-shadow .12s}
.dp-field:hover{border-color:#6b7489}
.dp-field[aria-expanded="true"]{border-color:var(--dp-accent);box-shadow:0 0 0 3px rgba(109,40,217,.14)}
.dp-field svg{display:block;width:17px;height:17px;stroke:#8b95a7;fill:none;stroke-width:2;flex-shrink:0}
.dp-field [data-value]{font-weight:600}
.dp-field [data-empty]{font-weight:400;color:var(--dp-muted)}
.dp-pop{position:absolute;top:calc(100% + 8px);left:0;width:300px;max-width:100%;padding:16px;z-index:5;background:#fff;
  border-radius:16px;box-shadow:0 14px 34px -18px rgba(18,24,40,.25)}
.dp-pop[hidden]{display:none}
.dp-hd{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}
.dp-hd h2{font-size:.9rem}
.dp-hd span{display:flex;gap:5px}
.dp-hd button{width:26px;height:26px;border:0;border-radius:7px;background:#f1f3f7;display:grid;place-items:center;font:inherit;
  color:#4b5567;font-size:.76rem;cursor:pointer}
.dp-hd button:hover{background:#e4e8ef}
.dp-grid{width:100%;border-collapse:separate;border-spacing:2px;table-layout:fixed}
.dp-grid th{font-size:.62rem;font-weight:800;color:var(--dp-muted);text-transform:uppercase;padding:6px 0;text-align:center}
.dp-grid td{padding:0}
.dp-d{width:100%;aspect-ratio:1;display:grid;place-items:center;border:0;background:none;font:inherit;font-size:.8rem;font-weight:600;
  border-radius:9px;cursor:pointer;color:#2c3546;transition:background .12s}
.dp-d:hover{background:#eef2f8}
.dp-d[aria-current="date"]{box-shadow:inset 0 0 0 2px var(--dp-accent);color:var(--dp-accent)}
.dp-d[aria-pressed="true"],.dp-d[aria-pressed="true"]:hover{background:var(--dp-accent);color:#fff;box-shadow:none}
.dp-acts{display:flex;justify-content:space-between;margin-top:12px;padding-top:11px;border-top:1px solid #eef1f5}
.dp-acts button{border:0;background:none;font:inherit;font-size:.8rem;font-weight:700;cursor:pointer;color:var(--dp-accent);
  padding:4px 6px;border-radius:6px}
.dp-acts button:hover{background:#f3efff}
.dp-acts .mute{color:var(--dp-muted)}
.dp button:focus-visible{outline:2px solid var(--dp-accent);outline-offset:2px}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
@media (prefers-reduced-motion:reduce){.dp-field,.dp-d{transition:none}}
JavaScript
/* Date picker dialog (WAI-ARIA APG pattern). The field button opens a modal dialog with a role="grid" month.
   Arrow keys move a day or a week, Home/End go to the start/end of the week, Page Up/Page Down change month,
   Enter or Space picks the focused day, Escape closes, and Tab cycles inside the dialog. Focus returns to the field. */
(() => {
  const root = document.querySelector('[data-dp]'); if (!root) return;
  const field = root.querySelector('[aria-haspopup="dialog"]'), val = root.querySelector('[data-value]'), input = root.querySelector('input[type="hidden"]');
  const dlg = root.querySelector('[role="dialog"]'), title = dlg.querySelector('[data-month]'), body = dlg.querySelector('tbody');
  const CLS = { day: 'dp-d' };
  const full = d => d.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
  const iso = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
  const same = (a, b) => !!a && !!b && a.toDateString() === b.toDateString();
  const addMonths = (d, n) => { const t = new Date(d.getFullYear(), d.getMonth() + n, 1);
    t.setDate(Math.min(d.getDate(), new Date(t.getFullYear(), t.getMonth() + 1, 0).getDate())); return t; };
  let view = new Date(), focus = new Date(), sel = null;
  function render(moveFocus) {
    const y = view.getFullYear(), m = view.getMonth(), today = new Date();
    if (focus.getMonth() !== m || focus.getFullYear() !== y) focus = new Date(y, m, 1);
    title.textContent = view.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
    const first = (new Date(y, m, 1).getDay() + 6) % 7, days = new Date(y, m + 1, 0).getDate();
    let h = '';
    for (let i = 0; i < Math.ceil((first + days) / 7) * 7; i++) {
      const n = i - first + 1, d = new Date(y, m, n);
      if (i % 7 === 0) h += '<tr>';
      h += n < 1 || n > days ? '<td></td>'
        : `<td><button type="button" class="${CLS.day}" data-day="${n}" tabindex="${same(d, focus) ? 0 : -1}" aria-label="${full(d)}" ` +
          `aria-pressed="${same(d, sel)}"${same(d, today) ? ' aria-current="date"' : ''}>${n}</button></td>`;
      if (i % 7 === 6) h += '</tr>';
    }
    body.innerHTML = h;
    if (moveFocus) body.querySelector('[tabindex="0"]').focus();
  }
  function open() {
    focus = sel ? new Date(sel) : new Date(); view = new Date(focus.getFullYear(), focus.getMonth(), 1);
    dlg.hidden = false; field.setAttribute('aria-expanded', 'true'); render(true);
  }
  function close(refocus) { dlg.hidden = true; field.setAttribute('aria-expanded', 'false'); if (refocus) field.focus(); }
  function choose(d) {
    sel = d; input.value = d ? iso(d) : '';
    val.textContent = d ? d.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }) : 'Select a date';
    val.toggleAttribute('data-empty', !d); close(true);
  }
  function go(d) { focus = d; view = new Date(d.getFullYear(), d.getMonth(), 1); render(true); }
  field.addEventListener('click', () => (dlg.hidden ? open() : close(false)));
  dlg.addEventListener('keydown', e => {
    if (e.key === 'Escape') { e.preventDefault(); close(true); return; }
    if (e.key === 'Tab') { // keep focus inside the modal dialog
      const f = [...dlg.querySelectorAll('button:not([tabindex="-1"])')], i = f.indexOf(document.activeElement);
      if (e.shiftKey && i === 0) { e.preventDefault(); f[f.length - 1].focus(); }
      else if (!e.shiftKey && i === f.length - 1) { e.preventDefault(); f[0].focus(); }
      return;
    }
    if (!e.target.matches('button[data-day]')) return;
    const f = focus, wd = (f.getDay() + 6) % 7, at = n => new Date(f.getFullYear(), f.getMonth(), f.getDate() + n);
    const d = { ArrowLeft: () => at(-1), ArrowRight: () => at(1), ArrowUp: () => at(-7), ArrowDown: () => at(7),
      Home: () => at(-wd), End: () => at(6 - wd), PageUp: () => addMonths(f, -1), PageDown: () => addMonths(f, 1) }[e.key];
    if (!d) return;
    e.preventDefault(); go(d());
  });
  body.addEventListener('click', e => {
    const b = e.target.closest('button[data-day]');
    if (b) choose(new Date(view.getFullYear(), view.getMonth(), Number(b.dataset.day)));
  });
  dlg.querySelectorAll('[data-move]').forEach(b => b.addEventListener('click', () => {
    view.setMonth(view.getMonth() + Number(b.dataset.move)); render(false);
  }));
  dlg.querySelector('[data-clear]').addEventListener('click', () => choose(null));
  dlg.querySelector('[data-today]').addEventListener('click', () => { const t = new Date(); choose(new Date(t.getFullYear(), t.getMonth(), t.getDate())); });
  document.addEventListener('click', e => { if (!dlg.hidden && !root.contains(e.target) && e.target.isConnected) close(false); });
})();

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div class="dp position-relative" data-dp>
  <label class="form-label fw-bold d-block" id="dp-label" for="dp-field">Departure date</label>
  <button type="button" class="form-control dp-field d-flex align-items-center gap-2 text-start" id="dp-field" aria-haspopup="dialog" aria-expanded="false" aria-controls="dp-dialog" aria-labelledby="dp-label dp-value">
    <svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="5" width="16" height="16" rx="2"/><path d="M8 3v4M16 3v4M4 11h16"/></svg>
    <span class="fw-semibold" id="dp-value" data-value data-empty>Select a date</span>
  </button>
  <input type="hidden" name="departure" value="">
  <div class="card dp-pop position-absolute" id="dp-dialog" role="dialog" aria-modal="true" aria-label="Choose departure date" hidden>
    <div class="card-body">
      <div class="dp-hd d-flex align-items-center justify-content-between">
        <h2 class="dp-month fw-bold mb-0" id="dp-month" data-month aria-live="polite"></h2>
        <div class="d-flex gap-1"><button type="button" class="btn dp-nav" data-move="-1" aria-label="Previous month">‹</button><button type="button" class="btn dp-nav" data-move="1" aria-label="Next month">›</button></div>
      </div>
      <table class="table table-borderless text-center dp-grid mb-0" role="grid" aria-labelledby="dp-month">
        <thead><tr>
          <th scope="col"><span aria-hidden="true">M</span><span class="visually-hidden">Monday</span></th>
          <th scope="col"><span aria-hidden="true">T</span><span class="visually-hidden">Tuesday</span></th>
          <th scope="col"><span aria-hidden="true">W</span><span class="visually-hidden">Wednesday</span></th>
          <th scope="col"><span aria-hidden="true">T</span><span class="visually-hidden">Thursday</span></th>
          <th scope="col"><span aria-hidden="true">F</span><span class="visually-hidden">Friday</span></th>
          <th scope="col"><span aria-hidden="true">S</span><span class="visually-hidden">Saturday</span></th>
          <th scope="col"><span aria-hidden="true">S</span><span class="visually-hidden">Sunday</span></th>
        </tr></thead>
        <tbody></tbody>
      </table>
      <div class="dp-acts d-flex justify-content-between"><button type="button" class="btn btn-link dp-act mute" data-clear>Clear</button><button type="button" class="btn btn-link dp-act" data-today>Today</button></div>
    </div>
  </div>
</div>
CSS
/* Bootstrap .form-label + a .form-control styled button, a .card popup (role="dialog") with a borderless .table grid,
   .btn-light month arrows and .btn-link actions. No Bootstrap JavaScript: the small script below runs the dialog. */
.dp{--dp-accent:#6d28d9;--dp-muted:#64748b;width:300px;max-width:100%;color:#191e28}
.dp .form-label{font-size:.79rem;margin-bottom:7px;color:#4b5567}
.dp-field{--bs-border-color:#8a94a6;--bs-border-radius:11px;padding:11px 13px;font-size:.9rem;line-height:normal;color:#191e28;cursor:pointer}
.dp-field:hover{border-color:#6b7489}
.dp-field:focus,.dp-field[aria-expanded="true"]{border-color:var(--dp-accent);box-shadow:0 0 0 3px rgba(109,40,217,.14)}
.dp-field svg{display:block;width:17px;height:17px;stroke:#8b95a7;fill:none;stroke-width:2;flex-shrink:0}
.dp-field [data-empty]{font-weight:400!important;color:var(--dp-muted)}
.dp-pop{--bs-card-border-width:0;--bs-card-border-radius:16px;--bs-card-spacer-x:16px;--bs-card-spacer-y:16px;top:calc(100% + 8px);
  left:0;width:300px;max-width:100%;z-index:5;box-shadow:0 14px 34px -18px rgba(18,24,40,.25)}
.dp-pop[hidden]{display:none}
.dp-month{font-size:.9rem;line-height:normal}
.dp-nav{--bs-btn-padding-x:0;--bs-btn-padding-y:0;--bs-btn-font-size:.76rem;--bs-btn-color:#4b5567;--bs-btn-bg:#f1f3f7;--bs-btn-border-width:0;
  --bs-btn-hover-color:#4b5567;--bs-btn-hover-bg:#e4e8ef;--bs-btn-active-bg:#dde2ea;--bs-btn-border-radius:7px;width:26px;height:26px}
.dp-grid{--bs-table-bg:transparent;border-collapse:separate;border-spacing:2px;table-layout:fixed}
.dp-grid>:not(caption)>*>*{padding:0}
.dp-grid th{font-size:.62rem;font-weight:800;color:var(--dp-muted);text-transform:uppercase;padding:6px 0}
.dp-d{--bs-btn-padding-x:0;--bs-btn-padding-y:0;--bs-btn-font-size:.8rem;--bs-btn-font-weight:600;--bs-btn-color:#2c3546;--bs-btn-border-width:0;
  --bs-btn-border-radius:9px;--bs-btn-hover-color:#2c3546;--bs-btn-hover-bg:#eef2f8;--bs-btn-active-color:#2c3546;--bs-btn-active-bg:#e4e8ef;
  width:100%;aspect-ratio:1;display:grid;place-items:center}
.dp-d[aria-current="date"]{box-shadow:inset 0 0 0 2px var(--dp-accent);color:var(--dp-accent)}
.dp-d[aria-pressed="true"],.dp-d[aria-pressed="true"]:hover{background:var(--dp-accent);color:#fff;box-shadow:none}
.dp-hd{margin-bottom:10px}
.dp-acts{margin-top:12px;padding-top:11px;border-top:1px solid #eef1f5}
.dp-act{--bs-btn-padding-x:6px;--bs-btn-padding-y:4px;--bs-btn-font-size:.8rem;--bs-btn-font-weight:700;--bs-btn-color:var(--dp-accent);
  --bs-btn-hover-color:var(--dp-accent);--bs-btn-hover-bg:#f3efff;--bs-btn-active-color:var(--dp-accent);--bs-btn-border-radius:6px;
  --bs-btn-line-height:normal;text-decoration:none}
.dp-act.mute{--bs-btn-color:var(--dp-muted);--bs-btn-hover-color:var(--dp-muted);--bs-btn-active-color:var(--dp-muted)}
.dp-field:focus-visible,.dp .btn:focus-visible{outline:2px solid var(--dp-accent);outline-offset:2px;box-shadow:none}
.dp-d:not([aria-pressed="true"]):focus-visible{background-color:transparent}
@media (prefers-reduced-motion:reduce){.dp-field,.dp .btn{transition:none}}
JavaScript
/* Date picker dialog (WAI-ARIA APG pattern). The field button opens a modal dialog with a role="grid" month.
   Arrow keys move a day or a week, Home/End go to the start/end of the week, Page Up/Page Down change month,
   Enter or Space picks the focused day, Escape closes, and Tab cycles inside the dialog. Focus returns to the field. */
(() => {
  const root = document.querySelector('[data-dp]'); if (!root) return;
  const field = root.querySelector('[aria-haspopup="dialog"]'), val = root.querySelector('[data-value]'), input = root.querySelector('input[type="hidden"]');
  const dlg = root.querySelector('[role="dialog"]'), title = dlg.querySelector('[data-month]'), body = dlg.querySelector('tbody');
  const CLS = { day: 'btn dp-d' };
  const full = d => d.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
  const iso = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
  const same = (a, b) => !!a && !!b && a.toDateString() === b.toDateString();
  const addMonths = (d, n) => { const t = new Date(d.getFullYear(), d.getMonth() + n, 1);
    t.setDate(Math.min(d.getDate(), new Date(t.getFullYear(), t.getMonth() + 1, 0).getDate())); return t; };
  let view = new Date(), focus = new Date(), sel = null;
  function render(moveFocus) {
    const y = view.getFullYear(), m = view.getMonth(), today = new Date();
    if (focus.getMonth() !== m || focus.getFullYear() !== y) focus = new Date(y, m, 1);
    title.textContent = view.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
    const first = (new Date(y, m, 1).getDay() + 6) % 7, days = new Date(y, m + 1, 0).getDate();
    let h = '';
    for (let i = 0; i < Math.ceil((first + days) / 7) * 7; i++) {
      const n = i - first + 1, d = new Date(y, m, n);
      if (i % 7 === 0) h += '<tr>';
      h += n < 1 || n > days ? '<td></td>'
        : `<td><button type="button" class="${CLS.day}" data-day="${n}" tabindex="${same(d, focus) ? 0 : -1}" aria-label="${full(d)}" ` +
          `aria-pressed="${same(d, sel)}"${same(d, today) ? ' aria-current="date"' : ''}>${n}</button></td>`;
      if (i % 7 === 6) h += '</tr>';
    }
    body.innerHTML = h;
    if (moveFocus) body.querySelector('[tabindex="0"]').focus();
  }
  function open() {
    focus = sel ? new Date(sel) : new Date(); view = new Date(focus.getFullYear(), focus.getMonth(), 1);
    dlg.hidden = false; field.setAttribute('aria-expanded', 'true'); render(true);
  }
  function close(refocus) { dlg.hidden = true; field.setAttribute('aria-expanded', 'false'); if (refocus) field.focus(); }
  function choose(d) {
    sel = d; input.value = d ? iso(d) : '';
    val.textContent = d ? d.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }) : 'Select a date';
    val.toggleAttribute('data-empty', !d); close(true);
  }
  function go(d) { focus = d; view = new Date(d.getFullYear(), d.getMonth(), 1); render(true); }
  field.addEventListener('click', () => (dlg.hidden ? open() : close(false)));
  dlg.addEventListener('keydown', e => {
    if (e.key === 'Escape') { e.preventDefault(); close(true); return; }
    if (e.key === 'Tab') { // keep focus inside the modal dialog
      const f = [...dlg.querySelectorAll('button:not([tabindex="-1"])')], i = f.indexOf(document.activeElement);
      if (e.shiftKey && i === 0) { e.preventDefault(); f[f.length - 1].focus(); }
      else if (!e.shiftKey && i === f.length - 1) { e.preventDefault(); f[0].focus(); }
      return;
    }
    if (!e.target.matches('button[data-day]')) return;
    const f = focus, wd = (f.getDay() + 6) % 7, at = n => new Date(f.getFullYear(), f.getMonth(), f.getDate() + n);
    const d = { ArrowLeft: () => at(-1), ArrowRight: () => at(1), ArrowUp: () => at(-7), ArrowDown: () => at(7),
      Home: () => at(-wd), End: () => at(6 - wd), PageUp: () => addMonths(f, -1), PageDown: () => addMonths(f, 1) }[e.key];
    if (!d) return;
    e.preventDefault(); go(d());
  });
  body.addEventListener('click', e => {
    const b = e.target.closest('button[data-day]');
    if (b) choose(new Date(view.getFullYear(), view.getMonth(), Number(b.dataset.day)));
  });
  dlg.querySelectorAll('[data-move]').forEach(b => b.addEventListener('click', () => {
    view.setMonth(view.getMonth() + Number(b.dataset.move)); render(false);
  }));
  dlg.querySelector('[data-clear]').addEventListener('click', () => choose(null));
  dlg.querySelector('[data-today]').addEventListener('click', () => { const t = new Date(); choose(new Date(t.getFullYear(), t.getMonth(), t.getDate())); });
  document.addEventListener('click', e => { if (!dlg.hidden && !root.contains(e.target) && e.target.isConnected) close(false); });
})();

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div class="relative w-[300px] max-w-full text-[#191e28]" data-dp>
  <label class="mb-[7px] block text-[.79rem] font-bold text-[#4b5567]" id="dp-label" for="dp-field">Departure date</label>
  <button type="button" class="flex w-full cursor-pointer items-center gap-2.5 rounded-[11px] border border-[#8a94a6] bg-white px-[13px] py-[11px] text-left text-[.9rem] text-[#191e28] transition-[border-color,box-shadow] duration-[120ms] hover:border-[#6b7489] motion-reduce:transition-none aria-expanded:border-dp-accent aria-expanded:shadow-[0_0_0_3px_rgba(109,40,217,.14)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-dp-accent" id="dp-field" aria-haspopup="dialog" aria-expanded="false" aria-controls="dp-dialog" aria-labelledby="dp-label dp-value">
    <svg viewBox="0 0 24 24" aria-hidden="true" class="block size-[17px] shrink-0 fill-none stroke-[#8b95a7] stroke-2"><rect x="4" y="5" width="16" height="16" rx="2"/><path d="M8 3v4M16 3v4M4 11h16"/></svg>
    <span class="font-semibold data-empty:font-normal data-empty:text-dp-muted" id="dp-value" data-value data-empty>Select a date</span>
  </button>
  <input type="hidden" name="departure" value="">
  <div class="absolute top-[calc(100%+8px)] left-0 z-5 w-[300px] max-w-full rounded-2xl bg-white p-4 shadow-[0_14px_34px_-18px_rgba(18,24,40,.25)]" id="dp-dialog" role="dialog" aria-modal="true" aria-label="Choose departure date" hidden>
    <div class="mb-2.5 flex items-center justify-between">
      <h2 class="text-[.9rem] font-bold" id="dp-month" data-month aria-live="polite"></h2>
      <span class="flex gap-[5px]"><button type="button" class="grid size-[26px] cursor-pointer place-items-center rounded-[7px] bg-[#f1f3f7] text-[.76rem] text-[#4b5567] hover:bg-[#e4e8ef] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-dp-accent" data-move="-1" aria-label="Previous month">‹</button><button type="button" class="grid size-[26px] cursor-pointer place-items-center rounded-[7px] bg-[#f1f3f7] text-[.76rem] text-[#4b5567] hover:bg-[#e4e8ef] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-dp-accent" data-move="1" aria-label="Next month">›</button></span>
    </div>
    <table class="w-full table-fixed border-separate border-spacing-[2px]" role="grid" aria-labelledby="dp-month">
      <thead class="text-center text-[.62rem] font-extrabold text-dp-muted uppercase"><tr>
        <th scope="col" class="py-1.5"><span aria-hidden="true">M</span><span class="sr-only">Monday</span></th>
        <th scope="col" class="py-1.5"><span aria-hidden="true">T</span><span class="sr-only">Tuesday</span></th>
        <th scope="col" class="py-1.5"><span aria-hidden="true">W</span><span class="sr-only">Wednesday</span></th>
        <th scope="col" class="py-1.5"><span aria-hidden="true">T</span><span class="sr-only">Thursday</span></th>
        <th scope="col" class="py-1.5"><span aria-hidden="true">F</span><span class="sr-only">Friday</span></th>
        <th scope="col" class="py-1.5"><span aria-hidden="true">S</span><span class="sr-only">Saturday</span></th>
        <th scope="col" class="py-1.5"><span aria-hidden="true">S</span><span class="sr-only">Sunday</span></th>
      </tr></thead>
      <tbody></tbody>
    </table>
    <div class="mt-3 flex justify-between border-t border-[#eef1f5] pt-[11px]"><button type="button" class="cursor-pointer rounded-md px-1.5 py-1 text-[.8rem] font-bold text-dp-muted hover:bg-[#f3efff] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-dp-accent" data-clear>Clear</button><button type="button" class="cursor-pointer rounded-md px-1.5 py-1 text-[.8rem] font-bold text-dp-accent hover:bg-[#f3efff] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-dp-accent" data-today>Today</button></div>
  </div>
</div>
CSS
@theme {
  --color-dp-accent: #6d28d9;
  --color-dp-muted: #64748b;
}
JavaScript
/* Date picker dialog (WAI-ARIA APG pattern). The field button opens a modal dialog with a role="grid" month.
   Arrow keys move a day or a week, Home/End go to the start/end of the week, Page Up/Page Down change month,
   Enter or Space picks the focused day, Escape closes, and Tab cycles inside the dialog. Focus returns to the field. */
(() => {
  const root = document.querySelector('[data-dp]'); if (!root) return;
  const field = root.querySelector('[aria-haspopup="dialog"]'), val = root.querySelector('[data-value]'), input = root.querySelector('input[type="hidden"]');
  const dlg = root.querySelector('[role="dialog"]'), title = dlg.querySelector('[data-month]'), body = dlg.querySelector('tbody');
  const CLS = { day: 'grid aspect-square w-full cursor-pointer place-items-center rounded-[9px] text-[.8rem] font-semibold text-[#2c3546] transition-colors duration-[120ms] hover:bg-[#eef2f8] motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-dp-accent aria-[current=date]:text-dp-accent aria-[current=date]:shadow-[inset_0_0_0_2px_var(--color-dp-accent)] aria-pressed:bg-dp-accent aria-pressed:text-white aria-pressed:shadow-none aria-pressed:hover:bg-dp-accent' };
  const full = d => d.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
  const iso = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
  const same = (a, b) => !!a && !!b && a.toDateString() === b.toDateString();
  const addMonths = (d, n) => { const t = new Date(d.getFullYear(), d.getMonth() + n, 1);
    t.setDate(Math.min(d.getDate(), new Date(t.getFullYear(), t.getMonth() + 1, 0).getDate())); return t; };
  let view = new Date(), focus = new Date(), sel = null;
  function render(moveFocus) {
    const y = view.getFullYear(), m = view.getMonth(), today = new Date();
    if (focus.getMonth() !== m || focus.getFullYear() !== y) focus = new Date(y, m, 1);
    title.textContent = view.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
    const first = (new Date(y, m, 1).getDay() + 6) % 7, days = new Date(y, m + 1, 0).getDate();
    let h = '';
    for (let i = 0; i < Math.ceil((first + days) / 7) * 7; i++) {
      const n = i - first + 1, d = new Date(y, m, n);
      if (i % 7 === 0) h += '<tr>';
      h += n < 1 || n > days ? '<td></td>'
        : `<td><button type="button" class="${CLS.day}" data-day="${n}" tabindex="${same(d, focus) ? 0 : -1}" aria-label="${full(d)}" ` +
          `aria-pressed="${same(d, sel)}"${same(d, today) ? ' aria-current="date"' : ''}>${n}</button></td>`;
      if (i % 7 === 6) h += '</tr>';
    }
    body.innerHTML = h;
    if (moveFocus) body.querySelector('[tabindex="0"]').focus();
  }
  function open() {
    focus = sel ? new Date(sel) : new Date(); view = new Date(focus.getFullYear(), focus.getMonth(), 1);
    dlg.hidden = false; field.setAttribute('aria-expanded', 'true'); render(true);
  }
  function close(refocus) { dlg.hidden = true; field.setAttribute('aria-expanded', 'false'); if (refocus) field.focus(); }
  function choose(d) {
    sel = d; input.value = d ? iso(d) : '';
    val.textContent = d ? d.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }) : 'Select a date';
    val.toggleAttribute('data-empty', !d); close(true);
  }
  function go(d) { focus = d; view = new Date(d.getFullYear(), d.getMonth(), 1); render(true); }
  field.addEventListener('click', () => (dlg.hidden ? open() : close(false)));
  dlg.addEventListener('keydown', e => {
    if (e.key === 'Escape') { e.preventDefault(); close(true); return; }
    if (e.key === 'Tab') { // keep focus inside the modal dialog
      const f = [...dlg.querySelectorAll('button:not([tabindex="-1"])')], i = f.indexOf(document.activeElement);
      if (e.shiftKey && i === 0) { e.preventDefault(); f[f.length - 1].focus(); }
      else if (!e.shiftKey && i === f.length - 1) { e.preventDefault(); f[0].focus(); }
      return;
    }
    if (!e.target.matches('button[data-day]')) return;
    const f = focus, wd = (f.getDay() + 6) % 7, at = n => new Date(f.getFullYear(), f.getMonth(), f.getDate() + n);
    const d = { ArrowLeft: () => at(-1), ArrowRight: () => at(1), ArrowUp: () => at(-7), ArrowDown: () => at(7),
      Home: () => at(-wd), End: () => at(6 - wd), PageUp: () => addMonths(f, -1), PageDown: () => addMonths(f, 1) }[e.key];
    if (!d) return;
    e.preventDefault(); go(d());
  });
  body.addEventListener('click', e => {
    const b = e.target.closest('button[data-day]');
    if (b) choose(new Date(view.getFullYear(), view.getMonth(), Number(b.dataset.day)));
  });
  dlg.querySelectorAll('[data-move]').forEach(b => b.addEventListener('click', () => {
    view.setMonth(view.getMonth() + Number(b.dataset.move)); render(false);
  }));
  dlg.querySelector('[data-clear]').addEventListener('click', () => choose(null));
  dlg.querySelector('[data-today]').addEventListener('click', () => { const t = new Date(); choose(new Date(t.getFullYear(), t.getMonth(), t.getDate())); });
  document.addEventListener('click', e => { if (!dlg.hidden && !root.contains(e.target) && e.target.isConnected) close(false); });
})();

About this set

A form field that opens a calendar, for booking forms, travel search, checkout delivery dates and admin filters. The field looks like a text input with a calendar icon and shows either a muted “Select a date” or the chosen date, such as “22 October 2026″. Pressing it opens a popup card with the month name, previous and next buttons, a month grid where today is ringed in violet and the selection filled, and Clear and Today actions at the bottom. It follows the WAI-ARIA date picker dialog pattern: the popup is a modal dialog, the month is a table with role=”grid”, and focus moves straight to the selected day or today. Arrow keys move by day or week, Home and End jump within the week, Page Up and Page Down change month, Enter or Space picks a day, and Escape closes; Tab cycles through the dialog’s controls, and focus returns to the field when the dialog closes. A hidden input holds the ISO date, so the value submits with a form. Everything runs on about seventy lines of vanilla JavaScript. The Bootstrap version styles the field as .form-control and the popup as a .card without Bootstrap’s JavaScript; the Tailwind version uses aria-expanded, aria-pressed and data variants.

What’s included

  • Field button that opens a modal date dialog
  • Full keyboard support: arrows, Home, End, Page keys, Enter, Escape
  • Focus trapped in the dialog and returned to the field
  • Hidden input submits an ISO date
  • Clear and Today shortcuts

Accessibility

The field is a button with aria-haspopup="dialog" and aria-expanded, named by its label and current value ("Departure date 22 October 2026"). The popup is role="dialog" with aria-modal and an aria-label; its grid has one roving tab stop, day buttons named with the full date, aria-pressed for the selection and aria-current="date" for today. The month title is a live region, the month buttons have aria-labels, every control shows a 2px violet focus outline, and transitions are removed under prefers-reduced-motion. The field border was darkened to reach 3:1.

Customise it

Set --dp-accent and --dp-muted on .dp and the 300px width. In Bootstrap the field is restyled through --bs-border-color on .form-control and the buttons through --bs-btn-* variables; in Tailwind edit --color-dp-accent and --color-dp-muted.