/* Booking range picker: a <table role="grid"> of day buttons. The first pick is check-in, the second check-out;
booked nights are aria-disabled and struck through. State is styled from data-range / aria-* attributes. */
.book{--book-accent:#047857;--book-band:#d9f2e7;--book-muted:#62786d;
position:relative;width:340px;max-width:100%;background:#fff;border-radius:18px;padding:22px;color:#132a22;
box-shadow:0 18px 42px -20px rgba(10,60,40,.28)}
.book-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px}
.book-title{font-size:.95rem}
.book-head button{width:30px;height:30px;border:1px solid #d8e5df;background:#fff;border-radius:9px;cursor:pointer;font:inherit;color:#3c5a4d}
.book-head button:hover{background:#f0f7f4}
.book-grid{width:100%;border-collapse:collapse;table-layout:fixed;text-align:center}
.book-grid th{font-size:.62rem;font-weight:800;color:#667a70;text-transform:uppercase;padding:6px 0}
.book-grid td{padding:0}
.book-d{width:100%;aspect-ratio:1;display:block;border:0;background:none;font:inherit;font-size:.8rem;font-weight:600;color:#28443a;
cursor:pointer;position:relative}
.book-d:not([aria-disabled="true"]):hover{background:#eef7f2;border-radius:10px}
.book-d[aria-current="date"]{text-decoration:underline;text-underline-offset:3px;font-weight:800}
.book-d[aria-disabled="true"]{color:#c6d4cd;text-decoration:line-through;cursor:default}
.book-d[data-range="mid"],.book-d[data-range="mid"]:hover{background:var(--book-band);border-radius:0}
.book-d[data-range="edge"],.book-d[data-range="edge"]:hover{background:var(--book-accent);color:#fff;border-radius:10px}
.book-head button:focus-visible,.book-d:focus-visible,.book-go:focus-visible{outline:2px solid var(--book-accent);outline-offset:2px;z-index:1}
.book-sum{margin-top:14px;border-top:1px dashed #dfe9e4;padding-top:14px;display:flex;justify-content:space-between;align-items:center;gap:10px}
.book-sum p{font-size:.76rem;color:var(--book-muted)}
.book-sum b{display:block;font-size:.88rem;color:#132a22}
.book-go{border:0;background:var(--book-accent);color:#fff;font:inherit;font-weight:800;font-size:.84rem;padding:10px 16px;border-radius:10px;cursor:pointer}
.book-go:hover{background:#065f46}
.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}
JavaScript
/* 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. A range that would cross a booked night starts again from the new day. */
(() => {
const root = document.querySelector('[data-book]'); if (!root) return;
const title = root.querySelector('#book-title'), body = root.querySelector('tbody'), out = root.querySelector('[aria-live] b');
const CLS = 'book-d';
const BOOKED = [4, 5, 11, 18, 19, 20]; // demo data: these days are taken in every month
const full = d => d.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
const short = d => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
const same = (x, y) => !!x && !!y && x.toDateString() === y.toDateString();
const booked = d => BOOKED.includes(d.getDate());
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(), a = null, b = null;
const role = d => same(d, a) ? 'check-in' : same(d, b) ? 'check-out' : a && b && d > a && d < b ? 'in your stay' : '';
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>';
if (d.getMonth() !== m) h += '<td></td>';
else {
const r = role(d), off = booked(d);
h += `<td><button type="button" class="${CLS}" data-day="${d.getDate()}" tabindex="${same(d, focus) ? 0 : -1}"` +
`${r ? ` data-range="${r === 'in your stay' ? 'mid' : 'edge'}"` : ''} aria-pressed="${!!r}"${off ? ' aria-disabled="true"' : ''}` +
`${same(d, today) ? ' aria-current="date"' : ''} aria-label="${full(d)}${off ? ', booked' : r ? ', ' + r : ''}">${d.getDate()}</button></td>`;
}
if (i % 7 === 6) h += '</tr>';
}
body.innerHTML = h;
if (moveFocus) body.querySelector('[tabindex="0"]').focus();
}
function pick(d) {
if (booked(d)) return;
if (!a || b || d <= a) { a = d; b = null; }
else {
let clear = true;
for (let t = new Date(a); t < d; t.setDate(t.getDate() + 1)) if (booked(t)) clear = false;
if (clear) b = d; else { a = d; b = null; }
}
const nights = b ? Math.round((b - a) / 864e5) : 0;
out.textContent = b ? `${short(a)} → ${short(b)} · ${nights} night${nights > 1 ? 's' : ''}` : `${short(a)} → select check-out`;
}
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 btn = e.target.closest('button[data-day]'); if (!btn) return;
focus = new Date(view.getFullYear(), view.getMonth(), Number(btn.dataset.day));
pick(focus); render(true);
});
root.querySelectorAll('[data-move]').forEach(btn => btn.addEventListener('click', () => {
view.setMonth(view.getMonth() + Number(btn.dataset.move)); render(false);
}));
render(false);
})();
/* Bootstrap .card, .btn and a borderless .table with role="grid". Range and booked states are styled from
data-range / aria-* attributes; colours go through --bs-btn-* variables. */
.book{--book-accent:#047857;--book-band:#d9f2e7;--book-muted:#62786d;--bs-card-border-width:0;--bs-card-border-radius:18px;
--bs-card-spacer-x:22px;--bs-card-spacer-y:22px;--bs-card-color:#132a22;width:340px;max-width:100%;box-shadow:0 18px 42px -20px rgba(10,60,40,.28)}
.book-title{font-size:.95rem;line-height:normal}
.book-nav{--bs-btn-padding-x:0;--bs-btn-padding-y:0;--bs-btn-color:#3c5a4d;--bs-btn-bg:#fff;--bs-btn-border-color:#d8e5df;
--bs-btn-hover-color:#3c5a4d;--bs-btn-hover-bg:#f0f7f4;--bs-btn-hover-border-color:#d8e5df;--bs-btn-active-color:#3c5a4d;
--bs-btn-active-bg:#e3f0ea;--bs-btn-active-border-color:#d8e5df;--bs-btn-border-radius:9px;width:30px;height:30px}
.book-grid{--bs-table-bg:transparent;table-layout:fixed}
.book-grid>:not(caption)>*>*{padding:0}
.book-grid th{font-size:.62rem;font-weight:800;color:#667a70;text-transform:uppercase;padding:6px 0}
.book-d{--bs-btn-padding-x:0;--bs-btn-padding-y:0;--bs-btn-font-size:.8rem;--bs-btn-font-weight:600;--bs-btn-color:#28443a;
--bs-btn-border-width:0;--bs-btn-border-radius:10px;--bs-btn-hover-color:#28443a;--bs-btn-hover-bg:#eef7f2;
--bs-btn-active-color:#28443a;--bs-btn-active-bg:#e3f0ea;width:100%;aspect-ratio:1;display:block;position:relative}
.book-d[aria-current="date"]{text-decoration:underline;text-underline-offset:3px;font-weight:800}
.book-d[aria-disabled="true"]{--bs-btn-color:#c6d4cd;--bs-btn-hover-color:#c6d4cd;--bs-btn-hover-bg:transparent;--bs-btn-active-bg:transparent;
--bs-btn-active-color:#c6d4cd;text-decoration:line-through;cursor:default}
.book-d[data-range="mid"]{--bs-btn-bg:var(--book-band);--bs-btn-hover-bg:var(--book-band);--bs-btn-active-bg:var(--book-band);--bs-btn-border-radius:0}
.book-d[data-range="edge"]{--bs-btn-bg:var(--book-accent);--bs-btn-color:#fff;--bs-btn-hover-bg:var(--book-accent);--bs-btn-hover-color:#fff;
--bs-btn-active-bg:var(--book-accent);--bs-btn-active-color:#fff}
.book-go{--bs-btn-bg:var(--book-accent);--bs-btn-border-color:var(--book-accent);--bs-btn-hover-bg:#065f46;--bs-btn-hover-border-color:#065f46;
--bs-btn-active-bg:#065f46;--bs-btn-active-border-color:#065f46;--bs-btn-font-size:.84rem;--bs-btn-font-weight:800;
--bs-btn-padding-x:16px;--bs-btn-padding-y:10px;--bs-btn-border-radius:10px;--bs-btn-line-height:normal}
.book .btn:focus-visible{outline:2px solid var(--book-accent);outline-offset:2px;box-shadow:none;z-index:1}
.book-d:not([data-range]):focus-visible{background-color:transparent}
.book-head{margin-bottom:14px}
.book-sum{margin-top:14px;padding-top:14px;border-top:1px dashed #dfe9e4}
.book-sum p{font-size:.76rem;color:var(--book-muted)}
.book-sum b{display:block;font-size:.88rem;color:#132a22}
@media (prefers-reduced-motion:reduce){.book .btn{transition:none}}
JavaScript
/* 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. A range that would cross a booked night starts again from the new day. */
(() => {
const root = document.querySelector('[data-book]'); if (!root) return;
const title = root.querySelector('#book-title'), body = root.querySelector('tbody'), out = root.querySelector('[aria-live] b');
const CLS = 'btn book-d';
const BOOKED = [4, 5, 11, 18, 19, 20]; // demo data: these days are taken in every month
const full = d => d.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
const short = d => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
const same = (x, y) => !!x && !!y && x.toDateString() === y.toDateString();
const booked = d => BOOKED.includes(d.getDate());
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(), a = null, b = null;
const role = d => same(d, a) ? 'check-in' : same(d, b) ? 'check-out' : a && b && d > a && d < b ? 'in your stay' : '';
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>';
if (d.getMonth() !== m) h += '<td></td>';
else {
const r = role(d), off = booked(d);
h += `<td><button type="button" class="${CLS}" data-day="${d.getDate()}" tabindex="${same(d, focus) ? 0 : -1}"` +
`${r ? ` data-range="${r === 'in your stay' ? 'mid' : 'edge'}"` : ''} aria-pressed="${!!r}"${off ? ' aria-disabled="true"' : ''}` +
`${same(d, today) ? ' aria-current="date"' : ''} aria-label="${full(d)}${off ? ', booked' : r ? ', ' + r : ''}">${d.getDate()}</button></td>`;
}
if (i % 7 === 6) h += '</tr>';
}
body.innerHTML = h;
if (moveFocus) body.querySelector('[tabindex="0"]').focus();
}
function pick(d) {
if (booked(d)) return;
if (!a || b || d <= a) { a = d; b = null; }
else {
let clear = true;
for (let t = new Date(a); t < d; t.setDate(t.getDate() + 1)) if (booked(t)) clear = false;
if (clear) b = d; else { a = d; b = null; }
}
const nights = b ? Math.round((b - a) / 864e5) : 0;
out.textContent = b ? `${short(a)} → ${short(b)} · ${nights} night${nights > 1 ? 's' : ''}` : `${short(a)} → select check-out`;
}
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 btn = e.target.closest('button[data-day]'); if (!btn) return;
focus = new Date(view.getFullYear(), view.getMonth(), Number(btn.dataset.day));
pick(focus); render(true);
});
root.querySelectorAll('[data-move]').forEach(btn => btn.addEventListener('click', () => {
view.setMonth(view.getMonth() + Number(btn.dataset.move)); render(false);
}));
render(false);
})();
/* 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. A range that would cross a booked night starts again from the new day. */
(() => {
const root = document.querySelector('[data-book]'); if (!root) return;
const title = root.querySelector('#book-title'), body = root.querySelector('tbody'), out = root.querySelector('[aria-live] b');
const CLS = 'block aspect-square w-full cursor-pointer text-[.8rem] font-semibold text-[#28443a] relative hover:rounded-[10px] hover:bg-[#eef7f2] focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-book-accent aria-[current=date]:font-extrabold aria-[current=date]:underline aria-[current=date]:underline-offset-3 aria-disabled:cursor-default aria-disabled:text-[#c6d4cd] aria-disabled:line-through aria-disabled:hover:bg-transparent data-[range=mid]:rounded-none data-[range=mid]:bg-book-band data-[range=mid]:hover:bg-book-band data-[range=edge]:rounded-[10px] data-[range=edge]:bg-book-accent data-[range=edge]:text-white data-[range=edge]:hover:bg-book-accent';
const BOOKED = [4, 5, 11, 18, 19, 20]; // demo data: these days are taken in every month
const full = d => d.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
const short = d => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
const same = (x, y) => !!x && !!y && x.toDateString() === y.toDateString();
const booked = d => BOOKED.includes(d.getDate());
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(), a = null, b = null;
const role = d => same(d, a) ? 'check-in' : same(d, b) ? 'check-out' : a && b && d > a && d < b ? 'in your stay' : '';
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>';
if (d.getMonth() !== m) h += '<td></td>';
else {
const r = role(d), off = booked(d);
h += `<td><button type="button" class="${CLS}" data-day="${d.getDate()}" tabindex="${same(d, focus) ? 0 : -1}"` +
`${r ? ` data-range="${r === 'in your stay' ? 'mid' : 'edge'}"` : ''} aria-pressed="${!!r}"${off ? ' aria-disabled="true"' : ''}` +
`${same(d, today) ? ' aria-current="date"' : ''} aria-label="${full(d)}${off ? ', booked' : r ? ', ' + r : ''}">${d.getDate()}</button></td>`;
}
if (i % 7 === 6) h += '</tr>';
}
body.innerHTML = h;
if (moveFocus) body.querySelector('[tabindex="0"]').focus();
}
function pick(d) {
if (booked(d)) return;
if (!a || b || d <= a) { a = d; b = null; }
else {
let clear = true;
for (let t = new Date(a); t < d; t.setDate(t.getDate() + 1)) if (booked(t)) clear = false;
if (clear) b = d; else { a = d; b = null; }
}
const nights = b ? Math.round((b - a) / 864e5) : 0;
out.textContent = b ? `${short(a)} → ${short(b)} · ${nights} night${nights > 1 ? 's' : ''}` : `${short(a)} → select check-out`;
}
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 btn = e.target.closest('button[data-day]'); if (!btn) return;
focus = new Date(view.getFullYear(), view.getMonth(), Number(btn.dataset.day));
pick(focus); render(true);
});
root.querySelectorAll('[data-move]').forEach(btn => btn.addEventListener('click', () => {
view.setMonth(view.getMonth() + Number(btn.dataset.move)); render(false);
}));
render(false);
})();
About this set
A date range picker for hotel stays, rentals, equipment hire and any booking that has a check-in and a check-out. The first day you pick becomes check-in, the second becomes check-out, and the nights between are joined into a pale green band with solid green ends. Booked nights are struck through and cannot be chosen, and a range that would cross one starts again from the new day, so a guest can never book over someone else. A summary line under the grid reads, for example, “Oct 13 → Oct 17 · 4 nights”, next to a Reserve button you can wire to your checkout. Previous and next buttons page between months and the selection survives the move. The grid is a table with role=”grid” and a single roving tab stop: the arrow keys move by day and week, Home and End jump within the week, Page Up and Page Down change month, and Enter or Space picks. Replace the BOOKED array with real availability from your API. The Bootstrap version restyles .btn day buttons through –bs-btn-* variables and uses .btn-success for Reserve; the Tailwind version styles range ends and the band with data-range variants.
What’s included
Check-in and check-out range with a joined band
Booked nights struck through and aria-disabled
Ranges cannot cross a booked night
Nights counted in a live summary line
Keyboard grid: arrows, Home, End, Page Up and Page Down
Accessibility
Day buttons are named with the full date plus their role, for example "Saturday, 17 October 2026, check-out" or "Sunday, 18 October 2026, booked", and booked days use aria-disabled so they stay reachable but cannot be picked. Only one day is in the Tab order; arrow keys move focus. The month title and the stay summary are polite live regions, and every button shows a 2px green focus outline. The green fill was darkened so white text on it reaches 4.5:1.
Customise it
Set --book-accent, --book-band and --book-muted on .book. In Bootstrap the same variables feed the --bs-btn-* variables of the day and Reserve buttons; in Tailwind edit --color-book-accent, --color-book-band and --color-book-muted, and in every version swap the BOOKED array for your data.