Skip to contentVibraUI
Metrics

Goal tracker

Progress toward a goal, with milestone ticks along the bar and the deadline underneath.

Server-compatible: no hooks, no client boundary. goalTone(current, goal) is exported and uses the same thresholds as a progress stat. A milestone tick is notched in the page background where it crosses the filled bar and drawn from the foreground where it crosses the muted track, so a milestone still ahead of you is the one that reads most clearly; a milestone label is centred on its tick. The bar is the progressbar, so aria-label, aria-labelledby and labelText all land on it and not on the root; a markup label leaves it unnamed unless one of them is given.

Install

npx shadcn@latest add @vibra/goal-tracker

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

Examples

Props

PropTypeDefaultDescription
labelReact.ReactNode—What the goal is for, e.g. "New ARR this year". Wraps rather than clipping, so a whole sentence stays readable.
labelTextstring—Names the bar when label is markup rather than a string; aria-label wins over both, and either lands on the progressbar rather than the root.
currentnumber—How far along the goal is.
goalnumber—The number being worked toward.
format((n: number) => string) | (Intl.NumberFormatOptions & { locale?: string })whole units with separatorsFormats the current value and the goal alike: a function, or the options of an Intl.NumberFormat — plain data a server component can pass, e.g. { style: "currency", currency: "USD", maximumFractionDigits: 0 }.
milestones{ value: number; label?: string }[]—Staging posts, marked on the bar at their share of the goal.
deadlineReact.ReactNode—When the goal runs out, shown under the bar beside a calendar icon.
tone"default" | "success" | "warning" | "danger" | "info" | "auto""auto"auto is success at or past the goal, default from 70 percent of it, warning from 40, danger below.

Dependencies

Source

components/ui/goal-tracker.tsx
import * as React from "react"
import { cva } from "class-variance-authority"
import { CalendarIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { clamp, formatNumber, numberFormatter, percentOf, type NumberFormat } from "@/lib/format"

type Tone = "default" | "success" | "warning" | "danger" | "info"

/** The tone progress toward a goal should take: success at or past it, default from 70% of it, warning from 40%, danger below that. */
export function goalTone(current: number, goal: number): Tone {
  const percent = percentOf(current, goal)
  if (percent >= 100) return "success"
  if (percent >= 70) return "default"
  if (percent >= 40) return "warning"
  return "danger"
}

const goalTrackerIndicatorVariants = cva("h-full rounded-full transition-[width] duration-(--duration-slow) ease-(--ease-standard)", {
  variants: {
    tone: {
      default: "bg-primary",
      success: "bg-success",
      warning: "bg-warning",
      danger: "bg-danger",
      info: "bg-info",
    },
  },
  defaultVariants: { tone: "default" },
})

const DEFAULT_FORMAT = (value: number) => formatNumber(value, { maximumFractionDigits: 0 })

export type GoalTrackerMilestone = {
  value: number
  /** Sits under the tick. Left off, the milestone is a bare notch on the bar. */
  label?: string
}

export type GoalTrackerProps = React.ComponentProps<"div"> & {
  label: React.ReactNode
  /** Names the bar when `label` is markup rather than a string. */
  labelText?: string
  current: number
  goal: number
  /**
   * Formats both numbers: a function, or the options of an Intl.NumberFormat —
   * plain data, which a server component can pass, e.g. `{ style: "currency",
   * currency: "USD", maximumFractionDigits: 0 }`. Defaults to whole units with
   * separators.
   */
  format?: NumberFormat
  /** Staging posts along the way, marked on the bar at their share of the goal. */
  milestones?: GoalTrackerMilestone[]
  /** When the goal runs out, shown under the bar. */
  deadline?: React.ReactNode
  /** "auto" derives the tone from how close the current value is to the goal. */
  tone?: Tone | "auto"
}

function GoalTracker({
  className,
  label,
  labelText,
  current,
  goal,
  format: formatOption = DEFAULT_FORMAT,
  milestones,
  deadline,
  tone = "auto",
  "aria-label": ariaLabel,
  "aria-labelledby": ariaLabelledBy,
  ...props
}: GoalTrackerProps) {
  const resolvedTone = tone === "auto" ? goalTone(current, goal) : tone
  const percent = Math.round(clamp(percentOf(current, goal), 0, 100))
  const format = numberFormatter(formatOption)
  const reading = `${format(current)} / ${format(goal)}`
  const at = (value: number) => `${Math.round(clamp(percentOf(value, goal), 0, 100) * 100) / 100}%`
  const hasMilestoneLabels = milestones?.some((milestone) => milestone.label) ?? false
  // The bar is the progressbar, so every way of naming one lands on the bar and
  // not on the root: an explicit `aria-label`, then the plain-text twin of a
  // markup label, then a label that is already plain text.
  const barLabel = ariaLabel ?? labelText ?? (typeof label === "string" ? label : undefined)

  return (
    <div
      data-slot="goal-tracker"
      data-tone={resolvedTone}
      className={cn("flex w-full flex-col gap-2", className)}
      {...props}
    >
      {/* A goal label is often a sentence, so it wraps rather than clipping to
          one line; the reading rides the first line beside it while there is
          room and drops to its own right-aligned row when there is not. */}
      <div
        data-slot="goal-tracker-header"
        className="flex flex-wrap items-baseline gap-x-3 gap-y-0.5 text-sm"
      >
        <span data-slot="goal-tracker-label" className="min-w-0 text-muted-foreground">
          {label}
        </span>
        <span
          data-slot="goal-tracker-reading"
          className="ms-auto flex shrink-0 items-baseline gap-3"
        >
          <span data-slot="goal-tracker-value" className="font-medium tabular-nums">
            {format(current)}
            <span className="font-normal text-muted-foreground">{` / ${format(goal)}`}</span>
          </span>
          <span
            data-slot="goal-tracker-percent"
            className="min-w-10 text-end font-medium tabular-nums"
          >{`${percent}%`}</span>
        </span>
      </div>

      <div
        data-slot="goal-tracker-track"
        role="progressbar"
        aria-label={barLabel}
        aria-labelledby={ariaLabelledBy}
        aria-valuenow={percent}
        aria-valuemin={0}
        aria-valuemax={100}
        aria-valuetext={reading}
        className="relative h-2 w-full overflow-hidden rounded-full bg-muted"
      >
        <div
          data-slot="goal-tracker-indicator"
          className={goalTrackerIndicatorVariants({ tone: resolvedTone })}
          style={{ width: `${percent}%` }}
        />
        {/* A tick has to read against whatever it crosses, and the two halves
            of the bar are opposites. Over the filled part, the page background
            cuts a notch — it inverts with the theme, so it stays light on a
            dark fill and dark on a light one. Over the muted track it would be
            all but invisible (oklch 0.99 on 0.96 in light), so an unreached
            milestone is drawn from the foreground instead: dark on the light
            track, light on the dark one. Unreached is the case a reader most
            needs to see, so it must not be the faint one. */}
        {milestones?.map((milestone, index) => (
          <span
            key={index}
            data-slot="goal-tracker-milestone"
            aria-hidden="true"
            className={cn(
              "absolute inset-y-0 w-px",
              milestone.value <= current ? "bg-background" : "bg-foreground/30"
            )}
            style={{ insetInlineStart: at(milestone.value) }}
          />
        ))}
      </div>

      {hasMilestoneLabels ? (
        <div data-slot="goal-tracker-milestones" className="relative h-4">
          {milestones?.map((milestone, index) =>
            milestone.label ? (
              <span
                key={index}
                data-slot="goal-tracker-milestone-label"
                className="absolute -translate-x-1/2 text-xs whitespace-nowrap text-muted-foreground rtl:translate-x-1/2"
                style={{ insetInlineStart: at(milestone.value) }}
              >
                {milestone.label}
              </span>
            ) : null
          )}
        </div>
      ) : null}

      {deadline ? (
        <div
          data-slot="goal-tracker-footer"
          className="flex items-center gap-1.5 text-xs text-muted-foreground"
        >
          <CalendarIcon aria-hidden="true" className="size-3.5 shrink-0" />
          <span>{deadline}</span>
        </div>
      ) : null}
    </div>
  )
}

export { GoalTracker, goalTrackerIndicatorVariants }