Period select
The reporting window a dashboard is scoped to, with a custom range behind the last option.
Controlled only. Choosing a period reports it together with the window it stands for, computed on the choice rather than in render, so nothing here reads the clock while rendering. 24h is a rolling window ending at now; every other option covers whole days ending today. Choosing "Custom range" puts a DateRangePicker beside the select and reports whatever it hands back under the same custom period. periodToRange is exported for scoping a query without rendering the control, and previousPeriodRange gives the window of the same length that ends where this one begins — the window a chart's compare ghost plots. Pass that back as compareTo and the control prints it in words after the select, so the dashed line on the chart beside it is named rather than inferred from a dash pattern.
Install
npx shadcn@latest add @vibra/period-selectNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { PeriodSelect, type Period } from "@/components/ui/period-select"
import { type DateRange } from "@/components/ui/date-range-picker"
import { formatDate } from "@/lib/format"
export default function PeriodSelectDemo() {
const [period, setPeriod] = React.useState<Period>("30d")
const [range, setRange] = React.useState<DateRange | undefined>()
return (
<div className="flex w-full flex-col gap-3">
<PeriodSelect
aria-label="Reporting period"
value={period}
customRange={range}
onValueChange={(next, nextRange) => {
setPeriod(next)
setRange(nextRange)
}}
/>
<p className="text-xs text-muted-foreground">
{range?.from && range?.to
? `Charts cover ${formatDate(range.from)} to ${formatDate(range.to)}.`
: "Pick a window to scope the charts below."}
</p>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | "24h" | "7d" | "30d" | "90d" | "12m" | "custom" | — | The period in force. |
| onValueChange | (period: Period, range?: DateRange) => void | — | Called with the period and its window; the window is undefined only for a custom range not yet picked. |
| customRange | { from?: Date; to?: Date } | — | The window behind "custom"; it also labels the range picker. |
| compareTo | { from?: Date; to?: Date } | — | The window this period is measured against, printed after the control. Usually previousPeriodRange(range). |
| options | { value: Period; label: string }[] | periodOptions | The fixed windows offered before the custom one. |
| allowCustom | boolean | true | Offers "Custom range", which opens a date range picker beside the select. |
| size | "sm" | "default" | "default" | sm drops both controls to h-7 for dense headers. |
| aria-label | string | "Period" | Names the select, e.g. Reporting period. |
| periodToRange | (period: Exclude<Period, 'custom'>, now?: Date) => { from?: Date; to?: Date } | now: new Date() | The window a period stands for; now is a parameter so a render never reads the clock. |
| className | string | — | Merged onto the root, which holds the select and the custom range picker; the remaining div props are spread onto it too. |
Dependencies
npm
Source
"use client"
import * as React from "react"
import { endOfDay, startOfDay, subDays, subMonths } from "date-fns"
import { cn } from "@/lib/utils"
import { DateRangePicker, type DateRange } from "@/components/ui/date-range-picker"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
// The comparison window is written the short way — "vs Jul 3 – Aug 1" — in the
// runtime's own zone, which is the zone the ranges themselves are built in.
const COMPARE_DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric" })
export type Period = "24h" | "7d" | "30d" | "90d" | "12m" | "custom"
export type PeriodOption = {
value: Period
label: string
}
/** The reporting windows a dashboard offers before anyone asks for a custom one. */
export const periodOptions: PeriodOption[] = [
{ value: "24h", label: "Last 24 hours" },
{ value: "7d", label: "Last 7 days" },
{ value: "30d", label: "Last 30 days" },
{ value: "90d", label: "Last 90 days" },
{ value: "12m", label: "Last 12 months" },
]
/**
* Turns a period into the window it stands for. 24h is a rolling window ending
* at `now`; the rest are whole days ending today. `now` is a parameter so a
* render never has to read the clock.
*/
export function periodToRange(period: Exclude<Period, "custom">, now: Date = new Date()): DateRange {
switch (period) {
case "24h":
return { from: subDays(now, 1), to: now }
case "7d":
return { from: startOfDay(subDays(now, 6)), to: endOfDay(now) }
case "30d":
return { from: startOfDay(subDays(now, 29)), to: endOfDay(now) }
case "90d":
return { from: startOfDay(subDays(now, 89)), to: endOfDay(now) }
case "12m":
return { from: startOfDay(subMonths(now, 12)), to: endOfDay(now) }
}
}
/**
* The window of the same length that ends where this one begins — what a chart's
* compare ghost is plotting. Returns undefined for a half-set custom range,
* because half a window has no length to step back by.
*/
export function previousPeriodRange(
range: DateRange | undefined
): { from: Date; to: Date } | undefined {
if (!range?.from || !range.to) return undefined
const length = range.to.getTime() - range.from.getTime()
// One millisecond before `from`, so the two windows touch without overlapping.
const to = new Date(range.from.getTime() - 1)
return { from: new Date(to.getTime() - length), to }
}
// aria-label is the one div prop that does not reach the root: it names the
// select inside, which is the control a reader actually operates.
export type PeriodSelectProps = Omit<React.ComponentProps<"div">, "value" | "aria-label"> & {
value: Period
/** Called with the period and, for everything but a half-set custom range, its window. */
onValueChange: (period: Period, range?: DateRange) => void
/** The window behind "custom"; it also labels the range picker's trigger. */
customRange?: DateRange
/**
* The window this period is measured against, printed after the control —
* usually `previousPeriodRange(range)`, and the same window a chart's compare
* ghost plots. Without it nothing is drawn.
*/
compareTo?: DateRange
options?: PeriodOption[]
/** Offers "Custom range", which opens a date range picker beside the select. */
allowCustom?: boolean
size?: "sm" | "default"
/** Names the select, e.g. "Reporting period". */
"aria-label"?: string
}
/** The reporting window a dashboard is scoped to, with a custom range behind the last option. */
function PeriodSelect({
value,
onValueChange,
customRange,
compareTo,
options = periodOptions,
allowCustom = true,
size = "default",
className,
"aria-label": ariaLabel = "Period",
...props
}: PeriodSelectProps) {
const all = allowCustom
? [...options, { value: "custom" as const, label: "Custom range" }]
: options
// A handful of entries rebuilt per render; memoising it would cost more than
// it saves, and `all` changes shape with allowCustom anyway.
const labels = Object.fromEntries(all.map((option) => [option.value, option.label]))
return (
<div
data-slot="period-select"
data-size={size}
data-period={value}
data-compare={compareTo ? "true" : undefined}
className={cn("flex w-fit flex-wrap items-center gap-1.5", className)}
{...props}
>
<Select
value={value}
onValueChange={(next) => {
const period = next as Period | null
if (!period || period === value) return
// The clock is read here, on the choice, and never in render.
onValueChange(period, period === "custom" ? customRange : periodToRange(period))
}}
>
<SelectTrigger size={size} aria-label={ariaLabel} className="min-w-36">
<SelectValue>{(current) => labels[current as Period] ?? current}</SelectValue>
</SelectTrigger>
<SelectContent>
{all.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{value === "custom" ? (
<DateRangePicker
value={customRange}
onValueChange={(range) => onValueChange("custom", range)}
size={size}
numberOfMonths={2}
className="w-auto"
/>
) : null}
{/* The comparison window in words, so the dashed ghost on the chart beside
it is named rather than left to be inferred from a dash pattern. */}
{compareTo?.from && compareTo.to ? (
<span data-slot="period-select-compare" className="type-eyebrow whitespace-nowrap">
vs {COMPARE_DAY.format(compareTo.from)} – {COMPARE_DAY.format(compareTo.to)}
</span>
) : null}
</div>
)
}
export { PeriodSelect }