Getting Started
Set up D3 UI in your React or Next.js project with Tailwind CSS and D3.js.
Prerequisites
- Node.js 18+ and npm/pnpm
- React project (Next.js recommended)
- Tailwind CSS installed
Install Tailwind CSS
Follow the official Tailwind setup guide if you haven’t already.
Install D3.js
Installing d3
npm install d3Add Shared Types
Copy the shared types into your components/types.ts file. These types are used across all D3 UI components.
./components/types.ts
import { ReactNode } from "react";
import { LabelProps } from "./primitives/Label";
// 1. Data Points
export type DataPoint<X = number, Y = number> = {
x: X;
y: Y;
};
// 2. Base Series (Color & Label)
export interface SeriesBase {
color?: string;
label: string;
}
// 3. Series with Array Data (Area, Line, Bar, Scatter)
export interface SeriesData<T = DataPoint> extends SeriesBase {
data: T[];
}
export type ChartData<T = DataPoint> = Record<string, SeriesData<T>>;
export type AreaData = ChartData<DataPoint>;
export type LineData = ChartData<DataPoint>;
export type ScatterData = ChartData<DataPoint>;
export type BarData = ChartData<DataPoint<string | number | Date, number>>;
// 4. Pie Chart Data
export interface PieSeriesData extends SeriesBase {
value: number;
}
export type PieData = Record<string, PieSeriesData>;
// 5. Heatmap Data
export interface HeatmapSeriesData extends SeriesBase {
data: number[][];
}
export type HeatmapData = Record<string, HeatmapSeriesData>;
// 6. Gauge Data
export interface GaugeData extends SeriesBase {
value: number;
}
// 7. Treemap Data
export interface TreemapNodeData {
name: string;
value?: number;
color?: string;
children?: TreemapNodeData[];
}
export interface TreemapData {
name: string;
children: TreemapNodeData[];
}
// 8. Chord Diagram Data
export type ChordRibbon =
| (SeriesBase & { value: number; sourceValue?: never; targetValue?: never })
| (SeriesBase & { sourceValue: number; targetValue: number; value?: never });
export interface ChordSeries extends SeriesBase {
ribbons: ChordRibbon[];
}
export type ChordData = ChordSeries[];
// 9. Shared Props
export interface ChartLabelProps {
labelFormatter?: (value: any) => ReactNode;
variant?: LabelProps["variant"];
className?: string;
}
export interface BaseSeriesProps {
dataKey: string;
label?: ChartLabelProps;
}
export interface BaseContainerProps<T> {
data: T;
width?: number;
height?: number;
children: ReactNode;
}
Add Utility Functions
Add the following helper function to components/lib/utils.tsx.
./components/lib/utils.tsx
export function cn(...classes: (string | undefined | false | null)[]) {
return classes.filter(Boolean).join(" ");
}
Add Custom Hooks
Add these hooks to components/hooks/ for seamless transitions and animations.
./components/hooks/useTransition.tsx
import { useEffect, useRef } from "react";
import * as d3 from "d3";
interface TransitionOptions {
duration?: number;
ease?: (t: number) => number;
before?: (selection: d3.Selection<any, unknown, null, undefined>) => void;
apply: (transition: d3.Transition<any, unknown, null, undefined>) => void;
deps?: React.DependencyList;
}
/**
* Generic D3 transition hook that works for any SVG element.
*
* Example:
* const ref = useD3Transition({
* duration: 800,
* apply: (t) => t.attr("opacity", 1).attr("x", 100),
* });
*/
export const useD3Transition = <T extends SVGElement>({
duration = 600,
ease = d3.easeCubicInOut,
before,
apply,
deps = [],
}: TransitionOptions) => {
const ref = useRef<T | null>(null);
useEffect(() => {
if (!ref.current) return;
const selection = d3.select(ref.current);
before?.(selection);
const t = selection.transition().duration(duration).ease(ease);
apply(t);
}, deps);
return ref;
};
./components/hooks/useGroupTransition.tsx
import * as d3 from "d3";
import { useEffect, useRef } from "react";
export function useD3GroupTransition<ElementType extends SVGElement = SVGElement>(
{
selector = "*",
duration = 600,
ease = d3.easeCubicInOut,
before,
apply,
stagger = true,
randomize = true,
deps = [],
}: {
selector?: string;
duration?: number;
ease?: (t: number) => number;
before?: (sel: d3.Selection<ElementType, unknown, any, undefined>) => void;
apply: (t: d3.Transition<ElementType, unknown, null, undefined>) => void;
stagger?: boolean;
randomize?: boolean;
deps?: React.DependencyList;
}
) {
const ref = useRef<SVGGElement | null>(null);
useEffect(() => {
if (!ref.current) return;
requestAnimationFrame(() => {
const selection = d3
.select(ref.current)
.selectAll<ElementType, unknown>(selector);
before?.(selection);
const total = selection.size();
const transition = selection
.transition()
.duration(duration)
.ease(ease)
.delay((_, i) => {
if (!stagger) return 0;
const baseDelay = (i / total) * duration * 0.8;
const randomOffset = randomize ? Math.random() * 200 : 0;
return baseDelay + randomOffset;
});
apply(transition);
});
}, deps);
return ref;
}
Add Primitive Components
The following primitives are used as building blocks for all charts. Place them in components/primitives/.
./components/primitives/Axis.tsx
"use client";
import { useEffect, useRef } from "react";
import * as d3 from "d3";
import { Axis as D3Axis } from "d3";
type Orient = "bottom" | "left" | "top" | "right";
interface AxisProps<Scale extends d3.AxisScale<any>> {
scale: Scale;
orient?: Orient;
transform?: string;
ticks?: number;
tickFormat?: (d: any) => string; // override
}
export function Axis<Scale extends d3.AxisScale<any>>({
scale,
orient = "bottom",
transform = "",
ticks = 5,
tickFormat,
}: AxisProps<Scale>) {
const ref = useRef<SVGGElement>(null);
useEffect(() => {
let axisGenerator: D3Axis<any> =
orient === "bottom" ? d3.axisBottom(scale) : d3.axisLeft(scale);
if ("ticks" in axisGenerator) axisGenerator.ticks(ticks);
if (tickFormat) {
axisGenerator.tickFormat(tickFormat as any);
}
const svgElement = d3.select(ref.current);
svgElement.call(axisGenerator as any);
// Apply theme-aware styles to the axis elements
svgElement.selectAll(".domain")
.attr("stroke", "var(--chart-axis)")
.attr("stroke-width", "1");
svgElement.selectAll(".tick line")
.attr("stroke", "var(--chart-axis)")
.attr("stroke-opacity", "0.2");
svgElement.selectAll(".tick text")
.attr("fill", "var(--chart-axis)")
.attr("font-size", "11px")
.attr("font-family", "var(--font-mono)");
}, [scale, orient, ticks, tickFormat]);
return <g ref={ref} transform={transform} className="chart-axis" />;
}
./components/primitives/Tooltip.tsx
"use client";
import { createContext, useContext, useRef, useState, ReactNode } from "react";
import { cn } from "../lib/utils";
type TooltipContextType = {
show: (options: { content: string; title?: string; color?: string }, e: React.MouseEvent) => void;
hide: () => void;
} | null;
const TooltipContext = createContext<TooltipContextType>(null);
export const useTooltip = () => {
const context = useContext(TooltipContext);
if (!context) {
throw new Error("useTooltip must be used within a TooltipProvider");
}
return context;
};
interface TooltipProviderProps {
children: ReactNode;
className?: string;
contentClassName?: string;
}
interface TooltipProps {
children: (props: {
show: (options: { content: string; title?: string; color?: string }, e: React.MouseEvent) => void;
hide: () => void;
}) => React.ReactNode;
className?: string;
contentClassName?: string;
}
export function TooltipProvider({ children, className, contentClassName }: TooltipProviderProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [tooltipData, setTooltipData] = useState<{ content: string; title?: string; color?: string }>({ content: "" });
const [position, setPosition] = useState({ x: 0, y: 0 });
const show = (options: { content: string; title?: string; color?: string }, e: React.MouseEvent) => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
setTooltipData(options);
setPosition({ x, y });
setVisible(true);
};
const hide = () => setVisible(false);
const value = { show, hide };
return (
<div ref={containerRef} className={cn("relative w-fit h-fit", className)}>
<TooltipContext.Provider value={value}>
{children}
</TooltipContext.Provider>
{visible && (
<div
className={cn(
"absolute pointer-events-none border border-gray-200 bg-white text-gray-900 text-xs",
"dark:border-slate-700 dark:bg-slate-800 dark:text-slate-50",
"transition-opacity duration-150",
contentClassName
)}
style={{
top: position.y,
left: position.x,
transform: "translate(10px, -50%)",
opacity: visible ? 1 : 0,
borderRadius: "var(--tooltip-radius, 8px)",
boxShadow: "var(--tooltip-shadow, 0 4px 6px -1px rgb(0 0 0 / 0.1))",
}}
>
{(tooltipData.title || tooltipData.color) && (
<div className="flex items-center gap-2 px-3 py-1.5">
{tooltipData.color && (
<div
className="w-2 h-2 rounded-sm flex-shrink-0"
style={{
backgroundColor: tooltipData.color,
minWidth: '8px',
minHeight: '8px',
}}
/>
)}
{tooltipData.title && (
<span className="font-medium whitespace-nowrap">{tooltipData.title}</span>
)}
</div>
)}
<div className="px-3 py-1.5">
{tooltipData.content}
</div>
</div>
)}
</div>
);
}
./components/primitives/Legend.tsx
import { cn } from "../lib/utils";
export interface LegendItem {
label: string;
color?: string;
}
export interface LegendProps {
className?: string;
itemClassName?: string;
items: LegendItem[];
orientation?: "horizontal" | "vertical";
position?: "top" | "bottom" | "left" | "right";
}
export function Legend({
items,
className,
itemClassName,
orientation = "horizontal"
}: LegendProps) {
return (
<div className={cn(
"flex flex-wrap gap-4 mt-4",
orientation === "vertical" ? "flex-col items-start" : "flex-row items-center justify-center",
className
)}>
{items.map((item) => (
<div
key={item.label}
className={cn("flex items-center gap-2", itemClassName)}
>
<span
className="block w-3.5 h-3.5 ring-2 ring-white dark:ring-slate-800"
style={{
backgroundColor: item.color,
borderRadius: "var(--tooltip-radius, 50%)"
}}
></span>
<span className="text-sm font-medium text-slate-600 dark:text-slate-400">
{item.label}
</span>
</div>
))}
</div>
);
}
./components/primitives/Label.tsx
"use client";
import React from "react";
import clsx from "clsx";
import { cn } from "../lib/utils";
export type LabelVariant =
| "text"
| "circle"
| "square"
| "circle-text"
| "none";
export interface LabelProps {
value: any;
variant?: LabelVariant;
color?: string;
x?: number;
y?: number;
formatter?: (value: any) => React.ReactNode;
className?: string;
offset?: { x?: number; y?: number };
}
export const Label: React.FC<LabelProps> = ({
value,
variant = "text",
color = "#333",
x = 0,
y = 0,
formatter,
className,
offset = { x: 0, y: 0 },
}) => {
const renderContent = () => {
if (formatter)
return (
<foreignObject x={x} y={y} width={80} height={30}>
{formatter(value)}
</foreignObject>
);
switch (variant) {
case "circle":
return <circle cx={x} cy={y} r={5} fill={color} />;
case "square":
return <rect x={x - 5} y={y - 5} width={10} height={10} fill={color} />;
case "circle-text":
return (
<g transform={`translate(${x},${y})`}>
<circle r={5} fill={color} />
<text
x={offset.x ?? 8}
y={offset.y ?? 4}
fill={color}
fontSize={12}
className="select-none"
>
{value}
</text>
</g>
);
case "none":
return null;
case "text":
default:
return (
<text
x={x + (offset.x ?? 0)}
y={y + (offset.y ?? 0)}
fill="currentColor"
fontSize={12}
className={cn("select-none fill-foreground/80 dark:fill-slate-400 font-sans", className)}
>
{value}
</text>
);
}
};
return <g className={clsx("label", className)}>{renderContent()}</g>;
};
Use the Chart Components
Now you can copy any chart component from our docs into your project’s components/ folder:
src/
components/
BarChart.tsxExample Usage
import BarChart from "@/components/BarChart";
import { BarData } from "@/components/types";
const data: BarData = {
"Sales": {
label: "Monthly Sales",
data: [
{ x: "Jan", y: 10 },
{ x: "Feb", y: 30 },
{ x: "Mar", y: 20 },
],
}
};
export default function Example() {
return (
<div className="h-96 w-full">
<BarChart data={data} title="Monthly Sales Overview" />
</div>
);
}That’s it! You’ve set up the core of D3 UI. Explore the rest of the charts in documentation to start building your dashboard.