Lunia DS

Actions and inputs

Date range picker

A pill that unfolds into a calendar panel where the selected range is one soft band per week that stretches between days, with dots that glide to the first and last day.

Loading preview

A report toolbar with the picker set to the last 7 days and presets on. Replay opens the panel, picks the 3rd of the shown month, moves the pointer to the 12th (the band follows), picks it, then chooses "Last 30 days" and applies. A second demo shows unit "nights" for a hotel stay without presets.

Installation

Run this in your app after the one-time setup on the Getting started page.

$ npx @ganisigit/lunia-ds add date-range-picker

Usage

The demo above, as code.

demo.tsx
'use client';

import * as React from 'react';
import { DateRangePicker } from './date-range-picker';
import { addDays, type DateRange, startOfDay } from './date-range-picker-core';

const last7 = (today: Date): DateRange => ({ start: addDays(today, -6), end: today });
const stayFrom = (today: Date): DateRange => ({ start: addDays(today, 14), end: addDays(today, 17) });
const noSubscribe = () => () => {};
const readToday = () => startOfDay(new Date()).getTime();

/** Replay steps: open, pick the 3rd, sweep to the 12th, pick it, choose Last 30 days, apply. */
function runScript(root: HTMLElement, today: Date, later: (ms: number, run: () => void) => void) {
  const trigger = root.querySelector<HTMLElement>('[data-slot="date-range-trigger"]');
  const panel = () => document.querySelector<HTMLElement>('[data-slot="date-range-panel"]');
  // The panel opens on the month where the applied range starts.
  const shown = last7(today).start;
  const day = (n: number) => {
    const iso = `${shown.getFullYear()}-${String(shown.getMonth() + 1).padStart(2, '0')}-${String(n).padStart(2, '0')}`;
    return panel()?.querySelector<HTMLElement>(`[data-date="${iso}"]`);
  };
  const move = (n: number) => day(n)?.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }));
  later(300, () => trigger?.click());
  later(1000, () => day(3)?.click());
  for (let n = 4; n <= 12; n++) later(1300 + (n - 4) * 120, () => move(n));
  later(2700, () => day(12)?.click());
  later(3600, () =>
    [...(panel()?.querySelectorAll<HTMLElement>('[aria-pressed]') ?? [])]
      .find((b) => b.textContent?.trim() === 'Last 30 days')
      ?.click(),
  );
  later(4600, () =>
    [...(panel()?.querySelectorAll<HTMLElement>('button') ?? [])]
      .find((b) => b.textContent?.trim() === 'Apply')
      ?.click(),
  );
}

export default function DateRangePickerDemo({ replay = 0 }: { replay?: number }) {
  const area = React.useRef<HTMLDivElement>(null);
  // The clock is read on the client only, so a page built on another day hydrates without a mismatch.
  const todayMs = React.useSyncExternalStore(noSubscribe, readToday, () => null);
  const today = todayMs === null ? null : new Date(todayMs);
  const [report, setReport] = React.useState<DateRange | null>(null);
  const [stay, setStay] = React.useState<DateRange | null>(null);

  React.useEffect(() => {
    if (replay === 0 || !area.current || todayMs === null) return;
    setReport(null);
    const timers: number[] = [];
    runScript(area.current, new Date(todayMs), (ms, run) => timers.push(window.setTimeout(run, ms)));
    return () => {
      for (const timer of timers) window.clearTimeout(timer);
    };
  }, [replay, todayMs]);

  return (
    <div ref={area} className="flex w-full max-w-[560px] flex-col gap-6">
      <div className="flex flex-wrap items-center justify-between gap-3 border-b border-secondary pb-4">
        <div className="flex flex-col">
          <h3 className="text-md font-semibold text-primary">Revenue</h3>
          <p className="text-sm text-tertiary">Orders paid in the selected period</p>
        </div>
        {today && <DateRangePicker align="end" value={report ?? last7(today)} onValueChange={setReport} />}
      </div>
      <div className="flex flex-wrap items-center justify-between gap-3">
        <div className="flex flex-col">
          <h3 className="text-md font-semibold text-primary">Your stay</h3>
          <p className="text-sm text-tertiary">Check-in and check-out dates</p>
        </div>
        {today && (
          <DateRangePicker
            align="end"
            unit="nights"
            presets={[]}
            min={today}
            value={stay ?? stayFrom(today)}
            onValueChange={setStay}
          />
        )}
      </div>
    </div>
  );
}

API

Parts: root, trigger pill (calendar icon, label text morph, chevron), panel (dialog, unfolds from the pill), presets (group of toggle buttons), preset highlight, month header (previous button, month title text morph, next button), weekday row, day grid (grid of gridcells), range band (one shape per week), start dot, end dot, footer (range label text morph, count number roll + unit text morph, Cancel, Apply), status (live region, screen reader only).

PropTypeDefaultDescription
value{ start: Date; end: Date } | nullundefinedControlled range.
defaultValue{ start: Date; end: Date } | nullnullStarting range when uncontrolled.
presets{ label: string; range: (today: Date) => { start: Date; end: Date } }[]Today, Last 7 days, Last 30 days, This month, Last month, This yearQuick ranges beside the month. An empty array hides the column.
minDateundefinedDays before this cannot be picked.
maxDateundefinedDays after this cannot be picked.
unit"days" | "nights""days"Count days (reports) or nights (stays; a stay needs at least one night).
weekStartsOn0 | 11Weeks start on Sunday (0) or Monday (1).
localesstring | string[]"en-US"Locale for the month name, weekdays and range label.
placeholderstring"Select dates"Pill label when no range is set.
align"start" | "end""start"Line the panel up with the left or right edge of the pill.
openbooleanundefinedControlled open state.
defaultOpenbooleanfalseStarting open state when uncontrolled.
EventDescription
valueChangeFires with the new range when Apply is pressed.
openChangeFires with true or false when the panel opens or closes.

Motion

Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.

IDTriggerElementPropertyFrom → toTimingReduced motion
DR1hover the pilltrigger pillbackground-colorbg-primary → bg-primary_hoverduration-xs (160)ease-outSwaps at once
DR2open (click the pill)panelclip-path (unfolds from the pill footprint, radius 18px to 20px)inset to the pill, 36px tall → full panelduration-2xl (420)ease-outShows at once
DR3open (click the pill)panelopacity0 → 1duration-sm (200)ease-outShows at once
DR4open or closechevrontransform rotate0deg → 180degduration-lg (320)ease-outSwaps at once
DR5pick the first daystart dottransform scale + opacity (pop in)0.5, 0 → 1, 1duration-lg (320)ease-throwShows at once
DR6a day becomes the first or last dayday numbercolortext-primary → text-white (on the dot)duration-sm (200)ease-outdelay 90msSwaps at once
DR7pointer or arrow keys over days after the first pickend dotleft + top (glides from day to day)previous day → day under the pointerduration-lg (320)ease-outMoves at once
DR8range changes (pointer, keys, second pick, preset)range band (per week)left + width (stretches between days)old span → new spanduration-lg (320)ease-outResizes at once
DR9range changesrange band (per week)opacity (week joins or leaves the range)0 → 1duration-sm (200)ease-outShows at once
DR10range changesrange band (per week)border-radius (round at the range ends, 8px where it wraps)8px → 999pxduration-md (260)ease-outSwaps at once
DR11pick the second dayrange band + end dotbackground-color (from preview tint to set tint)brand at 7%, dot brand at 80% → brand at 10%, dot brand solidduration-sm (200)ease-outSwaps at once
DR12range changesfooter range label + unittext morphold text → new textduration-3xl (460)ease-outSwaps at once
DR13range changesfooter countnumber rollold count → new countduration-6xl (900)ease-outSwaps at once
DR14choose a presetpreset highlighttransform translate + width + heightprevious preset → chosen presetduration-xl (380)ease-outMoves at once
DR15first preset chosen, or range no longer matches a presetpreset highlightopacity0 → 1duration-sm (200)ease-outShows at once
DR16hover a preset or a daypreset text, day backgroundcolor, background-colortext-tertiary, transparent → text-primary, bg-primary_hoverduration-xs (160)ease-outSwaps at once
DR17previous or next month (button, Page Up or Down, arrow keys, preset in another month)day gridopacity + transform translateX + filter blur (slides in from the side you went)0, distance-slide x direction, blur-text → 1, 0, 0duration-xl (380)ease-outShows at once
DR18month changesmonth titletext morphold month → new monthduration-3xl (460)ease-outSwaps at once
DR19close (Cancel, Apply, Escape, click outside)panelclip-path (folds back into the pill)full panel → inset to the pillduration-md (260)ease-outHides at once
DR20close (Cancel, Apply, Escape, click outside)panelopacity1 → 0duration-xs (160)ease-outdelay 40msHides at once
DR21Applytrigger pill labeltext morphold range → new rangeduration-3xl (460)ease-outSwaps at once

Accessibility

  • The pill is a button with aria-haspopup dialog, aria-expanded and aria-controls; the panel is a dialog named "Select dates".
  • Opening from the pill moves focus to the focused day; closing with Cancel, Apply or Escape returns focus to the pill; a click outside closes without moving focus.
  • The days are a grid (role grid named by the month, rows, gridcells as buttons) with one tab stop; Arrow keys move by day and week, Home and End go to the week edges, Page Up and Page Down change month, Enter or Space picks.
  • Each day has a full date as aria-label, aria-selected inside the range, aria-current date for today and aria-disabled outside min and max.
  • Presets are toggle buttons (aria-pressed) in a group named "Presets"; the month title is a polite live region; a status region reads the range after each pick, for example "3 Mar - 9 Mar 2026".
  • Day cells are 36 to 40px, month buttons 32px (Utility button sm), footer buttons 36px tall (Button sm); all above the 24px minimum target.
  • Reduced motion: the panel shows and hides at once; the band, dots and highlight jump to their places; the month swaps without a slide.