chartcn

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

ModeBehaviour
"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.

PropTypeDefaultDescription
classNamestring--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.

Evaporation
Rainfall
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.

Evaporation
Rainfall
Temperature

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.

Evaporation
Rainfall

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 BrushSlider to 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");  // → 10

Props

LineMark

PropTypeDefaultDescription
dataKeystringrequiredData key for this line series
namestring--Display name (used in legend and tooltip)
colorstringvar(--color-chart-1)Line stroke colour
curve"linear" | "monotoneX" | "step" | "stepBefore" | "stepAfter""linear"Interpolation curve
strokeWidthnumber2Line stroke width
dotboolean | DotConfigfalseShow dots on data points
activeDotboolean | DotConfigtrueShow 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
connectNullsbooleanfalseConnect across null values

AreaMark

PropTypeDefaultDescription
dataKeystringrequiredData key for this area series
namestring--Display name
colorstringvar(--color-chart-1)Line and fill colour
curveCurveType"linear"Interpolation curve
strokeWidthnumber1.5Line stroke width
fillOpacitynumber0.3Opacity of the area fill gradient
activeDotboolean | DotConfigtrueShow 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

PropTypeDefaultDescription
axis"x" | "y""x"Which axis the cursor aligns to
labelboolean | (value: string | number) => ReactNode--Display the active axis value
markerboolean | { className?: string; behaviour?: "interpolate" | "snap" | "absolute" }--Render a dot at the cursor/series intersection
type"line" | "band""line"Cursor visual style
highlightbooleanfalseDim non-hovered marks
lineColorstringmuted foregroundLine cursor colour
lineWidthnumber1.5Line cursor width
lineDashstring"4 4"Line cursor dash pattern
bandColorstringforegroundBand cursor fill colour
bandOpacitynumber0.06Band cursor fill opacity

XAxis / YAxis

PropTypeDefaultDescription
labelstring--Axis label
tickCountnumberautoNumber of ticks
tickFormat(value) => string--Custom tick label formatter
position"left" | "right""left"Y axis position
domain[number | "auto", number | "auto"]autoY axis domain
hideAxisLinebooleanfalseHide the axis line
hideTicksbooleanfalseHide tick marks

Grid

PropTypeDefaultDescription
horizontalbooleantrueShow horizontal grid lines
verticalbooleanfalseShow vertical grid lines
colorstring--Grid line colour
strokeDasharraystring"3 3"Dash pattern

useBrush

OptionTypeDefaultDescription
dataT[]requiredFull dataset
defaultZoom{ start: number; end: number }--Initial visible range as percentages (0--100)
minVisibleItemsnumber20Minimum data points visible when zoomed in
zoomFactornumber0.08Scroll-to-zoom sensitivity

Returns { visibleData, startIndex, endIndex, onBrushChange, containerRef }.

BrushSlider

PropTypeDefaultDescription
dataRecord<string, unknown>[]requiredFull dataset (for sparkline)
dataKeystringrequiredKey to plot in the sparkline
startIndexnumberrequiredStart of selected range
endIndexnumberrequiredEnd of selected range
onChange(range) => voidrequiredCalled when range changes
colorstringvar(--color-chart-1)Sparkline colour
heightnumber48Slider height in pixels

Chart (sync props)

PropTypeDefaultDescription
activeIndexnumber | null--Controlled active data-point index
onActiveIndexChange(index: number | null) => void--Called when hover index changes

YAxis (reversed)

PropTypeDefaultDescription
reversedbooleanfalseFlip axis so values grow downward

On this page