DateRangePicker
Two-month range calendar with presets, night count and apply/clear.
import { DateRangePicker }from "@/components/ui"
Specification
Selects a start/end date range from a two-month calendar with quick presets, a night/day count and apply/clear actions.
Props (5)
Every prop this component accepts, its type, default, and how to use it.
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | — | Explicit element id, wired to the label. |
| label | string | — | Visible field label shown above the trigger. |
| hint | string | — | Helper text shown below the field. |
| value* | [Date, Date] | null | — | Controlled [start, end] range; null when unset. |
| onChange* | (range: [Date, Date]) => void | — | Called with the applied [start, end] range. |
Range with presets
Full source
The complete component, read from
src/components/ui/date-range-picker.tsx.date-range-picker.tsx
"use client";
import { useState, useRef, useEffect, useCallback, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { createPortal } from "react-dom";
import { ChevronLeft, ChevronRight, ChevronDown, Calendar as CalIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { usePopoverPosition, useMounted } from "@/hooks";
import { Field, controlBase, controlClasses } from "./field";
import { MonthGrid } from "./calendar";
import { Button } from "./button";
const MONTHS = ["January","February","March","April","May","June","July","August","September","October","November","December"];
const fmt = (d: Date | null) => d ? d.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" }) : "";
const PRESETS: [string, () => [Date, Date]][] = [
["Today", () => { const t = new Date(); t.setHours(0,0,0,0); return [t, t]; }],
["Last 7 days", () => { const t = new Date(); t.setHours(0,0,0,0); const s = new Date(t); s.setDate(s.getDate() - 6); return [s, t]; }],
["This month", () => { const t = new Date(); t.setHours(0,0,0,0); return [new Date(t.getFullYear(), t.getMonth(), 1), t]; }],
["Last month", () => { const t = new Date(); return [new Date(t.getFullYear(), t.getMonth() - 1, 1), new Date(t.getFullYear(), t.getMonth(), 0)]; }],
["Quarter to date", () => { const t = new Date(); t.setHours(0,0,0,0); const qm = Math.floor(t.getMonth() / 3) * 3; return [new Date(t.getFullYear(), qm, 1), t]; }],
["Year to date", () => { const t = new Date(); t.setHours(0,0,0,0); return [new Date(t.getFullYear(), 0, 1), t]; }],
];
function yearRange(viewYear: number): number[] {
const start = viewYear - 100;
const end = viewYear + 10;
const years: number[] = [];
for (let y = start; y <= end; y++) years.push(y);
return years;
}
interface MiniSelectOption {
value: number;
label: string;
}
function MiniSelect({
value,
options,
ariaLabel,
onChange,
}: {
value: number;
options: readonly MiniSelectOption[];
ariaLabel: string;
onChange: (value: number) => void;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [highlighted, setHighlighted] = useState(-1);
const triggerRef = useRef<HTMLButtonElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const initialScrollDone = useRef(false);
const filteredOptions = query
? options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase()))
: options;
useEffect(() => {
if (!open) {
initialScrollDone.current = false;
return;
}
const h = (e: globalThis.MouseEvent) => {
const target = e.target as Node;
if (!triggerRef.current?.contains(target) && !listRef.current?.contains(target)) {
setOpen(false);
setQuery("");
}
};
document.addEventListener("mousedown", h);
return () => document.removeEventListener("mousedown", h);
}, [open]);
useEffect(() => {
if (open && !initialScrollDone.current) {
inputRef.current?.focus();
const idx = filteredOptions.findIndex((o) => o.value === value);
setHighlighted(idx >= 0 ? idx : 0);
requestAnimationFrame(() => {
const selected = listRef.current?.querySelector<HTMLElement>('[data-selected="true"]');
selected?.scrollIntoView({ block: "center" });
initialScrollDone.current = true;
});
}
}, [open, value, filteredOptions]);
useEffect(() => {
if (highlighted >= 0 && initialScrollDone.current) {
const el = listRef.current?.querySelector<HTMLElement>(`[data-index="${highlighted}"]`);
el?.scrollIntoView({ block: "nearest" });
}
}, [highlighted]);
const close = useCallback(() => {
setOpen(false);
setQuery("");
setHighlighted(-1);
}, []);
const select = useCallback((opt: MiniSelectOption) => {
onChange(opt.value);
close();
}, [onChange, close]);
const onKeyDown = (e: ReactKeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlighted((i) => Math.min(i + 1, filteredOptions.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlighted((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter" && filteredOptions[highlighted]) {
e.preventDefault();
select(filteredOptions[highlighted]);
} else if (e.key === "Escape") {
close();
}
};
const selected = options.find((o) => o.value === value);
return (
<div className="relative">
<button
ref={triggerRef}
type="button"
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-sm font-medium tabular-nums text-text hover:bg-bg-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
{selected?.label ?? value}
<ChevronDown size={13} className="text-text-tertiary" aria-hidden />
</button>
{open && (
<div
ref={listRef}
data-calendar-mini-select=""
className="absolute left-0 top-full z-50 mt-1 w-36 overflow-hidden rounded-lg border border-border bg-surface shadow-lg"
>
<div className="border-b border-border p-1.5">
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setHighlighted(0);
}}
onKeyDown={onKeyDown}
placeholder="Type to filter…"
className="w-full rounded bg-bg-secondary px-2 py-1 text-sm text-text outline-none placeholder:text-text-tertiary"
/>
</div>
<ul
role="listbox"
aria-label={ariaLabel}
className="max-h-48 overflow-auto p-1"
>
{filteredOptions.length === 0 ? (
<li className="px-2 py-1.5 text-sm text-text-tertiary">No match</li>
) : (
filteredOptions.map((option, idx) => {
const isSelected = option.value === value;
const isHighlighted = idx === highlighted;
return (
<li key={option.value}>
<button
type="button"
role="option"
aria-selected={isSelected}
data-selected={isSelected}
data-index={idx}
onMouseEnter={() => setHighlighted(idx)}
onClick={() => select(option)}
className={cn(
"w-full rounded px-2 py-1 text-left text-sm tabular-nums text-text focus-visible:outline-none",
isHighlighted && "bg-bg-hover",
isSelected && "font-semibold text-accent"
)}
>
{option.label}
</button>
</li>
);
})
)}
</ul>
</div>
)}
</div>
);
}
interface DateRangePickerProps {
id?: string;
label?: string;
hint?: string;
value: [Date, Date] | null;
onChange: (range: [Date, Date]) => void;
}
export function DateRangePicker({ id, label, hint, value, onChange }: DateRangePickerProps) {
const [open, setOpen] = useState(false);
const now = new Date();
const [cur, setCur] = useState(new Date(now.getFullYear(), now.getMonth() - 1, 1));
const [start, setStart] = useState<Date | null>(value?.[0] || null);
const [end, setEnd] = useState<Date | null>(value?.[1] || null);
const [hover, setHover] = useState<Date | null>(null);
const anchorRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const position = usePopoverPosition(anchorRef, open, { minWidth: 620, preferredHeight: 420 });
const mounted = useMounted();
useEffect(() => {
const h = (e: globalThis.MouseEvent) => {
if (!anchorRef.current?.contains(e.target as Node) && !panelRef.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", h);
return () => document.removeEventListener("mousedown", h);
}, []);
const pick = (d: Date) => {
if (!start || (start && end)) { setStart(d); setEnd(null); setHover(null); }
else if (d < start) { setStart(d); setEnd(start); }
else setEnd(d);
};
const shift = (n: number) => setCur(new Date(cur.getFullYear(), cur.getMonth() + n, 1));
const nights = start && end ? Math.round((end.getTime() - start.getTime()) / 86400000) + 1 : null;
const setMonth = (m: number) => setCur(new Date(cur.getFullYear(), m, 1));
const setYear = (y: number) => setCur(new Date(y, cur.getMonth(), 1));
const years = yearRange(cur.getFullYear());
const nextMonth = (cur.getMonth() + 1) % 12;
const nextYear = cur.getMonth() === 11 ? cur.getFullYear() + 1 : cur.getFullYear();
return (
<Field label={label} hint={hint} htmlFor={id}>
<button ref={anchorRef} id={id} onClick={() => setOpen((o) => !o)} className={cn(controlBase, controlClasses(), "flex h-10 items-center gap-2 px-3 text-left")}>
<CalIcon size={15} className="text-text-tertiary" aria-hidden />
<span className={cn("tabular-nums", value ? "text-text" : "text-text-tertiary")}>
{value ? `${fmt(value[0])} – ${fmt(value[1])}` : "Select a range"}
</span>
</button>
{open && position && mounted && createPortal(
<div ref={panelRef} className="z-50 flex overflow-visible rounded-lg border border-border bg-surface shadow-lg" style={position.style}>
<div className="w-36 shrink-0 border-r border-border py-2">
{PRESETS.map(([lbl, fn]) => (
<button key={lbl} onClick={() => { const [a, b] = fn(); setStart(a); setEnd(b); setCur(new Date(a.getFullYear(), a.getMonth(), 1)); }}
className="block w-full px-3 py-1.5 text-left text-sm text-text hover:bg-bg-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent">{lbl}</button>
))}
</div>
<div className="p-3">
<div className="mb-3 flex items-center justify-between">
<button onClick={() => shift(-1)} className="rounded p-1 text-text-secondary hover:bg-bg-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent" aria-label="Previous month"><ChevronLeft size={16} aria-hidden /></button>
<div className="flex gap-8">
<span className="flex items-center gap-0.5">
<MiniSelect
ariaLabel="Month (left calendar)"
value={cur.getMonth()}
onChange={setMonth}
options={MONTHS.map((name, i) => ({ value: i, label: name }))}
/>
<MiniSelect
ariaLabel="Year (left calendar)"
value={cur.getFullYear()}
onChange={setYear}
options={years.map((yr) => ({ value: yr, label: String(yr) }))}
/>
</span>
<span className="flex items-center gap-0.5">
<MiniSelect
ariaLabel="Month (right calendar)"
value={nextMonth}
onChange={(m) => setCur(new Date(m < cur.getMonth() ? cur.getFullYear() + 1 : cur.getFullYear(), m - 1, 1))}
options={MONTHS.map((name, i) => ({ value: i, label: name }))}
/>
<MiniSelect
ariaLabel="Year (right calendar)"
value={nextYear}
onChange={(y) => setCur(new Date(y, cur.getMonth(), 1))}
options={years.map((yr) => ({ value: yr, label: String(yr) }))}
/>
</span>
</div>
<button onClick={() => shift(1)} className="rounded p-1 text-text-secondary hover:bg-bg-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent" aria-label="Next month"><ChevronRight size={16} aria-hidden /></button>
</div>
<div className="flex gap-5" onMouseLeave={() => setHover(null)}>
<MonthGrid y={cur.getFullYear()} m={cur.getMonth()} rangeStart={start} rangeEnd={end} hover={hover} onPick={pick} onHover={setHover} />
<MonthGrid y={nextYear} m={nextMonth} rangeStart={start} rangeEnd={end} hover={hover} onPick={pick} onHover={setHover} />
</div>
<div className="mt-3 flex items-center justify-between border-t border-border pt-3">
<span className="text-xs tabular-nums text-text-secondary">
{start && end ? `${fmt(start)} – ${fmt(end)} · ${nights} days` : start ? `${fmt(start)} – select an end date` : "Select a start date"}
</span>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => { setStart(null); setEnd(null); }}>Clear</Button>
<Button size="sm" disabled={!start} onClick={() => { onChange([start!, end || start!]); setOpen(false); }}>Apply</Button>
</div>
</div>
</div>
</div>,
document.body
)}
</Field>
);
}Dependencies
To use DateRangePicker you also need the following. Copy these into your project alongside it.
Related components
Recommended folder structure
project structure
src/components/ui/ ├─ button.tsx ├─ calendar.tsx ├─ date-range-picker.tsx └─ field.tsx src/hooks/ ├─ use-mounted.ts └─ use-popover-position.ts src/lib/ └─ utils.ts
Dependency files
These supporting files aren’t components — copy each one into the matching path above.
src/components/ui/field.tsx
field.tsx
"use client";
import { type ReactNode } from "react";
import { cn } from "@/lib/utils";
interface FieldCounter {
text: string;
warn?: boolean;
}
interface FieldProps {
label?: string;
hint?: string;
error?: string;
required?: boolean;
htmlFor?: string;
counter?: FieldCounter;
messageId?: string;
children: ReactNode;
}
export function Field({
label,
hint,
error,
required,
htmlFor,
counter,
messageId,
children,
}: FieldProps) {
const message = error || hint;
return (
<div className="flex w-full flex-col gap-1.5">
{label && (
<label
htmlFor={htmlFor}
className="flex items-center gap-1 text-[13px] font-medium text-text"
>
{label}
{required && (
<span className="text-error" aria-hidden>
*
</span>
)}
</label>
)}
{children}
<div className="flex items-start justify-between gap-3">
<p
id={messageId}
className={cn("text-xs leading-snug", error ? "text-error" : "text-text-secondary")}
>
{message || "\u00A0"}
</p>
{counter && (
<span
className={cn(
"shrink-0 text-xs tabular-nums",
counter.warn ? "text-error" : "text-text-tertiary"
)}
>
{counter.text}
</span>
)}
</div>
</div>
);
}
export const controlBase = cn(
"w-full rounded-lg border text-sm bg-surface text-text transition-colors",
"placeholder:text-text-tertiary",
"focus:outline-none focus-visible:outline-none focus-visible:border-accent",
"disabled:cursor-not-allowed disabled:bg-bg-secondary disabled:opacity-60",
"read-only:bg-bg-secondary"
);
export function controlClasses(error?: string, disabled?: boolean, readOnly?: boolean) {
return cn(
error ? "border-error focus-visible:border-error" : "border-border",
readOnly && "read-only:bg-bg-secondary",
disabled && "text-text-tertiary"
);
}src/hooks/use-mounted.ts
use-mounted.ts
"use client";
import { useState, useEffect } from "react";
/**
* Returns true once the component has mounted on the client.
* Useful for portals and anything that needs `document` to exist.
*/
export function useMounted(): boolean {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return mounted;
}src/hooks/use-popover-position.ts
use-popover-position.ts
"use client";
import { useState, useEffect, useCallback, type RefObject, type CSSProperties } from "react";
interface PopoverPositionOptions {
align?: "start" | "center" | "end";
gap?: number;
viewportPadding?: number;
preferredHeight?: number;
matchWidth?: boolean;
minWidth?: number;
}
interface PopoverPosition {
placement: "top" | "bottom";
style: CSSProperties;
}
/**
* Compute a fixed-position popover style anchored to a ref element.
* Opens downward by default; flips upward when there isn't room below
* AND there's more room above. Clamps horizontally so nothing renders
* past the viewport edge. Re-measures on scroll/resize while open.
*/
export function usePopoverPosition(
anchorRef: RefObject<HTMLElement | null>,
open: boolean,
options: PopoverPositionOptions = {}
): PopoverPosition | null {
const {
align = "start",
gap = 4,
viewportPadding = 8,
preferredHeight = 320,
matchWidth = false,
minWidth,
} = options;
const [position, setPosition] = useState<PopoverPosition | null>(null);
const measure = useCallback(() => {
const anchor = anchorRef.current;
if (!anchor) return;
const rect = anchor.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
const spaceBelow = vh - rect.bottom - viewportPadding;
const spaceAbove = rect.top - viewportPadding;
const placement =
spaceBelow < Math.min(preferredHeight, 160) && spaceAbove > spaceBelow
? "top"
: "bottom";
const maxHeight = Math.max(
120,
Math.min(preferredHeight, placement === "top" ? spaceAbove : spaceBelow)
);
const width = matchWidth ? rect.width : minWidth;
let left = align === "end" && width ? rect.right - width : rect.left;
if (align === "center" && width)
left = rect.left + rect.width / 2 - width / 2;
left = width
? Math.min(Math.max(left, viewportPadding), vw - width - viewportPadding)
: Math.min(Math.max(left, viewportPadding), vw - viewportPadding);
setPosition({
placement,
style: {
position: "fixed",
top: placement === "top" ? undefined : rect.bottom + gap,
bottom: placement === "top" ? vh - rect.top + gap : undefined,
left,
width: matchWidth ? rect.width : undefined,
minWidth: !matchWidth ? minWidth : undefined,
maxHeight,
},
});
}, [anchorRef, align, gap, viewportPadding, preferredHeight, matchWidth, minWidth]);
useEffect(() => {
if (!open) {
setPosition(null);
return;
}
measure();
window.addEventListener("scroll", measure, true);
window.addEventListener("resize", measure);
return () => {
window.removeEventListener("scroll", measure, true);
window.removeEventListener("resize", measure);
};
}, [open, measure]);
return position;
}src/lib/utils.ts
utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/**
* Merge class names with clsx semantics + Tailwind conflict resolution.
* Prefer this in all components.
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
/**
* Concatenate class names, filtering out falsy values.
* @deprecated Prefer {@link cn} — it also resolves Tailwind class conflicts.
*/
export function cx(...args: (string | false | null | undefined)[]): string {
return args.filter(Boolean).join(" ");
}
/**
* Focus ring style helper.
* @deprecated Focus rings are now handled globally via the
* `:focus-visible` rule in `globals.css` and the `focus-visible:*`
* utilities. This remains only for backwards compatibility.
*/
export function ring(color: string = "var(--color-accent)") {
return { outlineColor: color };
}