Skip to contentVibraUI
Charts

Chart time range

The 7d / 30d / 90d / 12m switch that scopes a dashboard, which can never end up empty.

A client component. Pressing the selected range hands back an empty group; that is ignored, so a range is always chosen and the charts it scopes never blank out. The group is labelled "Time range", so the four abbreviations are not read out bare. Put one above everything it scopes rather than one per chart. Pass compare and onCompareChange together to add the Compare toggle, which draws the previous period as a dashed ghost on every chart under it: the state lives with the caller, so a toggle that does nothing is never rendered. Its tooltip says what it compares with, "Previous period", and is the accessible description of the toggle too (describeTrigger on its Tooltip), so a screen reader hears it without the tooltip open. It carries aria-pressed and aria-keyshortcuts, and c toggles it from anywhere on the page except a field the reader is typing into — the same matcher the command palette uses. compareHotkey renames the key, or false unbinds it and leaves the button.

Install

npx shadcn@latest add @vibra/chart-time-range

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

Examples

Props

PropTypeDefaultDescription
valuestring—The range currently in force.
onValueChange(v: string) => void—Called with the chosen range. Never called with an empty selection.
options{ value: string; label: string }[]7d, 30d, 90d, 12mThe ranges to offer, in order. CHART_TIME_RANGES is exported as the default.
compareboolean—Whether the previous period is being drawn. Pass it with onCompareChange to add the toggle.
onCompareChange(compare: boolean) => void—Called by the toggle and by the hotkey. Without it no toggle is drawn.
compareHotkeystring | false"c"The key that toggles Compare; false unbinds it and leaves the button.
size"default" | "sm" | "lg""default"Derived from the toggle primitive's own size axis; sm fits the header of a chart card.
tone"neutral" | "brand""neutral"Deprecated; leave it out. The chosen range wears the kit's selected fill, --brand-muted, in either tone — the Toggle's own pressed fill since it stopped using the grey hover paints too. Still recorded as data-tone on the root, so a page that sets it keeps compiling.

Dependencies

Source

components/ui/chart-time-range.tsx
"use client"

import * as React from "react"
import { type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"
import { matchesHotkey } from "@/lib/hotkeys"
import { KbdShortcut } from "@/components/ui/kbd-shortcut"
import { Toggle } from "@/components/ui/toggle"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import type { toggleVariants } from "@/components/ui/toggle"

export type ChartTimeRangeOption = { value: string; label: string }

/** The ranges a dashboard reaches for first — a week, a month, a quarter, a year. */
export const CHART_TIME_RANGES: ChartTimeRangeOption[] = [
  { value: "7d", label: "7d" },
  { value: "30d", label: "30d" },
  { value: "90d", label: "90d" },
  { value: "12m", label: "12m" },
]

/** The key that turns the previous period on and off. */
export const COMPARE_HOTKEY = "c"

// `multiple` is omitted deliberately: a multi-select group could be emptied,
// and the whole point of this control is that a range is always chosen.
export type ChartTimeRangeProps = Omit<
  React.ComponentProps<typeof ToggleGroup>,
  "value" | "defaultValue" | "onValueChange" | "multiple" | "size" | "variant"
> & {
  value: string
  onValueChange: (v: string) => void
  options?: ChartTimeRangeOption[]
  size?: NonNullable<VariantProps<typeof toggleVariants>["size"]>
  /** Adds a Compare toggle beside the ranges. Leave it off and none is drawn. */
  compare?: boolean
  onCompareChange?: (compare: boolean) => void
  /** The keystroke that toggles Compare; false unbinds it. */
  compareHotkey?: string | false
  /**
   * @deprecated Changes nothing: the chosen range wears the kit's selected
   * fill, --brand-muted, in either tone — the Toggle's own pressed fill since
   * it stopped using the grey that hover paints too. Still recorded as
   * `data-tone`, so a page that sets it keeps compiling; leave it out.
   */
  tone?: "neutral" | "brand"
  className?: string
}

function ChartTimeRange({
  className,
  value,
  onValueChange,
  options = CHART_TIME_RANGES,
  size = "default",
  compare,
  onCompareChange,
  compareHotkey = COMPARE_HOTKEY,
  tone = "neutral",
  ...props
}: ChartTimeRangeProps) {
  // The toggle only exists when a caller is holding the state for it: a Compare
  // switch that does nothing is worse than no switch.
  const showCompare = compare !== undefined && onCompareChange !== undefined

  // The listener is attached once per hotkey and reads the current state
  // through a ref, so an inline onCompareChange does not resubscribe it every
  // render — the same shape the command palette's ⌘K listener takes.
  const latest = React.useRef({ compare, onCompareChange })
  React.useEffect(() => {
    latest.current = { compare, onCompareChange }
  }, [compare, onCompareChange])

  React.useEffect(() => {
    if (!showCompare || compareHotkey === false) return
    const onKeyDown = (event: KeyboardEvent) => {
      if (!matchesHotkey(event, compareHotkey)) return
      event.preventDefault()
      latest.current.onCompareChange?.(!latest.current.compare)
    }
    window.addEventListener("keydown", onKeyDown)
    return () => window.removeEventListener("keydown", onKeyDown)
  }, [showCompare, compareHotkey])

  return (
    <div
      data-slot="chart-time-range"
      data-size={size}
      data-tone={tone}
      className={cn("flex w-fit items-center gap-2", className)}
    >
      <ToggleGroup
        aria-label="Time range"
        variant="outline"
        size={size}
        spacing={0}
        value={[value]}
        // Pressing the selected range hands back an empty group. Ignoring that
        // keeps a range always chosen, so the charts it scopes never lose their
        // window and blank out.
        onValueChange={(next) => {
          const [selected] = next
          if (selected) onValueChange(selected)
        }}
        className="w-fit"
        {...props}
      >
        {options.map((option) => (
          <ToggleGroupItem
            key={option.value}
            value={option.value}
            className="tabular-nums"
          >
            {option.label}
          </ToggleGroupItem>
        ))}
      </ToggleGroup>

      {showCompare ? (
        // Described, not only labelled: "Compare" does not say with what,
        // and the tooltip — the one place that does — reaches a pointer only.
        <Tooltip describeTrigger>
          <TooltipTrigger
            render={
              <Toggle
                data-slot="chart-compare-toggle"
                variant="outline"
                size={size}
                pressed={compare}
                onPressedChange={(next) => onCompareChange?.(next)}
                aria-keyshortcuts={compareHotkey === false ? undefined : compareHotkey}
              >
                Compare
              </Toggle>
            }
          />
          <TooltipContent className="flex items-center gap-2">
            Previous period
            {/* Shown, not described: the toggle announces its key through
                aria-keyshortcuts already. */}
            {compareHotkey === false ? null : <KbdShortcut keys={compareHotkey} aria-hidden="true" />}
          </TooltipContent>
        </Tooltip>
      ) : null}
    </div>
  )
}

export { ChartTimeRange }