Booking Date Range Picker Modal — Free HTML CSS JS Snippet
Booking Date Range Picker Modal · Modals · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Date Range Picker Modal — Two-Month Calendar with Hover Preview and a Real Nights Count

Booking flows need more than a single date field — they need a start and an end, with the days in between visually obvious before the second click even happens. This modal renders two adjacent month calendars sharing one range-selection state machine, previewing the in-between days on hover and computing a real nights count once both dates are picked.
A three-step click cycle, not just two independent date fields
selectDate(date) implements the standard range-picker cycle: if there's no rangeStart yet, or if a complete range already exists, the clicked date becomes a fresh rangeStart and any previous range clears. Otherwise, if the clicked date is before the existing rangeStart, it replaces the start (handling a visitor who picked a check-in, then changed their mind to an earlier date). Otherwise it becomes rangeEnd. This single function is the entire range logic — there's no separate "am I picking start or end" flag to keep in sync with it.
Hover preview reuses the exact same in-range check as the final range
While rangeStart is set but rangeEnd isn't, moving the mouse over a day sets hoverDate and re-renders. buildMonthGrid() computes previewEnd = rangeEnd || hoverDate once, then applies the identical date > rangeStart && date < previewEnd check whether previewEnd came from a confirmed end date or a live hover — so the preview highlighting a visitor sees before their second click is pixel-for-pixel the same styling the confirmed range will show afterward, not a separate lighter-weight preview state.
Past dates are genuinely disabled, not just styled to look that way
Every day before today gets both the .drm-day-disabled class and a real disabled attribute on the <button> — no click handler is even attached to a disabled day, so a booking cannot start or end in the past no matter how a visitor interacts with the calendar, not merely a visual style discouraging it.
Closures capture each day's own date correctly
Both the click and mouseenter handlers are bound via an immediately-invoked function that takes date as a parameter and returns the real handler — the same pattern needed anywhere event listeners are created inside a loop, since without it every handler would close over the loop's final date value instead of its own day.
The nights count is real arithmetic, not a lookup table
Math.round((rangeEnd - rangeStart) / 86400000) converts the millisecond difference between two Date objects into whole days by dividing by the number of milliseconds in a day — correct for any pair of dates regardless of the month or months they span, including a range that crosses a month boundary between the two visible calendars.
Customizing it
Add a maximum-range limit (e.g. a 30-night cap for a booking product) by adding a check inside selectDate()'s else branch that rejects an end date too far from rangeStart. Restyle .drm-day-in-range to your brand's accent color, and adjust the today cutoff logic if you need a minimum lead time (e.g. bookings must start at least 2 days out) instead of allowing "today" itself.
Build with AI
Build, Understand, Optimize, and Extend It With AI
Rather than tracing the range-selection state machine by hand, paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly how selectDate()'s three-branch logic (fresh start, replace start, set end) covers every click scenario without a separate "current mode" flag, and why buildMonthGrid() computes previewEnd as rangeEnd || hoverDate so the hover preview and the confirmed range share identical highlighting logic. The same assistant can help you extend it — ask it to add a maximum stay length (e.g. reject an end date more than 30 nights after the start), disable specific already-booked dates by checking them against a blocked-dates array passed into buildMonthGrid(), or add a minimum-lead-time rule so bookings cannot start today or tomorrow. It's also useful for an accessibility pass: ask whether the day buttons need aria-selected and aria-disabled attributes, and whether the whole calendar grid should be arrow-key navigable in addition to mouse interaction. Treat the code less like a finished artifact and more like a starting point for a conversation.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build a date-range picker modal in plain HTML, CSS, and vanilla JavaScript showing two adjacent month calendars for selecting a check-in and check-out date — no date-picker library.
Requirements:
- A trigger button that opens a modal (backdrop plus centered dialog with a fade/scale transition), closable via a close button, backdrop click, and the Escape key.
- Two side-by-side month calendar grids inside the modal, always showing two consecutive months, each with its own weekday header row and a 7-column day grid including correctly positioned leading blank cells for the first week. Provide previous/next month navigation arrows that move both calendars together by one month.
- Implement range selection as a single function handling three cases from one click: if no start date is selected yet (or a complete range already exists), the clicked date becomes a new start date and clears any previous range; if a start date exists and the clicked date is earlier than it, the clicked date replaces the start date; otherwise the clicked date becomes the end date, completing the range.
- While a start date is selected but no end date yet, hovering over any later date must live-preview-highlight every day strictly between the start and the hovered date, using the exact same "in range" visual style and highlighting logic that a confirmed range would use — not a separate, different-looking preview state.
- Disable every date earlier than today at the DOM level (a real disabled button attribute, no click or hover handler attached), not merely a visual style.
- Display a summary line that updates through three phases: prompting for a start date, prompting for an end date once a start is chosen (showing the chosen start date), and finally showing both dates plus a real computed number of nights between them once the range is complete.
- A "Confirm dates" button must stay disabled until a complete start-and-end range is selected.
- Ensure click and hover handlers created inside the day-generation loop correctly capture each individual day's own date value, not a shared loop variable.Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.
Step by step
How to Use
- 1Open the date pickerClick "Select dates" to open the two-month calendar modal.
- 2Click a start dateThe clicked day highlights and the summary asks for a check-out date.
- 3Hover before your second clickThe days between your start date and the hovered day preview-highlight live.
- 4Click an end dateThe range locks in, the summary shows a real computed nights count, and Confirm enables.
- 5Navigate monthsUse the arrow buttons to move both calendars forward or back one month together.
- 6Export in your formatClick "HTML" for a standalone file, "JSX" for a React component, or "Tailwind" for a React + Tailwind version.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
selectDate() checks if the clicked date is earlier than the current rangeStart, and if so, replaces rangeStart with it rather than treating it as an invalid click — this handles a visitor who picked a check-in date and then decided on an even earlier one, without forcing them to restart the whole selection.
buildMonthGrid() computes previewEnd as rangeEnd if it exists, otherwise hoverDate, then applies the same date > rangeStart && date < previewEnd check either way. Because both the live hover and the confirmed range flow through this identical check, the preview highlighting looks and behaves exactly like the final confirmed range, just before the second click has happened.
No — every calendar day before today gets both a disabled CSS class and a real disabled attribute on its button element, so no click event listener is even attached to it. This is enforced at the DOM level, not just visually discouraged with styling.
Math.round((rangeEnd - rangeStart) / 86400000) subtracts the two Date objects (which produces a millisecond difference) and divides by 86,400,000 — the number of milliseconds in a day — to get a whole number of nights. This works correctly for any two dates, including a range that spans across the month boundary between the two visible calendars.
selectDate() checks rangeStart && rangeEnd as a combined condition alongside "no rangeStart yet," and if a complete range already exists, the newly clicked date starts an entirely fresh range — clearing both the old start and end — rather than trying to extend or replace just one side of the existing range.
Yes — leftMonth is the only tracked month state, and rightMonth is always computed as one month after it inside renderCalendars(). The prev/next arrow buttons move leftMonth by one month and re-render both calendars together, so they can never fall out of consecutive alignment.