Componentpickers
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-pickerOr install the full npm package:
npm install bigbulluiUsage
import { DateRangePicker } from "@/components/ui/date-range-picker";
<DateRangePicker defaultValue={[{ start: new Date(2024, 0, 1), end: new Date(2024, 0, 7)}]} onValueChange={(v) => {}} />Source code
date-range-picker.tsxZero 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
| Prop | Type | Description |
|---|---|---|
| value | [Date, Date] | undefined | Controlled value [start, end] |
| defaultValue | [Date, Date] | Default uncontrolled value [start, end] |
| onValueChange | ((value: [Date, Date]) => void) | Change handler |
| min | Date | Minimum selectable date |
| max | Date | Maximum selectable date |