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-trackerNeeds the @vibra registry in your components.json — set it up once.
Examples
import { GoalTracker } from "@/components/ui/goal-tracker"
import { formatCurrency } from "@/lib/format"
const money = (value: number) => formatCurrency(value, "USD", { compact: true, maximumFractionDigits: 1 })
export default function GoalTrackerDemo() {
return (
<div className="w-full max-w-sm panel p-6">
<GoalTracker
label="New ARR this year"
current={1284000}
goal={2000000}
format={money}
milestones={[
{ value: 500000, label: "Q1" },
{ value: 1000000, label: "Q2" },
{ value: 1500000, label: "Q3" },
]}
deadline="Ends Dec 31"
/>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label | React.ReactNode | — | What the goal is for, e.g. "New ARR this year". Wraps rather than clipping, so a whole sentence stays readable. |
| labelText | string | — | 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. |
| current | number | — | How far along the goal is. |
| goal | number | — | The number being worked toward. |
| format | ((n: number) => string) | (Intl.NumberFormatOptions & { locale?: string }) | whole units with separators | Formats 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. |
| deadline | React.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
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 }