D3 Visualizations skill for Codex

Bespoke charts with honest scales and keyed joins. Codex first checks whether a chart library would do, then builds D3 charts with a static skeleton and an update function so nothing duplicates on re-render. It picks scales by encoding (zero baselines for bars, sqrt for circle area, UTC for UTC data), joins with stable keys, resizes with ResizeObserver, switches to canvas past a few thousand marks, and adds titles, summaries and reduced-motion handling.

  • Frontend
  • $d3-visualizations
  • 200 lines
  • 2 files
  • ~2,300 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- d3-visualizations

Installs to ~/.agents/skills/d3-visualizations/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $d3-visualizations, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Builds custom charts with D3 v7 - the margin convention, scales and axes, keyed data joins with selection.join, transitions, responsive SVG with ResizeObserver, tooltips, zoom and brush, canvas for large datasets, and accessible fallbacks - plus clean D3-in-React and Svelte integration. Use when a chart library cannot express the design, when building a bespoke line, bar, scatter, area, heatmap or network chart, when a D3 chart duplicates elements on update, or when a chart must resize or animate correctly.

Use it when

  • A chart design goes beyond what a chart library supports
  • A D3 chart duplicates elements, animates the wrong items or will not resize
  • Plotting large datasets that make SVG sluggish
  • Integrating D3 with React, Vue or Svelte components

Use something else when

How to check the result

Spot-check plotted values against raw data, then update with added, removed and reordered rows and resize from phone to desktop width without duplicate or overlapping elements.

The mistake it prevents

Joining data without a key function, so updates match by index and bars animate into the wrong categories.

~/.agents/skills/d3-visualizations/SKILL.md

--- name: d3-visualizations description: Builds custom charts with D3 v7 - the margin convention, scales and axes, keyed data joins with selection.join, transitions, responsive SVG with… license: MIT ---

A good D3 chart is correct first (scales match the data domain, time is parsed in the right zone, zero baselines where length encodes value), then legible (labelled axes, units, no overlapping ticks), then responsive and accessible. Animation comes last and must never misrepresent the data in between states.

Decide whether D3 is the right tool

SituationUse
Standard bar/line/area chart in a dashboardA chart library (Observable Plot, Recharts, ECharts, Vega-Lite); D3 is overkill
Exploratory or quick custom chartObservable Plot (@observablehq/plot): concise, built on D3
Bespoke encoding, custom interaction, editorial graphicD3
Over ~5k marks or real-time streamingD3 scales + canvas rendering (or WebGL)
Network or hierarchy layoutsd3-force, d3-hierarchy (tree, treemap, pack)

Read AGENTS.md, check package.json for d3 (v7, ESM-only) and the UI framework. Import only what you use: import { scaleLinear, axisBottom, select } from 'd3' is fine with tree-shaking; individual d3-scale packages are equivalent.

Structure: margin convention

import * as d3 from 'd3';

type Point = { date: Date; value: number };

export function lineChart(el: HTMLElement, data: Point[]) {
  const margin = { top: 16, right: 24, bottom: 32, left: 48 };
  const svg = d3.select(el).append('svg')
    .attr('role', 'img')
    .attr('aria-labelledby', 'chart-title chart-desc');
  svg.append('title').attr('id', 'chart-title').text('Daily active users');
  svg.append('desc').attr('id', 'chart-desc').text('Line chart of daily active users over the last 90 days');

  const g = svg.append('g');
  const xAxisG = g.append('g');
  const yAxisG = g.append('g');
  const path = g.append('path').attr('fill', 'none').attr('stroke', 'currentColor').attr('stroke-width', 1.5);

  const x = d3.scaleUtc();
  const y = d3.scaleLinear();
  const line = d3.line<Point>()
    .defined((d) => Number.isFinite(d.value))   // gaps for missing values, not zeros
    .x((d) => x(d.date))
    .y((d) => y(d.value));

  function render(width: number, height: number) {
    const w = width - margin.left - margin.right;
    const h = height - margin.top - margin.bottom;
    svg.attr('viewBox', `0 0 ${width} ${height}`).attr('width', width).attr('height', height);
    g.attr('transform', `translate(${margin.left},${margin.top})`);

    x.domain(d3.extent(data, (d) => d.date) as [Date, Date]).range([0, w]);
    y.domain([0, d3.max(data, (d) => d.value) ?? 0]).nice().range([h, 0]);

    xAxisG.attr('transform', `translate(0,${h})`)
      .call(d3.axisBottom(x).ticks(Math.max(2, Math.floor(w / 80))));
    yAxisG.call(d3.axisLeft(y).ticks(Math.max(2, Math.floor(h / 40))).tickFormat(d3.format('~s')));
    path.datum(data).attr('d', line);
  }

  const ro = new ResizeObserver(([entry]) => {
    const { width } = entry.contentRect;
    render(width, Math.max(200, width * 0.5));
  });
  ro.observe(el);
  return () => { ro.disconnect(); svg.remove(); };
}

Create the static skeleton (groups, axes containers) once; render only updates attributes. Appending axes inside render is the usual cause of duplicated elements.

Scales

DataScaleNotes
Quantity, length encodes value (bars)scaleLinear with domain starting at 0Never truncate a bar’s baseline
Quantity, position only (line, scatter)scaleLinear with .nice()Non-zero domain allowed, label it
Wide-ranging positive valuesscaleLog (domain must not include 0) or scaleSymlogSay “log scale” on the axis
TimestampsscaleUtc for UTC data, scaleTime for localParse with d3.utcParse / d3.isoParse
Categories on an axisscaleBand().padding(0.2)Use bandwidth() for bar width
Categories to colorscaleOrdinal(palette) with an explicit domainFix the domain so colors stay stable when filtering
Continuous to colorscaleSequential(d3.interpolateViridis); diverging with scaleDivergingPerceptually uniform interpolators only
Area of circlesscaleSqrtRadius is not proportional to value

Data joins

function updateBars(g: d3.Selection<SVGGElement, unknown, null, undefined>,
                    data: { id: string; label: string; value: number }[]) {
  const t = g.transition().duration(prefersReducedMotion() ? 0 : 400);
  g.selectAll<SVGRectElement, (typeof data)[number]>('rect')
    .data(data, (d) => d.id)                       // key: stable identity, not index
    .join(
      (enter) => enter.append('rect')
        .attr('x', (d) => x(d.label)!).attr('width', x.bandwidth())
        .attr('y', y(0)).attr('height', 0)
        .call((s) => s.transition(t).attr('y', (d) => y(d.value)).attr('height', (d) => y(0) - y(d.value))),
      (update) => update
        .call((s) => s.transition(t)
          .attr('x', (d) => x(d.label)!).attr('width', x.bandwidth())
          .attr('y', (d) => y(d.value)).attr('height', (d) => y(0) - y(d.value))),
      (exit) => exit.call((s) => s.transition(t).attr('height', 0).attr('y', y(0)).remove()),
    );
}

const prefersReducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  • Without a key function, D3 joins by index and bars “morph” into the wrong items.
  • Interrupting transitions: starting a new transition on the same element with the same name cancels the old one, which is what you want for rapid updates.
  • Line and area paths morph badly when point counts change; use a fixed sample grid or transition opacity instead of d.

Interaction

  • Tooltips: one absolutely positioned DOM element, moved on pointermove. For line charts, find the nearest point with d3.bisector((d) => d.date).center(data, x.invert(mx)) where [mx] = d3.pointer(event, g.node()). Add a transparent overlay rect to catch events.
  • Zoom: d3.zoom().scaleExtent([1, 20]).on('zoom', (e) => { const zx = e.transform.rescaleX(x); ... }) and redraw with the rescaled scale; do not transform the whole g (it scales stroke widths and text).
  • Brush: d3.brushX().extent([[0, 0], [w, h]]).on('end', ({ selection }) => ...); selection is null when cleared.
  • Keyboard: make focusable marks (tabindex="0", role="graphics-symbol" or a list of buttons) only for small datasets; for large ones, provide a data table.

Large data: canvas

Above roughly 5,000 SVG nodes, interactions lag. Keep D3 scales and axes in SVG, draw marks to a canvas behind it:

const ctx = canvas.getContext('2d')!;
const dpr = window.devicePixelRatio || 1;
canvas.width = w * dpr; canvas.height = h * dpr;
canvas.style.width = `${w}px`; canvas.style.height = `${h}px`;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h);
for (const d of data) { ctx.fillRect(x(d.a) - 1, y(d.b) - 1, 2, 2); }

Hit-test with d3.quadtree(data, (d) => x(d.a), (d) => y(d.b)).find(mx, my, 10).

Framework integration

ApproachWhen
Framework renders SVG elements, D3 computes scales, shapes, layoutsDefault for React/Vue/Svelte: declarative, SSR-friendly, testable
D3 owns a container via ref + effectComplex transitions, zoom/brush behaviours, porting existing D3 code
// React: D3 for math, JSX for elements
const x = useMemo(() => d3.scaleBand(data.map((d) => d.label), [0, w]).padding(0.2), [data, w]);
return (
  <g>{data.map((d) => (
    <rect key={d.id} x={x(d.label)} width={x.bandwidth()} y={y(d.value)} height={y(0) - y(d.value)} />
  ))}</g>
);

For D3-owned containers: create in the effect, return cleanup that removes listeners and nodes, and never let React render children into the same element.

Accessibility and honesty

  • role="img" plus <title>/<desc> on the SVG, or aria-hidden on the SVG with an adjacent data table or text summary for screen readers.
  • Do not encode meaning with color alone; add direct labels, patterns or shapes.
  • Check contrast of marks against the background (3:1 for graphical objects).
  • Label units and time zones on axes; state the data source and date under the chart.

Gotchas

  • d3.max returns undefined for empty arrays; guard domains or the chart renders NaN paths.
  • Date strings without a zone parse as local time in new Date(); use d3.utcParse for UTC data or days shift for users west of UTC.
  • selection.attr('d', ...) with NaN values silently renders nothing: validate input.
  • Arrow functions in .on('click', (event, d) => ...): this is not the element; use event.currentTarget.
  • Axis tick text inherits font-family sans-serif from D3’s defaults; set font: inherit on .tick text for design consistency.

Verification

  • Hand-check two plotted values against the raw data (hover or inspect d/y attrs)
  • Update with added, removed and reordered data: no duplicates, correct items animate
  • Resize from 320px to 1440px: ticks do not overlap, labels stay inside the viewBox
  • Empty, single-point and all-zero datasets render a sensible state
  • Reduced motion disables transitions; screen reader announces title and summary

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "D3 Visualizations"
  short_description: "Bespoke charts with honest scales and keyed joins"
  default_prompt: "$d3-visualizations Build a D3 line chart with a hover tooltip and brush to zoom the date range"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem