Custom controllers, elements and plugins
Subclass a controller, write a custom element draw method, hook into the render pipeline with inline plugins, and know when the annotation plugin is the right answer.
Custom elements and controllers
import { Chart, BarController, BarElement, CategoryScale, LinearScale,
Tooltip, Legend } from 'chart.js';
// A custom element: same data pipeline as a bar, different drawing.
class ProgressBarElement extends BarElement {
static id = 'progressBar';
// Return the element's bounds. A bar's geometry, so reuse it.
static getDefaults() {
return { ...super.getDefaults(), borderRadius: 6 };
}
draw(ctx) {
const { x, y, base, width, height } = this.getProps(
['x', 'y', 'base', 'width', 'height'], true
);
const radius = Math.min(this.options.borderRadius, height / 2);
// the track
ctx.save();
ctx.fillStyle = 'rgba(148, 163, 184, 0.18)';
ctx.beginPath();
ctx.roundRect(x - width / 2, y, width, base - y, radius);
ctx.fill();
// the value
ctx.fillStyle = this.options.backgroundColor;
ctx.beginPath();
ctx.roundRect(x - width / 2, y, width, base - y, radius);
ctx.clip();
ctx.fill();
ctx.restore();
// the handle at the end of the value
ctx.save();
ctx.fillStyle = this.options.borderColor ?? '#0f172a';
ctx.beginPath();
ctx.arc(x, y, 3, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
// A custom controller wires the element to a chart type.
class ProgressBarController extends BarController {
static id = 'progressBar';
static defaults = { dataElementType: 'progressBar' };
static overrides = { scales: { x: { stacked: false }, y: { stacked: false, beginAtZero: true } } };
}
Chart.register(ProgressBarController, ProgressBarElement, CategoryScale, LinearScale, Tooltip, Legend);
// usage
new Chart(canvas, {
type: 'progressBar',
data: { labels: ['Build', 'Test', 'Deploy'], datasets: [{ label: 'Progress', data: [92, 74, 41] }] }
});| Extension point | What you override | Use it for |
|---|---|---|
Element draw(ctx) | Canvas drawing | A different mark with the same data model |
Element getDefaults() | Default options | Sensible values for your element |
Element inRange(mouseX, mouseY) | Hit testing | Custom hover behaviour |
Controller getDefaults() | Controller defaults | Pointing at your element type |
Controller parse() | Data parsing | A data shape Chart.js does not handle |
Controller updateElements() | Element state calculation | New semantics such as a percentage bar |
Controller draw() | The whole series draw | Drawing order you cannot get otherwise |
⚠️
The canvas is in a scaled coordinate space. Never hard-code pixel sizes inside a custom draw: read the dimensions from
this.getProps or from the chart area. A custom element that looks right at one container size breaks at every other.Inline plugins and the render pipeline
const targetLine = {
id: 'targetLine',
// Plugin hooks, in the order they fire during a render:
// beforeInit -> afterInit -> beforeUpdate -> afterUpdate
// -> beforeLayout -> afterLayout -> beforeDraw -> afterDraw
// -> beforeDatasetsDraw -> afterDatasetsDraw -> afterRender -> resize -> destroy
defaults: { color: '#ef4444', width: 1.5, label: 'Target' },
beforeDatasetsDraw(chart, args, options) {
const { ctx, chartArea, scales } = chart;
if (!chartArea) return; // nothing to draw yet
const y = scales.y.getPixelForValue(options.value);
if (!Number.isFinite(y)) return; // outside the visible range
ctx.save();
ctx.beginPath();
ctx.setLineDash([6, 4]);
ctx.lineWidth = options.width;
ctx.strokeStyle = options.color;
ctx.moveTo(chartArea.left, y);
ctx.lineTo(chartArea.right, y);
ctx.stroke();
ctx.setLineDash([]);
ctx.font = '11px system-ui';
ctx.fillStyle = options.color;
ctx.textAlign = 'right';
ctx.fillText(options.label, chartArea.right - 4, y - 4);
ctx.restore();
}
};
// Register globally: every chart gets the hook, which returns early when the
// option is absent.
Chart.register(targetLine);
// Or scope it to one chart:
const chart = new Chart(canvas, {
type: 'line',
data,
options: { plugins: { targetLine: { value: 900, label: 'Target 900' } } },
plugins: [targetLine] // only this chart runs the hook
});
// Disabling a globally registered plugin on one chart:
// options: { plugins: { targetLine: false } }// A plugin that needs to react to hover: use afterEvent instead of afterDraw.
const crosshair = {
id: 'crosshair',
afterEvent(chart, args) {
const event = args.event;
if (event.type !== 'mousemove' && event.type !== 'mouseout') return;
chart.$crosshair = event.type === 'mousemove' ? event.x : null;
// Returning true tells Chart.js a re-render is needed.
return event.type !== 'mouseout';
},
afterDatasetsDraw(chart) {
const x = chart.$crosshair;
if (x == null) return;
const { ctx, chartArea } = chart;
ctx.save();
ctx.strokeStyle = 'rgba(100, 116, 139, 0.5)';
ctx.beginPath();
ctx.moveTo(x, chartArea.top);
ctx.lineTo(x, chartArea.bottom);
ctx.stroke();
ctx.restore();
}
};
// Cleanup: a plugin that adds DOM elements must remove them on destroy.
const badgePlugin = {
id: 'badge',
afterInit(chart) {
const badge = document.createElement('div');
badge.className = 'chart-badge';
chart.canvas.parentNode.appendChild(badge);
chart.$badge = badge;
},
afterDatasetsDraw(chart) {
if (chart.$badge) chart.$badge.textContent = chart.data.datasets[0].data.length + ' points';
},
destroy(chart) {
chart.$badge?.remove();
delete chart.$badge;
}
};beforeDatasetsDrawdraws behind the data, which is what a reference line or a shaded band wants.afterDatasetsDrawdraws in front.- Any plugin that appends DOM must implement
destroyand remove it, or a page that re-renders charts leaks a node each time. afterEventreturningtruerequests a re-render. Returning nothing means Chart.js will not redraw, and the visual will not change.- Storing state on the chart object under a
$-prefixed key is the documented informal convention for plugin-local state.
When to use a plugin package instead
// chartjs-plugin-annotation covers the cases that would otherwise be 200 lines
// of canvas code.
import annotationPlugin from 'chartjs-plugin-annotation';
Chart.register(annotationPlugin);
const options = {
plugins: {
annotation: {
annotations: {
target: {
type: 'line',
yMin: 900, yMax: 900,
borderColor: '#ef4444',
borderWidth: 1.5,
borderDash: [6, 4],
label: {
display: true,
content: 'Target 900',
position: 'end',
backgroundColor: 'rgba(239, 68, 68, 0.9)',
color: '#fff',
padding: 4
}
},
incident: {
type: 'box',
xMin: 'Mar', xMax: 'Apr',
backgroundColor: 'rgba(239, 68, 68, 0.08)',
borderWidth: 0,
label: { display: true, content: 'Incident', position: { x: 'center', y: 'start' } }
},
lastValue: {
type: 'label',
xValue: 'Q4', yValue: 1290,
content: ['1290', 'peak'],
backgroundColor: '#0f172a', color: '#f8fafc', padding: 6
},
circle: {
type: 'point',
xValue: 'Q2', yValue: 932,
radius: 6, borderColor: '#6d28d9', borderWidth: 2, backgroundColor: '#fff'
}
}
}
}
};| Need | Inline plugin | Annotation plugin | Scriptable option |
|---|---|---|---|
| One reference line | Fine | Also fine, less code | Not possible |
| A shaded band | More work | The natural fit | Not possible |
| Colour by value | Overkill | Wrong tool | The right approach |
| A gradient fill | Necessary | Wrong tool | Only via a plugin |
| A DOM badge over the chart | Necessary | Wrong tool | Not possible |
| An interactive annotation | More work | Supported with events | Not possible |
The decision rule is simple: if the plugin package models what you want declaratively, use it and stop. Write a custom plugin when the behaviour is specific to your product — a DOM overlay, a live cursor readout, a watermark — or when adding another dependency costs more than the fifty lines it would replace.
FAQ
Do custom plugins slow down the chart?
Only if the hook does expensive work per frame.
beforeDatasetsDraw runs on every render, so keep it to a handful of canvas calls and precompute anything derived from the data outside the hook.How do I stop a globally registered plugin from running on one chart?
Set
options.plugins.<pluginId> = false for that chart. A plugin can also check the option value and return early, which is the usual pattern for optional features such as a reference line.Related
Options, built-in plugins and tooltips Installing Chart.js and tree-shaking the bundle
Last refreshed 2026-09-18.