The Chart.ts Plugin System: Building Custom Chart Types
Create fully custom chart types with defineChartType(). Render context, hit testing, custom scales, and renderer-agnostic render nodes.
Chart.ts ships 65+ chart types. But sometimes your visualization does not fit any standard category. A radial gauge. A genome browser. A custom risk matrix. A chord diagram with domain-specific interaction rules.
The plugin system lets you define entirely new chart types that work across all three renderers (SVG, Canvas, WebGL), integrate with the theme system, support Tailwind CSS classes, and behave like first-class Chart.ts citizens. This tutorial walks through building a custom gauge chart from scratch.
The defineChartType() API
Every chart type in Chart.ts is defined with defineChartType(). This is the same API used internally for the built-in bar, line, scatter, and pie charts. There is no internal/external divide. Your custom chart has access to the same capabilities.
import { defineChartType } from "@chartts/core"
const GaugeChart = defineChartType({
name: "gauge",
defaultOptions: {
min: 0,
max: 100,
value: 0,
startAngle: -135,
endAngle: 135,
thickness: 20,
colors: ["#22c55e", "#f59e0b", "#ef4444"],
thresholds: [33, 66, 100],
},
setup(ctx) {
// Called once when the chart mounts
},
render(ctx) {
// Called on every frame
return [] // Return render nodes
},
hitTest(ctx, point) {
// Called on mouse/touch interaction
return null
},
})The three lifecycle methods give you complete control. setup() runs once for initialization. render() produces the visual output on every frame. hitTest() determines what the user is interacting with.
The render context
The ctx parameter in render() provides everything you need:
render(ctx) {
const {
width, // Container width in pixels
height, // Container height in pixels
chartArea, // { top, right, bottom, left, width, height }
theme, // Current theme colors and typography
options, // Merged default + user options
data, // The data array
scales, // Computed scale functions
animate, // Animation progress (0 to 1)
} = ctx
// ...
}The chartArea is the drawable region after margins and axes. The theme object contains all colors, font sizes, and spacing values from the active Chart.ts theme. The animate value transitions from 0 to 1 during mount and data change animations.
Building the gauge: render nodes
Render nodes are the primitives you return from render(). They describe what to draw without specifying how. Chart.ts translates them to SVG elements, Canvas draw calls, or WebGL geometry depending on the active renderer.
Available render nodes: circle, arc, rect, line, path, text, group, and polygon.
Here is the gauge background arc:
import { defineChartType, arc, text, line, group } from "@chartts/core"
const GaugeChart = defineChartType({
name: "gauge",
defaultOptions: {
min: 0,
max: 100,
value: 50,
startAngle: -135,
endAngle: 135,
thickness: 20,
colors: ["#22c55e", "#f59e0b", "#ef4444"],
thresholds: [33, 66, 100],
label: "",
},
render(ctx) {
const { width, height, options, theme, animate } = ctx
const cx = width / 2
const cy = height / 2
const radius = Math.min(cx, cy) - 20
const { min, max, startAngle, endAngle, thickness, colors, thresholds, value, label } = options
const nodes = []
// Background track
nodes.push(
arc({
cx,
cy,
innerRadius: radius - thickness,
outerRadius: radius,
startAngle,
endAngle,
fill: theme.colors.muted,
})
)
// Colored segments based on thresholds
let prevAngle = startAngle
const totalRange = endAngle - startAngle
for (let i = 0; i < thresholds.length; i++) {
const thresholdNorm = (thresholds[i] - min) / (max - min)
const segEnd = startAngle + totalRange * thresholdNorm
nodes.push(
arc({
cx,
cy,
innerRadius: radius - thickness,
outerRadius: radius,
startAngle: prevAngle,
endAngle: Math.min(segEnd, startAngle + totalRange * animate),
fill: colors[i],
opacity: 0.3,
})
)
prevAngle = segEnd
}
// Value arc (animated)
const valueNorm = ((value - min) / (max - min)) * animate
const valueAngle = startAngle + totalRange * valueNorm
const valueColorIndex = thresholds.findIndex((t) => value <= t)
const valueColor = colors[Math.max(0, valueColorIndex)]
nodes.push(
arc({
cx,
cy,
innerRadius: radius - thickness,
outerRadius: radius,
startAngle,
endAngle: valueAngle,
fill: valueColor,
})
)
// Needle
const needleAngleRad = ((valueAngle - 90) * Math.PI) / 180
const needleLength = radius - thickness - 10
nodes.push(
line({
x1: cx,
y1: cy,
x2: cx + Math.cos(needleAngleRad) * needleLength,
y2: cy + Math.sin(needleAngleRad) * needleLength,
stroke: theme.colors.foreground,
strokeWidth: 2,
})
)
// Center value text
nodes.push(
text({
x: cx,
y: cy + 10,
content: `${Math.round(value * animate)}`,
fontSize: 32,
fontWeight: "bold",
fill: theme.colors.foreground,
textAnchor: "middle",
})
)
// Label
if (label) {
nodes.push(
text({
x: cx,
y: cy + 36,
content: label,
fontSize: 14,
fill: theme.colors.mutedForeground,
textAnchor: "middle",
})
)
}
return nodes
},
hitTest(ctx, point) {
const { width, height, options } = ctx
const cx = width / 2
const cy = height / 2
const radius = Math.min(cx, cy) - 20
const dist = Math.sqrt((point.x - cx) ** 2 + (point.y - cy) ** 2)
if (dist <= radius && dist >= radius - options.thickness) {
return { type: "gauge-arc", value: options.value }
}
return null
},
})The animate value transitions from 0 to 1 when the chart first renders and whenever the data changes. This makes the gauge needle sweep from zero to its target value on mount.
Hit testing
The hitTest() method receives a { x, y } point in chart coordinates and returns either null (no hit) or an object describing what was hit. Chart.ts calls this on mousemove, click, and touch events.
The return value is passed to event handlers and tooltip formatters:
import { createChart } from "@chartts/core"
const chart = createChart("#container", {
type: "gauge",
value: 73,
min: 0,
max: 100,
label: "CPU Usage",
onHover(hit) {
if (hit) {
console.log(`Hovering gauge at value: ${hit.value}`)
}
},
onClick(hit) {
if (hit) {
console.log(`Clicked gauge at value: ${hit.value}`)
}
},
})For more complex chart types, your hit test can return different objects for different visual regions. A chord diagram might return { type: "chord", source, target } or { type: "node", id } depending on whether the user hovers a connection or a node.
Using your custom chart in React
Custom chart types integrate directly with @chartts/react:
import { Chart } from "@chartts/react"
import { GaugeChart } from "./gauge-chart"
export function CPUGauge({ usage }: { usage: number }) {
return (
<Chart
type={GaugeChart}
value={usage}
min={0}
max={100}
label="CPU %"
colors={["#22c55e", "#f59e0b", "#ef4444"]}
thresholds={[50, 80, 100]}
className="h-48 w-48"
/>
)
}The Chart component accepts any chart type, whether built-in or custom. TypeScript infers the correct props from your defaultOptions definition, so you get full autocomplete and type checking.
Renderer-agnostic rendering
The render nodes you return from render() work identically on SVG, Canvas, and WebGL. This is a core architectural decision in Chart.ts.
When the renderer is SVG, an arc() node produces an <path> element with the correct d attribute. When the renderer is Canvas, it produces ctx.arc() and ctx.fill() calls. When the renderer is WebGL, it tessellates the arc into triangles and uploads them to a vertex buffer.
You never write renderer-specific code. The same gauge chart works in an SVG email report, a Canvas dashboard, and a WebGL monitoring display.
// Same chart type, different renderers
createChart("#svg-container", { type: GaugeChart, value: 75, renderer: "svg" })
createChart("#canvas-container", { type: GaugeChart, value: 75, renderer: "canvas" })
createChart("#webgl-container", { type: GaugeChart, value: 75, renderer: "webgl" })Registering plugins globally
If you want your chart type available by string name (like "gauge" instead of passing the object), register it globally:
import { registerChartType } from "@chartts/core"
import { GaugeChart } from "./gauge-chart"
registerChartType(GaugeChart)
// Now usable by name
createChart("#container", { type: "gauge", value: 73 })This is useful for configuration-driven dashboards where chart types come from a database or API response.
Adding custom scales
Some chart types need non-standard scales. A polar chart needs angle and radius scales. A treemap needs a hierarchical layout scale. The scales option in defineChartType() lets you define these:
const RadarChart = defineChartType({
name: "radar",
scales: {
angle: { type: "band", domain: (data, options) => options.axes },
radius: { type: "linear", domain: (data) => [0, Math.max(...data.map((d) => d.value))] },
},
render(ctx) {
const { scales } = ctx
const angle = scales.angle // Band scale for axis labels
const radius = scales.radius // Linear scale for values
// ...
},
})Custom scales integrate with the Chart.ts scale system. They support zoom, pan, and animation out of the box.
Publishing your plugin
Package your chart type as an npm package for others to use:
{
"name": "@myorg/chartts-gauge",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"peerDependencies": {
"@chartts/core": "^1.0.0"
}
}The only peer dependency is @chartts/core. Your plugin works with any framework package (@chartts/react, @chartts/vue, @chartts/svelte, etc.) without additional adapters.
What the plugin system enables
The defineChartType() API is the foundation for every chart type in Chart.ts, including the 65+ built-in types. The same system that powers the basic BarChart also powers the complex Sankey, Treemap, and ChordDiagram types.
This means the ecosystem can grow without core library changes. A domain-specific chart type for genomics, network topology, or sports analytics can be published as a standalone package that integrates perfectly with themes, animation, interaction, and all three renderers.
If the built-in 65 chart types do not cover your use case, you are not stuck. The plugin system gives you the same tools the library authors use.