Skip to contentVibraUI
Inputs & filters

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-select

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
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 }[]periodOptionsThe fixed windows offered before the custom one.
allowCustombooleantrueOffers "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-labelstring"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.
classNamestring—Merged onto the root, which holds the select and the custom range picker; the remaining div props are spread onto it too.

Dependencies

Source

components/ui/period-select.tsx
"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 }