Skip to content
All Taste components

Inputs

Date range picker

A month calendar for picking a start and end date, with past days blocked.

range · today · disabled past · outside days · footer actions

Stay dates
"use client";
import { useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";

export type DateRange = { from: Date | null; to: Date | null };

const WEEKDAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
const STEPS: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 };
const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate());
const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
const same = (a: Date | null, b: Date | null) => !!a && !!b && a.getTime() === b.getTime();
const fmt = (d: Date, o: Intl.DateTimeFormatOptions) => d.toLocaleDateString("en-IN", o);

/** Whole weeks covering the month, Monday first. */
function monthDays(month: Date) {
  const lead = (month.getDay() + 6) % 7;
  const count = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate();
  return Array.from({ length: Math.ceil((lead + count) / 7) * 7 }, (_, i) => addDays(month, i - lead));
}

export function DateRangePicker({ value, onChange, onApply, minDate = startOfDay(new Date()) }:
  { value: DateRange; onChange: (r: DateRange) => void; onApply?: () => void; minDate?: Date }) {
  const anchor = value.from ?? minDate;
  const [month, setMonth] = useState(() => new Date(anchor.getFullYear(), anchor.getMonth(), 1));
  const [hover, setHover] = useState<Date | null>(null);
  const today = startOfDay(new Date());
  const end = value.to ?? (value.from && hover && hover > value.from ? hover : null);
  const nights = value.from && value.to ? Math.round((value.to.getTime() - value.from.getTime()) / 86400000) : 0;

  function pick(d: Date) {
    if (!value.from || value.to || d <= value.from) onChange({ from: d, to: null });
    else onChange({ from: value.from, to: d });
  }

  function onKeyDown(e: React.KeyboardEvent<HTMLButtonElement>, d: Date) {
    const step = STEPS[e.key];
    if (!step) return;
    e.preventDefault();
    const next = addDays(d, step);
    if (next.getMonth() !== month.getMonth()) setMonth(new Date(next.getFullYear(), next.getMonth(), 1));
    requestAnimationFrame(() =>
      document.querySelector<HTMLButtonElement>('[data-day="' + next.toDateString() + '"]')?.focus(),
    );
  }

  const shift = (n: number) => setMonth(new Date(month.getFullYear(), month.getMonth() + n, 1));

  return (
    <div role="dialog" aria-label="Choose dates" className="w-full max-w-[320px] rounded-lg border border-border bg-bg p-3 shadow-lg">
      <div className="mb-1 flex items-center justify-between">
        <button type="button" aria-label="Previous month" onClick={() => shift(-1)}
          className="grid size-[30px] place-items-center rounded text-fg-muted transition hover:bg-surface-2 hover:text-fg">
          <ChevronLeft className="size-4" />
        </button>
        <p aria-live="polite" className="text-sm font-medium text-fg">{fmt(month, { month: "long", year: "numeric" })}</p>
        <button type="button" aria-label="Next month" onClick={() => shift(1)}
          className="grid size-[30px] place-items-center rounded text-fg-muted transition hover:bg-surface-2 hover:text-fg">
          <ChevronRight className="size-4" />
        </button>
      </div>

      <div className="grid grid-cols-7">
        {WEEKDAYS.map((w) => <span key={w} className="grid h-7 place-items-center text-xs font-medium text-fg-subtle">{w}</span>)}
      </div>

      <div className="grid grid-cols-7 gap-y-0.5" onMouseLeave={() => setHover(null)}>
        {monthDays(month).map((d) => {
          const isStart = same(d, value.from);
          const isEnd = same(d, end) && !isStart;
          const inRange = !!value.from && !!end && d > value.from && d < end;
          const edge = isStart || isEnd;
          return (
            <div
              key={d.toISOString()}
              className={cn(
                "relative grid h-9",
                inRange && "bg-primary-soft [&:nth-child(7n+1)]:rounded-l [&:nth-child(7n)]:rounded-r",
                isStart && end && "before:absolute before:inset-y-0 before:left-1/2 before:right-0 before:bg-primary-soft",
                isEnd && "before:absolute before:inset-y-0 before:left-0 before:right-1/2 before:bg-primary-soft",
              )}
            >
              <button
                type="button"
                data-day={d.toDateString()}
                disabled={d < minDate}
                aria-pressed={edge}
                aria-current={same(d, today) ? "date" : undefined}
                aria-label={fmt(d, { weekday: "long", day: "numeric", month: "long", year: "numeric" })}
                onClick={() => pick(d)}
                onMouseEnter={() => setHover(d)}
                onKeyDown={(e) => onKeyDown(e, d)}
                className={cn(
                  "relative mx-auto h-9 w-full max-w-10 rounded text-[13px] tabular-nums text-fg transition hover:bg-surface-2",
                  "disabled:pointer-events-none disabled:opacity-40",
                  d.getMonth() !== month.getMonth() && "text-fg-subtle",
                  inRange && "text-primary-soft-fg hover:bg-primary/10",
                  edge && "bg-primary font-medium text-primary-fg hover:bg-primary-hover",
                  same(d, today) && "after:absolute after:bottom-1 after:left-1/2 after:size-1 after:-translate-x-1/2 after:rounded-full after:bg-current",
                )}
              >
                {d.getDate()}
              </button>
            </div>
          );
        })}
      </div>

      <div className="mt-2.5 flex items-center gap-2 border-t border-border pt-3">
        <p className="mr-auto text-[13px] text-fg-muted">{nights ? nights + (nights === 1 ? " night" : " nights") : "Pick a check-out date"}</p>
        <button type="button" onClick={() => onChange({ from: null, to: null })}
          className="h-[30px] rounded px-2.5 text-[13px] font-medium text-fg transition hover:bg-surface-2">Clear</button>
        <button type="button" disabled={!value.to} onClick={onApply}
          className="h-[30px] rounded bg-primary px-2.5 text-[13px] font-medium text-primary-fg transition hover:bg-primary-hover disabled:opacity-50">Apply</button>
      </div>
    </div>
  );
}

The code uses the Taste Tailwind preset. Make yours on the Taste page with Export my system.