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) / seekIndex(i) / 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) / seekIndex(i)
</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) / seekIndex(i) / stepForward() / stepBack(), plus onStep and formatPeriod in the config for custom UI.
  • seek(period) looks for the period first, comparing as text, so seek(2021) and seek("2021") both land on 2021 whether your dates are numbers or strings. A number counts as a position (0 = first) only when no period matches, and a string that matches none does nothing. seekIndex(i) always goes by position, as the built-in scrubber does.
  • 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.

Corner radius ​

Every bar is drawn with 5px corners by default. On a small screen the bars get thin - the grouped layout's half-bands can be about 10px thick - and a 5px radius on a 10px bar makes a pill. barRadius sets the radius in px: 0 for square corners, 2-4 for square-ish ones.

barRadius: 5 (default)
barRadius: 2
barRadius: 0

Same chart, same data; only barRadius changes. Switch the renderer: svg and canvas round the corners identically.

tsx
<ComparableHorizontalBarChart {...props} layout="grouped" barRadius={2} />
vue
<ComparableHorizontalBarChart :options="{ ...props, layout: 'grouped', barRadius: 2 }" />
svelte
<div use:comparableHorizontalBarChart={{ ...props, layout: "grouped", barRadius: 2 }}></div>
ts
applyComparableHorizontalBarChartProps(this.c.nativeElement, { ...props, layout: "grouped", barRadius: 2 });
html
<michi-vz-comparable-horizontal-bar-chart layout="grouped" bar-radius="2"></michi-vz-comparable-horizontal-bar-chart>
  • The drawn radius never exceeds half the bar's width or height, in svg and canvas alike.
  • Leaving barRadius out keeps the historical 5px look. A negative value draws square corners.
  • The experimental webgpu renderer draws square corners whatever the value.

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 by default, set by barRadius) 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).