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.
PropTypeDefaultDescription
idstring—Explicit element id, wired to the label.
labelstring—Visible field label shown above the trigger.
hintstring—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.

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 };
}