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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- d3-visualizations Installs to ~/.agents/skills/d3-visualizations/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install d3-visualizations The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project d3-visualizations Installs to .agents/skills/d3-visualizations/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/d3-visualizations.zipunzip -o d3-visualizations.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) d3-visualizations PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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
- You are deciding which chart type, colors or annotations to use: use data-visualization-design
- The visual is a 3D scene rather than a chart: use threejs-scenes
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.
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
| Situation | Use |
|---|---|
| Standard bar/line/area chart in a dashboard | A chart library (Observable Plot, Recharts, ECharts, Vega-Lite); D3 is overkill |
| Exploratory or quick custom chart | Observable Plot (@observablehq/plot): concise, built on D3 |
| Bespoke encoding, custom interaction, editorial graphic | D3 |
| Over ~5k marks or real-time streaming | D3 scales + canvas rendering (or WebGL) |
| Network or hierarchy layouts | d3-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
| Data | Scale | Notes |
|---|---|---|
| Quantity, length encodes value (bars) | scaleLinear with domain starting at 0 | Never truncate a bar’s baseline |
| Quantity, position only (line, scatter) | scaleLinear with .nice() | Non-zero domain allowed, label it |
| Wide-ranging positive values | scaleLog (domain must not include 0) or scaleSymlog | Say “log scale” on the axis |
| Timestamps | scaleUtc for UTC data, scaleTime for local | Parse with d3.utcParse / d3.isoParse |
| Categories on an axis | scaleBand().padding(0.2) | Use bandwidth() for bar width |
| Categories to color | scaleOrdinal(palette) with an explicit domain | Fix the domain so colors stay stable when filtering |
| Continuous to color | scaleSequential(d3.interpolateViridis); diverging with scaleDiverging | Perceptually uniform interpolators only |
| Area of circles | scaleSqrt | Radius 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 withd3.bisector((d) => d.date).center(data, x.invert(mx))where[mx] = d3.pointer(event, g.node()). Add a transparent overlayrectto 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 wholeg(it scales stroke widths and text). - Brush:
d3.brushX().extent([[0, 0], [w, h]]).on('end', ({ selection }) => ...);selectionis 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
| Approach | When |
|---|---|
| Framework renders SVG elements, D3 computes scales, shapes, layouts | Default for React/Vue/Svelte: declarative, SSR-friendly, testable |
D3 owns a container via ref + effect | Complex 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, oraria-hiddenon 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.maxreturnsundefinedfor empty arrays; guard domains or the chart renders NaN paths.- Date strings without a zone parse as local time in
new Date(); used3.utcParsefor 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) => ...):thisis not the element; useevent.currentTarget. - Axis tick text inherits
font-familysans-serif from D3’s defaults; setfont: inheriton.tick textfor design consistency.
Verification
- Hand-check two plotted values against the raw data (hover or inspect
d/yattrs) - 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.yaml
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.