Booking Date Range Picker Modal — Free HTML CSS JS Snippet

Booking Date Range Picker Modal · Modals · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Two adjacent month calendars sharing one range-selection state machine
Three-click cycle: pick start, pick end, click again to start a fresh range
Live hover preview highlights in-between days before the end date is confirmed
Hover preview and confirmed range share the exact same highlighting logic
Past dates are genuinely disabled with no click handler attached, not just styled
Real millisecond-based nights calculation, correct across any month boundary
Confirm button disables until a complete start-and-end range is selected
Closures correctly capture each calendar day's own date for click and hover handlers
Export as HTML file, React JSX, or React + Tailwind CSS
Mobile (375px), Tablet (768px), Desktop device preview buttons

About this UI Snippet

Date Range Picker Modal — Two-Month Calendar with Hover Preview and a Real Nights Count

Screenshot of the Booking Date Range Picker Modal snippet rendered live

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:

text
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

  1. 1
    Open the date pickerClick "Select dates" to open the two-month calendar modal.
  2. 2
    Click a start dateThe clicked day highlights and the summary asks for a check-out date.
  3. 3
    Hover before your second clickThe days between your start date and the hovered day preview-highlight live.
  4. 4
    Click an end dateThe range locks in, the summary shows a real computed nights count, and Confirm enables.
  5. 5
    Navigate monthsUse the arrow buttons to move both calendars forward or back one month together.
  6. 6
    Export 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

Hotel, rental, and travel booking flows
Let a guest pick check-in and check-out dates with a real nights count before proceeding to payment.
Equipment and venue rental platforms
Pair with a pricing calculator that multiplies the selected nights by a nightly rate.
Project and reporting date-range filters
Reuse the same two-month range picker for filtering analytics or reports by a custom date window.
Learn range-selection state machine patterns
Study how one selectDate() function handles the full start-then-end-then-reset click cycle without extra mode flags.
Event and appointment scheduling tools
Adapt the calendar grid for multi-day event scheduling with the same hover-preview technique.
Related: Date Range Picker
See the Date Range Picker for an inline, non-modal version of this same range-selection pattern.

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.