Skip to content

Comparable Horizontal Bar

Comparison

Did it get better or worse? Put before and after side by side on one bar per label, and the gap that closed (or opened) is the first thing the reader sees.

Example
  • 2019 (before, pale tint)
  • 2024 (after, solid)
canvas · responsive
Go deeper: Insights guide·DevTools guide

The chart above is the same engine in every framework - only the integration code below differs.

Heavy data on WebGPU Experimental

ComparableHorizontalBarChart has an opt-in renderer="webgpu" that paints the two sub-bars per row as GPU-instanced rectangles while axes, labels and tooltips stay on the SVG layer. It is capability-gated: on a browser without WebGPU it downgrades to canvas automatically, and getContext().renderer reports whichever actually painted.

⚗️ Experimental - not yet stable. WebGPU rendering is an opt-in preview. It needs a WebGPU-capable browser (Chrome / Edge, or Safari 26+); everywhere else it falls back to canvas automatically. Axes, labels and tooltips stay on the SVG layer - only the data marks are painted on the GPU.
Heavy-data demo · ~120 rows… detecting

Play through the years

Give every row a date and flip on timeline: the bar chart becomes a year-by-year story with its own play button and scrubber, snapshotting one period's before/after gap at a time. Off by default - nothing changes until a chart opts in.

Press the play button under the chart: it steps through the years, one snapshot at a time. Drag the scrubber to jump to any year.

tsx
const ref = useRef<ComparableHorizontalBarChartHandle>(null);

<ComparableHorizontalBarChart ref={ref} {...props} timeline={{ speedMs: 1000, loop: true }} />;
// ref.current?.timeline() -> play() / pause() / seek(year) / stepForward()
vue
<ComparableHorizontalBarChart :options="{ ...props, timeline: { speedMs: 1000, loop: true } }" />
svelte
<div use:comparableHorizontalBarChart={{ ...props, timeline: { speedMs: 1000, loop: true } }}></div>
ts
applyComparableHorizontalBarChartProps(this.c.nativeElement, { ...props, timeline: { speedMs: 1000, loop: true } });
html
<michi-vz-comparable-horizontal-bar-chart id="c"></michi-vz-comparable-horizontal-bar-chart>
<script>
  const el = document.getElementById("c");
  el.timeline = { speedMs: 1000, loop: true };
  // el.getTimeline() -> play() / pause() / seek(year)
</script>
  • speedMs sets the pace, loop wraps around, autoplay: true starts on mount, showControl: false hides the built-in bar.
  • Values glide between periods by default (interpolate); tune the motion with tweenMs and easing, or set interpolate: false for hard cuts. Reduced motion always gets the hard cut.
  • The headless controller is always available: chart.timeline() exposes play() / pause() / toggle() / seek(period) / stepForward() / stepBack(), plus onStep and formatPeriod in the config for custom UI.
  • A filter (top-N, sorting) still applies inside each period, so a top 5 per year race works out of the box.
  • Rows without a date stay visible in every period.

Usage

tsx
import { ComparableHorizontalBarChart } from "@michi-vz/react";

export default () => <ComparableHorizontalBarChart {...props} />; // props = the chart options
vue
<script setup>
import { ComparableHorizontalBarChart } from "@michi-vz/vue";
</script>

<template>
  <ComparableHorizontalBarChart :options="props" />
</template>
svelte
<script>
  import { comparableHorizontalBarChart } from "@michi-vz/svelte";
</script>

<div use:comparableHorizontalBarChart={props}></div>
ts
// main.ts - register the elements once
import "@michi-vz/angular";
import { applyComparableHorizontalBarChartProps } from "@michi-vz/angular";

// component (uses CUSTOM_ELEMENTS_SCHEMA)
// template: <michi-vz-comparable-horizontal-bar-chart #c></michi-vz-comparable-horizontal-bar-chart>
applyComparableHorizontalBarChartProps(this.c.nativeElement, props);
html
<script type="module" src="https://cdn.jsdelivr.net/npm/@michi-vz/wc/dist/michi-vz-wc.bundle.js"></script>

<michi-vz-comparable-horizontal-bar-chart id="c"></michi-vz-comparable-horizontal-bar-chart>
<script>
  Object.assign(document.getElementById("c"), props); // dataSet/series, title, …
</script>
ts
import { mountComparableHorizontalBarChart } from "@michi-vz/core";

const chart = mountComparableHorizontalBarChart(el, props);
chart.update(next);
chart.getContext(); // renderer-agnostic, LLM-ready
chart.destroy();

API

Props are typed as ComparableHorizontalBarChartProps in @michi-vz/core. Shared across all charts: width, height, margin, colors / colorsMapping, renderer ("svg", "canvas", or experimental "webgpu"), highlightItems, disabledItems, and the on* callbacks. onChartDataProcessed / getContext() return the renderer-agnostic ChartContext.

Behaviour notes

Two sub-bars per row

Each row draws valueBased (pale) and valueCompared (solid), diverging from x=0; the shorter sub-bar is drawn on top so both stay visible whichever way the value moved. colorsBasedMapping gives the value-based sub-bar its own colour per label: pair an opaque light tint with valueBasedOpacity: 1 (as the demo above does) for the crispest before/after contrast in both themes. valueBasedOpacity / valueComparedOpacity set their fill opacity. A sub-bar whose resolved fill is transparent is skipped (consumers hide one half via CSS). minBarWidth (default 5) floors a non-zero bar so near-zero values stay visible.

patternsMapping - hatch / image fills

patternsMapping: Record<label, imageSrc> fills the value-based sub-bar with a tiled image instead of a flat colour. createHatchPattern({ color, angle?, spacing?, strokeWidth? }) (exported from @michi-vz/core and @michi-vz/react) returns a diagonal-hatch SVG data-URI for the common case. The canvas renderer tiles it via ctx.createPattern and re-renders once the image loads.

Value axis (x)

xAxisPredefinedDomain: [min, max] fixes the value-axis range (alias of xAxisDomain). showZeroLineForXAxis draws a solid line at x=0 (diverging charts); showGrid toggles vertical gridlines (default off). xAxisFormat formats the tick labels.

Label column (y)

The y-axis category labels live in a left column tickHtmlWidth px wide (default 100, ellipsised). padding.left insets the plot (bars + value axis) to the right WITHOUT moving the labels - opening room for a wide label column. horizontalTickPosition: { x, y } nudges the labels to align with an external legend. hideTickLabels hides them entirely (when the category names live in a legend instead).

Tooltip

tooltipFormatter(datum, dataSet, type) receives the hovered row, all rows, and the hovered sub-bar type ("based" | "compared"). It returns an HTML string; the React wrapper additionally accepts a React node (converted to static HTML). The built-in tooltip is edge-aware (flips near the right/top edges).

Loading / no-data + interaction

isLoading and isNodata drive the overlay (React: isLoadingComponent / isNodataComponent). Hovering highlights a row (others dim) and mouseleave clears it; the bars are rounded (radius 5) with a 1px border.

Consumer colour authorities: the context carries legendData ({ label, color, dataLabelSafe }) so a CSS-injection colour system can key per-label rules; onChartDataProcessed is only emitted when the context changes (re-emitting an unchanged context every render can loop a consumer that dispatches on each call).