# Date Range (Lumen Halo): prompt.md (v1.2.0)

- id: `glass-date-range` · version 1.2.0 · component · pro (All-Access)
- category: Inputs
- build: Base UI (one set of files; its dependencies follow the build)
- install (this build): `npx shadcn@latest add @beautiful-ui-pro/glass-date-range`
- npm dependencies: @web-kits/audio@^0.2
- registry dependencies: https://beautiful-ui.dev/r/beautiful-ui.json
- docs: https://beautiful-ui.dev/components/glass-date-range
- The install command carries everything this item needs (files, CSS, tokens, npm and registry dependencies). Prefer it to copying source by hand.

A liquid-glass date range picker in dark and light for a whole dashboard: presets under a gliding lens, a previous-period or year-over-year comparison, typed dates, a month rail for older periods and a two-month calendar whose fill ripples out from the start date, with a phone bottom sheet.

## Build it
- Stack: React 19 (`ref` is a plain prop), TypeScript, Tailwind CSS v4 utilities, a shadcn-initialised project with the `@/*` alias.
- Packages: `@web-kits/audio`.
- Source: `components/beautiful-ui/glass-date-range.tsx`, shared code in `lib/beautiful-ui/`.
- Exports to keep: `GlassDateRange`, and every exported type.
- CSS: none to add. Themes, tokens and keyframes are inlined by the component (a deduped `<style>` built with `lumenThemeCss` from `lib/beautiful-ui/glass.tsx`); retheme through the `--glass-*` variables, never with Tailwind colour classes inside the component.

```tsx
import { /* … */ } from "@/components/beautiful-ui/glass-date-range";
```

## Parts

| Part | data-slot | What it is for |
|---|---|---|
| `GlassDateRange` | — | The date range picker: trigger, presets, two-month calendar, compare and apply. |

## Sound
- Keep every `data-slot` and `data-sound` attribute: the sound layer reads them.
- Installing this item adds no audio. Nothing plays until the app mounts `GlassSoundProvider` once (install: `npx shadcn@latest add https://beautiful-ui.dev/r/glass-sound.json`, import from `@/components/beautiful-ui/glass-sound`); `GlassSoundToggle` is its mute control. Without a provider the audio engine never loads; `sound={false}` silences one instance.
- This item: The trigger opens with open and every close (Escape, outside press, Cancel, Done) plays close; days, presets, months and compare modes play select (days pitched by weekday); the compare switch plays toggleOn / toggleOff; Apply plays success instead of a close; arrow, Page and Home / End moves tick quietly. Hover alone is silent. Needs a GlassSoundProvider; sound={false} silences this instance.

## Match the original
- Read `components/beautiful-ui/glass-date-range.tsx` as the reference implementation before changing or recreating anything, and match it: sizes, colours per theme, motion timings, copy and behaviour.
- If you deviate (a prop you can't honour, a style you changed, a dependency you swapped), say so in your reply, part by part.
- Keep the accessibility contract, the keyboard map and the motion levels listed below.

## Use it when
- date range picker, period picker, dashboard date filter, last 7 days, last 30 days, this quarter, year to date, compare to previous period, year over year, reporting period, analytics time range
- The one period control of a dashboard or report: every chart reads the applied range (Glass Trend, Glass Cell Bars, Glass Stat Cards)
- When people compare periods: the previous period or the same period last year, drawn as a dashed amber range
- Picking an older period fast: the month rail selects a month in one click, several with a drag
- Settings or onboarding screens that need the picker in place: inline renders the panel with no popover

### Not when
- A single date (a due date, a birthday): use a plain date field
- Date and time together (scheduling to the minute): this works in whole days
- Ranges in the future (bookings, trips): pass maxDate to allow them, but a booking calendar with prices and availability is a different component

## Mistakes
- Treat values as dates, not instants: the range is DateOnly integers, so use toISODate for APIs instead of new Date(value)
- Pass today on server-rendered pages when the dates must be in the first HTML; otherwise they appear right after mount
- From a server component pass dates as "YYYY-MM-DD" strings: the helpers live in a client module, and a Date is read in the runtime's zone (the server's and the browser's can differ) unless you pass timeZone
- The end is inclusive: to query up to the end of the last day, add one day (addDays(range.end, 1)) for an exclusive bound
- With value controlled, update it from onChange or the picker snaps back to your value
- Future days are disabled by default; pass maxDate to allow them
- className and style go on the trigger's wrapper; the popover portals to document.body, so style it with panelClassName / panelStyle (CSS variables like --glass-accent set on the root or an ancestor are carried over for you)
- Inside a modal or drawer with a z-index above 60, pass zIndex (or set --glass-z-popover) or the popover opens behind it
- With a re-branded light accent, pick a colour dark enough for text (4.5:1 on white): chips and the active month use it as ink
- To translate built-in presets use labels.presetLabels; re-declaring preset objects works too but copies their range logic

## Usage

```tsx
import {
  GlassDateRange,
  toISODate,
  type GlassDateRangeChange,
} from "@/components/beautiful-ui/glass-date-range";

// A Spanish reporting page: brand accent, renamed presets, above a modal layer
<GlassDateRange
  locale="es-ES"
  accent={{ dark: "#38bdf8", light: "#0369a1" }}
  zIndex={1100}
  data-testid="report-period"
  labels={{
    apply: "Aplicar",
    cancel: "Cancelar",
    compare: "Comparar",
    presetLabels: { "7d": { label: "Últimos 7 días" }, "30d": { label: "Últimos 30 días" } },
  }}
  onChange={({ range }) => fetchReport(toISODate(range.start), toISODate(range.end))}
/>

// Drop in: Last 30 days vs the previous period, Apply to change
<GlassDateRange onChange={({ range, compareRange }) => refetch(range, compareRange)} />

// Controlled, with your own bounds, presets and a full-width trigger
const [period, setPeriod] = useState<GlassDateRangeChange | null>(null);
<GlassDateRange
  block
  value={period?.range ?? { start: "2026-09-01", end: "2026-09-27" }}
  compare={period?.compare ?? null}
  minDate="2024-01-01"
  presets={[
    "7d",
    "30d",
    { id: "fy", label: "Fiscal year", chip: "FY", range: (today) => ({ start: fiscalStart(today), end: today }) },
  ]}
  onChange={(next) => {
    setPeriod(next);
    router.push(`?from=${toISODate(next.range.start)}&to=${toISODate(next.range.end)}`);
  }}
/>
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `value / defaultValue` | `{ start; end } (DateOnly \| Date \| "YYYY-MM-DD")` | `Last 30 days` | The applied range, inclusive. Controlled or uncontrolled. A start after the end is swapped. A value outside minDate / maxDate shows on the trigger as given; the calendar opens on the part inside the bounds. |
| `onChange` | `({ range, preset, compare, compareRange }) => void` |  | Called on Apply (or on every complete change with autoApply). range holds DateOnly integers; convert with toISODate, toDate or dateOnlyParts. |
| `defaultPreset` | `string` | `"30d"` | Uncontrolled: the preset applied at first when there is no defaultValue. It rolls with today. |
| `presets` | `(BuiltInPresetId \| { id; label; hint?; chip?; range(today) })[]` | `["today", "7d", "30d", "quarter", "year"]` | Built-ins: today, yesterday, 7d, 14d, 30d, 90d, month, quarter, year, last-month, last-quarter, last-year. Your own objects return a range for a given today (or null when unavailable); ranges are clipped to minDate / maxDate. |
| `compare / defaultCompare / onCompareChange` | `"previous" \| "yoy" \| null / … / (compare) => void` | `"previous"` | The comparison, controlled or uncontrolled. previous: the same length ending the day before; yoy: the same dates a year earlier (Feb 29 becomes Feb 28). onCompareChange fires when an applied change turns it on, off or to the other mode (onChange carries it too). |
| `open / defaultOpen / onOpenChange` | `boolean / boolean / (open) => void` |  | Controlled or uncontrolled popover state. |
| `today` | `DateOnly \| Date \| string` | `read after mount` | Pass it (as "YYYY-MM-DD" or a DateOnly) for identical server and client output. Without it the trigger shows the preset's name until mount, then the dates (no hydration mismatch). |
| `minDate / maxDate` | `DateOnly \| Date \| string` | `none / today` | Selectable bounds. Days outside are disabled; typed dates outside show an inline error. |
| `weekStartsOn` | `0–6` | `1 (Monday)` | First column of the calendar (0 = Sunday). |
| `locale` | `string` | `"en-US"` | Month, weekday and date formats through Intl. Typed dates also accept that locale's month names. |
| `timeZone` | `string (IANA)` | `the browser's` | Named in the footer ("All dates in Europe/Berlin · days end at midnight") and used to read today. |
| `labels` | `Partial<GlassDateRangeLabels>` | `defaultGlassDateRangeLabels` | All interface copy (Apply, Compare, errors, the footer note…), for translation. labels.presetLabels renames presets by id without redeclaring them: { "7d": { label: "Últimos 7 días", chip: "7D" } } (label, chip, hint). |
| `formatRange` | `(range, { locale }) => string` | `formatDateRange` | Trigger, summary and aria text for a range. |
| `formatDate` | `(day, { locale, year }) => string` | `formatDateOnly` | A single date: error messages and single-day preset hints. |
| `formatWeekday / formatMonth` | `(weekday 0–6 \| month 1–12, { locale }) => string` | `formatGlassDateRangeWeekday / formatGlassDateRangeMonth` | Calendar column headers and month rail tiles. The defaults give "MO" / "JAN" in English and each locale's own short name in capitals elsewhere ("MIÉ", "FÉVR"), never cut. |
| `trigger` | `(api) => ReactNode` |  | Your own trigger. Spread api.props on your button so the popover anchors to it; api has open, toggle, range, label, compareLabel, chip. |
| `inline` | `boolean` | `false` | The panel in place, always open, no trigger or popover. Under 680px wide it stacks: presets in a row, one month. |
| `block` | `boolean` | `false` | The trigger fills its container's width. |
| `align / side` | `"start" \| "end" / "auto" \| "bottom" \| "top"` | `"end" / "auto"` | Popover edge and side. It stays 16px inside the viewport and flips above when there's more room. |
| `portal` | `boolean` | `true` | Render the popover into document.body so no parent clips it. false positions it inside the component, kept inside the nearest clipping or scrolling ancestor; when that leaves less than breakpoint px it stacks to one month. |
| `zIndex` | `number` | `var(--glass-z-popover, 60)` | Stacking order of the popover and sheet (the scrim sits one below). Raise it above your modals and toasts, or set --glass-z-popover in CSS. |
| `panelClassName / panelStyle` | `string / CSSProperties` |  | Class and style on the popover / sheet overlay, which portals out of the root so className and style don't reach it. Inline: on the panel. |
| `accent` | `string \| { dark; light }` | `#8B93FF / #6B74F5` | The accent (range fill, focus ring, chips, month tiles). The same as setting --glass-accent. |
| `triggerRef` | `Ref<HTMLElement>` |  | The trigger button (or your custom trigger's element), for focus management from forms. ref is the root. |
| `autoApply` | `boolean` | `false` | Apply every complete change at once. The popover shows Done instead of Cancel / Apply; inline shows no buttons. |
| `onDraftChange` | `({ range \| null, preset, compare }) => void` |  | Every change to the unapplied draft, for live previews (range is null while picking the end). |
| `isDateDisabled` | `(day: DateOnly) => boolean` |  | Days that can't start or end a range (weekends, holidays), besides minDate / maxDate. Keyboard focus steps over them. |
| `maxLength` | `number` |  | The longest range in days: while picking, days beyond it are disabled; typed ranges beyond it show an error. |
| `parseDate` | `(text, { today, locale }) => DateOnly \| null` | `parseDateInput` | Your own parser for the date fields. |
| `name` | `string` |  | Form integration: submits hidden `${name}_start` and `${name}_end` ("YYYY-MM-DD") and `${name}_compare`. |
| `disabled` | `boolean` | `false` | Disables the trigger (and an inline panel). |
| `portalContainer` | `HTMLElement \| null` | `document.body` | Where the popover portals to (a modal, a shadow root). A "system" theme follows the trigger's resolved theme. |
| `autoFocus` | `boolean` | `true` | Move focus into the calendar when the popover opens. |
| `layout` | `"auto" \| "desktop" \| "mobile"` | `"auto"` | auto uses the bottom sheet (drag down to dismiss) below a sheetBreakpoint viewport, and one stacked month when the panel has less than breakpoint px. |
| `breakpoint / sheetBreakpoint` | `number / number` | `680 / 700` | breakpoint: the panel width (inline: its own; portal={false}: its container's) under which it stacks to one month. sheetBreakpoint: the viewport width under which the popover becomes a bottom sheet. |
| `motion` | `"full" \| "subtle" \| "off"` | `"full"` | full: the designed motion. subtle: calm curve, shorter, no ripple delays. off: instant. prefers-reduced-motion is always respected. |
| `sound` | `boolean \| "subtle"` | `true` | true plays Lumen cues when a GlassSoundProvider enables sound; false silences this instance; "subtle" plays at 55%. Without a provider nothing plays and the audio engine never loads. |
| `theme` | `"system" \| "dark" \| "light"` | `"system"` | system follows a .dark / .light class or data-theme on an ancestor, else the OS. |
| `className / style / ref / id` | `string / CSSProperties / Ref<HTMLDivElement> / string` |  | Applied to the root element (the trigger wrapper, or the inline panel's wrapper). The panel gets id `${id}-panel`. |
| `...rest` | `HTML attributes` |  | data-*, aria-* (aria-describedby…), role and handlers pass through to the root; unknown props are dropped. |
| `CSS variables` | `--glass-accent, --glass-warn (+ -dark / -light), --glass-z-popover` | `#8B93FF / #6B74F5, #FFB547 / #F2981C, 60` | Set on the root or any ancestor to re-brand without props: --glass-accent drives every range, focus and chip tint; --glass-warn the comparison amber. They are copied onto the portaled popover while it is open. |

Full docs: https://beautiful-ui.dev/components/glass-date-range

## Customising
- Theme: `theme="system"` (default) follows a `.dark` / `.light` class or `data-theme` on an ancestor, else the OS. `"dark"` / `"light"` pin one.
- Motion: `motion="full"` (default) | `"subtle"` | `"off"`. prefers-reduced-motion is always respected.
- Phones: below 720px it switches to its phone layout in CSS. Force one with `layout="desktop"` or `layout="mobile"`.
- Colours: the family variables (`--glass-accent`, `--glass-good`, `--glass-series-1` …) on the component or any ancestor; the neutral glass is `--lg-*` in `lib/beautiful-ui/glass.tsx`.
- Update later by re-running the install with `--overwrite` (review the diff if you edited it). Changelog: https://beautiful-ui.dev/r/changelog.json

## Keyboard

| Keys | Action |
|---|---|
| ← → ↑ ↓ | Move a day or a week (the calendar follows focus) |
| PageUp / PageDown | Previous or next month (Shift: a year) |
| Home / End | First or last day of the month |
| Enter / Space | Pick the focused day: the first pick sets the start, the second the end |
| Enter (in a date field) | Commit the typed date (start after end swaps them) |
| Esc | Close and restore the last applied range |
| Tab | Moves through presets, compare, fields, rail, calendar and footer (trapped in the popover) |

## Motion inventory

| Interaction | What moves |
|---|---|
| Sound | The trigger opens with open and every close (Escape, outside press, Cancel, Done) plays close; days, presets, months and compare modes play select (days pitched by weekday); the compare switch plays toggleOn / toggleOff; Apply plays success instead of a close; arrow, Page and Home / End moves tick quietly. Hover alone is silent. Needs a GlassSoundProvider; sound={false} silences this instance |
| Open | The popover settles from 97% scale, 6px off and 4px blurred (420ms, the family curve, like Glass Command and the Glass Nav menu); on phones the sheet rises from below and drags down to dismiss |
| Presets | The glass lens glides to the picked preset (600ms) and the calendar's month track slides to the range (650ms) |
| Pick | The range fills out from the start date, 14ms per day; range ends pop to 104%; hovering while picking previews the range with no delay |
| Numbers | The day count rolls digit by digit (800ms) and the rail's year rolls (600ms) |
| Compare | The switch thumb springs (450ms) and the two options open with a height ease; comparison cells outline in dashed amber |
| motion="subtle" / "off" / reduced motion | Subtle: calm curve, 30% shorter, no ripple delays. Off: instant. Reduced: 150ms fades only, no ripple or rolling digits |

## Accessibility contract (preserve when editing)
- The popover is a dialog labelled Choose date range: focus moves into the calendar and returns to the trigger on close; it closes on Escape, an outside press or focus leaving it. On phones it is a modal sheet (aria-modal) that traps focus and locks page scroll
- Each month is an ARIA grid with one day in the tab order (roving tabindex); every day has a label like "Sep 14, 2026, in range, comparison" (or range start / range end), aria-selected and aria-current for today; month changes are announced politely
- Compare is a switch; the two comparisons are a radio group
- Typed-date errors use role=alert and are tied to the field with aria-describedby; the day count is a polite live region
- Days outside the bounds are disabled buttons; ranges and comparisons are never colour alone (the footer spells both out)
- On phones every control has a 40px or larger hit area

## Install

```bash
npx shadcn@latest add @beautiful-ui-pro/glass-date-range
```

Pro item: needs the `@beautiful-ui-pro` registry in `components.json` and `BEAUTIFUL_UI_TOKEN` in `.env.local` (https://beautiful-ui.dev/account). Setup: https://beautiful-ui.dev/docs/pro

## Credits
- Sound by @web-kits/audio (https://www.npmjs.com/package/@web-kits/audio)
