Line Chart
Composable line charts with crosshair cursors, axis labels, adaptive ticks, and smooth curve interpolation.
Build line charts from composable primitives. Combine LineMark with cursors, tooltips, grids, and legends in any configuration.
Basic line chart
The simplest line chart needs a Chart wrapper, one LineMark, and axes. The xDataKey prop tells the chart which field to use for the X axis.
import { Chart, Cursor, Grid, LineMark, Tooltip, XAxis, YAxis } from "@exhibit/charts";
const data = [
{ month: "Jan", revenue: 4200 },
{ month: "Feb", revenue: 4800 },
// ...
];
<Chart data={data} xDataKey="month" height={300}>
<Grid horizontal />
<XAxis />
<YAxis tickFormat={(v) => `$${(v / 1000).toFixed(0)}k`} />
<LineMark dataKey="revenue" name="Revenue" color="var(--color-chart-1)" />
<Cursor />
<Tooltip />
</Chart>Multi-series
Add multiple LineMark components for multi-series. Each gets its own dataKey, color, and name. Add <Legend /> to let users toggle visibility, and <Cursor highlight /> to dim non-closest lines on hover.
<Chart data={data} xDataKey="month" height={350}>
<Grid horizontal />
<XAxis />
<YAxis />
<LineMark dataKey="desktop" name="Desktop" color="var(--color-chart-1)" dot />
<LineMark dataKey="mobile" name="Mobile" color="var(--color-chart-2)" dot />
<LineMark dataKey="tablet" name="Tablet" color="var(--color-chart-3)" dot />
<Cursor highlight />
<Tooltip />
<Legend />
</Chart>Curve interpolation
The curve prop controls how data points are connected. All five curve types are shown below -- linear draws straight segments, monotoneX produces smooth curves that never overshoot, and the step variants create staircase patterns useful for discrete or quantised data.
linear
monotoneX
step
stepBefore
// Smooth interpolation (no overshoot)
<LineMark dataKey="y" curve="monotoneX" />
// Step function — change happens at the midpoint
<LineMark dataKey="y" curve="step" />
// Step — change at the start of each interval
<LineMark dataKey="y" curve="stepBefore" />
// Step — change at the end of each interval
<LineMark dataKey="y" curve="stepAfter" />Step line for discrete data
Step interpolation is ideal for data that changes in discrete jumps -- server instance counts, pricing tiers, or deployment states.
<LineMark
dataKey="instances"
name="Active instances"
color="var(--color-chart-4)"
curve="step"
dot
strokeWidth={2.5}
/>Marker position
The markerPosition prop controls how the active dot tracks the cursor. All three modes use the same data, but produce different visual behaviour on hover.
interpolate
snap
absolute
| Mode | Behaviour |
|---|---|
"interpolate" | Eased CSS transition between data points. Default. |
"snap" | Animates along the actual line path using requestAnimationFrame. |
"absolute" | Locks onto the line at the cursor's exact X position. |
// Smooth slide between data points (default)
<LineMark dataKey="temperature" markerPosition="interpolate" />
// Dot walks along the polyline path
<LineMark dataKey="temperature" markerPosition="snap" />
// Dot follows mouse X exactly on the line
<LineMark dataKey="temperature" markerPosition="absolute" />Cursor marker
The Cursor component supports a marker prop that renders a dot where the cursor intersects the nearest series. Pass true for defaults (snap behaviour), or an object with className and behaviour for full control.
// Boolean — snap behaviour with default styling
<Cursor axis="x" marker />
<Cursor axis="y" marker />
// Object — custom behaviour and Tailwind classes
<Cursor axis="x" marker={{ behaviour: "absolute" }} />
<Cursor axis="y" marker={{ className: "fill-rose-500", behaviour: "interpolate" }} />The marker renders as an SVG <circle> with sensible default attributes (r=4, background fill, series-coloured stroke). Your className is merged via cn() -- Tailwind's CSS properties override the SVG presentation attributes, so classes like fill-rose-500 or stroke-3 work as expected.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | -- | Tailwind classes merged onto the marker circle |
behaviour | "interpolate" | "snap" | "absolute" | "snap" | How the marker tracks the cursor (same as LineMark.markerPosition) |
Dots and active dots
The dot prop shows dots at every data point. The activeDot prop (on by default) controls the hover dot. Both accept a boolean or a config object.
Default dots
Custom dots
// Default dots
<LineMark dataKey="y" dot />
// Custom dot and active dot styling
<LineMark
dataKey="y"
dot={{ r: 4, fill: "#fbbf24", stroke: "#f59e0b", strokeWidth: 2 }}
activeDot={{ r: 8, fill: "#f43f5e", strokeWidth: 3 }}
/>
// Hide the active dot entirely
<LineMark dataKey="y" activeDot={false} />Area chart
Use AreaMark instead of LineMark for filled area charts. It shares the same composable API -- layer multiple areas, combine with lines, or use alongside bar marks.
import { AreaMark, Chart, Cursor, Grid, Legend, Tooltip, XAxis, YAxis } from "@exhibit/charts";
<Chart data={data} xDataKey="month" height={350}>
<Grid horizontal />
<XAxis />
<YAxis />
<AreaMark dataKey="desktop" name="Desktop" color="var(--color-chart-1)" fillOpacity={0.15} />
<AreaMark dataKey="mobile" name="Mobile" color="var(--color-chart-2)" fillOpacity={0.15} />
<Cursor />
<Tooltip />
<Legend />
</Chart>Dual Y axes
Add a second YAxis with position="right" and bind marks to it via yAxisId="right". This is useful when series have different units or scales.
<Chart data={data} xDataKey="month" height={350} padding={{ left: 56, right: 56 }}>
<Grid horizontal />
<XAxis />
<YAxis label="Visitors" tickFormat={(v) => `${(v / 1000).toFixed(1)}k`} />
<YAxis position="right" label="Conversion" tickFormat={(v) => `${v}%`} domain={[0, 5]} />
<LineMark dataKey="visitors" name="Visitors" color="var(--color-chart-1)" strokeWidth={2.5} />
<LineMark
dataKey="conversion"
name="Conversion %"
color="var(--color-chart-3)"
yAxisId="right"
curve="monotoneX"
dot
markerPosition="snap"
/>
<Cursor />
<Tooltip>{/* custom tooltip */}</Tooltip>
<Legend />
</Chart>Custom tooltips
Pass a render function to <Tooltip> for full control over content. The function receives { datum, index } and returns JSX.
<Tooltip>
{({ datum }) => {
const latency = typeof datum.latency === "number" ? datum.latency : null;
const status = latency != null && latency > 60 ? "degraded" : "healthy";
return (
<TooltipShell className="min-w-44">
<div className="flex items-center justify-between gap-3">
<TooltipHeader>{String(datum.time)}</TooltipHeader>
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${
status === "degraded"
? "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400"
: "bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-400"
}`}>
{status}
</span>
</div>
<TooltipRow color="#ef4444" indicator="dot" label="Latency" unit="ms" value={latency} />
<TooltipRow color="var(--color-chart-1)" indicator="dot" label="Throughput" unit="req/s" value={datum.throughput} />
</TooltipShell>
);
}}
</Tooltip>Crosshair cursor with axis labels
Add two Cursor components with axis="x" and axis="y" to create a crosshair that tracks the mouse position independently on each axis. Pass label to display the active axis value inline.
<Chart data={data} xDataKey="time" height={450} padding={{ left: 56, right: 20 }}>
<Grid horizontal />
<XAxis />
<YAxis label="Power" tickFormat={(v) => `${v} W`} />
<LineMark
dataKey="electricity"
name="Electricity"
color="#16a34a"
curve="monotoneX"
dot
markerPosition="snap"
/>
<Cursor axis="x" label />
<Cursor axis="y" label marker />
<Tooltip>
{({ datum }) => (
<TooltipShell className="min-w-36">
<TooltipHeader>{String(datum.time)}</TooltipHeader>
<TooltipRow
color="#16a34a"
indicator="dot"
label="Electricity"
unit="W"
value={typeof datum.electricity === "number" ? datum.electricity : null}
/>
</TooltipShell>
)}
</Tooltip>
<Legend />
</Chart>Custom cursor labels
Pass a render function to label for full control over how the axis value is displayed.
// X-axis: custom styled label
<Cursor axis="x" label={(value) => (
<div className="rounded bg-blue-900 px-2 py-0.5 text-xs text-white font-mono">
{value}
</div>
)} />
// Y-axis: append a unit
<Cursor axis="y" label={(value) => (
<div className="rounded bg-blue-900 px-2 py-0.5 text-xs text-white font-mono">
{Number(value).toFixed(0)} W
</div>
)} />Multi-line with highlight
Hover the chart to see the closest line highlighted. Click legend items to toggle series.
<Chart data={data} xDataKey="month" height={350}>
<Grid horizontal />
<XAxis />
<YAxis />
<LineMark dataKey="revenue" name="Revenue" color="var(--color-chart-1)" dot markerPosition="snap" />
<LineMark dataKey="costs" name="Costs" color="var(--color-chart-2)" dot markerPosition="snap" />
<LineMark dataKey="profit" name="Profit" color="var(--color-chart-3)" dot markerPosition="snap" />
<Cursor highlight type="line" />
<Tooltip />
<Legend />
</Chart>Synced grid charts
Multiple Chart instances stacked vertically with a shared active index. Each panel has its own Y axis and scale, while the X axis, cursor, and tooltip stay in sync. Use useBrush for data zooming with scroll-to-zoom support.
Synced dual grid
Two panels sharing a cursor -- evaporation on a normal axis, rainfall with an inverted Y axis (0 at top). The activeIndex and onActiveIndexChange props on each Chart keep them in sync. useBrush handles view-range state, scroll-to-zoom, and data slicing.
import {
BrushSlider, Chart, Cursor, Grid, LineMark,
Tooltip, TooltipHeader, TooltipRow, TooltipShell,
useBrush, XAxis, YAxis,
} from "@exhibit/charts";
import { useState } from "react";
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const { visibleData, startIndex, endIndex, onBrushChange, containerRef } =
useBrush({ data, defaultZoom: { start: 30, end: 70 } });
<div ref={containerRef}>
<Chart
activeIndex={activeIndex}
onActiveIndexChange={setActiveIndex}
data={visibleData}
xDataKey="date"
height={280}
animate={false}
>
<Grid horizontal />
<XAxis />
<YAxis label="Evaporation (m³/s)" />
<LineMark dataKey="evaporation" color="var(--color-chart-1)" dot name="Evaporation" />
<Cursor />
<Tooltip>
{({ datum }) => (
<TooltipShell>
<TooltipHeader>{datum.date}</TooltipHeader>
<TooltipRow label="Evaporation" unit="m³/s" value={datum.evaporation} color="var(--color-chart-1)" />
<TooltipRow label="Rainfall" unit="mm" value={datum.rainfall} color="var(--color-chart-2)" />
</TooltipShell>
)}
</Tooltip>
</Chart>
<Chart
activeIndex={activeIndex}
onActiveIndexChange={setActiveIndex}
data={visibleData}
xDataKey="date"
height={280}
animate={false}
>
<Grid horizontal />
<XAxis />
<YAxis label="Rainfall (mm)" reversed />
<LineMark dataKey="rainfall" color="var(--color-chart-2)" dot name="Rainfall" />
<Cursor />
</Chart>
<BrushSlider
data={data}
dataKey="evaporation"
startIndex={startIndex}
endIndex={endIndex}
onChange={onBrushChange}
/>
</div>Three-grid layout
Add as many panels as you need. Each Chart instance manages its own Y axis independently.
Without brush
Drop BrushSlider and skip containerRef for a compact static view. useBrush still handles the initial zoom window via defaultZoom.
Custom styling
Override colours per panel and fix the Y domain with the domain prop on YAxis.
Inverted axes
Pass reversed to YAxis to flip the scale so that 0 is at the top and values grow downward. This is commonly used for rainfall, depth, or any metric where "more" should visually descend.
<YAxis label="Rainfall (mm)" reversed />Zoom and navigation
useBrush provides two zoom mechanisms:
- Brush slider -- drag the handles or the selected region on
BrushSliderto pan and zoom - Scroll-to-zoom -- scroll the mouse wheel over the chart container (requires attaching
containerRef)
const { visibleData, startIndex, endIndex, onBrushChange, containerRef } =
useBrush({
data,
defaultZoom: { start: 30, end: 70 },
minVisibleItems: 20, // zoom limit
zoomFactor: 0.08, // scroll sensitivity
});Syncing charts
The sync mechanism is explicit React state -- no magic syncId or global context. Each Chart receives the same activeIndex and calls onActiveIndexChange when the user hovers.
const [activeIndex, setActiveIndex] = useState<number | null>(null);
<Chart activeIndex={activeIndex} onActiveIndexChange={setActiveIndex} ...>
...
</Chart>
<Chart activeIndex={activeIndex} onActiveIndexChange={setActiveIndex} ...>
...
</Chart>The tooltip should live in one panel (typically the first) and render data from all series, since all charts share the same data array.
Ticks and scaling
Tick positions are computed using an algorithm aligned with d3's tickStep -- ticks always land on round multiples of 1, 2, or 5 × 10n, with thresholds at the geometric means (√2, √10, √50) between candidates.
Adaptive tick count
When tickCount is not provided, the axis calculates an optimal number of ticks based on the available pixel space.
- Y-axis: ~40 px minimum spacing between ticks
- X-axis (linear): ~70 px minimum spacing
- X-axis (band): labels are automatically thinned when they would overlap (~40 px threshold)
// Auto ticks — the chart calculates tick density from available space.
<Chart data={data} xDataKey="month" height={200}>
<YAxis /> {/* auto ~5 ticks for 200px */}
</Chart>
<Chart data={data} xDataKey="month" height={600}>
<YAxis /> {/* auto ~13 ticks for 600px */}
</Chart>Explicit tick count
Override the automatic calculation by passing tickCount. The actual number of ticks may differ slightly because the algorithm rounds to "nice" step boundaries.
<YAxis tickCount={3} /> {/* may produce 3-5 depending on the domain */}
<YAxis tickCount={10} /> {/* useful for tall charts with fine-grained data */}Tick formatting
Use tickFormat on either axis to control how values are displayed.
<YAxis tickFormat={(v) => `$${v.toLocaleString()}`} /> {/* Currency */}
<YAxis tickFormat={(v) => `${v}%`} /> {/* Percentage */}
<XAxis tickFormat={(v) => new Date(v).toLocaleDateString()} /> {/* Date */}Advanced: tickStep and suggestTickCount
For building custom components, the tick utilities are exported:
import { tickStep, suggestTickCount } from "@exhibit/charts";
tickStep(0, 100, 5); // → 20
suggestTickCount(400, "y"); // → 10Props
LineMark
| Prop | Type | Default | Description |
|---|---|---|---|
dataKey | string | required | Data key for this line series |
name | string | -- | Display name (used in legend and tooltip) |
color | string | var(--color-chart-1) | Line stroke colour |
curve | "linear" | "monotoneX" | "step" | "stepBefore" | "stepAfter" | "linear" | Interpolation curve |
strokeWidth | number | 2 | Line stroke width |
dot | boolean | DotConfig | false | Show dots on data points |
activeDot | boolean | DotConfig | true | Show active dot on hover |
markerPosition | "interpolate" | "snap" | "absolute" | "interpolate" | How the active marker tracks the cursor |
yAxisId | "left" | "right" | "left" | Which Y axis to bind to |
connectNulls | boolean | false | Connect across null values |
AreaMark
| Prop | Type | Default | Description |
|---|---|---|---|
dataKey | string | required | Data key for this area series |
name | string | -- | Display name |
color | string | var(--color-chart-1) | Line and fill colour |
curve | CurveType | "linear" | Interpolation curve |
strokeWidth | number | 1.5 | Line stroke width |
fillOpacity | number | 0.3 | Opacity of the area fill gradient |
activeDot | boolean | DotConfig | true | Show active dot on hover |
markerPosition | "interpolate" | "snap" | "absolute" | "interpolate" | How the active marker tracks the cursor |
yAxisId | "left" | "right" | "left" | Which Y axis to bind to |
Cursor
| Prop | Type | Default | Description |
|---|---|---|---|
axis | "x" | "y" | "x" | Which axis the cursor aligns to |
label | boolean | (value: string | number) => ReactNode | -- | Display the active axis value |
marker | boolean | { className?: string; behaviour?: "interpolate" | "snap" | "absolute" } | -- | Render a dot at the cursor/series intersection |
type | "line" | "band" | "line" | Cursor visual style |
highlight | boolean | false | Dim non-hovered marks |
lineColor | string | muted foreground | Line cursor colour |
lineWidth | number | 1.5 | Line cursor width |
lineDash | string | "4 4" | Line cursor dash pattern |
bandColor | string | foreground | Band cursor fill colour |
bandOpacity | number | 0.06 | Band cursor fill opacity |
XAxis / YAxis
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | -- | Axis label |
tickCount | number | auto | Number of ticks |
tickFormat | (value) => string | -- | Custom tick label formatter |
position | "left" | "right" | "left" | Y axis position |
domain | [number | "auto", number | "auto"] | auto | Y axis domain |
hideAxisLine | boolean | false | Hide the axis line |
hideTicks | boolean | false | Hide tick marks |
Grid
| Prop | Type | Default | Description |
|---|---|---|---|
horizontal | boolean | true | Show horizontal grid lines |
vertical | boolean | false | Show vertical grid lines |
color | string | -- | Grid line colour |
strokeDasharray | string | "3 3" | Dash pattern |
useBrush
| Option | Type | Default | Description |
|---|---|---|---|
data | T[] | required | Full dataset |
defaultZoom | { start: number; end: number } | -- | Initial visible range as percentages (0--100) |
minVisibleItems | number | 20 | Minimum data points visible when zoomed in |
zoomFactor | number | 0.08 | Scroll-to-zoom sensitivity |
Returns { visibleData, startIndex, endIndex, onBrushChange, containerRef }.
BrushSlider
| Prop | Type | Default | Description |
|---|---|---|---|
data | Record<string, unknown>[] | required | Full dataset (for sparkline) |
dataKey | string | required | Key to plot in the sparkline |
startIndex | number | required | Start of selected range |
endIndex | number | required | End of selected range |
onChange | (range) => void | required | Called when range changes |
color | string | var(--color-chart-1) | Sparkline colour |
height | number | 48 | Slider height in pixels |
Chart (sync props)
| Prop | Type | Default | Description |
|---|---|---|---|
activeIndex | number | null | -- | Controlled active data-point index |
onActiveIndexChange | (index: number | null) => void | -- | Called when hover index changes |
YAxis (reversed)
| Prop | Type | Default | Description |
|---|---|---|---|
reversed | boolean | false | Flip axis so values grow downward |