Web InnoventixFreeCode

Calendar Range Picker

Original · free

date range picker with two months

byWeb InnoventixReact + Tailwind
calendarrangepickercalendars
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/calendar-range-picker.json
calendar-range-picker.tsx
"use client";

import {
  useEffect,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { motion, useReducedMotion } from "motion/react";

/* ------------------------------------------------------------------ */
/* date helpers                                                        */
/* ------------------------------------------------------------------ */

const MS_DAY = 86_400_000;
const WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] as const;

const fmtFull = new Intl.DateTimeFormat("en-US", {
  weekday: "long",
  month: "long",
  day: "numeric",
  year: "numeric",
});
const fmtChip = new Intl.DateTimeFormat("en-US", {
  weekday: "short",
  month: "short",
  day: "numeric",
});
const fmtMonth = new Intl.DateTimeFormat("en-US", {
  month: "long",
  year: "numeric",
});

type Cell = { date: Date; inMonth: boolean };

function startOfDay(d: Date): Date {
  return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
function startOfMonth(d: Date): Date {
  return new Date(d.getFullYear(), d.getMonth(), 1);
}
function endOfMonth(d: Date): Date {
  return new Date(d.getFullYear(), d.getMonth() + 1, 0);
}
function addDays(d: Date, n: number): Date {
  const nd = new Date(d);
  nd.setDate(nd.getDate() + n);
  return nd;
}
function addMonths(d: Date, n: number): Date {
  const nd = new Date(d.getFullYear(), d.getMonth() + n, 1);
  const last = new Date(nd.getFullYear(), nd.getMonth() + 1, 0).getDate();
  nd.setDate(Math.min(d.getDate(), last));
  return nd;
}
function same(a: Date | null, b: Date | null): boolean {
  return (
    !!a &&
    !!b &&
    a.getFullYear() === b.getFullYear() &&
    a.getMonth() === b.getMonth() &&
    a.getDate() === b.getDate()
  );
}
function isBefore(a: Date, b: Date): boolean {
  return startOfDay(a).getTime() < startOfDay(b).getTime();
}
function within(d: Date, lo: Date, hi: Date): boolean {
  const t = startOfDay(d).getTime();
  return t >= startOfDay(lo).getTime() && t <= startOfDay(hi).getTime();
}
function nightsBetween(a: Date, b: Date): number {
  return Math.round(
    (startOfDay(b).getTime() - startOfDay(a).getTime()) / MS_DAY,
  );
}
function isoKey(d: Date): string {
  return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
}
function buildWeeks(view: Date): Cell[][] {
  const first = startOfMonth(view);
  const gridStart = addDays(first, -first.getDay());
  const weeks: Cell[][] = [];
  for (let w = 0; w < 6; w++) {
    const row: Cell[] = [];
    for (let d = 0; d < 7; d++) {
      const date = addDays(gridStart, w * 7 + d);
      row.push({ date, inMonth: date.getMonth() === view.getMonth() });
    }
    weeks.push(row);
  }
  return weeks;
}
function cx(...c: Array<string | false | null | undefined>): string {
  return c.filter(Boolean).join(" ");
}

/* ------------------------------------------------------------------ */
/* icons                                                               */
/* ------------------------------------------------------------------ */

function ChevronLeft() {
  return (
    <svg
      viewBox="0 0 24 24"
      className="h-4 w-4"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="m15 18-6-6 6-6" />
    </svg>
  );
}
function ChevronRight() {
  return (
    <svg
      viewBox="0 0 24 24"
      className="h-4 w-4"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="m9 18 6-6-6-6" />
    </svg>
  );
}
function CalendarIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      className="h-4 w-4"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.8}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <rect x="3" y="4" width="18" height="18" rx="2" />
      <path d="M16 2v4M8 2v4M3 10h18" />
    </svg>
  );
}
function ArrowIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      className="h-4 w-4"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M5 12h14M13 6l6 6-6 6" />
    </svg>
  );
}

/* ------------------------------------------------------------------ */
/* component                                                           */
/* ------------------------------------------------------------------ */

type Preset = { id: string; label: string; get: () => [Date, Date] };

export default function CalendarRangePicker() {
  const reduce = useReducedMotion();

  const [today] = useState<Date>(() => startOfDay(new Date()));
  const [viewMonth, setViewMonth] = useState<Date>(() => startOfMonth(today));
  const [start, setStart] = useState<Date | null>(null);
  const [end, setEnd] = useState<Date | null>(null);
  const [hover, setHover] = useState<Date | null>(null);
  const [focused, setFocused] = useState<Date>(today);
  const [applied, setApplied] = useState<{ start: Date; end: Date } | null>(
    null,
  );

  const dayRefs = useRef<Map<string, HTMLButtonElement | null>>(new Map());
  const navigating = useRef(false);

  useEffect(() => {
    if (!navigating.current) return;
    navigating.current = false;
    dayRefs.current.get(isoKey(focused))?.focus();
  }, [focused, viewMonth]);

  const presets = useMemo<Preset[]>(() => {
    const sat = addDays(today, (6 - today.getDay() + 7) % 7);
    const sun = addDays(sat, 1);
    return [
      { id: "weekend", label: "This weekend", get: () => [sat, sun] },
      {
        id: "next-weekend",
        label: "Next weekend",
        get: () => [addDays(sat, 7), addDays(sun, 7)],
      },
      { id: "week", label: "1 week", get: () => [today, addDays(today, 7)] },
      {
        id: "two-weeks",
        label: "2 weeks",
        get: () => [today, addDays(today, 14)],
      },
      { id: "month", label: "1 month", get: () => [today, addMonths(today, 1)] },
    ];
  }, [today]);

  const activePreset = useMemo(() => {
    if (!start || !end) return null;
    return (
      presets.find((p) => {
        const [lo, hi] = p.get();
        return same(lo, start) && same(hi, end);
      })?.id ?? null
    );
  }, [presets, start, end]);

  const previewRange = useMemo(() => {
    if (start && end) return { lo: start, hi: end };
    if (start && !end && hover && !isBefore(hover, start)) {
      return { lo: start, hi: hover };
    }
    return null;
  }, [start, end, hover]);

  const nights = start && end ? nightsBetween(start, end) : 0;
  const choosingEnd = Boolean(start && !end);

  /* --------------------------- actions ---------------------------- */

  function moveFocus(next: Date) {
    let target = next;
    if (isBefore(target, today)) target = today;
    if (same(target, focused)) return;
    navigating.current = true;
    const lo = startOfMonth(viewMonth);
    const hi = endOfMonth(addMonths(viewMonth, 1));
    if (isBefore(target, lo)) setViewMonth(startOfMonth(target));
    else if (startOfDay(target).getTime() > startOfDay(hi).getTime())
      setViewMonth(startOfMonth(addMonths(target, -1)));
    setFocused(target);
  }

  function selectDate(date: Date) {
    if (isBefore(date, today)) return;
    setApplied(null);
    setFocused(date);
    if (!start || (start && end)) {
      setStart(date);
      setEnd(null);
      setHover(null);
      return;
    }
    if (same(date, start)) return;
    if (isBefore(date, start)) {
      setStart(date);
    } else {
      setEnd(date);
      setHover(null);
    }
  }

  function clampFocus(view: Date): Date {
    const lo = startOfMonth(view);
    const hi = endOfMonth(addMonths(view, 1));
    let f = focused;
    if (isBefore(f, lo)) f = lo;
    else if (startOfDay(f).getTime() > startOfDay(hi).getTime()) f = hi;
    if (isBefore(f, today)) f = today;
    return f;
  }

  function shiftView(delta: number) {
    const next = addMonths(viewMonth, delta);
    setViewMonth(next);
    setFocused(clampFocus(next));
  }

  function applyPreset(p: Preset) {
    const [lo, hi] = p.get();
    setStart(lo);
    setEnd(hi);
    setHover(null);
    setApplied(null);
    setFocused(lo);
    setViewMonth(startOfMonth(lo));
  }

  function clearAll() {
    setStart(null);
    setEnd(null);
    setHover(null);
    setApplied(null);
  }

  function onDayKeyDown(e: ReactKeyboardEvent<HTMLButtonElement>, date: Date) {
    let handled = true;
    switch (e.key) {
      case "ArrowLeft":
        moveFocus(addDays(date, -1));
        break;
      case "ArrowRight":
        moveFocus(addDays(date, 1));
        break;
      case "ArrowUp":
        moveFocus(addDays(date, -7));
        break;
      case "ArrowDown":
        moveFocus(addDays(date, 7));
        break;
      case "Home":
        moveFocus(addDays(date, -date.getDay()));
        break;
      case "End":
        moveFocus(addDays(date, 6 - date.getDay()));
        break;
      case "PageUp":
        moveFocus(addMonths(date, -1));
        break;
      case "PageDown":
        moveFocus(addMonths(date, 1));
        break;
      case "Enter":
      case " ":
        selectDate(date);
        break;
      default:
        handled = false;
    }
    if (handled) e.preventDefault();
  }

  const prevDisabled =
    startOfDay(viewMonth).getTime() <= startOfMonth(today).getTime();

  const liveMessage = applied
    ? `Dates confirmed from ${fmtChip.format(applied.start)} to ${fmtChip.format(
        applied.end,
      )}.`
    : start && end
      ? `Selected ${fmtChip.format(start)} to ${fmtChip.format(end)}, ${nights} ${
          nights === 1 ? "night" : "nights"
        }.`
      : start
        ? `Check-in ${fmtChip.format(start)} selected, now choose a check-out date.`
        : "No dates selected.";

  /* ---------------------------- render ---------------------------- */

  function renderMonth(base: Date, offset: number) {
    const weeks = buildWeeks(base);
    const label = fmtMonth.format(base);
    const headingId = `crp-month-${offset}`;

    return (
      <div className="min-w-0 flex-1">
        <div className="mb-3 flex items-center justify-between">
          {offset === 0 ? (
            <button
              type="button"
              onClick={() => shiftView(-1)}
              disabled={prevDisabled}
              aria-label="Previous month"
              className="flex h-8 w-8 items-center justify-center rounded-lg text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-30 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
            >
              <ChevronLeft />
            </button>
          ) : (
            <span className="h-8 w-8" aria-hidden="true" />
          )}

          <h3
            id={headingId}
            className="text-sm font-semibold text-slate-900 dark:text-white"
          >
            {label}
          </h3>

          {offset === 1 ? (
            <button
              type="button"
              onClick={() => shiftView(1)}
              aria-label="Next month"
              className="flex h-8 w-8 items-center justify-center rounded-lg text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
            >
              <ChevronRight />
            </button>
          ) : (
            <span className="h-8 w-8" aria-hidden="true" />
          )}
        </div>

        <div role="grid" aria-labelledby={headingId} className="w-full">
          <div role="row" className="grid grid-cols-7">
            {WEEKDAYS.map((w) => (
              <div
                key={w}
                role="columnheader"
                aria-label={w}
                className="flex h-8 items-center justify-center text-xs font-medium uppercase tracking-wide text-slate-400 dark:text-slate-500"
              >
                {w}
              </div>
            ))}
          </div>

          {weeks.map((week, wi) => (
            <div role="row" key={wi} className="grid grid-cols-7">
              {week.map((cell) => {
                if (!cell.inMonth) {
                  return (
                    <div
                      role="gridcell"
                      key={isoKey(cell.date)}
                      className="h-10"
                    />
                  );
                }

                const date = cell.date;
                const disabled = isBefore(date, today);
                const isToday = same(date, today);
                const isStart = same(date, start);
                const isEnd = same(date, end);
                const inR =
                  previewRange !== null &&
                  within(date, previewRange.lo, previewRange.hi);
                const isLo = previewRange !== null && same(date, previewRange.lo);
                const isHi = previewRange !== null && same(date, previewRange.hi);
                const solid = isStart || isEnd || isLo || isHi;

                // range strip (behind the day button)
                let strip: string | null = null;
                if (inR && !(isLo && isHi)) {
                  const rowStart = date.getDay() === 0;
                  const rowEnd = date.getDay() === 6;
                  let pos = "inset-x-0";
                  if (isLo) pos = "left-1/2 right-0";
                  else if (isHi) pos = "left-0 right-1/2";
                  let rounded = "";
                  if (rowStart && !isLo) rounded += " rounded-l-full";
                  if (rowEnd && !isHi) rounded += " rounded-r-full";
                  strip = cx(
                    "pointer-events-none absolute inset-y-1 bg-indigo-100 dark:bg-indigo-500/25",
                    pos,
                    rounded,
                  );
                }

                return (
                  <div
                    role="gridcell"
                    aria-selected={solid || inR}
                    key={isoKey(date)}
                    className="relative flex h-10 items-center justify-center"
                  >
                    {strip && <span className={strip} aria-hidden="true" />}
                    {disabled ? (
                      <span className="flex h-9 w-9 items-center justify-center rounded-full text-sm text-slate-300 dark:text-slate-600">
                        {date.getDate()}
                      </span>
                    ) : (
                      <button
                        type="button"
                        ref={(el) => {
                          dayRefs.current.set(isoKey(date), el);
                        }}
                        tabIndex={same(date, focused) ? 0 : -1}
                        aria-label={cx(
                          fmtFull.format(date),
                          isStart && "(check-in)",
                          isEnd && "(check-out)",
                          isToday && "(today)",
                        )}
                        aria-current={isToday ? "date" : undefined}
                        onClick={() => selectDate(date)}
                        onKeyDown={(e) => onDayKeyDown(e, date)}
                        onMouseEnter={() => {
                          if (choosingEnd) setHover(date);
                        }}
                        onFocus={() => {
                          if (choosingEnd) setHover(date);
                        }}
                        className={cx(
                          "relative z-10 flex h-9 w-9 select-none items-center justify-center rounded-full text-sm tabular-nums transition-colors",
                          "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900",
                          solid
                            ? "bg-indigo-600 font-semibold text-white hover:bg-indigo-600"
                            : inR
                              ? "text-slate-900 hover:bg-indigo-200/70 dark:text-slate-100 dark:hover:bg-indigo-500/30"
                              : "text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800",
                          isToday &&
                            !solid &&
                            "ring-1 ring-inset ring-indigo-400/70",
                        )}
                      >
                        {date.getDate()}
                      </button>
                    )}
                  </div>
                );
              })}
            </div>
          ))}
        </div>
      </div>
    );
  }

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 dark:bg-slate-950 sm:px-6 sm:py-20">
      <style>{`
        @keyframes crp-pop {
          0%   { transform: scale(0.7); opacity: 0; }
          60%  { transform: scale(1.08); }
          100% { transform: scale(1); opacity: 1; }
        }
        @media (prefers-reduced-motion: reduce) {
          .crp-anim { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto w-full max-w-4xl">
        <header className="mb-8">
          <span className="inline-flex items-center gap-1.5 rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-medium uppercase tracking-widest text-indigo-700 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300">
            <CalendarIcon />
            Availability
          </span>
          <h2 className="mt-4 text-3xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-4xl">
            When are you traveling?
          </h2>
          <p className="mt-2 max-w-xl text-base text-slate-600 dark:text-slate-400">
            Pick your check-in and check-out across both months. Use the arrow
            keys to move day by day, Enter to select.
          </p>
        </header>

        <motion.div
          initial={reduce ? false : { opacity: 0, y: 12 }}
          whileInView={reduce ? undefined : { opacity: 1, y: 0 }}
          viewport={{ once: true, amount: 0.2 }}
          transition={{ duration: 0.4, ease: [0.22, 1, 0.36, 1] }}
          className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/30"
        >
          {/* selection summary bar */}
          <div className="flex flex-wrap items-center gap-3 border-b border-slate-200 bg-slate-50/80 px-5 py-4 dark:border-slate-800 dark:bg-slate-900/60">
            <DateChip
              caption="Check-in"
              value={start ? fmtChip.format(start) : null}
              active={!choosingEnd}
            />
            <ArrowIcon />
            <DateChip
              caption="Check-out"
              value={end ? fmtChip.format(end) : null}
              active={choosingEnd}
            />
            {start && end && (
              <span
                key={`${isoKey(start)}-${isoKey(end)}`}
                className="crp-anim ml-auto inline-flex items-center rounded-full bg-emerald-100 px-3 py-1 text-sm font-semibold text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300"
                style={reduce ? undefined : { animation: "crp-pop 0.3s ease" }}
              >
                {nights} {nights === 1 ? "night" : "nights"}
              </span>
            )}
          </div>

          <div className="flex flex-col gap-6 p-5 lg:flex-row lg:p-6">
            {/* presets */}
            <div className="flex shrink-0 flex-wrap gap-2 lg:w-40 lg:flex-col">
              <span className="hidden text-xs font-semibold uppercase tracking-wider text-slate-400 lg:block dark:text-slate-500">
                Quick ranges
              </span>
              {presets.map((p) => {
                const active = activePreset === p.id;
                return (
                  <button
                    key={p.id}
                    type="button"
                    onClick={() => applyPreset(p)}
                    aria-pressed={active}
                    className={cx(
                      "rounded-lg border px-3 py-1.5 text-left text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500",
                      active
                        ? "border-indigo-500 bg-indigo-50 font-medium text-indigo-700 dark:border-indigo-400/60 dark:bg-indigo-500/15 dark:text-indigo-200"
                        : "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800",
                    )}
                  >
                    {p.label}
                  </button>
                );
              })}
            </div>

            {/* two months */}
            <div
              className="grid flex-1 grid-cols-1 gap-8 sm:grid-cols-2"
              onMouseLeave={() => setHover(null)}
            >
              {renderMonth(viewMonth, 0)}
              {renderMonth(addMonths(viewMonth, 1), 1)}
            </div>
          </div>

          {/* footer */}
          <div className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-200 bg-slate-50/80 px-5 py-4 dark:border-slate-800 dark:bg-slate-900/60">
            <p className="text-sm text-slate-600 dark:text-slate-400" aria-hidden="true">
              {applied ? (
                <span className="font-medium text-emerald-700 dark:text-emerald-300">
                  Held {fmtChip.format(applied.start)} → {fmtChip.format(applied.end)}. We&apos;ll email your confirmation.
                </span>
              ) : start && end ? (
                <span>
                  {fmtChip.format(start)} → {fmtChip.format(end)}
                </span>
              ) : (
                "Select a check-in date to begin."
              )}
            </p>

            <div className="flex items-center gap-2">
              <button
                type="button"
                onClick={clearAll}
                disabled={!start && !end}
                className="rounded-lg px-4 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-40 dark:text-slate-300 dark:hover:bg-slate-800"
              >
                Clear
              </button>
              <button
                type="button"
                onClick={() =>
                  start && end && setApplied({ start, end })
                }
                disabled={!(start && end)}
                className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-40 dark:focus-visible:ring-offset-slate-900"
              >
                Reserve dates
              </button>
            </div>
          </div>
        </motion.div>

        <p role="status" aria-live="polite" className="sr-only">
          {liveMessage}
        </p>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* sub-view                                                            */
/* ------------------------------------------------------------------ */

function DateChip({
  caption,
  value,
  active,
}: {
  caption: string;
  value: string | null;
  active: boolean;
}) {
  return (
    <div
      className={cx(
        "flex min-w-[8.5rem] flex-col rounded-xl border px-3 py-2 transition-colors",
        active
          ? "border-indigo-400 bg-indigo-50/60 dark:border-indigo-400/50 dark:bg-indigo-500/10"
          : "border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900",
      )}
    >
      <span className="text-[0.65rem] font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500">
        {caption}
      </span>
      <span
        className={cx(
          "text-sm font-semibold",
          value
            ? "text-slate-900 dark:text-white"
            : "text-slate-400 dark:text-slate-500",
        )}
      >
        {value ?? "Add date"}
      </span>
    </div>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →