UI Library

Date range picker

Two months side by side with a preset rail (today, last week, this year…), a live hover preview while picking the end date, event dots, and editable start and end fields. Nothing applies until Apply; Cancel reverts. Arrow keys move through the days.

Formsdatecalendarrangepickerkeyboard
Preview
apps/site/blocks/components/DateRangePicker.tsx
'use client';

import { useMemo, useState, useSyncExternalStore, type KeyboardEvent } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';

export interface DateRange {
  from: Date;
  to: Date;
}

type PresetId =
  | 'today'
  | 'yesterday'
  | 'this-week'
  | 'last-week'
  | 'this-month'
  | 'last-month'
  | 'this-year'
  | 'last-year'
  | 'all';

const PRESETS: { id: PresetId; label: string }[] = [
  { id: 'today', label: 'Today' },
  { id: 'yesterday', label: 'Yesterday' },
  { id: 'this-week', label: 'This week' },
  { id: 'last-week', label: 'Last week' },
  { id: 'this-month', label: 'This month' },
  { id: 'last-month', label: 'Last month' },
  { id: 'this-year', label: 'This year' },
  { id: 'last-year', label: 'Last year' },
  { id: 'all', label: 'All time' },
];

const WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];

// ── Date helpers ─────────────────────────────────────────────────────────────
// Every date is a local midnight. No library: the arithmetic a range picker
// needs is small, and `new Date(y, m, d)` normalises overflow for us.

const day = (y: number, m: number, d: number) => new Date(y, m, d);
const addDays = (d: Date, n: number) => day(d.getFullYear(), d.getMonth(), d.getDate() + n);
const addMonths = (d: Date, n: number) => day(d.getFullYear(), d.getMonth() + n, 1);
const startOfMonth = (d: Date) => day(d.getFullYear(), d.getMonth(), 1);
const endOfMonth = (d: Date) => day(d.getFullYear(), d.getMonth() + 1, 0);
/** Monday on or before `d`. */
const startOfWeek = (d: Date) => addDays(d, -((d.getDay() + 6) % 7));
const sameDay = (a: Date, b: Date) => a.getTime() === b.getTime();
const sameMonth = (a: Date, b: Date) =>
  a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
const monthsBetween = (a: Date, b: Date) =>
  (b.getFullYear() - a.getFullYear()) * 12 + b.getMonth() - a.getMonth();
const keyOf = (d: Date) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
const ordered = (a: Date, b: Date): DateRange => (a <= b ? { from: a, to: b } : { from: b, to: a });

const fmtDate = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
const fmtMonth = new Intl.DateTimeFormat('en-US', { month: 'long', year: 'numeric' });
const fmtLong = new Intl.DateTimeFormat('en-US', { dateStyle: 'full' });

function presetRange(id: PresetId, today: Date, earliest: Date): DateRange {
  const y = today.getFullYear();
  switch (id) {
    case 'today':
      return { from: today, to: today };
    case 'yesterday': {
      const d = addDays(today, -1);
      return { from: d, to: d };
    }
    case 'this-week':
      return { from: startOfWeek(today), to: today };
    case 'last-week': {
      const from = addDays(startOfWeek(today), -7);
      return { from, to: addDays(from, 6) };
    }
    case 'this-month':
      return { from: startOfMonth(today), to: today };
    case 'last-month': {
      const from = addMonths(today, -1);
      return { from, to: endOfMonth(from) };
    }
    case 'this-year':
      return { from: day(y, 0, 1), to: today };
    case 'last-year':
      return { from: day(y - 1, 0, 1), to: day(y - 1, 11, 31) };
    case 'all':
      return { from: earliest, to: today };
  }
}

/** Left-hand month to show so the range is in view. */
function viewFor(range: DateRange): Date {
  return monthsBetween(range.from, range.to) <= 1
    ? startOfMonth(range.from)
    : addMonths(range.to, -1);
}

// "Today" differs between the static build and the visitor's browser, so it is
// read on the client only. The server snapshot is null and the calendar renders
// once hydrated, which avoids a hydration mismatch on the date grid.
const noopSubscribe = () => () => {};
const todayKey = () => {
  const n = new Date();
  return `${n.getFullYear()}-${n.getMonth()}-${n.getDate()}`;
};

function useToday(override?: Date): Date | null {
  const key = useSyncExternalStore(noopSubscribe, todayKey, () => null);
  return useMemo(() => {
    if (override) return day(override.getFullYear(), override.getMonth(), override.getDate());
    if (!key) return null;
    const [y, m, d] = key.split('-').map(Number);
    return day(y, m, d);
  }, [key, override]);
}

// ── Component ────────────────────────────────────────────────────────────────

export function DateRangePicker({
  defaultValue,
  today: todayProp,
  earliest = day(2020, 0, 1),
  marked,
  onApply,
  onCancel,
}: {
  /** Initial range. Defaults to the 14 days ending today. */
  defaultValue?: DateRange;
  /** Pin "today" (tests, screenshots). Defaults to the visitor's local date. */
  today?: Date;
  /** Start of "All time". */
  earliest?: Date;
  /** Days that get a dot under the number — events, releases, deploys. */
  marked?: Date[];
  onApply?: (range: DateRange) => void;
  onCancel?: () => void;
}) {
  const today = useToday(todayProp);

  if (!today) {
    // Same footprint as the real thing, so the page does not jump on hydrate.
    return (
      <div
        aria-hidden
        className="h-[372px] w-full max-w-[760px] rounded-[12px] border border-border bg-popover shadow-lg"
      />
    );
  }

  return (
    <Picker
      today={today}
      earliest={earliest}
      initial={defaultValue ?? { from: addDays(today, -13), to: today }}
      marked={marked ?? demoMarks(today)}
      onApply={onApply}
      onCancel={onCancel}
    />
  );
}

/** A few dots around today so the preview has something to show. */
function demoMarks(today: Date): Date[] {
  return [-40, -27, -20, -9, -2, 4, 13, 22].map((n) => addDays(today, n));
}

function Picker({
  today,
  earliest,
  initial,
  marked,
  onApply,
  onCancel,
}: {
  today: Date;
  earliest: Date;
  initial: DateRange;
  marked: Date[];
  onApply?: (range: DateRange) => void;
  onCancel?: () => void;
}) {
  const [applied, setApplied] = useState<DateRange>(initial);
  const [draft, setDraft] = useState<DateRange>(initial);
  // Set while the first click is waiting for the second.
  const [anchor, setAnchor] = useState<Date | null>(null);
  const [hover, setHover] = useState<Date | null>(null);
  const [view, setView] = useState<Date>(() => viewFor(initial));
  const [focused, setFocused] = useState<Date>(initial.to);

  const markedKeys = useMemo(() => new Set(marked.map(keyOf)), [marked]);

  // While picking the end, the hovered day previews the range.
  const shown = anchor ? ordered(anchor, hover ?? anchor) : draft;

  const activePreset = anchor
    ? null
    : (PRESETS.find(({ id }) => {
        const r = presetRange(id, today, earliest);
        return sameDay(r.from, draft.from) && sameDay(r.to, draft.to);
      })?.id ?? null);

  function select(range: DateRange) {
    setDraft(range);
    setAnchor(null);
    setHover(null);
    setView(viewFor(range));
    setFocused(range.to);
  }

  function pick(d: Date) {
    setFocused(d);
    if (anchor) {
      setDraft(ordered(anchor, d));
      setAnchor(null);
      setHover(null);
    } else {
      setDraft({ from: d, to: d });
      setAnchor(d);
    }
  }

  // Roving focus: one tabbable day per picker, arrows move it, and moving past
  // the visible months pages the view along with it.
  function moveFocus(next: Date) {
    setFocused(next);
    if (anchor) setHover(next);
    const offset = monthsBetween(view, next);
    if (offset < 0) setView(startOfMonth(next));
    else if (offset > 1) setView(addMonths(next, -1));
    requestAnimationFrame(() => {
      document.querySelector<HTMLButtonElement>(`[data-day="${keyOf(next)}"]`)?.focus();
    });
  }

  function onGridKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    const steps: Record<string, () => Date> = {
      ArrowLeft: () => addDays(focused, -1),
      ArrowRight: () => addDays(focused, 1),
      ArrowUp: () => addDays(focused, -7),
      ArrowDown: () => addDays(focused, 7),
      Home: () => startOfWeek(focused),
      End: () => addDays(startOfWeek(focused), 6),
      PageUp: () => day(focused.getFullYear(), focused.getMonth() - 1, focused.getDate()),
      PageDown: () => day(focused.getFullYear(), focused.getMonth() + 1, focused.getDate()),
    };
    const step = steps[event.key];
    if (step) {
      event.preventDefault();
      moveFocus(step());
    } else if (event.key === 'Escape' && anchor) {
      setAnchor(null);
      setHover(null);
      setDraft(applied);
    }
  }

  const months = [view, addMonths(view, 1)];

  return (
    <div className="flex w-full max-w-[760px] flex-col overflow-hidden rounded-[12px] border border-border bg-popover text-sm text-popover-foreground shadow-lg sm:flex-row">
      {/* Preset rail — a scrolling row on phones, a column beside the months above. */}
      <nav
        aria-label="Date presets"
        className="flex shrink-0 gap-1 overflow-x-auto border-b border-border p-2 sm:w-36 sm:flex-col sm:justify-between sm:overflow-visible sm:border-b-0 sm:border-r sm:px-0"
      >
        <ul className="flex gap-1 sm:flex-col sm:gap-0.5">
          {PRESETS.map(({ id, label }) => (
            <li key={id}>
              <PresetButton
                active={activePreset === id}
                onClick={() => select(presetRange(id, today, earliest))}
              >
                {label}
              </PresetButton>
            </li>
          ))}
        </ul>
        <PresetButton
          active={activePreset === null}
          onClick={() => document.getElementById('drp-from')?.focus()}
        >
          Custom
        </PresetButton>
      </nav>

      <div className="flex min-w-0 flex-1 flex-col">
        <div className="flex divide-x divide-border" onKeyDown={onGridKeyDown}>
          {months.map((month, i) => (
            <Month
              key={keyOf(month)}
              month={month}
              className={i === 1 ? 'hidden md:block' : undefined}
              showPrev={i === 0}
              // On phones only the first month shows, so it carries both arrows.
              nextClassName={i === 0 ? 'md:invisible' : undefined}
              onPrev={() => setView(addMonths(view, -1))}
              onNext={() => setView(addMonths(view, 1))}
              range={shown}
              today={today}
              focused={focused}
              markedKeys={markedKeys}
              onPick={pick}
              onHover={anchor ? setHover : undefined}
            />
          ))}
        </div>

        <div className="flex flex-wrap items-center justify-between gap-3 border-t border-border p-3">
          <div className="flex items-center gap-2">
            <DateField
              key={`from-${keyOf(draft.from)}`}
              id="drp-from"
              label="Start date"
              value={draft.from}
              onCommit={(d) => select(ordered(d, draft.to))}
            />
            <span aria-hidden className="text-muted-foreground">
              –
            </span>
            <DateField
              key={`to-${keyOf(draft.to)}`}
              id="drp-to"
              label="End date"
              value={draft.to}
              onCommit={(d) => select(ordered(draft.from, d))}
            />
          </div>
          <div className="ml-auto flex gap-2">
            <button
              type="button"
              onClick={() => {
                select(applied);
                onCancel?.();
              }}
              className="inline-flex h-8 items-center rounded-[6px] border border-border bg-background px-3 font-medium transition-colors hover:bg-accent"
            >
              Cancel
            </button>
            <button
              type="button"
              disabled={anchor !== null}
              onClick={() => {
                setApplied(draft);
                onApply?.(draft);
              }}
              className="inline-flex h-8 items-center rounded-[6px] bg-primary px-3 font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-50"
            >
              Apply
            </button>
          </div>
        </div>
      </div>
    </div>
  );
}

function PresetButton({
  active,
  onClick,
  children,
}: {
  active: boolean;
  onClick: () => void;
  children: string;
}) {
  return (
    <button
      type="button"
      aria-pressed={active}
      onClick={onClick}
      className={`relative w-full whitespace-nowrap rounded-[6px] px-3 py-1.5 text-left transition-colors sm:rounded-none sm:px-4 ${
        active
          ? 'bg-accent font-medium text-foreground'
          : 'text-muted-foreground hover:bg-accent/60 hover:text-foreground'
      }`}
    >
      {active ? (
        <span aria-hidden className="absolute inset-y-0 left-0 hidden w-0.5 bg-primary sm:block" />
      ) : null}
      {children}
    </button>
  );
}

function Month({
  month,
  className,
  showPrev,
  nextClassName,
  onPrev,
  onNext,
  range,
  today,
  focused,
  markedKeys,
  onPick,
  onHover,
}: {
  month: Date;
  className?: string;
  showPrev: boolean;
  nextClassName?: string;
  onPrev: () => void;
  onNext: () => void;
  range: DateRange;
  today: Date;
  focused: Date;
  markedKeys: Set<string>;
  onPick: (d: Date) => void;
  onHover?: (d: Date) => void;
}) {
  const first = startOfWeek(month);
  // Always six weeks, so the two months stay the same height side by side.
  const days = Array.from({ length: 42 }, (_, i) => addDays(first, i));
  const last = endOfMonth(month);
  const title = fmtMonth.format(month);

  return (
    <div className={`flex-1 p-3 ${className ?? ''}`}>
      <div className="mb-2 flex items-center justify-between">
        <NavButton label="Previous month" onClick={onPrev} className={showPrev ? '' : 'invisible'}>
          <ChevronLeft className="h-4 w-4" />
        </NavButton>
        <h3 className="font-semibold" aria-live="polite">
          {title}
        </h3>
        <NavButton label="Next month" onClick={onNext} className={nextClassName}>
          <ChevronRight className="h-4 w-4" />
        </NavButton>
      </div>

      <div role="group" aria-label={title} className="grid grid-cols-7 gap-y-1 tabular-nums">
        {WEEKDAYS.map((w) => (
          <div
            key={w}
            className="flex h-8 items-center justify-center text-xs font-medium text-muted-foreground"
          >
            {w}
          </div>
        ))}

        {days.map((d, i) => {
          const inMonth = sameMonth(d, month);
          const isFrom = sameDay(d, range.from);
          const isTo = sameDay(d, range.to);
          const isEnd = inMonth && (isFrom || isTo);
          // The band is drawn only on in-month days so a range is not painted
          // twice where the two grids overlap.
          const inBand = inMonth && d >= range.from && d <= range.to;
          const col = i % 7;
          const roundL = col === 0 || isFrom || d.getDate() === 1;
          const roundR = col === 6 || isTo || sameDay(d, last);
          const isToday = sameDay(d, today);
          const hasMark = markedKeys.has(keyOf(d));

          return (
            <div
              key={keyOf(d)}
              className={`flex justify-center ${
                inBand
                  ? `bg-accent ${roundL ? 'rounded-l-[8px]' : ''} ${roundR ? 'rounded-r-[8px]' : ''}`
                  : ''
              }`}
            >
              <button
                type="button"
                data-day={inMonth ? keyOf(d) : undefined}
                tabIndex={inMonth && sameDay(d, focused) ? 0 : -1}
                aria-label={fmtLong.format(d)}
                aria-pressed={isEnd}
                aria-current={isToday ? 'date' : undefined}
                onClick={() => onPick(d)}
                onMouseEnter={onHover ? () => onHover(d) : undefined}
                onFocus={onHover ? () => onHover(d) : undefined}
                className={`relative flex h-8 w-full max-w-9 items-center justify-center rounded-[8px] outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring ${
                  isEnd
                    ? 'bg-primary font-semibold text-primary-foreground'
                    : inMonth
                      ? `hover:bg-muted ${isToday ? 'font-semibold text-primary' : ''}`
                      : 'text-muted-foreground/60 hover:bg-muted'
                }`}
              >
                {d.getDate()}
                {hasMark ? (
                  <span
                    aria-hidden
                    className={`absolute bottom-0.5 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full ${
                      isEnd ? 'bg-primary-foreground' : 'bg-primary'
                    } ${inMonth ? '' : 'opacity-50'}`}
                  />
                ) : null}
              </button>
            </div>
          );
        })}
      </div>
    </div>
  );
}

function NavButton({
  label,
  onClick,
  className,
  children,
}: {
  label: string;
  onClick: () => void;
  className?: string;
  children: React.ReactNode;
}) {
  return (
    <button
      type="button"
      aria-label={label}
      onClick={onClick}
      className={`inline-flex h-7 w-7 items-center justify-center rounded-[6px] border border-border text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${className ?? ''}`}
    >
      {children}
    </button>
  );
}

/**
 * A typed date. Keyed by its value upstream, so a range change from the grid
 * resets the text; a typo reverts on blur instead of clearing the range.
 */
function DateField({
  id,
  label,
  value,
  onCommit,
}: {
  id: string;
  label: string;
  value: Date;
  onCommit: (d: Date) => void;
}) {
  const [text, setText] = useState(() => fmtDate.format(value));

  function commit() {
    const parsed = new Date(text);
    if (Number.isNaN(parsed.getTime())) {
      setText(fmtDate.format(value));
      return;
    }
    const d = day(parsed.getFullYear(), parsed.getMonth(), parsed.getDate());
    if (sameDay(d, value)) setText(fmtDate.format(value));
    else onCommit(d);
  }

  return (
    <input
      id={id}
      aria-label={label}
      value={text}
      onChange={(e) => setText(e.target.value)}
      onBlur={commit}
      onKeyDown={(e) => {
        if (e.key === 'Enter') commit();
      }}
      className="h-8 w-[7.5rem] rounded-[6px] border border-border bg-background px-2.5 tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-ring"
    />
  );
}

Dependencies

  • lucide-react — npm package

Colours come from CSS variables (--background, --primary, --border) using the shadcn/ui token names, so the block adopts your palette rather than importing ours.