Open demo

CSS code

HTML
<div class="mini" data-mini>
  <div class="mini-head">
    <button type="button" data-move="-1" aria-label="Previous month">‹</button>
    <h2 class="mini-title" id="mini-title" aria-live="polite"></h2>
    <button type="button" data-move="1" aria-label="Next month">›</button>
  </div>
  <table class="mini-grid" role="grid" aria-labelledby="mini-title">
    <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>
  <p class="mini-foot" aria-live="polite">Selected: <b>nothing yet</b></p>
</div>
CSS
/* Mini picker: a <table role="grid"> of day buttons. One day is in the Tab order; arrow keys move between days. */
.mini{--mini-accent:#6d28d9;--mini-muted:#64748b;
  position:relative;width:300px;max-width:100%;background:#fff;border-radius:16px;padding:18px;color:#191e28;
  box-shadow:0 14px 34px -18px rgba(18,24,40,.25)}
.mini-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}
.mini-title{font-size:.92rem}
.mini-head button{width:28px;height:28px;border:0;background:#f1f3f7;border-radius:8px;cursor:pointer;font:inherit;
  color:#4b5567;transition:background .15s}
.mini-head button:hover{background:#e4e8ef}
.mini-grid{width:100%;border-collapse:separate;border-spacing:2px;table-layout:fixed;text-align:center}
.mini-grid th{font-size:.62rem;font-weight:800;color:var(--mini-muted);text-transform:uppercase;padding:6px 0}
.mini-grid td{padding:0}
.mini-d{width:100%;aspect-ratio:1;display:flex;align-items:center;justify-content:center;font:inherit;font-size:.8rem;
  font-weight:600;border-radius:9px;border:0;background:none;color:#2c3546;transition:background .12s}
button.mini-d{cursor:pointer}
button.mini-d:hover{background:#eef2f8}
.mini-d.dim{color:#6b7280;font-weight:500}
.mini-d[aria-current="date"]{box-shadow:inset 0 0 0 2px var(--mini-accent);color:var(--mini-accent)}
.mini-d[aria-pressed="true"],.mini-d[aria-pressed="true"]:hover{background:var(--mini-accent);color:#fff}
.mini-head button:focus-visible,.mini-d:focus-visible{outline:2px solid var(--mini-accent);outline-offset:2px}
.mini-foot{margin-top:10px;text-align:center;font-size:.76rem;color:var(--mini-muted)}
.mini-foot b{color:#191e28}
.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){.mini-head button,.mini-d{transition:none}}
JavaScript
/* Arrow keys move a day or a week, Home/End jump to the start/end of the week, Page Up/Page Down change month,
   Enter or Space selects. Each day button is named with its full date; today has aria-current="date". */
(() => {
  const root = document.querySelector('[data-mini]'); if (!root) return;
  const title = root.querySelector('#mini-title'), body = root.querySelector('tbody'), out = root.querySelector('.mini-foot b');
  const CLS = { day: 'mini-d', dim: 'mini-d dim' };
  const full = d => d.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
  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(); view.setDate(1);
  let 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 start = new Date(y, m, 1 - (new Date(y, m, 1).getDay() + 6) % 7);
    let h = '';
    for (let i = 0; i < 42; i++) {
      const d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
      if (i % 7 === 0) h += '<tr>';
      h += d.getMonth() !== m
        ? `<td><span class="${CLS.dim}" aria-hidden="true">${d.getDate()}</span></td>`
        : `<td><button type="button" class="${CLS.day}" data-day="${d.getDate()}" tabindex="${same(d, focus) ? 0 : -1}" ` +
          `aria-label="${full(d)}" aria-pressed="${same(d, sel)}"${same(d, today) ? ' aria-current="date"' : ''}>${d.getDate()}</button></td>`;
      if (i % 7 === 6) h += '</tr>';
    }
    body.innerHTML = h;
    if (moveFocus) body.querySelector('[tabindex="0"]').focus();
  }
  function go(d) { focus = d; view = new Date(d.getFullYear(), d.getMonth(), 1); render(true); }
  body.addEventListener('keydown', e => {
    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) return;
    sel = focus = new Date(view.getFullYear(), view.getMonth(), Number(b.dataset.day));
    out.textContent = sel.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
    render(true);
  });
  root.querySelectorAll('[data-move]').forEach(b => b.addEventListener('click', () => {
    view.setMonth(view.getMonth() + Number(b.dataset.move)); render(false);
  }));
  render(false);
})();

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div class="card mini" data-mini>
  <div class="card-body">
    <div class="d-flex align-items-center justify-content-between mb-2 pb-1">
      <button type="button" class="btn mini-nav" data-move="-1" aria-label="Previous month">‹</button>
      <h2 class="mini-title fw-bold mb-0" id="mini-title" aria-live="polite"></h2>
      <button type="button" class="btn mini-nav" data-move="1" aria-label="Next month">›</button>
    </div>
    <table class="table table-borderless text-center mini-grid mb-0" role="grid" aria-labelledby="mini-title">
      <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>
    <p class="mini-foot text-center mt-2 mb-0" aria-live="polite">Selected: <b>nothing yet</b></p>
  </div>
</div>
CSS
/* Bootstrap .card, .btn and a borderless .table with role="grid"; state colours come from aria-pressed / aria-current. */
.mini{--mini-accent:#6d28d9;--mini-muted:#64748b;--bs-card-border-width:0;--bs-card-border-radius:16px;--bs-card-spacer-x:18px;
  --bs-card-spacer-y:18px;--bs-card-color:#191e28;width:300px;max-width:100%;box-shadow:0 14px 34px -18px rgba(18,24,40,.25)}
.mini-title{font-size:.92rem;line-height:normal}
.mini-nav{--bs-btn-padding-x:0;--bs-btn-padding-y:0;--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-color:#4b5567;--bs-btn-active-bg:#dde2ea;
  --bs-btn-border-radius:8px;--bs-btn-focus-box-shadow:none;width:28px;height:28px}
.mini-grid{--bs-table-bg:transparent;--bs-table-color:#2c3546;border-collapse:separate;border-spacing:2px;table-layout:fixed}
.mini-grid>:not(caption)>*>*{padding:0}
.mini-grid th{font-size:.62rem;font-weight:800;color:var(--mini-muted);text-transform:uppercase;padding:6px 0}
.mini-d{--bs-btn-padding-x:0;--bs-btn-padding-y:0;--bs-btn-font-size:.8rem;--bs-btn-font-weight:600;--bs-btn-line-height:1;
  --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;--bs-btn-focus-box-shadow:none;
  width:100%;aspect-ratio:1;display:flex;align-items:center;justify-content:center}
span.mini-d{color:#6b7280;font-size:.8rem;font-weight:500}
.mini-d[aria-current="date"]{box-shadow:inset 0 0 0 2px var(--mini-accent);color:var(--mini-accent)}
.mini-d[aria-pressed="true"],.mini-d[aria-pressed="true"]:hover{background:var(--mini-accent);color:#fff}
.mini .btn:focus-visible{outline:2px solid var(--mini-accent);outline-offset:2px}
.mini-d:not([aria-pressed="true"]):focus-visible{background-color:transparent}
.mini-foot{font-size:.76rem;color:var(--mini-muted)}
.mini-foot b{color:#191e28}
@media (prefers-reduced-motion:reduce){.mini .btn{transition:none}}
JavaScript
/* Arrow keys move a day or a week, Home/End jump to the start/end of the week, Page Up/Page Down change month,
   Enter or Space selects. Each day button is named with its full date; today has aria-current="date". */
(() => {
  const root = document.querySelector('[data-mini]'); if (!root) return;
  const title = root.querySelector('#mini-title'), body = root.querySelector('tbody'), out = root.querySelector('.mini-foot b');
  const CLS = { day: 'btn mini-d', dim: 'mini-d' };
  const full = d => d.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
  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(); view.setDate(1);
  let 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 start = new Date(y, m, 1 - (new Date(y, m, 1).getDay() + 6) % 7);
    let h = '';
    for (let i = 0; i < 42; i++) {
      const d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
      if (i % 7 === 0) h += '<tr>';
      h += d.getMonth() !== m
        ? `<td><span class="${CLS.dim}" aria-hidden="true">${d.getDate()}</span></td>`
        : `<td><button type="button" class="${CLS.day}" data-day="${d.getDate()}" tabindex="${same(d, focus) ? 0 : -1}" ` +
          `aria-label="${full(d)}" aria-pressed="${same(d, sel)}"${same(d, today) ? ' aria-current="date"' : ''}>${d.getDate()}</button></td>`;
      if (i % 7 === 6) h += '</tr>';
    }
    body.innerHTML = h;
    if (moveFocus) body.querySelector('[tabindex="0"]').focus();
  }
  function go(d) { focus = d; view = new Date(d.getFullYear(), d.getMonth(), 1); render(true); }
  body.addEventListener('keydown', e => {
    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) return;
    sel = focus = new Date(view.getFullYear(), view.getMonth(), Number(b.dataset.day));
    out.textContent = sel.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
    render(true);
  });
  root.querySelectorAll('[data-move]').forEach(b => b.addEventListener('click', () => {
    view.setMonth(view.getMonth() + Number(b.dataset.move)); render(false);
  }));
  render(false);
})();

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div class="relative w-[300px] max-w-full rounded-2xl bg-white p-[18px] text-[#191e28] shadow-[0_14px_34px_-18px_rgba(18,24,40,.25)]" data-mini>
  <div class="mb-3 flex items-center justify-between">
    <button type="button" data-move="-1" aria-label="Previous month" class="size-7 cursor-pointer rounded-lg bg-[#f1f3f7] text-[#4b5567] transition-colors duration-150 hover:bg-[#e4e8ef] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-mini-accent motion-reduce:transition-none">‹</button>
    <h2 class="text-[.92rem] font-bold" id="mini-title" aria-live="polite"></h2>
    <button type="button" data-move="1" aria-label="Next month" class="size-7 cursor-pointer rounded-lg bg-[#f1f3f7] text-[#4b5567] transition-colors duration-150 hover:bg-[#e4e8ef] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-mini-accent motion-reduce:transition-none">›</button>
  </div>
  <table class="w-full table-fixed border-separate border-spacing-[2px] text-center" role="grid" aria-labelledby="mini-title">
    <thead class="text-[.62rem] font-extrabold text-mini-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>
  <p class="mt-2.5 text-center text-[.76rem] text-mini-muted" aria-live="polite">Selected: <b class="text-[#191e28]">nothing yet</b></p>
</div>
CSS
@theme {
  --color-mini-accent: #6d28d9;
  --color-mini-muted: #64748b;
}
JavaScript
/* Arrow keys move a day or a week, Home/End jump to the start/end of the week, Page Up/Page Down change month,
   Enter or Space selects. Each day button is named with its full date; today has aria-current="date". */
(() => {
  const root = document.querySelector('[data-mini]'); if (!root) return;
  const title = root.querySelector('#mini-title'), body = root.querySelector('tbody'), out = root.querySelector('p b');
  const BASE = 'flex aspect-square w-full items-center justify-center rounded-[9px] text-[.8rem]';
  const CLS = {
    day: `${BASE} cursor-pointer font-semibold text-[#2c3546] transition-colors duration-[120ms] hover:bg-[#eef2f8] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-mini-accent motion-reduce:transition-none aria-[current=date]:text-mini-accent aria-[current=date]:shadow-[inset_0_0_0_2px_var(--color-mini-accent)] aria-pressed:bg-mini-accent aria-pressed:text-white aria-pressed:hover:bg-mini-accent`,
    dim: `${BASE} font-medium text-[#6b7280]` };
  const full = d => d.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
  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(); view.setDate(1);
  let 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 start = new Date(y, m, 1 - (new Date(y, m, 1).getDay() + 6) % 7);
    let h = '';
    for (let i = 0; i < 42; i++) {
      const d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
      if (i % 7 === 0) h += '<tr>';
      h += d.getMonth() !== m
        ? `<td><span class="${CLS.dim}" aria-hidden="true">${d.getDate()}</span></td>`
        : `<td><button type="button" class="${CLS.day}" data-day="${d.getDate()}" tabindex="${same(d, focus) ? 0 : -1}" ` +
          `aria-label="${full(d)}" aria-pressed="${same(d, sel)}"${same(d, today) ? ' aria-current="date"' : ''}>${d.getDate()}</button></td>`;
      if (i % 7 === 6) h += '</tr>';
    }
    body.innerHTML = h;
    if (moveFocus) body.querySelector('[tabindex="0"]').focus();
  }
  function go(d) { focus = d; view = new Date(d.getFullYear(), d.getMonth(), 1); render(true); }
  body.addEventListener('keydown', e => {
    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) return;
    sel = focus = new Date(view.getFullYear(), view.getMonth(), Number(b.dataset.day));
    out.textContent = sel.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
    render(true);
  });
  root.querySelectorAll('[data-move]').forEach(b => b.addEventListener('click', () => {
    view.setMonth(view.getMonth() + Number(b.dataset.move)); render(false);
  }));
  render(false);
})();

About this set

A compact 300 pixel calendar card for sidebars, booking widgets and settings pages where someone needs to pick one date. The header has round-cornered previous and next buttons around the month name, the grid uses single-letter weekday headers, and a footer line reports the current choice, for example “Selected: Fri, Oct 23″. Days of the shown month are buttons; the leading and trailing days from the neighbouring months are shown in grey for orientation but are not interactive. Today gets a violet ring and the selected day a solid violet fill. Under the hood the grid is a table with role=”grid” and one roving tab stop: Tab lands on a single day, then the arrow keys move by day or week, Home and End jump within the week, and Page Up and Page Down change month, rendering the new month as focus crosses its edge. Enter or Space selects. The script is about fifty lines with no dependencies. Use it inline on a page; for a picker that opens from a form field, see the date picker input in the same pack. The Bootstrap version uses .card, .btn and a borderless .table, and the Tailwind version styles state with aria-pressed and aria-current variants.

What’s included

  • Single-date picker in a 300px card
  • role="grid" with roving tabindex and arrow-key navigation
  • Page Up and Page Down move by month
  • Today ringed, selection filled, both driven by ARIA attributes
  • Footer line announces the chosen date

Accessibility

Each day is a button named with its full date ("Friday, 23 October 2026") and aria-pressed for the selected day; today has aria-current="date". Only one day is in the Tab order and the arrow, Home, End and Page keys move focus through the grid. The month title and the selection line are polite live regions, the month buttons have aria-labels, and all buttons show a 2px violet focus outline. Hover transitions are removed under prefers-reduced-motion.

Customise it

Set --mini-accent and --mini-muted on .mini for colour, and the card width on .mini. In Bootstrap the same variables sit on the .card and the day buttons are restyled through --bs-btn-* variables; in Tailwind change --color-mini-accent and --color-mini-muted in the @theme block.