Trellis (Small Multiples)

Premium feature

Trellis (Small Multiples) is a Premium feature

Available on the Premium and OEM plans. It ships in the ApexCharts package as an opt-in import; add import 'apexcharts/features/trellis' to enable it.

Trellis is an opt-in import since 7.0

It is no longer part of the default bundle, so importing apexcharts alone does not include it. Add one line:

import ApexCharts from 'apexcharts'
import 'apexcharts/features/trellis'

Or, without a bundler, a second script tag after the main one: <script src=".../dist/features/trellis.js"></script>

If the feature is missing, ApexCharts says so in the console rather than failing quietly. See the v7 migration guide.

Comparing eight regions in one chart gives you eight overlapping lines and a legend nobody reads. The alternative, a grid of small charts, has always been possible by hand: build eight chart instances, work out a common y domain, then keep their plot rectangles, colours, legends and zoom windows in agreement forever. That last part is where hand-built small multiples go wrong, because a panel that is 4px narrower than its neighbour quietly lies about the shape it is showing.

Trellis makes it one key. trellis: { by: 'region' } splits the series array into one panel per region, and the host owns everything the panels have to agree on.

import ApexCharts from 'apexcharts'
import 'apexcharts/features/trellis'

const options = {
  chart: { type: 'line', height: 640 },
  series: [
    { name: 'Revenue', region: 'North', data: [/* … */] },
    { name: 'Revenue', region: 'South', data: [/* … */] },
    { name: 'Revenue', region: 'East',  data: [/* … */] },
  ],
  trellis: {
    by: 'region',
  },
}

That is the whole configuration. Every panel is a real chart of the host's chart.type, and the trellis resolves the union y domain, the shared x window, pixel-identical plot rectangles, colour by series name, one legend, one toolbar, one title, and a crosshair that sweeps all panels at the same x.

See it live

Six regions, drawn both ways. Switch to Trellis to apply the one key, then switch the y scale to see what a shared domain is doing for you.

Layout
Y scale
// no trellis: six series on one axis

Same seven series both ways. One chart puts six regions on one axis, where the lines cross often enough that reading any single region means hunting for its colour in the legend. Trellis is the one key in the snippet above: each region gets a panel, all six share the y domain and the x window, and one crosshair sweeps them together.

The dashed Target line carries no region key, so it belongs to no panel and is drawn in all of them. Switch Y scale to independent and each panel takes its own domain: the axis labels change but the plot rectangles still line up to the pixel, because the grid measures every panel’s label gutter and pushes the widest as a shared floor. Shapes stay comparable even when the numbers no longer are.

Where it is useful

  • Per-entity comparison. One panel per region, store, service, patient or ticker, where the shapes matter more than the exact values.
  • Distributions across cohorts. A histogram or box plot per cohort, sharing bins and a domain so the spreads compare.
  • Dashboard grids. A gauge or KPI panel per store, driven from one config instead of N chart instances.
  • Two-way breakdowns. Department by quarter, channel by market: a 2-D grid where both dimensions are labelled once.

Reference series repeat in every panel

A series that does not carry the facet key belongs to no single panel, so it is drawn in all of them. That is how a target, a benchmark or a prior-year line reaches every panel without being duplicated in the data:

series: [
  { name: 'Revenue', region: 'North', data: northData },
  { name: 'Revenue', region: 'South', data: southData },
  { name: 'Target', data: targetData },   // no `region`, so it appears in both panels
]

facet is the typed field for this on a series object, so TypeScript users can write facet: 'North' with trellis: { by: 'facet' }. From plain JavaScript any key name works, and the function form (by: (s) => s.meta.region) works from either.

Shared or independent scales

A shared domain is what makes panels comparable. An independent one is what makes a small panel readable. scales decides per channel:

trellis: {
  by: 'ticker',
  scales: {
    y: 'independent',   // each ticker on its own scale
  },
}

With y: 'shared' (the default) a taller shape really is a bigger number, which is the point of small multiples. With y: 'independent' each panel gets its own honest domain, which is what you want for series whose magnitudes differ by orders of magnitude.

Panels stay pixel-aligned either way. Independent scales mean different label widths, which would normally leave every panel a slightly different size. A gutter pass measures each panel's axis-label width and pushes the widest as a shared floor, so the plot rectangles still agree to the pixel and the shapes stay comparable even when the numbers do not.

In a 2-D grid, y also takes 'independent-row' and 'independent-column': one shared domain per row or per column, comparable along the group and free across groups.

Panels stay honest per chart type

A shared y domain is not enough for every chart type. Some draw a domain that is not their data's y values, and some carry a scale channel the y machinery never sees. Left alone, each panel would derive that hidden frame from its own data and the panels would quietly stop being comparable, which is the worst thing a trellis can do. So the grid resolves one shared frame per type over the union of all panels:

histogramOne set of bin edges across the grid, and a bin-count y domain. Per-panel bins would put the same bar width over different value ranges, and the observations' own extent is a different axis entirely.
violinOne KDE bandwidth. The automatic rule derives bandwidth from each panel's own spread, so identical options would otherwise smooth each panel differently.
heatmapOne colorScale min and max, because colour is the value channel here. scales.color: 'independent' is refused with a warning rather than honoured: the same colour meaning different values in neighbouring panels is a silent lie.
bubbleOne z extent, pushed through plotOptions.bubble.minZ / maxZ, so bubble areas compare across panels.
pie / donut / polarAreaPer-panel radius from the panel's total via radiusByTotal, scaled so area rather than radius is proportional.

Heatmap and the radial types have no y axis carrying their values (heatmap rows are categories), so the shared-y push is skipped for them entirely. Radar is the exception that proves the rule: its yaxis.max is the radial scale, so the ordinary shared-y push is exactly what "one shared max radius" means.

2-D grids: row × column

Set row and column instead of by and the grid becomes every combination of the two, in row-major order. Column labels draw once across the top, row labels once down the left, so neither is repeated in every cell:

trellis: {
  row: 'dept',
  column: 'quarter',
}

A 2-D grid has a fixed column count (one per column key) and panels shrink rather than re-column on resize, because re-columning would break the row and column labels. Combinations with no data are governed by emptyPanels: 'placeholder' (the default) mounts a real empty panel at the shared geometry with a quiet "no data" label, so the grid stays rectangular and the labels keep lining up.

Reference semantics work per dimension. A series carrying only the row key repeats across that row; only the column key, down that column; neither, everywhere.

Tidy rows instead of series

If your data arrives as a row table, you can hand it over as-is and name the columns rather than pivoting it yourself:

trellis: {
  data: rows,        // [{ month: '2025-01', region: 'North', revenue: 58 }, …]
  by: 'region',
  x: 'month',
  y: 'revenue',
  seriesBy: 'channel',   // optional: which column becomes the series name
}

Rows win over series when both are given. Aggregation is deliberately out of scope: two rows landing on the same (panel, series, x) keep the last and warn, because silently summing is worse than refusing. Aggregate first if that is what you meant.

One grid, one set of chrome

The trellis owns the shared furniture, so it behaves like one chart rather than N:

legend: 'shared'One legend for the grid. Clicking an entry toggles that series name in every panel.
toolbar: 'shared'One zoom / pan / reset toolbar, plus a download menu that exports the whole grid.
zoom: 'sync'A zoom or pan in any panel moves every panel.
axes: { labels: 'edges' }y labels on the first column, x labels on each column's bottom panel. Label space is reserved everywhere regardless, so panels stay aligned.
headerThe per-cell facet label, with a formatter receiving (key, { dimension, index, count }).

Exports compose one artifact for the whole grid rather than one per panel: dataURI() and getSvgString() render the panels at their true cell offsets, and exportToCSV() emits wide-form rows with a facet column.

Tooltips across panels

The crosshair always sweeps every panel. tooltip decides where the card goes:

  • 'panel' (default) shows a card only in the hovered panel.
  • 'sync' shows each panel its own card at the hovered x.
  • 'grid' shows one card near the cursor with a row per panel at the hovered x, composed from the panels' own tooltips so every formatter is honoured.

For panels a normal card would cover, pair 'grid' with tooltip.compact, which collapses the card to one tight line.

Annotations across panels

Declare an annotation once on the host and it draws in every panel, projected through that panel's own scale. scope narrows it to named panels:

annotations: {
  yaxis: [
    { y: 60, y2: 100, fillColor: '#22C55E', opacity: 0.1 },   // every panel
  ],
  xaxis: [
    { x: medicationTime, scope: 'P-07', borderColor: '#D97706' },  // one panel
  ],
}

A reference band declared once is the common case: the band means the same thing in every panel, and repeating it per panel would be N copies to keep in step.

Promoting a panel

A grid answers "which of these is different"; a promoted panel answers "so what happened in that one". Clicking a cell header expands that panel to the grid's full width, with an "All panels" breadcrumb back. It is on by default (promote: true) and also driven from code:

await chart.promotePanel('checkout')
await chart.restorePanels()

Reaching into a panel

The host re-exposes what is shared. For anything per-panel, take the panel's own instance and use the ordinary per-chart API:

chart.getPanels()          // [{ key, index, chart, el }, …] in grid order
chart.getPanel('North')    // that panel's ApexCharts instance, or null

getPanel() returns null for a panel that is not currently mounted, which happens under virtualization.

Large grids

virtualize: 'auto' mounts only the panels intersecting the viewport, plus one row, once the grid exceeds 64 panels. An unmounted cell keeps its header and a fixed-height skeleton, so page height and scroll position never shift while you scroll. A panel that scrolls out is destroyed with its view state stashed, and a remount restores its zoom window.

For a top-N grid, limit renders the first N panels in order and warns about the rest, so you do not have to pre-slice the data.

Pie panels and honest area

A grid of equal-size pies cannot encode magnitude: every panel looks like the same amount of business. radiusByTotal: true scales each panel's radius so its area is proportional to that panel's total, which is what makes a pie trellis worth drawing.

Chart types

Most types work as panels: line, area, bar, column, histogram, box plot, violin, scatter, bubble, heatmap, candlestick, pie, donut, polarArea and radialBar.

Three are refused, with a console warning and a fallback to a single chart:

treemapArea encoding needs room a panel cannot give.
sunburstIts labels are illegible at panel size.
unitA redirect, not a limitation: plotOptions.unit.grid.split already draws one mini-waffle per category inside a single instance, which beats N unit panels.

A by key that no series carries is also a fallback rather than an error: the chart renders as a single chart and the warning names the offending key.

Building a trellis imperatively

The static entry point creates a host directly, for code that never assembles an options object by hand:

const chart = ApexCharts.trellis(document.querySelector('#grid'), options)

options must carry trellis.by. It returns null when the trellis feature is not loaded, and render() on the returned instance settles with the same in-flight mount.

Per-panel overrides

panel is merged last, per panel, for what genuinely differs:

trellis: {
  by: 'store',
  panel: (key, { index, seriesNames }) => ({
    colors: [attainment(key) >= 85 ? '#15803D' : '#DC2626'],
  }),
}

Anything shared belongs in the host options instead, so the trellis can keep the panels aligned.

Reference

Every option is listed in the trellis options reference. For the panel grid live, see the trellis demos.

Trellis ships as a tree-shakeable entry point; see the tree-shaking guide.