D3.js provides powerful primitives for custom data visualizations — d3.select(ref.current) selects DOM elements. d3.scaleLinear().domain([0, max]).range([0, width]) creates linear scales. d3.axisBottom(xScale).ticks(6).tickFormat(d3.format(".0s")) generates axes. d3.line<D>().x(accessor).y(accessor).curve(d3.curveMonotoneX) builds line generators. d3.area<D>() builds area generators with .x0()/.x1()/.y0()/.y1(). d3.pie<D>().value(d => d.count) computes pie slices. d3.arc() renders arc paths. d3.hierarchy(root) creates tree data. d3.treemap().size([w, h]) computes treemap rects. d3.forceSimulation(nodes) with .force("link", d3.forceLink(links)) builds network graphs. d3.zoom().on("zoom", handler) adds pan/zoom. d3.brushX().on("end", handler) adds range brushes. selection.transition().duration(600).attr(...) animates changes. Claude Code generates D3 custom charts, force graphs, treemaps, and animated dashboards.
CLAUDE.md for D3.js Advanced
## D3.js Advanced Stack
- Version: d3 >= 7.9 (full bundle) or individual @d3/* packages
- React integration: const ref = useRef<SVGSVGElement>(null); useEffect(() => { const svg = d3.select(ref.current); /* draw */ return () => svg.selectAll("*").remove() }, [data])
- Scale: const x = d3.scaleLinear().domain(d3.extent(data, d => d.x) as [number, number]).range([margin.left, width - margin.right])
- Axis: svg.append("g").attr("transform", `translate(0,${height - margin.bottom})`).call(d3.axisBottom(x).ticks(5))
- Transition: selection.transition().duration(400).ease(d3.easeQuadOut).attr("y", d => y(d.value))
- Zoom: const zoom = d3.zoom<SVGSVGElement, unknown>().scaleExtent([0.5, 8]).on("zoom", (e) => g.attr("transform", e.transform))
useD3 Hook
// lib/hooks/useD3.ts — React hook for D3 DOM integration
import { useRef, useEffect, type RefObject } from "react"
import type * as d3 from "d3"
type D3Selection = d3.Selection<SVGSVGElement, unknown, null, undefined>
export function useD3(
renderFn: (svg: D3Selection) => (() => void) | void,
deps: React.DependencyList,
): RefObject<SVGSVGElement | null> {
const ref = useRef<SVGSVGElement | null>(null)
useEffect(() => {
if (!ref.current) return
// Dynamic import to avoid SSR issues
import("d3").then((d3) => {
const svg = d3.select(ref.current!)
const cleanup = renderFn(svg as any)
return () => {
cleanup?.()
svg.selectAll("*").remove()
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps)
return ref
}
Multi-Series Line Chart
// components/viz/MultiLineChart.tsx — D3 line chart with zoom and brush
"use client"
import { useRef, useEffect, useCallback } from "react"
import * as d3 from "d3"
type DataPoint = { date: Date; value: number }
type Series = { name: string; color: string; data: DataPoint[] }
const MARGIN = { top: 24, right: 32, bottom: 48, left: 56 }
interface MultiLineChartProps {
series: Series[]
width?: number
height?: number
title?: string
}
export function MultiLineChart({ series, width = 700, height = 380, title }: MultiLineChartProps) {
const svgRef = useRef<SVGSVGElement>(null)
const innerW = width - MARGIN.left - MARGIN.right
const innerH = height - MARGIN.top - MARGIN.bottom
const brushH = 60
const draw = useCallback(() => {
if (!svgRef.current || series.length === 0) return
const svg = d3.select(svgRef.current)
svg.selectAll("*").remove()
// All data points combined for global domain
const allData = series.flatMap((s) => s.data)
const xDomain = d3.extent(allData, (d) => d.date) as [Date, Date]
const yMax = d3.max(allData, (d) => d.value) ?? 0
const xFull = d3.scaleTime().domain(xDomain).range([0, innerW])
const y = d3.scaleLinear().domain([0, yMax * 1.05]).range([innerH - brushH - 24, 0])
const xBrush = d3.scaleTime().domain(xDomain).range([0, innerW])
const yBrush = d3.scaleLinear().domain([0, yMax]).range([brushH, 0])
// Clip path
svg.append("defs").append("clipPath").attr("id", "chart-clip")
.append("rect").attr("width", innerW).attr("height", innerH - brushH - 24)
const root = svg.append("g").attr("transform", `translate(${MARGIN.left},${MARGIN.top})`)
const chartArea = root.append("g").attr("clip-path", "url(#chart-clip)")
// ── Gridlines
root.append("g").attr("class", "grid")
.call(d3.axisLeft(y).ticks(5).tickSize(-innerW).tickFormat(() => ""))
.call((g) => g.select(".domain").remove())
.call((g) => g.selectAll(".tick line").attr("stroke", "#e5e7eb").attr("stroke-dasharray", "3,3"))
// ── Axes
const xAxis = root.append("g")
.attr("transform", `translate(0,${innerH - brushH - 24})`)
.call(d3.axisBottom(xFull).ticks(6).tickFormat(d3.timeFormat("%b %Y") as any))
root.append("g").call(
d3.axisLeft(y).ticks(5).tickFormat((v) => d3.format(".2s")(v as number)),
).call((g) => g.select(".domain").remove())
// ── Line generator
const lineGen = (xs: d3.ScaleTime<number, number>) =>
d3.line<DataPoint>().x((d) => xs(d.date)).y((d) => y(d.value)).curve(d3.curveMonotoneX)
const areaGen = (xs: d3.ScaleTime<number, number>) =>
d3.area<DataPoint>().x((d) => xs(d.date)).y0(innerH - brushH - 24).y1((d) => y(d.value)).curve(d3.curveMonotoneX)
// ── Series
const seriesGroups = chartArea.selectAll(".series")
.data(series)
.join("g")
.attr("class", "series")
// Area fills (lighter)
seriesGroups.append("path")
.attr("fill", (d) => d.color)
.attr("fill-opacity", 0.08)
.attr("d", (d) => areaGen(xFull)(d.data))
// Lines
seriesGroups.append("path")
.attr("fill", "none")
.attr("stroke", (d) => d.color)
.attr("stroke-width", 2)
.attr("d", (d) => lineGen(xFull)(d.data))
.attr("stroke-dashoffset", function () { return (this as SVGPathElement).getTotalLength() })
.attr("stroke-dasharray", function () { return (this as SVGPathElement).getTotalLength() })
.transition().duration(900).ease(d3.easeQuadOut)
.attr("stroke-dashoffset", 0)
// ── Brush
const brushGroup = root.append("g").attr("transform", `translate(0,${innerH - brushH + 8})`)
// Mini lines in brush
brushGroup.selectAll(".mini-line")
.data(series)
.join("path")
.attr("fill", "none")
.attr("stroke", (d) => d.color)
.attr("stroke-width", 1)
.attr("opacity", 0.5)
.attr("d", (d) => d3.line<DataPoint>().x((p) => xBrush(p.date)).y((p) => yBrush(p.value)).curve(d3.curveMonotoneX)(d.data))
brushGroup.append("g").call(d3.axisBottom(xBrush).ticks(4).tickFormat(d3.timeFormat("%Y") as any))
.attr("transform", `translate(0,${brushH})`)
const brush = d3.brushX()
.extent([[0, 0], [innerW, brushH]])
.on("end", (event) => {
if (!event.selection) return xAxis.call(d3.axisBottom(xFull).ticks(6).tickFormat(d3.timeFormat("%b %Y") as any))
const [x0, x1] = (event.selection as [number, number]).map(xBrush.invert)
const xNew = xFull.copy().domain([x0, x1])
xAxis.call(d3.axisBottom(xNew).ticks(6).tickFormat(d3.timeFormat("%b %Y") as any))
seriesGroups.select("path:nth-child(2)").attr("d", (d: any) => lineGen(xNew)(d.data))
seriesGroups.select("path:nth-child(1)").attr("d", (d: any) => areaGen(xNew)(d.data))
})
brushGroup.append("g").call(brush)
// ── Legend
const legend = svg.append("g").attr("transform", `translate(${MARGIN.left},${height - 12})`)
series.forEach((s, i) => {
const g = legend.append("g").attr("transform", `translate(${i * 130},0)`)
g.append("line").attr("x1", 0).attr("x2", 20).attr("y1", 0).attr("y2", 0)
.attr("stroke", s.color).attr("stroke-width", 2)
g.append("text").attr("x", 26).attr("y", 4).attr("font-size", 11).attr("fill", "#6b7280").text(s.name)
})
}, [series, width, height, innerW, innerH])
useEffect(() => { draw() }, [draw])
return (
<div className="rounded-2xl border bg-card px-2 py-4">
{title && <p className="text-sm font-semibold text-center mb-2">{title}</p>}
<svg ref={svgRef} width={width} height={height} className="overflow-visible" />
</div>
)
}
Force-Directed Network Graph
// components/viz/NetworkGraph.tsx — D3 force simulation
"use client"
import { useRef, useEffect } from "react"
import * as d3 from "d3"
type GraphNode = { id: string; group: number; label: string; value?: number }
type GraphLink = { source: string; target: string; weight?: number }
interface NetworkGraphProps {
nodes: GraphNode[]
links: GraphLink[]
width?: number
height?: number
}
const GROUP_COLORS = ["#6366f1", "#ef4444", "#22c55e", "#f59e0b", "#3b82f6", "#8b5cf6"]
export function NetworkGraph({ nodes, links, width = 600, height = 500 }: NetworkGraphProps) {
const svgRef = useRef<SVGSVGElement>(null)
useEffect(() => {
if (!svgRef.current) return
const svg = d3.select(svgRef.current)
svg.selectAll("*").remove()
const sim = d3.forceSimulation<GraphNode>(nodes)
.force("link", d3.forceLink<GraphNode, GraphLink>(links).id((d) => d.id).distance(80))
.force("charge", d3.forceManyBody().strength(-200))
.force("center", d3.forceCenter(width / 2, height / 2))
.force("collision", d3.forceCollide<GraphNode>().radius((d) => nodeRadius(d) + 4))
// Zoom
const g = svg.append("g")
svg.call(
d3.zoom<SVGSVGElement, unknown>()
.scaleExtent([0.3, 4])
.on("zoom", (e) => g.attr("transform", e.transform)),
)
// Links
const link = g.append("g")
.selectAll("line")
.data(links)
.join("line")
.attr("stroke", "#d1d5db")
.attr("stroke-width", (d) => Math.sqrt(d.weight ?? 1))
// Nodes
const node = g.append("g")
.selectAll("g")
.data(nodes)
.join("g")
.call(d3.drag<SVGGElement, GraphNode>()
.on("start", (event, d) => { if (!event.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y })
.on("drag", (event, d) => { d.fx = event.x; d.fy = event.y })
.on("end", (event, d) => { if (!event.active) sim.alphaTarget(0); d.fx = null; d.fy = null }),
)
node.append("circle")
.attr("r", nodeRadius)
.attr("fill", (d) => GROUP_COLORS[d.group % GROUP_COLORS.length])
.attr("fill-opacity", 0.85)
.attr("stroke", "#fff")
.attr("stroke-width", 2)
node.append("text")
.text((d) => d.label)
.attr("text-anchor", "middle")
.attr("dy", (d) => nodeRadius(d) + 13)
.attr("font-size", 10)
.attr("fill", "#6b7280")
node.append("title").text((d) => d.label)
sim.on("tick", () => {
link
.attr("x1", (d) => (d.source as any).x)
.attr("y1", (d) => (d.source as any).y)
.attr("x2", (d) => (d.target as any).x)
.attr("y2", (d) => (d.target as any).y)
node.attr("transform", (d) => `translate(${d.x ?? 0},${d.y ?? 0})`)
})
return () => { sim.stop() }
}, [nodes, links, width, height])
return (
<div className="rounded-2xl border bg-card overflow-hidden">
<svg ref={svgRef} width={width} height={height} />
</div>
)
}
function nodeRadius(d: GraphNode) {
return Math.max(8, Math.sqrt((d.value ?? 10) * 4))
}
For the Recharts alternative when a React-native chart component experience with a familiar JSX API, built-in chart types (Bar, Line, Area, Pie, Radar, Scatter), and less imperative DOM code is preferred — Recharts covers 90% of dashboard chart use cases without touching D3 directly, while D3 is indispensable for custom or physics-based visualizations, see the Recharts guide. For the Observable Plot alternative when a higher-level grammar-of-graphics API built on D3 (similar to Vega-Lite) with concise one-liner mark-based syntax and first-class support in Observable notebooks is preferred — Observable Plot is the fastest path to many standard charts while D3 provides the lowest-level control for completely custom SVG graphics, see the Observable Plot guide. The Claude Skills 360 bundle includes D3.js advanced skill sets covering force graphs, zooming, brushing, and animated transitions. Start with the free tier to try custom visualization generation.