Chart
One series over time with a frame: a labelled scale with gridlines, labelled periods, a dashed target line, gaps for unknown values, and each value on hover.
Installation
$
npx shadcn@latest add https://ui.mattdowney.com/r/chart.jsonUsage
import { Chart } from "@/components/ui/chart"Examples
Default
A line for a level over time. The newest point carries a dot; hover any column for its value.
Followers
540
+28 this week510
520
530
540
550
0Oct 6Oct 7Oct 8Oct 9Oct 10Oct 11Oct 12
Bars with a goal
Bars for a count per period, measured from zero. The dashed line is the goal, named in the key above the plot.
Sign-ups per week
39
Goal: 35 a weekGoal 35
0
20
40
60
0Aug 31Sep 14Sep 28Oct 12
Unknown values
A day with no data is bridged with a dotted line instead of dropped to zero, so a missed export never reads as a crash.
Conversion
24.0%
Target: 25%Target 25%
18%
20%
22%
24%
26%
0Oct 6Oct 7Oct 8Oct 9Oct 10Oct 11Oct 12
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| data* | { label: string; value: number | null }[] | - | The series, oldest first. A null value is a gap. |
| label* | string | - | Accessible name: what the series is. |
| type | "line" | "bar" | "line" | bar for a count per period, measured from zero. line for a level over time. |
| reference | { value: number; label: string } | null | - | A dashed target or milestone line, named in a key above the plot. |
| format | (value: number) => string | grouped integer | Formats values on the axis and in the hover readout. |
| data*{ label: string; value: number | null }[]The series, oldest first. A null value is a gap. |
| label*stringAccessible name: what the series is. |
type"line" | "bar" = "line"bar for a count per period, measured from zero. line for a level over time. |
| reference{ value: number; label: string } | nullA dashed target or milestone line, named in a key above the plot. |
| format(value: number) => string = grouped integerFormats values on the axis and in the hover readout. |
import * as React from "react"
import { cn } from "@/registry/lib/utils"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/registry/ui/tooltip"
// Chart: one series over time, with the frame Sparkline leaves out on purpose.
// Sparkline is a glance inside a card; this is the chart you READ: a
// labelled scale with a hairline at each step, labelled periods, a dashed
// target line, a dotted bridge where a value is unknown, and the value of any
// period under the pointer.
//
// It keeps Sparkline's two baseline rules, because they are about what a mark
// means rather than about size. A bar's length is the value, so bars measure
// from zero. A line is the shape of a change, so lines measure the min-to-max
// band (padded, and never below zero for a series that never goes below it).
//
// No "use client": nothing here holds state. The hover readout is the kit's
// Tooltip, which carries its own client boundary, so a Server Component can
// render a Chart as long as it does not pass `format`, a function.
type ChartType = "line" | "bar"
export interface ChartPoint {
/** The period's name, e.g. "Oct 6". Shown on the axis ends and in the readout. */
label: string
/** The value, or null when it is unknown. Null is a gap, never a zero. */
value: number | null
}
// The line is a 100 x 100 viewBox stretched to the plot with
// preserveAspectRatio="none", so x and y are percentages of the plot box and
// every stroke carries vector-effect="non-scaling-stroke" to stay round. Same
// deal as Sparkline's line, for the same reason: a percentage-width plot has
// no pixel width at render time.
const VIEW = 100
// 2px, the dataviz line weight. Lighter than Sparkline's 2.5: there the line
// is the whole card's subject at 40px tall, here it sits in a labelled frame
// nearly four times that height and the frame carries part of the weight.
const STROKE_WIDTH = 2
// A zero-length round-capped stroke paints a true circle in a stretched
// viewBox, where a <circle> would turn into an ellipse. 8px: a marker under
// that reads as a kink in the line rather than a reading.
const DOT_SIZE = 8
// The bridge over a gap: a round-capped 0-length dash every 5px paints a row
// of dots the width of the line.
const GAP_DASH = "0 5"
// The bar takes 60% of its column at every width, Sparkline's ratio.
const BAR_RATIO = 0.6
// How many steps the scale is divided into when picking round axis ends.
const TICKS = 4
// More figures than this on the scale and every other one is dropped.
const MAX_TICKS = 6
// About this many period labels fit under a card-width chart.
const MAX_PERIOD_LABELS = 7
function defaultFormat(value: number) {
return value.toLocaleString("en-US")
}
/** A step of 1, 2, 2.5 or 5 times a power of ten, so the axis ends read as
* round numbers (0 to 50, 15% to 30%) rather than the data's own extremes. */
function niceStep(span: number) {
const raw = span / TICKS
const mag = 10 ** Math.floor(Math.log10(raw))
const unit = raw / mag
const nice = unit <= 1 ? 1 : unit <= 2 ? 2 : unit <= 2.5 ? 2.5 : unit <= 5 ? 5 : 10
return nice * mag
}
function domain(values: number[], type: ChartType, reference: number | null) {
const all = reference === null ? values : [...values, reference]
let lo = Math.min(...all)
let hi = Math.max(...all)
if (type === "bar") {
lo = Math.min(0, lo)
hi = Math.max(0, hi)
} else if (hi === lo) {
const pad = Math.abs(hi) * 0.1 || 1
lo = lo >= 0 ? Math.max(0, lo - pad) : lo - pad
hi += pad
}
// A series of whole numbers (followers, counts) never gets a fractional
// step, or a flat week of 3,599 reads 3,598.75 to 3,600.25 on the axis.
const whole = all.every(Number.isInteger)
let step = niceStep(hi - lo || 1)
if (whole && !Number.isInteger(step)) step = step < 1 ? 1 : step * 2
// Snapped outward to the step, so the top mark never touches the frame.
// Floating point is cleaned up so 0.15000000000000002 reads as 0.15.
const snap = (v: number) => Number(v.toPrecision(12))
lo = snap(Math.floor(lo / step) * step)
hi = snap(Math.ceil(hi / step) * step)
if (hi === Math.max(...all) && type === "line") hi = snap(hi + step)
if (lo === Math.min(...all) && type === "line" && lo !== 0) lo = snap(lo - step)
if (hi === lo) hi = snap(lo + step)
return { lo, hi, step }
}
// @use-when a series over time you read rather than glance at: a labelled
// frame with a labelled scale and gridlines, labelled periods, an optional
// dashed target line, gaps for unknown values, and each period's value on
// hover. For a glance inside a card with no frame, use Sparkline.
function Chart({
data,
type = "line",
reference,
format = defaultFormat,
label,
className,
...props
}: Omit<React.ComponentProps<"div">, "children"> & {
/** The series, oldest first. */
data: ChartPoint[]
/** `bar` for a count per period, measured from zero. `line` for a level over time. */
type?: ChartType
/** A dashed target or milestone line, named in a key above the plot. */
reference?: { value: number; label: string } | null
/** Formats a value for the axis and the readout. Defaults to a grouped integer. */
format?: (value: number) => string
/** Accessible name: what the series is, e.g. "Followers per day". */
label: string
}) {
const known = data.map((d) => d.value).filter((v): v is number => v !== null)
const ref = reference ?? null
// Nothing known still renders the full frame, so a card does not jump when
// the first value arrives. The caller decides whether to say "No data yet".
const { lo, hi, step } =
known.length > 0 ? domain(known, type, ref?.value ?? null) : { lo: 0, hi: 1, step: 1 }
const pitch = data.length > 0 ? VIEW / data.length : VIEW
const cx = (i: number) => (i + 0.5) * pitch
// Percent from the TOP of the plot.
const top = (v: number) => (1 - (v - lo) / (hi - lo)) * VIEW
const zero = top(Math.max(lo, Math.min(0, hi)))
const baseline = type === "bar" ? zero : VIEW
// Line runs, broken at every null.
const runs: { i: number; v: number }[][] = []
let run: { i: number; v: number }[] = []
data.forEach((d, i) => {
if (d.value === null) {
if (run.length) runs.push(run)
run = []
} else run.push({ i, v: d.value })
})
if (run.length) runs.push(run)
// Every step of the scale gets a figure and a hairline across the plot, the
// way a chart people read is drawn: two ends alone made the reader estimate
// every value in between. Past MAX_TICKS the labels thin to every other step,
// so a whole-number scale that doubled its step stays readable.
const ticks: number[] = []
if (known.length > 0) {
for (let v = lo; v <= hi + step / 2; v += step) ticks.push(Number(v.toPrecision(12)))
}
const shown =
ticks.length > MAX_TICKS ? ticks.filter((_, i) => (ticks.length - 1 - i) % 2 === 0) : ticks
// Periods are labelled under their own column, thinned to about
// MAX_PERIOD_LABELS and counted back from the newest so it always shows.
const every = Math.max(1, Math.ceil(data.length / MAX_PERIOD_LABELS))
const periodShown = (i: number) => (data.length - 1 - i) % every === 0
// On a narrow chart every other one of those drops too, newest kept.
const periodNarrowHidden = (i: number) => ((data.length - 1 - i) / every) % 2 === 1
// The key row exists only with a reference, so the plot and the period
// labels sit one row lower when it does.
const plotRow = ref ? "row-start-2" : "row-start-1"
const periodRow = ref ? "row-start-3" : "row-start-2"
// role="img" hides everything inside from a screen reader, and the hover
// readout is pointer-only, so the name carries the reading: where the
// series starts and ends, any unknown periods, and the reference.
const firstKnown = data.find((d) => d.value !== null)
const lastKnown = [...data].reverse().find((d) => d.value !== null)
const missing = data.length - known.length
const summary = [
label,
firstKnown && lastKnown
? `${firstKnown.label} ${format(firstKnown.value!)} to ${lastKnown.label} ${format(lastKnown.value!)}`
: "No data",
missing > 0 && known.length > 0
? `${missing} ${missing === 1 ? "period" : "periods"} unknown`
: null,
ref ? `${ref.label}` : null,
]
.filter(Boolean)
.join(". ")
return (
<div
data-slot="chart"
data-type={type}
role="img"
aria-label={summary}
// The HEIGHT lives on the plot (`min-h-40`), not on the root. A fixed
// root height made the labels and the key eat into the plot, so the
// same chart drew 136px of data without a key and 116px with one. A
// root height from `className` still works: the plot row is `1fr` and
// grows past its minimum.
//
// 8px between the plot and its labels, both ways. Each scale
// figure centres on its gridline, and the outer two sit on the edges, so half an 11px label line
// (about 7.4px) hangs past the plot. At 4px the bottom figure overlapped
// the first period's row and the top one overlapped the key.
className={cn(
"text-accent @container/chart grid w-full grid-cols-[auto_1fr] gap-2",
// A row is only laid out for the key when there is one: an empty
// track still takes a gap, which is 8px of nothing above the plot.
// Without the key, 8px of top padding holds the overhang of the
// scale's top figure inside the chart's own box instead.
ref ? "grid-rows-[auto_1fr_auto]" : "grid-rows-[1fr_auto] pt-2",
className
)}
{...props}
>
{/* The key for the reference line. Above the plot rather than on the
line, where it collides with any mark that reaches that height. Only
rendered when there is a reference, so a plain chart carries no empty
row above it; still rendered before the data arrives, so the card
does not jump when it does. */}
{ref && (
<div
aria-hidden="true"
className="text-muted-foreground col-start-2 flex min-h-4 items-center justify-end gap-1.5 text-2xs"
>
{known.length > 0 && (
<>
<span className="border-border-strong w-3 border-t border-dashed" />
<span className="tabular-nums">{ref.label}</span>
</>
)}
</div>
)}
{/* The scale. Each figure centres on its own hairline. The figures are
positioned, so an invisible copy in flow gives the column its width.
Text keeps the text tokens; only the marks take the series colour. */}
<div
aria-hidden="true"
className={cn(
"text-subtle-foreground relative col-start-1 text-right text-2xs tabular-nums",
plotRow
)}
>
<div className="invisible h-0 overflow-hidden">
{shown.map((v) => (
<div key={v}>{format(v)}</div>
))}
</div>
{shown.map((v) => (
<span
key={v}
className="absolute right-0 -translate-y-1/2 whitespace-nowrap"
style={{ top: `${top(v)}%` }}
>
{format(v)}
</span>
))}
</div>
<div data-slot="chart-plot" className={cn("relative col-start-2 min-h-48 min-w-0", plotRow)}>
{/* Hover columns sit UNDER the marks, one per period and the full
height, so pointing anywhere in the column is enough. A bar's
column tints behind it. A line's column draws a hairline guide
down the period and a dot on its value instead, because a tinted
band behind a thin line reads as a selected row, not a reading. */}
<div className="absolute inset-0 flex">
{data.map((d, i) => (
<Tooltip key={i}>
<TooltipTrigger
render={<div aria-hidden="true" />}
className={cn(
"group/col relative h-full flex-1",
type === "bar" && "hover:bg-muted rounded-xs"
)}
>
{type === "line" && (
<>
<span className="bg-border-strong absolute inset-y-0 left-1/2 hidden w-px group-hover/col:block" />
{d.value !== null && (
<span
className="absolute left-1/2 hidden size-2.5 -translate-1/2 rounded-full bg-current group-hover/col:block"
style={{ top: `${top(d.value)}%` }}
/>
)}
</>
)}
</TooltipTrigger>
{/* The period above, the reading under it: the reading is what
the reader came for, so it takes the weight. */}
<TooltipContent side="top">
<span className="block font-normal">{d.label}</span>
<span className="block font-semibold tabular-nums">
{d.value === null ? "No data" : format(d.value)}
</span>
</TooltipContent>
</Tooltip>
))}
</div>
{shown.map((v) => (
<div
key={v}
aria-hidden="true"
className="bg-border pointer-events-none absolute inset-x-0 h-px"
style={{ top: `${top(v)}%` }}
/>
))}
{/* The baseline is a step stronger than the grid: it is where a bar
starts, or the floor of the scale. */}
<div
aria-hidden="true"
className="bg-border-strong pointer-events-none absolute inset-x-0 h-px"
style={{ top: `${baseline}%` }}
/>
{type === "bar" &&
data.map((d, i) => {
if (d.value === null) return null
const y = top(d.value)
const up = y <= zero
return (
<div
key={i}
aria-hidden="true"
className={cn(
"pointer-events-none absolute bg-current",
up ? "rounded-t-xs" : "rounded-b-xs"
)}
style={{
left: `${cx(i) - (pitch * BAR_RATIO) / 2}%`,
width: `${pitch * BAR_RATIO}%`,
top: `${up ? y : zero}%`,
height: `${Math.abs(zero - y)}%`,
}}
/>
)
})}
{type === "line" && known.length > 0 && (
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full overflow-visible"
viewBox={`0 0 ${VIEW} ${VIEW}`}
preserveAspectRatio="none"
>
{/* A gap is bridged, not left open. A line that simply stops reads
as a broken chart; a dotted span from the last known value to
the next one reads as "no data here" and keeps the series one
line. Dotted in the series colour, so it cannot be mistaken for
the reference line, which is dashed and grey. */}
{runs.slice(1).map((r, n) => {
const a = runs[n][runs[n].length - 1]
const b = r[0]
return (
<line
key={`gap-${n}`}
x1={cx(a.i)}
y1={top(a.v)}
x2={cx(b.i)}
y2={top(b.v)}
stroke="currentColor"
strokeWidth={STROKE_WIDTH}
strokeLinecap="round"
strokeDasharray={GAP_DASH}
vectorEffect="non-scaling-stroke"
/>
)
})}
{runs.map((r, n) => (
<g key={n}>
{r.length > 1 && (
<polyline
points={r.map((p) => `${cx(p.i)},${top(p.v)}`).join(" ")}
fill="none"
stroke="currentColor"
strokeWidth={STROKE_WIDTH}
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
)}
{/* A dot on a lone point, which would otherwise draw nothing,
and on the newest point of the last run. */}
{(r.length === 1 || n === runs.length - 1) && (
<line
x1={cx(r[r.length - 1].i)}
y1={top(r[r.length - 1].v)}
x2={cx(r[r.length - 1].i)}
y2={top(r[r.length - 1].v)}
stroke="currentColor"
strokeWidth={DOT_SIZE}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
)}
</g>
))}
</svg>
)}
{ref && known.length > 0 && (
<div
aria-hidden="true"
className="border-border-strong pointer-events-none absolute inset-x-0 border-t border-dashed"
style={{ top: `${top(ref.value)}%` }}
/>
)}
</div>
<div
aria-hidden="true"
className={cn("text-subtle-foreground relative col-start-2 text-2xs", periodRow)}
>
{/* Holds the row's height; every label is positioned. */}
<span className="invisible">0</span>
{data.map((d, i) =>
periodShown(i) ? (
<span
key={i}
className={cn(
"absolute top-0 -translate-x-1/2 whitespace-nowrap",
periodNarrowHidden(i) && "@max-md/chart:hidden"
)}
style={{ left: `${cx(i)}%` }}
>
{d.label}
</span>
) : null
)}
</div>
</div>
)
}
export { Chart }