Event Handling
Add interactivity to your charts with event handling and overlay components.
Here's a fully interactive chart combining markers and tooltips. Hover over the chart to see both the vertical marker line and a tooltip showing all series values:
import { useState } from "react";
import { LineChart, VerticalMarker, SeriesTooltip } from "@vuer-ai/vuer-viz";
const xValues = Array.from({ length: 500 }, (_, i) => i);
const series = [
{
label: "Temperature",
x: xValues,
y: xValues.map(i => 20 + Math.sin(i / 5) * 8 + Math.random() * 3),
color: "#e74c3c",
},
{
label: "Humidity",
x: xValues,
y: xValues.map(i => 30 + Math.cos(i / 7) * 15 + Math.random() * 5),
color: "#3498db",
},
{
label: "Pressure",
x: xValues,
y: xValues.map(i => 70 + Math.sin(i / 10) * 5 + Math.random() * 2),
color: "#2ecc71",
},
];
export default function CompleteInteractiveExample() {
const [cursorX, setCursorX] = useState<number | null>(null);
return (
<LineChart
series={series}
width={700}
height={300}
xLabel="Time"
yLabel="Values"
title="Interactive Chart with Marker and Tooltip"
xFormat="minutes"
onChange={(e) => setCursorX(e.x)}
>
<VerticalMarker x={cursorX} color="#f00" lineWidth={1} />
<SeriesTooltip
x={cursorX}
series={series}
xFormat="minutes"
/>
</LineChart>
);
}
Overview
Charts support a unified onChange event handler that fires when the user interacts with the chart:
- Hover - Moving the mouse over the chart
- Click - Clicking on the chart
- Drag - Panning the chart (future feature)
- Scroll - Zooming the chart (future feature)
The event provides canonical data coordinates, allowing you to build interactive features like cursor tracking, value inspection, and synchronized multi-chart views.
Event API
onChange Handler
Charts accept an onChange prop that receives a ChartChangeEvent:
interface ChartChangeEvent { // Current pointer position in canonical data units (null outside plot area) x: number | null; y: number | null; // Current visible bounds bounds: { xMin: number; xMax: number; yMin: number; yMax: number; }; // Event type type: 'hover' | 'click' | 'drag' | 'scroll'; // Nearest data point (LineChart only) nearestPoint?: { seriesId: string; seriesLabel: string; dataIndex: number; x: number; y: number; }; // Original DOM event nativeEvent?: MouseEvent | WheelEvent; }
Coordinate System
Events return values in canonical data units — the same units used in your series data:
- x, y: Canonical values (e.g., Unix timestamps, sample indices, or any numeric values)
- bounds: Always in the chart's canonical units
- xFormatter / yFormatter: Convert canonical values to display strings
This design allows synchronized charts with the same canonical domain to share cursor positions directly.
Overlay Components
Overlay components are rendered as children of LineChart and receive chart context automatically.
VerticalMarker
Renders a vertical line at a specific x-coordinate. Pass null to hide it.
<LineChart series={series} onChange={(e) => setCursorX(e.x)}> <VerticalMarker x={cursorX} color="#666" lineWidth={1} dashArray="4,4" /> </LineChart>
import { useState } from "react";
import { LineChart, VerticalMarker } from "@vuer-ai/vuer-viz";
const x = Array.from({ length: 50 }, (_, i) => i);
const y = x.map(i => 20 + Math.sin(i / 5) * 10 + Math.random() * 3);
const series = [
{
label: "Temperature",
x,
y,
color: "#e74c3c",
},
];
export default function BasicHoverExample() {
const [hoverX, setHoverX] = useState<number | null>(null);
return (
<LineChart
series={series}
width={600}
height={300}
xLabel="Time (s)"
yLabel="Temperature (°C)"
title="Hover to Track Values"
onChange={(e) => setHoverX(e.x)}
>
<VerticalMarker x={hoverX} color="#3498db" lineWidth={2} />
</LineChart>
);
}
SeriesTooltip
Shows the value of every series at the hovered x position. Must be a child of LineChart. The header shows the nearest actual data point x; each row shows the series color indicator, label, and y value at that position.
<LineChart series={series} onChange={(e) => setCursorX(e.x)}> <SeriesTooltip x={cursorX} series={series} xFormatter="minutes" yFormatter="si" /> </LineChart>
Positioning
By default the tooltip follows the cursor (preferring the right side, falling back to the left). Use the position prop to pin it to a fixed corner of the plot area — useful for dense charts where a following tooltip would cover data.
position | Behavior |
|---|---|
"follow" (default) | Follows the cursor; prefers right side, falls back to left |
"top-left" | Fixed to the top-left corner of the plot area |
"top-right" | Fixed to the top-right corner of the plot area |
"bottom-left" | Fixed to the bottom-left corner of the plot area |
"bottom-right" | Fixed to the bottom-right corner of the plot area |
import { useState } from "react";
import { LineChart, VerticalMarker, SeriesTooltip } from "@vuer-ai/vuer-viz";
const x = Array.from({ length: 80 }, (_, i) => i);
const series = [
{ label: "Loss", x, y: x.map(i => 2.5 * Math.exp(-i / 20) + 0.05 + Math.random() * 0.05), color: "#e74c3c" },
{ label: "Val Loss", x, y: x.map(i => 2.8 * Math.exp(-i / 22) + 0.12 + Math.random() * 0.08), color: "#3498db" },
{ label: "Accuracy", x, y: x.map(i => 1 - 0.9 * Math.exp(-i / 18) + Math.random() * 0.02), color: "#2ecc71" },
];
export default function TooltipPositionExample() {
const [cursorX, setCursorX] = useState<number | null>(null);
return (
<LineChart
series={series}
width={700}
height={300}
xLabel="Step"
yLabel="Value"
title="Pinned Tooltip — Top Right"
legend={false}
grid
highlightOnHover
highlightThreshold={20}
onChange={(e) => setCursorX(e.x)}
>
<VerticalMarker x={cursorX} />
<SeriesTooltip
x={cursorX}
series={series}
position="top-right"
/>
</LineChart>
);
}
Integration with highlightOnHover
When LineChart's highlightOnHover prop is enabled, the row matching the nearest series receives a color-tinted background in the tooltip. See Line Chart → Highlight on Hover for details on highlightOnHover and highlightThreshold.
Tooltip
Displays formatted values at a position:
<Tooltip x={hoverX} // Canonical x value y={hoverY} // Optional y value formatX={(x) => new Date(x).toLocaleTimeString()} // Format function formatY={(y) => y.toFixed(2)} // Format function />
Synchronized Charts
Cursor Synchronization
Lift cursor state to a shared parent and pass it to each chart's onChange. Charts with the same canonical x-domain can share the value directly:
import { useState } from "react";
import { LineChart, SeriesTooltip, VerticalMarker } from "@vuer-ai/vuer-viz";
import { temperatureData, pressureData, humidityData } from "./synchronized-cursor-data";
export default function SynchronizedCursorExample() {
const [scrubX, setScrubX] = useState<number | null>(null);
return (
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
<LineChart
series={temperatureData}
width={700}
height={200}
xLabel="Time"
yLabel="Temperature (°C)"
title="Temperature Sensors"
xFormat="time"
onChange={(e) => setScrubX(e.x)}
>
<VerticalMarker x={scrubX} />
<SeriesTooltip x={scrubX} series={temperatureData} xFormat="time" />
</LineChart>
<LineChart
series={pressureData}
width={700}
height={200}
xLabel="Time"
yLabel="Pressure (kPa)"
title="Atmospheric Pressure"
xFormat="time"
onChange={(e) => setScrubX(e.x)}
>
<VerticalMarker x={scrubX} />
<SeriesTooltip x={scrubX} series={pressureData} xFormat="time" />
</LineChart>
<LineChart
series={humidityData}
width={700}
height={200}
xLabel="Time"
yLabel="Humidity (%)"
title="Relative Humidity"
xFormat="time"
onChange={(e) => setScrubX(e.x)}
>
<VerticalMarker x={scrubX} />
<SeriesTooltip x={scrubX} series={humidityData} xFormat="time" />
</LineChart>
</div>
);
}
Key Insight: All three charts use the same canonical x-domain (Unix timestamps in milliseconds). The scrubX state is shared directly because the charts have compatible units. Each chart applies its own xFormatter to display the timestamps in a human-readable format.
const [sharedX, setSharedX] = useState<number | null>(null); <LineChart series={temperatureData} onChange={(e) => setSharedX(e.x)}> <VerticalMarker x={sharedX} /> </LineChart> <LineChart series={pressureData} onChange={(e) => setSharedX(e.x)}> <VerticalMarker x={sharedX} /> </LineChart>
If charts have different canonical domains (e.g. one uses timestamps, another uses sample indices), convert between them at the onChange boundary:
const TIME_START = 1699123400000; const SAMPLE_RATE = 10; // samples per second const timestampToIndex = (ms: number) => ((ms - TIME_START) / 1000) * SAMPLE_RATE; const indexToTimestamp = (i: number) => TIME_START + (i / SAMPLE_RATE) * 1000; <LineChart series={timeData} onChange={(e) => setRefX(e.x)}> <VerticalMarker x={refX} /> </LineChart> <LineChart series={sampleData} onChange={(e) => setRefX(indexToTimestamp(e.x))}> <VerticalMarker x={refX ? timestampToIndex(refX) : null} /> </LineChart>
Bounds Synchronization
Share xMin / xMax state across charts so zooming or panning one chart affects all. Each chart needs its own useZoomPan instance (separate DOM ref), but they all read and write the same bounds state:
Option + Scroll to zoom, Click + Drag to pan. All charts share the same visible x-range.
import { useState } from "react";
import { LineChart, SeriesTooltip, VerticalMarker, useZoomPan } from "@vuer-ai/vuer-viz";
import { temperatureData, pressureData, humidityData } from "./synchronized-cursor-data";
export default function SynchronizedBoundsExample() {
// Shared bounds state - all charts will display the same x-range
const startTime = temperatureData[0].x[0];
const endTime = temperatureData[0].x[temperatureData[0].x.length - 1];
const [xMin, setXMin] = useState(startTime);
const [xMax, setXMax] = useState(startTime + (endTime - startTime) * 0.4); // Start with 40% visible
const [scrubX, setScrubX] = useState<number | null>(null);
// Each chart needs its own plotRef, but they all share the same xMin/xMax state
const zoomPanOptions = {
min: xMin,
max: xMax,
setMin: setXMin,
setMax: setXMax,
minLimit: startTime,
maxLimit: endTime,
};
const { plotRef: plotRef1 } = useZoomPan(zoomPanOptions);
const { plotRef: plotRef2 } = useZoomPan(zoomPanOptions);
const { plotRef: plotRef3 } = useZoomPan(zoomPanOptions);
return (
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
<LineChart
plotRef={plotRef1}
series={temperatureData}
width={700}
height={200}
xLabel="Time"
yLabel="Temperature (°C)"
title="Temperature Sensors"
xFormat="time"
xMin={xMin}
xMax={xMax}
onChange={(e) => e.type === "hover" && setScrubX(e.x)}
>
<VerticalMarker x={scrubX} />
<SeriesTooltip x={scrubX} series={temperatureData} xFormat="time" />
</LineChart>
<LineChart
plotRef={plotRef2}
series={pressureData}
width={700}
height={200}
xLabel="Time"
yLabel="Pressure (kPa)"
title="Atmospheric Pressure"
xFormat="time"
xMin={xMin}
xMax={xMax}
onChange={(e) => e.type === "hover" && setScrubX(e.x)}
>
<VerticalMarker x={scrubX} />
<SeriesTooltip x={scrubX} series={pressureData} xFormat="time" />
</LineChart>
<LineChart
plotRef={plotRef3}
series={humidityData}
width={700}
height={200}
xLabel="Time"
yLabel="Humidity (%)"
title="Relative Humidity"
xFormat="time"
xMin={xMin}
xMax={xMax}
onChange={(e) => e.type === "hover" && setScrubX(e.x)}
>
<VerticalMarker x={scrubX} />
<SeriesTooltip x={scrubX} series={humidityData} xFormat="time" />
</LineChart>
<p className="text-sm text-gray-600 dark:text-gray-400">
Option + Scroll to zoom, Click + Drag to pan. All charts share the same visible x-range.
</p>
</div>
);
}
Key Insight: Each chart needs its own useZoomPan hook instance (for separate DOM refs), but they all share the same xMin/xMax state and setters. When any chart is zoomed or panned, it updates the shared state, causing all charts to re-render with the new bounds. Combined with cursor synchronization, this creates a fully linked multi-chart view.
Click to Pin
Click to lock the marker at a position for detailed inspection while the cursor moves freely:
import { useState } from "react";
import { LineChart, VerticalMarker } from "@vuer-ai/vuer-viz";
const x = Array.from({ length: 50 }, (_, i) => i);
const y = x.map(i => Math.sin(i / 3) * 50 + 50 + Math.random() * 10);
const series = [
{
label: "Signal",
x,
y,
color: "#3498db",
},
];
export default function ClickToPinExample() {
const [pinnedX, setPinnedX] = useState<number | null>(25);
const [hoverX, setHoverX] = useState<number | null>(null);
const handleChange = (e: any) => {
if (e.type === "click") {
// Toggle pin: if clicking near the pinned position, unpin
if (pinnedX !== null && Math.abs(e.x - pinnedX) < 2) {
setPinnedX(null);
} else {
setPinnedX(e.x);
}
} else if (e.type === "hover") {
setHoverX(e.x);
}
};
return (
<div>
<LineChart
series={series}
width={600}
height={300}
xLabel="Sample"
yLabel="Amplitude"
title="Click to Pin Marker (Click again to unpin)"
onChange={handleChange}
>
{/* Pinned marker (solid red) */}
{pinnedX !== null && (
<VerticalMarker x={pinnedX} color="#e74c3c" lineWidth={2} />
)}
{/* Hover marker (dashed blue) - only show when not pinned at same location */}
{hoverX !== null && hoverX !== pinnedX && (
<VerticalMarker x={hoverX} color="#3498db" lineWidth={1} dashArray="4,1" />
)}
</LineChart>
<div className="mt-2.5 text-sm text-gray-600 dark:text-gray-400">
{pinnedX !== null ? (
<div>
<strong>Pinned at x = {pinnedX.toFixed(1)}</strong> (click near marker to unpin)
</div>
) : (
<div>No marker pinned. Click anywhere on the chart to pin.</div>
)}
</div>
</div>
);
}
BarChart Event Handling
BarCharts emit discrete selection events:
- x: Bar index (0, 1, 2, ...)
- y: Bar value
- nearestPoint: Contains the bar's label and data
const [selectedBar, setSelectedBar] = useState<number | null>(null); <BarChart data={barData} onChange={(e) => { if (e.type === 'click') { setSelectedBar(e.x); } }} />
Canvas Mode Compatibility
Event handling works with both SVG (canvas={false}) and hybrid canvas modes (canvas={true}):
- Data renders on canvas for performance
- Event layer and overlays use SVG
- Full interactivity is preserved
<LineChart series={largeDataset} canvas={true} onChange={(e) => setX(e.x)} > <VerticalMarker x={x} /> </LineChart>
Best Practices
- Use canonical coordinates — Store positions in data units, not pixels
- Share domains for synchronization — Synced charts should use the same x-units
- Provide formatters — Use
xFormatter/yFormatterfor display - Handle null values — Overlay components should check for
x === null - Distinguish event types — Use
e.typeto handle hover vs click differently
API Reference
LineChart / BarChart Props
{ onChange?: (event: ChartChangeEvent) => void; children?: React.ReactNode; // Overlay components }
ChartChangeEvent
interface ChartChangeEvent { x: number | null; y: number | null; bounds: { xMin: number; xMax: number; yMin: number; yMax: number }; type: 'hover' | 'click' | 'drag' | 'scroll'; nearestPoint?: { seriesId: string; seriesLabel: string; dataIndex: number; x: number; y: number; }; nativeEvent?: MouseEvent | WheelEvent; }
VerticalMarker Props
interface VerticalMarkerProps { x: number | null; // Canonical x position (null hides the marker) color?: string; // Default: "#666" lineWidth?: number; // Default: 1 dashArray?: string; // Default: "4,4" }
SeriesTooltip Props
type SeriesTooltipPosition = | "follow" // default — follows the cursor | "top-left" | "top-right" | "bottom-left" | "bottom-right"; interface SeriesTooltipProps { x: number | null; // Canonical x position (null hides the tooltip) series: LineSeries[]; // Must match the series passed to LineChart position?: SeriesTooltipPosition; // Default: "follow" offsetX?: number; // Cursor offset X when following. Default: 10 offsetY?: number; // Cursor offset Y when following. Default: -10 backgroundColor?: string; // Uses theme default when omitted textColor?: string; // Uses theme default when omitted fontSize?: number; // Default: 12 padding?: number; // Inner padding. Default: 8 xFormatter?: Formatter; // x-axis formatter yFormatter?: Formatter; // y-axis formatter }
Tooltip Props
interface TooltipProps { x: number | null; y?: number | null; content?: React.ReactNode; formatX?: (x: number) => string; formatY?: (y: number) => string; offset?: { x: number; y: number }; backgroundColor?: string; textColor?: string; fontSize?: number; padding?: number; }