Wooden building blocks assembled into chart shapes on a workbench, with a gray-blue cat watching from the far end.

visx: Airbnb's Low-Level Lego Bricks for React Charts

The Gray Cat
The Gray Cat
1 view

Most charting libraries make a deal with you. You hand over your data, they hand back a finished chart, and everyone is happy right up until the moment a designer slides a mockup across the table that the library simply cannot draw. Now you are fighting the abstraction, overriding internals, and reading source code to figure out which undocumented prop bends the axis the way you need. visx makes a different deal.

@visx/visx is a collection of reusable, low-level visualization components built and maintained by Airbnb. The name is short for "visualization components," and the word components is doing a lot of work. visx is deliberately not a charting library in the Recharts or Chart.js sense. There is no <BarChart data={...} /> that renders a complete chart for you. Instead, visx gives you the raw building blocks, scales, shapes, axes, gradients, gridlines, tooltips, and you assemble them into exactly the visualization you want. Under the hood it leans on D3 for the heavy math and lets React own the DOM, which means you write ordinary SVG and never touch a single D3 selection.

That trade, a little more code in exchange for no ceiling, is what makes visx the tool teams reach for when they outgrow off-the-shelf charts. It is also why Airbnb recommends building your own in-house chart library on top of visx, hiding the primitives behind components your team finds easy to use.

The Two Mental Models Problem

To understand why visx exists, picture the old way of doing custom dataviz in React. You had two unappealing options. The first was a high-level charting library, fast to get a standard chart on screen, but a wall the moment your design diverged from what the library exposed. The second was raw D3 dropped into a useEffect, which meant running D3's imperative enter() / exit() / update() selection model right alongside React's declarative rendering. Two systems both trying to own the DOM, neither aware of the other.

visx threads the needle between them. It keeps D3's genuinely excellent calculation primitives, the scales, shape generators, hierarchies, and layouts, but throws away D3's DOM manipulation entirely. Everything renders as plain React and SVG. The result is that if you can read React, you can read a visx chart. There are no selections to reason about, no enter/exit choreography, just components rendering SVG elements from props.

The library is also published as roughly thirty independent scoped packages rather than one monolith, so you install only the pieces you actually use and keep your bundles small. A simple bar chart pulls in a handful of tiny modules; a network diagram pulls in different ones.

Getting the Pieces You Need

There is an umbrella package, @visx/visx, that pulls in everything, but the idiomatic approach is to install the individual packages your chart needs. For a basic bar chart, that means scales, shapes, and a grouping helper.

npm install @visx/shape @visx/scale @visx/group @visx/axis

Or with yarn:

yarn add @visx/shape @visx/scale @visx/group @visx/axis

A quick note on requirements: visx v4, the current stable release, needs React 18 or 19. The v4 release dropped support for React 16 and 17, bumped its internal d3-shape and d3-path dependencies to version 3, removed prop-types in favor of being fully TypeScript-first, and shed its lodash usage for a slimmer dependency tree. If you are coming from v3, the repository ships a migration guide, and the main friction points are the React version requirement and the D3 bump.

Drawing Your First Bars

Let us build the canonical visx example, a bar chart, so you can see how the pieces fit. Three concepts carry almost every visx chart: scales that map your data values to pixel coordinates, accessors that pull fields out of your data, and shapes that render the SVG.

import { letterFrequency } from '@visx/mock-data';
import { Group } from '@visx/group';
import { Bar } from '@visx/shape';
import { scaleLinear, scaleBand } from '@visx/scale';

const data = letterFrequency;
const width = 500;
const height = 500;
const margin = { top: 20, bottom: 20, left: 20, right: 20 };

const xMax = width - margin.left - margin.right;
const yMax = height - margin.top - margin.bottom;

const getLetter = (d: { letter: string }) => d.letter;
const getFrequency = (d: { frequency: number }) => d.frequency * 100;

const xScale = scaleBand<string>({
  range: [0, xMax],
  round: true,
  domain: data.map(getLetter),
  padding: 0.4,
});

const yScale = scaleLinear<number>({
  range: [yMax, 0],
  round: true,
  domain: [0, Math.max(...data.map(getFrequency))],
});

function BarChart() {
  return (
    <svg width={width} height={height}>
      <Group top={margin.top} left={margin.left}>
        {data.map((d) => {
          const letter = getLetter(d);
          const barHeight = yMax - (yScale(getFrequency(d)) ?? 0);
          return (
            <Bar
              key={`bar-${letter}`}
              x={xScale(letter)}
              y={yMax - barHeight}
              height={barHeight}
              width={xScale.bandwidth()}
              fill="#fc2e1c"
            />
          );
        })}
      </Group>
    </svg>
  );
}

There is more code here than a high-level library would need, and that verbosity is the entire point. Every pixel is visible and under your control. A few details are worth calling out because they trip people up. The scaleLinear range is written as [yMax, 0], deliberately inverted, because SVG's vertical axis grows downward and you want larger values to appear higher. The <Group> component is just an SVG <g> element with convenient top and left props, and wrapping your content in one offset by the margins is the classic D3 margin convention. Forget that wrapper and your chart clips against the edges of the SVG.

Axes, Gridlines, and Reading the Data

Bars alone are not a chart anyone can read. visx ships dedicated packages for the supporting cast. The @visx/axis package gives you AxisBottom, AxisLeft, AxisTop, and AxisRight, while @visx/grid provides gridlines. Because these consume the very same scales you already defined, wiring them in is almost trivial.

import { AxisBottom, AxisLeft } from '@visx/axis';
import { GridRows, GridColumns } from '@visx/grid';

function ChartWithAxes() {
  return (
    <svg width={width} height={height}>
      <Group top={margin.top} left={margin.left}>
        <GridRows scale={yScale} width={xMax} stroke="#e0e0e0" />
        <GridColumns scale={xScale} height={yMax} stroke="#e0e0e0" />
        {/* ...bars rendered here... */}
        <AxisBottom top={yMax} scale={xScale} numTicks={data.length} />
        <AxisLeft scale={yScale} />
      </Group>
    </svg>
  );
}

The shared-scale pattern is what makes visx feel cohesive despite being a pile of separate packages. The scale is the single source of truth for "where does this value live in pixel space," and bars, axes, gridlines, and tooltips all read from it. Change the domain in one place and everything redraws consistently.

Making It Fit the Container

Charts in visx are fixed-size by default, which surprises people who expect responsiveness for free. The fix lives in @visx/responsive, whose ParentSize component measures its container and hands the dimensions to a render prop. You compute your scales from those measured values instead of hardcoded numbers.

import { ParentSize } from '@visx/responsive';

function ResponsiveChart() {
  return (
    <div style={{ width: '100%', height: 400 }}>
      <ParentSize>
        {({ width, height }) => <BarChart width={width} height={height} />}
      </ParentSize>
    </div>
  );
}

This pushes you toward parameterizing your chart component on width and height rather than module-level constants, which is good practice anyway. Once your scales derive from props, the same chart renders crisply at any size.

Tooltips and Interaction Without the Pain

Interactivity is where homegrown D3 React code usually turns into spaghetti. visx keeps it declarative with a useTooltip hook from @visx/tooltip. The hook hands you the tooltip state and a pair of functions to show and hide it; you decide when to call them, typically from mouse handlers on your bars.

import { useTooltip, TooltipWithBounds } from '@visx/tooltip';

function InteractiveChart() {
  const {
    tooltipData,
    tooltipLeft,
    tooltipTop,
    tooltipOpen,
    showTooltip,
    hideTooltip,
  } = useTooltip<{ letter: string; frequency: number }>();

  return (
    <div style={{ position: 'relative' }}>
      <svg width={width} height={height}>
        {data.map((d) => (
          <Bar
            key={d.letter}
            /* ...positioning props... */
            onMouseMove={(event) =>
              showTooltip({
                tooltipData: d,
                tooltipLeft: event.clientX,
                tooltipTop: event.clientY,
              })
            }
            onMouseLeave={hideTooltip}
          />
        ))}
      </svg>
      {tooltipOpen && tooltipData && (
        <TooltipWithBounds left={tooltipLeft} top={tooltipTop}>
          <strong>{tooltipData.letter}</strong>: {tooltipData.frequency}
        </TooltipWithBounds>
      )}
    </div>
  );
}

One thing to understand: tooltips are rendered as HTML overlays positioned above the SVG, not as SVG elements inside it. That is why the chart is wrapped in a position: relative container, and it is why TooltipWithBounds can keep the tooltip from spilling off the edge of the viewport.

When You Want Less Boilerplate

If all of this feels like a lot of plumbing for a line chart, visx has an escape hatch that stays inside its own ecosystem: @visx/xychart. This is the one genuinely higher-level package in the collection, offering composable series components with shared scales, tooltips, and event handling already wired together, including animated variants of axes and series.

import {
  XYChart,
  AnimatedAxis,
  AnimatedLineSeries,
  Tooltip,
} from '@visx/xychart';

const series = [
  { x: 'Mon', y: 50 },
  { x: 'Tue', y: 80 },
  { x: 'Wed', y: 65 },
];

function QuickChart() {
  return (
    <XYChart height={300} xScale={{ type: 'band' }} yScale={{ type: 'linear' }}>
      <AnimatedAxis orientation="bottom" />
      <AnimatedAxis orientation="left" />
      <AnimatedLineSeries
        dataKey="Sales"
        data={series}
        xAccessor={(d) => d.x}
        yAccessor={(d) => d.y}
      />
      <Tooltip
        renderTooltip={({ tooltipData }) => (
          <div>{tooltipData?.nearestDatum?.datum.y}</div>
        )}
      />
    </XYChart>
  );
}

You trade some of the pixel-level control for far less code, which is a perfectly reasonable trade when your chart is conventional. The nice thing is you can mix and match: reach for xychart on the standard charts and drop down to the low-level primitives only where your design demands it.

Beyond Bars and Lines

The bar chart is just the doorway. visx ships specialized packages for whole families of visualization that would each be a project on their own elsewhere. There is @visx/hierarchy for trees, treemaps, packs, and partitions, wrapping d3-hierarchy. There is @visx/network for node-and-link graphs, @visx/sankey for flow diagrams, @visx/heatmap for grids, @visx/geo for map projections, and even @visx/wordcloud and @visx/voronoi. On the interaction side, @visx/zoom gives you a pan-and-scale transform matrix, @visx/drag handles dragging, and @visx/brush does drag-to-select. They all follow the same philosophy: math from D3, rendering from React, no selections in sight.

One deliberate omission is worth naming, because it is the most common criticism leveled at visx. There is no built-in animation, and the maintainers are upfront that this is a conscious choice rather than an oversight. Their reasoning is that animation is too powerful and too app-specific to bake in, and since visx is just React, you can bring whatever animation library you already use, react-spring, framer-motion, react-move, without paying for a bundled one you do not want. The @visx/xychart package does offer animated components for those who want them out of the box.

Should You Reach For It

visx is the right tool when you are building a custom, design-specific visualization in React and a stock charting library cannot express what your designers drew, and when you want to stay inside React's declarative model instead of bolting D3 onto a useEffect. It shines when you are building an in-house chart library for your team, which is its explicitly intended use, when bundle size matters enough that importing only the packages you touch is a real win, or when you simply want complete control over every SVG element, interaction, and accessibility detail.

Reach for something else when you need a standard chart by the end of the day, in which case Recharts or Nivo will get you there faster, or when you must render very large or streaming datasets at high frame rates, where a Canvas or WebGL library will comfortably outpace anything built on SVG. visx asks you for a little more upfront code and a working understanding of scales, domains, and ranges. In return it gives you a small, composable, genuinely React-native foundation with no ceiling, which is precisely why the teams that outgrow off-the-shelf charts keep landing here.