Componentpickers2.21 KB gzipZero dependencies

DateRangePicker

Two-month mini calendar grid for selecting start and end dates with nights count

Preview

Live Preview
Start:JANUARY 1
End:JANUARY 7
JANUARY 2024
SU
MO
TU
WE
TH
FR
SA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
FEBRUARY 2024
SU
MO
TU
WE
TH
FR
SA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
Nights: 6

Installation

Tokens guide →

Add to your project via CLI (zero dependencies, code you own):

npx bigbullui add date-range-picker

Or install the full npm package:

npm install bigbullui
import { DateRangePicker } from "@/components/ui/date-range-picker";

<DateRangePicker defaultValue={[{ start: new Date(2024, 0, 1), end: new Date(2024, 0, 7)}]} onValueChange={(v) => {}} />

Usage guidance

Use DateRangePicker when a ticket stub surface needs Two-month mini calendar grid for selecting start and end dates with nights count Keep props minimal and prefer composition with sibling stubs.

Avoid DateRangePicker for long form flows or dense data grids where a dedicated layout block fits better.

Source code

date-range-picker.tsx

Zero dependencies (only React + utils). Copy and paste directly into your project:

"use client";

import * as React from "react";
import { cn } from "./lib/utils";

export interface DateRangePickerProps {
  value?: [Date, Date];
  defaultValue?: [Date, Date];
  onValueChange?: (value: [Date, Date] | undefined) => void;
  min?: Date;
  max?: Date;
  className?: string;
}

const MONTHS = [
  "JANUARY", "FEBRUARY", "MARCH", "APRIL", "MAY", "JUNE",
  "JULY", "AUGUST", "SEPTEMBER", "OCTOBER", "NOVEMBER", "DECEMBER",
];

const DAYS = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"];

export function DateRangePicker({
  value: controlledValue,
  defaultValue: defaultVals,
  onValueChange,
  min,
  max,
  className,
}: DateRangePickerProps) {
  const [internalValue, setInternalValue] = React.useState<[Date, Date] | undefined>(defaultVals);
  const [viewDate, setViewDate] = React.useState<Date>(() => defaultVals?.[0] || new Date());
  const [focusedMonth, setFocusedMonth] = React.useState<Date>(() => new Date());
  const [selectedStart, setSelectedStart] = React.useState<Date | undefined>(defaultVals?.[0]);
  const [selectedEnd, setSelectedEnd] = React.useState<Date | undefined>(defaultVals?.[1]);

  const isControlled = controlledValue !== undefined;
  const currentStart = isControlled ? controlledValue[0] : selectedStart;
  const currentEnd = isControlled ? controlledValue[1] : selectedEnd;

  React.useEffect(() => {
    if (isControlled) {
      setSelectedStart(controlledValue?.[0]);
      setSelectedEnd(controlledValue?.[1]);
    }
  }, [isControlled, controlledValue]);

  const adjustDate = (d: Date, min: Date, max: Date) => {
    if (d < min) return new Date(min);
    if (d > max) return new Date(max);
    return new Date(d);
  };

  const renderMonth = (date: Date, isStart: boolean) => {
    const year = date.getFullYear();
    const month = date.getMonth();
    const firstDay = new Date(year, month, 1).getDay();
    const daysInMonth = new Date(year, month + 1, 0).getDate();
    const prevMonthDays = new Date(year, month, 0).getDate();

    const days: (number | null)[] = [];
    for (let i = 0; i < firstDay + daysInMonth; i++) {
      if (i < firstDay) {
        days.push(null);
      } else {
        days.push(i - firstDay + 1);
      }
    }

    const today = new Date();
    const thisMonth = today.getMonth();
    const thisYear = today.getFullYear();

    return (
      <div key={`${year}-${month}`} className="relative">
        <div className="flex justify-between mb-2">
          <button
            onClick={() => setViewDate(new Date(year, month - 1, 1))}
            className="prev-button absolute left-0"
          >
            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
              <path d="M15 18l-6-6L3 9l6-3 6 6-6 6Z" />
            </svg>
          </button>
          <span className="font-mono uppercase tracking-[0.15em] text-[10px] text-muted-foreground">
            {MONTHS[month]} {year}
          </span>
          <button
            onClick={() => setViewDate(new Date(year, month + 1, 1))}
            className="next-button absolute right-0"
          >
            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
              <path d="M9 18l6-6v-3L15 9l-6-6 6-6Z" />
            </svg>
          </button>
        </div>

        <div className="grid grid-cols-7 gap-1">
          {DAYS.map((day) => (
            <div key={day} className="font-[10px] uppercase tracking-[0.15em] text-muted-foreground">
              {day}
            </div>
          ))}
        </div>

        <div className="grid grid-cols-7 gap-1 pt-1">
          {days.map((day, idx) => {
            if (day === null) return null;
            const dayDate = new Date(year, month, day);
            const isToday = dayDate.getFullYear() === today.getFullYear() &&
              dayDate.getMonth() === today.getMonth() &&
              dayDate.getDate() === today.getDate();
            const isStartSelected = selectedStart && dayDate.getTime() === selectedStart.getTime();
            const isEndSelected = selectedEnd && dayDate.getTime() === selectedEnd.getTime();
            const isBetween = selectedStart && selectedEnd &&
              dayDate >= selectedStart && dayDate <= selectedEnd;
            const isDisabled = min && dayDate < min || max && dayDate > max;

            const handleClick = () => {
              if (isDisabled) return;

              if (isStartSelected && !isEndSelected) {
                // Click on same start: select end from here
                const afterStart = days.find(d => d && new Date(year, month, d).getTime() >= selectedStart.getTime());
                if (afterStart) {
                  setSelectedEnd(new Date(year, month, afterStart));
                  onValueChange?.([selectedStart, new Date(year, month, afterStart)]);
                }
              } else if (!isStartSelected) {
                setSelectedStart(dayDate);
                setSelectedEnd(undefined);
                onValueChange?.(undefined);
              } else if (isStartSelected && isEndSelected && dayDate.getTime() === selectedStart.getTime()) {
                setSelectedStart(undefined);
                setSelectedEnd(undefined);
                onValueChange?.(undefined);
              } else if (isStartSelected && !isEndSelected && dayDate.getTime() > selectedStart.getTime()) {
                setSelectedEnd(dayDate);
                onValueChange?.([selectedStart, dayDate]);
              } else {
                // Reset and select this as start
                setSelectedStart(dayDate);
                setSelectedEnd(undefined);
                onValueChange?.(undefined);
              }
            };

            const bgClass = cn(
              "rounded-sm px-1.5 py-1.5 cursor-pointer select-none",
              isToday && "bg-primary/10 text-primary-foreground",
              isStartSelected && "bg-accent text-accent-foreground",
              isEndSelected && "bg-accent text-accent-foreground",
              isBetween && "bg-accent/10 text-accent-foreground",
              isDisabled && "opacity-50 cursor-not-allowed",
              "hover:bg-accent/20 transition-colors"
            );

            return (
              <div
                key={idx}
                onClick={handleClick}
                className={bgClass}
              >
                {day}
              </div>
            );
          })}
        </div>
      </div>
    );
  };

  return (
    <div
      className={cn(
        "relative flex w-full rounded-md border border-border bg-card p-4 font-mono",
        className,
      )}
    >
      <div className="flex gap-2">
        <span className="font-mono uppercase tracking-[0.15em] text-[10px] text-muted-foreground start-label">
          Start:
        </span>
        <span className="font-mono uppercase tracking-[0.15em] text-[10px] text-accent-strong selected-start">
          {currentStart ? `${MONTHS[currentStart.getMonth()]} ${currentStart.getDate()}` : "Select start"}
        </span>
      </div>
      <div className="flex gap-2">
        <span className="font-mono uppercase tracking-[0.15em] text-[10px] text-muted-foreground end-label">
          End:
        </span>
        <span className="font-mono uppercase tracking-[0.15em] text-[10px] text-accent-strong selected-end">
          {currentEnd ? `${MONTHS[currentEnd.getMonth()]} ${currentEnd.getDate()}` : "Select end"}
        </span>
      </div>

      <div className="relative flex-1 mt-4">
        {renderMonth(new Date(viewDate.getFullYear(), viewDate.getMonth(), 1), true)}
        {renderMonth(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1), false)}
      </div>

      <div className="mt-3 pt border-t border-border/40">
        <div className="flex items-center justify-between">
          <span className="font-mono text-[10px] uppercase tracking-[0.15em] text-muted-foreground">
            {selectedStart && currentEnd ? `Nights: ${Math.max(0, (currentEnd.getTime() - selectedStart.getTime()) / 86400000)}` : ""}
          </span>
          <button
            onClick={() => {
              setSelectedStart(undefined);
              setSelectedEnd(undefined);
              onValueChange?.(undefined);
            }}
            className="text-[10px] uppercase tracking-[0.15em] text-secondary-foreground hover:text-destructive transition-colors"
          >
            Clear
          </button>
        </div>
      </div>
    </div>
  );
}

DateRangePicker.displayName = "DateRangePicker";

Props

PropTypeDescription
value?[Date, Date]
defaultValue?[Date, Date]
onValueChange?(value: [Date, Date] | undefined) => void
min?Date
max?Date
className?string