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 pointWhat you overrideUse it for
Element draw(ctx)Canvas drawingA different mark with the same data model
Element getDefaults()Default optionsSensible values for your element
Element inRange(mouseX, mouseY)Hit testingCustom hover behaviour
Controller getDefaults()Controller defaultsPointing at your element type
Controller parse()Data parsingA data shape Chart.js does not handle
Controller updateElements()Element state calculationNew semantics such as a percentage bar
Controller draw()The whole series drawDrawing 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;
  }
};
  • beforeDatasetsDraw draws behind the data, which is what a reference line or a shaded band wants. afterDatasetsDraw draws in front.
  • Any plugin that appends DOM must implement destroy and remove it, or a page that re-renders charts leaks a node each time.
  • afterEvent returning true requests 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'
        }
      }
    }
  }
};
NeedInline pluginAnnotation pluginScriptable option
One reference lineFineAlso fine, less codeNot possible
A shaded bandMore workThe natural fitNot possible
Colour by valueOverkillWrong toolThe right approach
A gradient fillNecessaryWrong toolOnly via a plugin
A DOM badge over the chartNecessaryWrong toolNot possible
An interactive annotationMore workSupported with eventsNot 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.

Options, built-in plugins and tooltips Installing Chart.js and tree-shaking the bundle

Last refreshed 2026-09-18.