Plugins (Weave)

Weave answers a question ApexCharts got for years: how do I draw my own thing on the chart without forking it or hacking the SVG? A Weave plugin is the supported answer. It registers once by name, activates per chart, subscribes to lifecycle hooks like draw, and paints into its own sandboxed layer. It can read the chart's scales, data, and theme, but it never touches internal state, so it cannot break rendering and it survives version upgrades.

That last part is the point. Because a plugin only ever draws into its own layer and only reads a fixed API, it is safe to publish to npm and drop into any chart. Weave is what makes a real third-party plugin ecosystem for ApexCharts possible.

See it live

The dashed mean line below is not part of the chart config. A plugin computes it on every draw pass: it reads the series, averages the values, converts the mean to a pixel, paints a line and label into its own layer, and emits the value back to the page. Press New data and the plugin recomputes and redraws.

Series with a plugin-drawn mean lineplugin: mean-line

The dashed mean line is not part of the chart config. A Weave plugin computes it on every draw pass and paints it into its own layer.

Where Weave is useful

  • Reference overlays. A mean, median, or target line, control limits for statistical process control, a moving average, an SLA threshold band. The shipped example is the dashed mean-line plugin above, which recomputes on every redraw.
  • Domain overlays. Support and resistance levels or forecast cones in trading, capacity lines in ops dashboards.
  • Branding. A watermark or logo layer applied consistently across every chart in an app.
  • External event markers. Deploy markers, incidents, or release dates pulled from another system and painted on top of a time series.
  • Org-wide reusable behaviors. Publish one plugin to npm so every team's charts get the same overlay or instrumentation by adding a single name to plugins.
  • Analytics instrumentation. Emit namespaced events (a hover, a threshold crossing) for product analytics without modifying chart internals.

Enable the feature

Weave is in the default bundle, so import ApexCharts from 'apexcharts' already has it and there is nothing to enable. If you are assembling from the lean core, or you want the import to state the dependency explicitly, add the feature entry point:

import ApexCharts from 'apexcharts'
import 'apexcharts/features/weave'   // already included in the default bundle

See the tree-shaking guide for every entry point and what each one costs.

Register a plugin

ApexCharts.registerPlugin(def) registers a plugin globally, once. Each plugin declares a name, an apiVersion, and a setup(api) function that subscribes to hooks. Call api.layer() inside each draw handler (layers are wiped at the start of every draw pass, so a handle cached across draws points at a detached node):

ApexCharts.registerPlugin({
  name: 'watermark',
  apiVersion: 1,
  setup(api) {
    api.on('draw', () => {
      const layer = api.layer()
      layer.text({ x: 12, y: 22, text: 'ACME', color: '#c8ccd4', size: '12px' })
    })
  },
})

ApexCharts.unregisterPlugin(name) removes it again, which is useful in tests and hot-reload.

Activate a plugin per chart

Registering makes a plugin available; a chart opts in through the plugins array. Registration is global, activation is per chart, so the same plugin can be on some charts and not others. You can pass per-chart options and an order:

const options = {
  plugins: [{ name: 'watermark', options: { text: 'ACME' } }],
}

At runtime, chart.updateOptions({ plugins: [{ name, options }] }) reconfigures an active plugin in place; the new options arrive live as api.options.

The plugin API

setup(api) receives a stable facade, never the chart's internals:

api.on(hook, fn)Subscribe to a lifecycle hook: afterParse, afterScales, draw, afterUpdate, or destroy.
api.layer(opts?)A plugin-owned SVG group with line, path, rect, circle, text, and clear. Pass { z: 'front' } or 'behind' to stack it. Wiped at the start of every draw pass.
api.scalesConvert data values to pixels for the current view: scales.x(v), scales.y(v), plus gridWidth and gridHeight.
api.dataRead-only access to the resolved series: name, color, hidden, points, and raw.
api.themeThe resolved theme: mode, foreColor, seriesColor(i), and token(name).
api.storePer-plugin state that survives redraws.
api.emit(name, detail)Send an event back to the page. It fires on the chart's bus as plugin:<name>:<event>.
api.infoWhat kind of chart this is, so a plugin can decide whether it applies. Details below. v2
api.categoriesThe display labels per x position, resolved so they survive every render path. v2
api.markDerived(names)Declare which series on the chart are the plugin's own rather than the caller's. v2
api.reserve(box)Reserve space inside the chart's container for the plugin's own UI. v3
api.pointer(fn)Subscribe to the data point the viewer is pointing at or has selected. Returns an unsubscribe. v4
api.can(name)Whether this host supports a named capability. Details below. v6
api.capabilitiesThe same list as a frozen array, for logging and support. v6
api.claim(option, entries)Set a positional option for your own series without writing the caller's config. Details below. v6
api.drawn()Everything on the chart, including other features' output. Read only. Details below. v6
api.declare(item)Say what this plugin has drawn, so it appears in drawn(). v6

Two properties of this API are what make plugins safe. First, the layer is sandboxed and cleared before each draw, so a plugin can never corrupt the chart's own output. Second, api.emit is namespaced as plugin:<name>:<event>, so a plugin's events can never trigger the chart's internal lifecycle subscribers.

Versions, and why you should not declare the newest one

The contract is at v6 as of ApexCharts 7.5. Every change so far has been additive:

VersionShipped inAdded
v16.0on, layer, scales, data, theme, store, emit, options, chart
v27.2data[].raw, api.info, api.categories, api.markDerived()
v37.2api.reserve()
v47.3api.pointer()
v57.4api.info.stroke.dashArray
v67.5api.can(), api.capabilities, api.claim(), api.drawn(), api.declare(), api.info.title, modifiers on the pointer payload

The gate is forward-compatible: a host serves a plugin that declares an older version, and skips only a plugin that needs a newer host than itself. So the version you declare is a minimum requirement, not a statement of what you were built against.

That makes declaring the newest version actively harmful. A plugin declaring apiVersion: 3 is skipped outright by a 7.1 host rather than served a smaller API, and a skipped plugin is silent. Declare the oldest version your plugin genuinely cannot work without, and ask for the rest.

Before 7.2 the gate demanded an exact match, which would have disabled every v1 plugin the moment v2 landed. Plugins written for v1 run unchanged on a v6 host.

Asking what the host supports

Declaring a low apiVersion and feature-detecting the rest was always the right shape, but until v6 the only way to do the detection was to sniff the facade yourself:

if (typeof api.reserve === 'function') { /* v3, optional */ }

A version integer never answered "does this host have X". api.can(name) does, and api.capabilities is the same list as a frozen array for logging and bug reports:

ApexCharts.registerPlugin({
  name: 'panel',
  apiVersion: 2,                    // the minimum this plugin needs
  setup(api) {
    if (api.can?.('reserve')) {      // optional-chained: v5 hosts have no `can`
      api.reserve({ right: 220 })
    }
  },
})

Note the ?.. A host older than 7.5 has no can at all, so a plugin that supports both has to guard the guard. Once you require 7.5 or newer, api.can('reserve') reads plainly.

The names are the capability, not the member: one name can cover a group of related members.

NameCoversSince
layerapi.layer()v1
derivedapi.markDerived()v2
reserveapi.reserve()v3
pointerapi.pointer()v4
stroke-infoapi.info.strokev5
claimapi.claim()v6
drawnapi.drawn(), api.declare()v6

The list is probed off the facade that was actually built, rather than copied from a constant. A member that is ever made conditional drops out of capabilities instead of being advertised and then missing, which is the failure mode a hand-maintained list eventually has.

All of v6 is reachable from a plugin still declaring apiVersion: 2. That is the point of having capabilities at all.

TypeScript authors: you want 7.6.0

api.info, api.markDerived(), api.reserve() and api.pointer() were implemented from v2 to v4 but were missing from the ApexPluginAPI type until 7.6.0. They worked at runtime the whole time; a plugin written in TypeScript simply could not call them without a cast. If you are writing against the types, require 7.6.0 even though the runtime contract is v6.

api.info: what kind of chart is this?

A plugin that adds a computed series or draws an analysis overlay cannot work on every chart type, and the alternative to asking is adding a series and letting the core warn at the user. api.info is a frozen snapshot, read fresh each time:

typeThe type the caller asked for. Survives the aliasing that rewrites e.g. raincloud to violin.
axisChartfalse for pie, donut and radialBar, where each entry is one number rather than a row of values.
datetimeXWhether the x axis is a datetime axis.
horizontalBarsThe core refuses to draw a horizontal bar in a combo, so a plugin must not add a derived series to one.
dataLabels.enabled / .enabledOnSeriesWhether the chart prints a value on each point, and for which series. There is no per-series data-label flag, so enabledOnSeries is the only way to keep labels off a computed series, and narrowing it without knowing the caller's own value would silently discard it.
stroke.dashArray (v5)The caller's own dashing. Scalar means every series, an array means per series.
title (v6)The name the page already gave this chart, from config.title.text. An empty string rather than undefined when the chart is untitled, so it can be used directly. Useful for a readout that would otherwise head each row with a container id.

stroke.dashArray exists for the same reason and against the same trap as enabledOnSeries. The option is indexed by series position with no per-series escape hatch, so a plugin that wants its own derived series dashed has to write the whole array. Reporting the current value is what lets it put back what it found instead of flattening the caller's dashed lines. There is no "unset" to report: the option defaults to 0, and 0 already means no dashing, so restoring it restores exactly what was there.

Adding a derived series

Three pieces of v2 exist for one job: computing a series from the caller's data and putting it on the chart without the chart mistaking it for the caller's own.

Copy the shape from data[].raw, not from points. points is normalised, and its x falls back to the ordinal position on render paths where the parsed x values are not populated. That is fine to read and wrong to write back, because the three accepted shapes ([1, 2], [{x, y}], [[x, y]]) are not interchangeable: hand back the wrong one and it parses to all-null and draws nothing, without an error. raw is the caller's own array, untouched by parsing.

Key on api.categories, not on an index. These are the resolved display labels, config-first. Reading globals.categoryLabels or globals.labels directly gives you real labels on first paint and ordinals after any updateSeries(), because both are populated on mount and emptied on update.

Declare what you added with api.markDerived(names). The core cannot tell a computed series from the caller's own, and several behaviours depend on the difference. The host uses it to keep your series out of the initial-series snapshot, so resetSeries() and the toolbar's reset restore the caller's data rather than your output. It is idempotent; pass an empty array when your series are gone. Since v6 it also decides the owner column in api.drawn(), so a series you computed is attributed to you rather than to the chart.

api.claim: setting an option for your own series

Some options are indexed by series position with no per-series alternative. stroke.dashArray is the clearest: a plugin that adds a projection and wants only the projection dashed has to write the array covering every series, and then put the caller's value back.

That restore has four failure modes, three of which no amount of care fixes:

  1. It flattens the caller's own dashes unless it first reads them (which is what api.info.stroke.dashArray was added in v5 to allow).
  2. It restores a stale value if the caller ran their own updateOptions meanwhile.
  3. It leaks the write permanently if the plugin throws before restoring.
  4. Two plugins doing it fight, and whichever releases last restores the other's write.

A claim is resolved where the option is read instead. Nothing is written to the caller's config, so releasing is a deletion rather than a restore, and there is nothing to put back or to get wrong. A caller's own updateOptions composes with the claim instead of reverting it.

const claim = api.claim('stroke.dashArray', [
  { series: 'Projection', value: 6 },
])

claim.update([{ series: 'Projection', value: 3 }])  // same place in the order
claim.release()                                      // idempotent

Name the series rather than its position where you can. The name is resolved each time the option is read, so the claim follows that series when others are added, removed or reordered. An index is resolved the same way but means whatever is in that slot at read time.

Two options are claimable today:

OptionValue per series
stroke.dashArraya number
dataLabels.enabledOnSeriesa boolean

dataLabels.enabledOnSeries is a membership list in the caller's config and a boolean in a claim on purpose: a claim answers "does this series print labels", which is the question the consumption site actually asks, rather than restating the caller's list shape.

The allowlist is the point rather than a limitation. An unbounded claim(path, value) would be "write anything to the caller's config", which is exactly what this platform refuses to do. An option earns a place only if it is positional, has no per-series alternative in the caller's own data, and is read in few enough places to resolve at all. colors fails the second test: a series carries its own color, so a plugin adding a series sets it there and needs nothing from the host.

Behaviour worth knowing before you rely on it:

  • It returns null for an option that is not claimable, so a plugin written against a newer host degrades instead of throwing. The warning names the claimable options.
  • A bad entry is dropped, not fatal. Claim ten series and get one wrong and the other nine still apply, with a warning naming the option and the value.
  • The last claim on a series wins, in the order plugins ran, which is deterministic and independent of render order.
  • Every claim is released on teardown, on destroy, and if the host disables the plugin.

api.drawn: what is on this chart

A plugin sees the chart through the facade, and until v6 the facade described the chart's series and nothing else: not the caller's annotations, not another feature's ink, not another plugin's overlay. So the panel everyone reaches for first, a list of what is on this chart, could only ever enumerate the asking plugin's own tools, which its own toolbar already shows.

api.drawn() returns that inventory, and api.declare() is how a plugin puts its own drawings into it:

api.on('draw', () => {
  const layer = api.layer()
  // ... paint the overlay ...
  api.declare({ id: 'trend', label: 'Trend line', visible: true })
})

api.drawn()
// [
//   { id: '…', kind: 'series',     label: 'Revenue',    owner: 'core',       visible: true },
//   { id: '…', kind: 'annotation', label: 'Launch',     owner: 'core',       visible: true },
//   { id: '…', kind: 'annotation', label: 'Check this', owner: 'ink',        visible: true },
//   { id: '…', kind: 'overlay',    label: 'Trend line', owner: 'my-plugin',  visible: true },
// ]

kind is 'series', 'annotation' or 'overlay'. Annotations cover the caller's xaxis, yaxis, points, texts and images buckets. For a series, visible reflects whether the viewer has switched it off in the legend, including a series on a secondary axis.

owner is 'core', the name of the plugin that declared it, or 'ink' for a note the viewer drew with Ink. That third case matters for the question this list is usually built to answer. Ink strokes are annotations, so before 7.6.1 they arrived through the annotation contributor and defaulted to 'core', reporting something a viewer had drawn as the page author's own. A readout asking "which of this is mine" got the wrong answer, which is worse than declining to answer.

Three properties of this API are deliberate and will not change without a version:

It is read only. Nothing here removes or hides anything. A list you can act on would mean letting a plugin change another feature's output, which is a much larger promise than this platform makes today.

It is partial, and it says so. Every contributor opts in. A feature that declares nothing is absent rather than approximated, which is exactly why each entry carries owner: a reader can tell what the list covers instead of assuming it is exhaustive.

Declarations cannot outlive what they describe. They are cleared with the layers at the start of every draw, so declare from your draw handler rather than once in setup. Emptying your layer also drops your declarations, which is how a plugin says "that is no longer on screen" for a drawing removed by an interaction rather than by a redraw.

If you consume this list, require 7.5.1 rather than 7.5.0. On 7.5.0 declarations were dropped only on a chart redraw, so an overlay switched off by an interaction (which empties its layer and repaints without a chart render) stayed in the list, and drawn() went on reporting something the viewer had just dismissed. A reader can see that a list covers only the features that opted in; they cannot see that a row in it is stale.

Making room for your own UI

A plugin that renders its own HTML beside the chart, a docked panel or a toolbar of its own, cannot make room for it. The chart sizes itself from the element the caller handed it, so a sibling inserted into that element does not narrow the chart: the chart is drawn at full width underneath. Every workaround is worse. Writing chart.width means owning config the caller owns and losing it on their next updateOptions. Positioning over the chart means guessing a size you cannot know and being clipped by any ancestor with overflow: hidden. Narrowing the container means writing to the caller's own element.

api.reserve() has the host do the arithmetic, in the one place that already does it. The container keeps its size and the chart draws inside what is left:

api.reserve({ right: 220 })   // a right-hand gutter for your panel
api.reserve(null)             // give the space back
  • Reservations are per plugin and summed, so two plugins each asking for a right-hand gutter get one each instead of overlapping.
  • The total is clamped to half the container on each axis. A plugin may not reduce the chart it is annotating to nothing. If your UI needs more room than that, render below the chart, which you can do without asking.
  • It is applied after the auto-height calculation, so a side panel narrows the chart without also shortening it and shifting the page below.
  • Calling it with an unchanged box does nothing, so calling it on every render is free. Changing it re-renders one task later, so calling it from inside a draw handler cannot re-enter the render.

Following the pointer

The chart already resolves the series and point under the pointer, for its own tooltip and for the dataPointMouseEnter, dataPointMouseLeave and dataPointSelection events. api.pointer(fn) forwards those three as one normalised payload, so a plugin gets the host's answer instead of hit-testing the SVG itself and then disagreeing with the tooltip on the same pixel:

const off = api.pointer((e) => {
  // e.type            'enter' | 'leave' | 'select'
  // e.seriesIndex     which series
  // e.dataPointIndex  which point
  // e.category        the resolved display label, e.g. 'Mar'
  // e.seriesName      undefined on a pie, where series carry bare numbers
  // e.selected        on 'select' only: is the point now in or out
  // e.modifiers       v6: { shift, ctrl, alt, meta }, the keys held
})

off()  // unsubscribe

category is the same string api.categories carries, because a plugin coordinating two charts keys on the label: an index means something different on each chart, and reading globals.labels directly reports 3 where the chart shows Mar. selected comes from the chart's own selection set, so a second click reads as a deselect rather than another select.

modifiers (v6) reports the keys held during the interaction, which is what makes shift-click to add to a selection expressible at all. The keys were on the DOM event the pointer handler had been discarding. All four are false when the interaction came from somewhere with no DOM event, such as the keyboard, so treat "no modifier" and "not a mouse" as the same case rather than inferring intent from them.

Nothing here lets a plugin intercept or cancel. The chart's tooltip, its selection state and the caller's own dataPoint* events are unaffected, and a handler that throws is contained rather than allowed to break the interaction it was watching. Wiring is lazy and torn down with the chart, so a chart whose plugins never ask pays nothing.

Pie, donut and radialBar

Weave works on non-axis charts. Before 7.2 every plugin silently did nothing on one: those charts hold one number per entry rather than a row of values, the data snapshot called .map on a number, and the per-plugin guard caught the exception and disabled the plugin, so the failure looked like the plugin's fault. Each slice is now presented as a one-point series, which is what it is. Check api.info.axisChart if your plugin needs a real axis.

Weave vs Marks

Both are extensibility features, but at different levels:

  • Weave adds overlays and cross-cutting behaviors that are not one-per-datum: reference lines, bands, watermarks, instrumentation. A Weave plugin decorates the whole chart.
  • Marks adds a new data-driven series type: one shape per datum, tied to your data. A Mark defines how a series is drawn.

For a one-off drawing on a single chart, annotations are simpler than either.