A line chart with four curves, dots or value labels, dashed series, labels at the end of each line instead of a legend and gaps for missing data, in a frame with a table view, a toggling legend, patterns and states.
Accounts
Active and trial, January to June
"use client"
import { ChartLine } from "@/components/ballmac/chart-line"
const data = [
{ month: "Jan", active: 8420, trial: 2110 },
{ month: "Feb", active: 9130, trial: 2540 },
{ month: "Mar", active: 9780, trial: 2380 },
{ month: "Apr", active: 10940, trial: 2960 },
{ month: "May", active: 12210, trial: 3320 },
{ month: "Jun", active: 13650, trial: 3710 },
]
export default function ChartLineDemo() {
return (
<ChartLine
className="w-full max-w-2xl"
title="Accounts"
description="Active and trial, January to June"
data={data}
xKey="month"
categoryLabel="Month"
series={[
{ key: "active", label: "Active" },
{ key: "trial", label: "Trial", color: "var(--chart-3)" },
]}
/>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/chart-lineInstall the dependencies.
$ pnpm add recharts@^3Add the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/chart @ballmac/chart-frameCopy the source into your project.
components/ballmac/chart-line.tsx// Ballmac UI: Chart Line. https://ui.ballmac.com/components/chart-line "use client" import * as React from "react" import { CartesianGrid, LabelList, Line, LineChart, ReferenceLine, usePlotArea, useXAxisScale, useYAxisScale, XAxis, YAxis } from "recharts" import { ChartTooltip, ChartTooltipContent } from "@/components/ballmac/chart" import { ChartFrame, domainTicks, niceScale, type ChartFrameProps, type ChartRow, type ChartSeries } from "@/components/ballmac/chart-frame" type ChartLineProps = Omit<ChartFrameProps, "children" | "summaryMode" | "skeleton" | "table"> & { /** How the line moves between points. */ curve?: "monotone" | "linear" | "step" | "natural" /** Draw a dot on every point. */ dots?: boolean /** Series keys drawn dashed, for forecasts or targets: dashes tell lines apart without color. */ dashed?: string[] /** Write each series' name at the end of its line, so people read it without finding the legend. */ directLabels?: boolean /** Write the value on every point. */ labels?: boolean /** Keep the gap where a value is missing instead of joining the points either side. */ gaps?: boolean grid?: "horizontal" | "both" | "none" yAxis?: boolean yFormatter?: (value: number) => string yDomain?: [number | "auto", number | "auto"] referenceLines?: { y: number; label: string }[] tooltip?: "line" | "dot" | "dashed" | "none" } const DASH = "6 5" /** Each line's name at its last point, pushed apart where two lines end close together. Lives inside the chart to read its scales. */ function EndLabels({ data, xKey, series, rtl, color }: { data: ChartRow[]; xKey: string; series: ChartSeries[]; rtl: boolean; color: (key: string) => string }) { const xScale = useXAxisScale() const yScale = useYAxisScale() const plot = usePlotArea() const last = data.at(-1) if (!xScale || !yScale || !plot || !last) return null const x = xScale(last[xKey] as never) if (typeof x !== "number") return null const gap = 15 const items = series .map((s) => ({ s, y: yScale(last[s.key] as never) })) .filter((i): i is { s: ChartSeries; y: number } => typeof i.y === "number" && Number.isFinite(i.y)) .sort((a, b) => a.y - b.y) const placed = items.map((i) => ({ ...i, at: i.y })) for (let i = 1; i < placed.length; i++) if (placed[i]!.at - placed[i - 1]!.at < gap) placed[i]!.at = placed[i - 1]!.at + gap const bottom = plot.y + plot.height for (let i = placed.length - 1; i >= 0; i--) { const limit = i === placed.length - 1 ? bottom : placed[i + 1]!.at - gap if (placed[i]!.at > limit) placed[i]!.at = limit } return ( <g aria-hidden="true"> {placed.map(({ s, at }) => ( <text key={s.key} x={rtl ? x - 10 : x + 10} y={at} dy={4} textAnchor={rtl ? "end" : "start"} className="text-xs font-medium" fill={color(s.key)}> {s.label} </text> ))} </g> ) } /** * A line chart in a ChartFrame: four curves, dots or value labels, dashed series, labels at the end of each line instead of a legend, a table view, * a toggling legend, patterns (as dash styles), loading, empty and error states and mirroring in right-to-left. */ function ChartLine({ data, xKey, series, curve = "monotone", dots = false, dashed = [], directLabels = false, labels = false, gaps = false, grid = "horizontal", yAxis = true, yFormatter, yDomain, referenceLines, tooltip = "line", legend, ...frame }: ChartLineProps) { return ( <ChartFrame data={data} xKey={xKey} series={series} summaryMode="trend" skeleton="lines" legend={legend ?? (directLabels ? "none" : undefined)} {...frame}> {(chart) => { const compact = new Intl.NumberFormat(chart.locale, { notation: "compact", maximumFractionDigits: 1 }) const last = data.length - 1 const scale = yDomain ? undefined : niceScale({ data, keys: chart.visible, zero: false }) return ( <LineChart data={data} margin={chart.margin({ top: labels ? 22 : 8, right: directLabels ? 88 : 12, left: 0, bottom: 0 })} accessibilityLayer> <defs>{chart.defs}</defs> <CartesianGrid vertical={grid === "both"} horizontal={grid !== "none"} /> <XAxis dataKey={xKey} tickLine={false} axisLine={false} tickMargin={8} minTickGap={24} reversed={chart.rtl} tickFormatter={(v: unknown) => chart.formatCategory(v)} padding={{ left: 8, right: 8 }} /> {yAxis ? <YAxis width="auto" tickLine={false} axisLine={false} tickMargin={8} orientation={chart.rtl ? "right" : "left"} domain={yDomain ?? scale?.domain} ticks={scale?.ticks ?? domainTicks(yDomain)} tickFormatter={(v: number) => (yFormatter ? yFormatter(v) : compact.format(v))} /> : null} {referenceLines?.map((r) => ( <ReferenceLine key={r.label} y={r.y} stroke="var(--muted-foreground)" strokeDasharray="4 4" strokeOpacity={0.7} label={{ value: r.label, position: chart.rtl ? "insideTopLeft" : "insideTopRight", fill: "var(--muted-foreground)", fontSize: 11 }} /> ))} {tooltip !== "none" ? <ChartTooltip cursor={{ strokeDasharray: "3 3" }} content={<ChartTooltipContent indicator={tooltip} />} /> : null} {series.map((s, i) => ( <Line key={s.key} dataKey={s.key} type={curve} hide={chart.isHidden(s.key)} stroke={chart.color(s.key)} strokeWidth={2.25} // with patterns on, lines are told apart by their dashes strokeDasharray={dashed.includes(s.key) ? DASH : chart.patterns && i % 2 === 1 ? "2 4" : undefined} strokeLinecap="round" dot={dots ? { r: 3, strokeWidth: 2, fill: "var(--card)" } : false} activeDot={{ r: 4.5 }} connectNulls={!gaps} isAnimationActive={chart.animate} animationDuration={700} > {labels ? <LabelList dataKey={s.key} position="top" offset={8} className="fill-foreground text-xs" formatter={(v: unknown) => (typeof v === "number" ? chart.format(v, s.key) : "")} /> : null} </Line> ))} {directLabels ? <EndLabels data={data} xKey={xKey} series={series.filter((s) => !chart.isHidden(s.key))} rtl={chart.rtl} color={chart.color} /> : null} </LineChart> ) }} </ChartFrame> ) } export { ChartLine, type ChartLineProps }Update the import paths to match your project setup.
Usage
import { ChartLine } from "@/components/ballmac/chart-line"The full example is in the Code tab above.
Examples
Linear with dots
Sprint velocity
Story points completed
"use client"
import { ChartLine } from "@/components/ballmac/chart-line"
const data = [
{ sprint: "S12", points: 31 },
{ sprint: "S13", points: 36 },
{ sprint: "S14", points: 29 },
{ sprint: "S15", points: 41 },
{ sprint: "S16", points: 44 },
{ sprint: "S17", points: 39 },
{ sprint: "S18", points: 47 },
]
export default function ChartLineDots() {
return (
<ChartLine
className="w-full max-w-2xl"
title="Sprint velocity"
description="Story points completed"
data={data}
xKey="sprint"
categoryLabel="Sprint"
curve="linear"
dots
grid="both"
series={[{ key: "points", label: "Points", color: "var(--chart-2)" }]}
yDomain={[20, 50]}
/>
)
}Actual against plan
Revenue against plan
The dashed line is the plan
"use client"
import { ChartLine } from "@/components/ballmac/chart-line"
const data = [
{ month: "Jul", actual: 21400, plan: 21000 },
{ month: "Aug", actual: 23800, plan: 23500 },
{ month: "Sep", actual: 26100, plan: 26000 },
{ month: "Oct", actual: 28900, plan: 28500 },
{ month: "Nov", actual: 30200, plan: 31000 },
{ month: "Dec", actual: 33600, plan: 33500 },
]
export default function ChartLineDashed() {
return (
<ChartLine
className="w-full max-w-2xl"
title="Revenue against plan"
description="The dashed line is the plan"
data={data}
xKey="month"
categoryLabel="Month"
series={[
{ key: "actual", label: "Actual" },
{ key: "plan", label: "Plan", color: "var(--muted-foreground)" },
]}
dashed={["plan"]}
yFormatter={(v) => `$${v / 1000}k`}
valueFormatter={(v) => `$${v.toLocaleString("en-US")}`}
/>
)
}Labels on the lines
Customers by office
Hundreds, labeled on the lines instead of in a legend
"use client"
import { ChartLine } from "@/components/ballmac/chart-line"
const data = [
{ year: "2021", oslo: 41, lima: 33, pune: 27, reno: 22 },
{ year: "2022", oslo: 44, lima: 36, pune: 31, reno: 24 },
{ year: "2023", oslo: 47, lima: 42, pune: 38, reno: 25 },
{ year: "2024", oslo: 49, lima: 47, pune: 46, reno: 27 },
{ year: "2025", oslo: 52, lima: 51, pune: 55, reno: 28 },
]
export default function ChartLineDirect() {
return (
<ChartLine
className="w-full max-w-2xl"
title="Customers by office"
description="Hundreds, labeled on the lines instead of in a legend"
data={data}
xKey="year"
categoryLabel="Year"
directLabels
curve="monotone"
series={[
{ key: "oslo", label: "Oslo" },
{ key: "lima", label: "Lima" },
{ key: "pune", label: "Pune" },
{ key: "reno", label: "Reno" },
]}
/>
)
}Value labels
Net promoter score
Weekly survey
"use client"
import { ChartLine } from "@/components/ballmac/chart-line"
const data = [
{ week: "W1", nps: 31 },
{ week: "W2", nps: 34 },
{ week: "W3", nps: 33 },
{ week: "W4", nps: 38 },
{ week: "W5", nps: 42 },
{ week: "W6", nps: 41 },
]
export default function ChartLineLabels() {
return (
<ChartLine
className="w-full max-w-xl"
title="Net promoter score"
description="Weekly survey"
data={data}
xKey="week"
categoryLabel="Week"
labels
dots
yAxis={false}
grid="none"
yDomain={[20, 50]}
series={[{ key: "nps", label: "NPS" }]}
/>
)
}Step
Plan price
Changes only when the price changes
"use client"
import { ChartLine } from "@/components/ballmac/chart-line"
const data = [
{ month: "Jan", price: 9 },
{ month: "Mar", price: 9 },
{ month: "Apr", price: 12 },
{ month: "Jul", price: 12 },
{ month: "Aug", price: 15 },
{ month: "Nov", price: 15 },
{ month: "Dec", price: 19 },
]
export default function ChartLineStep() {
return (
<ChartLine
className="w-full max-w-2xl"
title="Plan price"
description="Changes only when the price changes"
data={data}
xKey="month"
categoryLabel="Month"
curve="step"
yFormatter={(v) => `$${v}`}
valueFormatter={(v) => `$${v}`}
yDomain={[0, 20]}
series={[{ key: "price", label: "Price", color: "var(--chart-4)" }]}
/>
)
}Gaps for missing data
Cold room temperature
The sensor was offline on Wednesday and Thursday
"use client"
import { ChartLine } from "@/components/ballmac/chart-line"
const data = [
{ day: "Mon", sensor: 21.4 },
{ day: "Tue", sensor: 22.1 },
{ day: "Wed", sensor: null },
{ day: "Thu", sensor: null },
{ day: "Fri", sensor: 23.6 },
{ day: "Sat", sensor: 22.8 },
{ day: "Sun", sensor: 21.9 },
]
export default function ChartLineGaps() {
return (
<ChartLine
className="w-full max-w-2xl"
title="Cold room temperature"
description="The sensor was offline on Wednesday and Thursday"
data={data}
xKey="day"
categoryLabel="Day"
gaps
dots
yDomain={[20, 25]}
yFormatter={(v) => `${v}°`}
valueFormatter={(v) => `${v}°C`}
series={[{ key: "sensor", label: "Temperature", color: "var(--chart-5)" }]}
/>
)
}Five series with patterns
Latency by region
Milliseconds. Turn series off in the legend, or switch Patterns on
"use client"
import { ChartLine } from "@/components/ballmac/chart-line"
const data = [
{ hour: "00", nyc: 82, fra: 71, sin: 164, gru: 142, syd: 188 },
{ hour: "04", nyc: 79, fra: 69, sin: 158, gru: 139, syd: 181 },
{ hour: "08", nyc: 96, fra: 88, sin: 171, gru: 151, syd: 196 },
{ hour: "12", nyc: 118, fra: 104, sin: 182, gru: 163, syd: 204 },
{ hour: "16", nyc: 124, fra: 112, sin: 176, gru: 158, syd: 199 },
{ hour: "20", nyc: 101, fra: 93, sin: 168, gru: 147, syd: 190 },
]
export default function ChartLineCompare() {
return (
<ChartLine
className="w-full max-w-2xl"
title="Latency by region"
description="Milliseconds. Turn series off in the legend, or switch Patterns on"
data={data}
xKey="hour"
categoryLabel="Hour (UTC)"
patterns="toggle"
series={[
{ key: "nyc", label: "New York" },
{ key: "fra", label: "Frankfurt" },
{ key: "sin", label: "Singapore" },
{ key: "gru", label: "São Paulo" },
{ key: "syd", label: "Sydney" },
]}
valueFormatter={(v) => `${v} ms`}
/>
)
}API reference
| Prop | Type | Default |
|---|---|---|
curveHow the line moves between points. | "monotone" | "linear" | "step" | "natural" | "monotone" |
dotsDraw a dot on every point. | boolean | false |
dashedSeries keys drawn dashed, for forecasts or targets: dashes tell lines apart without color. | string[] | [] |
directLabelsWrite each series' name at the end of its line, so people read it without finding the legend. | boolean | false |
labelsWrite the value on every point. | boolean | false |
gapsKeep the gap where a value is missing instead of joining the points either side. | boolean | false |
grid | "horizontal" | "both" | "none" | "horizontal" |
yAxis | boolean | true |
yFormatter | (value: number) => string | — |
yDomain | [number | "auto", number | "auto"] | — |
referenceLines | { y: number; label: string }[] | — |
tooltip | "line" | "dot" | "dashed" | "none" | "line" |
y* | number | — |
label* | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Arrow keys | Move between data points (accessibilityLayer) |
| Tab, Enter / Space | Legend buttons switch series; the Chart / Table switch shows the numbers as a table |
| Dashes | Dashed lines tell series apart without color; patterns add dash styles to every other line |
Quality scorecard
Every applicable check passes
Measured in Chromium 153.0.8010.12 on October 19, 2026, on version 1.0.0, across 8 examples.
Passed: Accessibility scan
No serious or critical issues in light and dark, on a phone and a desktop (axe-core 4.13.0, WCAG 2.1 AA and best-practice rules).
Passed: Small screens
No sideways scrolling at 360, 390 or 1280 px wide.
Passed: Reduced motion
No endless animation to begin with.
Not applicable: Right to left
Not mirrored by design: Recharts draws its axes in SVG coordinates, which do not mirror.
Passed: Clean console
No console errors or React hydration errors while loading.
Passed: Keyboard
26 tab stops, each showing a visible change when it takes focus. 3 documented keys.
Not applicable: Loading, empty and error
Does not show data or take submissions.
Passed: Automated tests
Covered by charts-batch-families.test.tsx.
For information: Footprint
2.5 KB of source (gzip) in 1 file. Installs recharts.
Automated checks find a subset of problems and do not replace testing with a screen reader and a keyboard in your own app. What they miss.
Use with AI
<ChartLine title description data xKey series={[{ key, label, color? }]} />. curve: monotone | linear | step | natural. Also dots, dashed (series keys), directLabels (write names at the line ends and drop the legend), labels (values on points), gaps (null breaks the line), yFormatter, yDomain, referenceLines, legend, patterns, state and every ChartFrame prop. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Chart Line (@ballmac/chart-line) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Trends where the exact path matters and fills would get in the way
- Several series that cross (use directLabels)
- Forecast against actual (dashed)
Not for
- Part-to-whole over time (use chart-area with stack)
- Comparing a few categories (use chart-bar)
Registry JSON: https://ui.ballmac.com/r/chart-line.json
Credits
Free to use in personal and commercial projects.
- npm
- recharts@^3
Pairs well with
Chart Frame
The card around a Recharts chart: a Table view with CSV download, a legend that toggles series, patterns so series differ by more than color, a summary for screen readers, and loading, empty and error states.
Stat Card
A metric card with a clear comparison trend, optional visual, and readable value hierarchy.
Segmented Control
A macOS segmented control: mutually exclusive options with a selected pill that springs between segments and hairline dividers between unselected ones. A Radix radio group underneath.
Activity Feed
A compact actor and action feed with timestamps and an empty state.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.