You've built a dashboard that needs a custom timeline visualization, but react-chartjs-2 can't bend to your design. You reach for canvas, then realize SVG in React for custom visuals is often the better choice. SVG elements behave like JSX components, respond to state changes, and integrate seamlessly with React's event system. In this post, we'll build interactive charts and data visualizations without external charting libraries.
SVG is just JSX — Rect, path and text elements with props and state
The biggest mental shift is realizing SVG elements are just React components. A <rect> or <circle> responds to props the same way a <Button> does. When your component state changes, so does the SVG.
Let's build a simple bar chart. Each bar is a <rect> element, its height driven by data:
interface DataPoint {
label: string;
value: number;
}
interface BarChartProps {
data: DataPoint[];
width: number;
height: number;
}
export function BarChart({ data, width, height }: BarChartProps) {
const padding = 40;
const chartWidth = width - padding * 2;
const chartHeight = height - padding * 2;
const maxValue = Math.max(...data.map(d => d.value));
const barWidth = chartWidth / data.length;
return (
<svg width={width} height={height} style={{ border: '1px solid #ccc' }}>
{/* Y-axis */}
<line x1={padding} y1={padding} x2={padding} y2={height - padding} stroke="#333" />
{/* X-axis */}
<line x1={padding} y1={height - padding} x2={width - padding} y2={height - padding} stroke="#333" />
{/* Bars */}
{data.map((point, i) => {
const barHeight = (point.value / maxValue) * chartHeight;
const x = padding + i * barWidth;
const y = height - padding - barHeight;
return (
<g key={point.label}>
<rect
x={x}
y={y}
width={barWidth * 0.8}
height={barHeight}
fill="#0066cc"
/>
<text
x={x + barWidth / 2}
y={height - padding + 20}
textAnchor="middle"
fontSize="12"
>
{point.label}
</text>
<text
x={x + barWidth / 2}
y={y - 5}
textAnchor="middle"
fontSize="11"
fill="#666"
>
{point.value}
</text>
</g>
);
})}
</svg>
);
}This is pure JSX. No canvas manipulation, no imperative drawing commands. When data changes, React diffs the SVG elements and updates only what's necessary. You can use conditional rendering, state, and hooks exactly as you would with DOM elements.
Coordinate math — Map data values to pixels with scale functions
The real challenge in custom SVG work is translating your data domain (0 to 1000, for example) into pixel coordinates. This is where scale functions come in. I use them on every visualization project.
A linear scale maps a data value to a pixel position. Here's a reusable approach:
function createScale(
domainMin: number,
domainMax: number,
rangeMin: number,
rangeMax: number
) {
return (value: number) => {
const normalizedValue = (value - domainMin) / (domainMax - domainMin);
return rangeMin + normalizedValue * (rangeMax - rangeMin);
};
}
// Usage in a line chart
const data = [
{ x: 0, y: 10 },
{ x: 1, y: 25 },
{ x: 2, y: 18 },
{ x: 3, y: 40 }
];
const xScale = createScale(0, 3, padding, width - padding);
const yScale = createScale(0, 50, height - padding, padding);
const pathData = data
.map((point, i) => {
const px = xScale(point.x);
const py = yScale(point.y);
return `${i === 0 ? 'M' : 'L'} ${px} ${py}`;
})
.join(' ');
return <path d={pathData} stroke="#0066cc" fill="none" />;The key insight: scale functions are pure and testable. They don't care about React or SVG. They're just math. I often write scale utilities in a separate file and unit test them independently.
For real projects, I've found d3-scale useful, but custom scales keep dependencies minimal. Choose based on complexity.
Interactions — Pointer events for drag, hover and selection
SVG elements support all standard pointer events: onMouseEnter, onClick, onMouseMove, and more. This is where custom visualizations shine—you can build exactly the interaction you need.
Let's add hover interaction to highlight bars:
export function InteractiveBarChart({ data, width, height }: BarChartProps) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const padding = 40;
const chartWidth = width - padding * 2;
const chartHeight = height - padding * 2;
const maxValue = Math.max(...data.map(d => d.value));
const barWidth = chartWidth / data.length;
return (
<svg width={width} height={height}>
{data.map((point, i) => {
const barHeight = (point.value / maxValue) * chartHeight;
const x = padding + i * barWidth;
const y = height - padding - barHeight;
const isHovered = hoveredIndex === i;
return (
<g key={point.label}>
<rect
x={x}
y={y}
width={barWidth * 0.8}
height={barHeight}
fill={isHovered ? '#0052a3' : '#0066cc'}
onMouseEnter={() => setHoveredIndex(i)}
onMouseLeave={() => setHoveredIndex(null)}
style={{ cursor: 'pointer', transition: 'fill 0.2s' }}
/>
{isHovered && (
<text
x={x + barWidth / 2}
y={y - 15}
textAnchor="middle"
fill="#0066cc"
fontSize="14"
fontWeight="bold"
>
{point.value}
</text>
)}
</g>
);
})}
</svg>
);
}For more complex interactions like dragging a slider or selecting a range, track mouse position and calculate the corresponding data value using the inverse of your scale function:
function invertScale(
domainMin: number,
domainMax: number,
rangeMin: number,
rangeMax: number
) {
return (pixel: number) => {
const normalizedPixel = (pixel - rangeMin) / (rangeMax - rangeMin);
return domainMin + normalizedPixel * (domainMax - domainMin);
};
}
// In a drag handler:
const handleMouseMove = (e: React.MouseEvent<SVGSVGElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const dataValue = invertScale(0, 100, padding, width - padding)(mouseX);
setSelectedValue(Math.max(0, Math.min(100, dataValue)));
};In production ride-hailing dashboards, I've used this pattern to let operators select time ranges and filter trip data on interactive timelines. The interaction feels instant because there's no intermediate library overhead.
Keep it accessible — Add titles and aria-labels to meaningful shapes
SVG accessibility is often overlooked. A bar chart with no labels is useless to a screen reader user. Every meaningful shape should have a description.
Use <title> elements (browser tooltips) and aria-label attributes:
export function AccessibleBarChart({ data, width, height }: BarChartProps) {
const padding = 40;
const chartWidth = width - padding * 2;
const chartHeight = height - padding * 2;
const maxValue = Math.max(...data.map(d => d.value));
const barWidth = chartWidth / data.length;
return (
<svg
width={width}
height={height}
role="img"
aria-label={`Bar chart showing ${data.map(d => d.label).join(', ')}`}
>
{data.map((point, i) => {
const barHeight = (point.value / maxValue) * chartHeight;
const x = padding + i * barWidth;
const y = height - padding - barHeight;
return (
<g key={point.label}>
<rect
x={x}
y={y}
width={barWidth * 0.8}
height={barHeight}
fill="#0066cc"
role="img"
aria-label={`${point.label}: ${point.value}`}
>
<title>{point.label}: {point.value}</title>
</rect>
</g>
);
})}
</svg>
);
}The <title> element appears as a native browser tooltip on hover. The role="img" and aria-label on the SVG root tell assistive technology that this is an image and provide a summary. Individual bars get their own aria-label for detail.
For more complex charts, consider adding a data table below the visualization as an alternative representation. This is what I've done in SaaS dashboards where compliance matters.
Common mistakes
- Forgetting to use keys in map. SVG groups need stable keys just like lists. Use the data identifier, not the index, if the order can change.
- Hardcoding pixel values. Make padding, spacing, and dimensions props or constants. Custom visuals are reused; hardcoded magic numbers are maintenance nightmares.
- Neglecting performance with many elements. Rendering 10,000 SVG circles will lag. For large datasets, consider canvas or aggregating data before rendering.
- Ignoring viewBox. Use
viewBoxto make SVG responsive without CSS aspect-ratio hacks. This is especially important on mobile. - Event handler closures capturing stale state. If you use `useCallback`, make sure dependencies are correct. A `handleClick` referencing `selectedIndex` without adding it to the dependency array will use an old value.
Key takeaways
- SVG elements in React are just JSX components. State and props drive rendering.
- Scale functions translate data domain to pixel coordinates. Keep them pure and testable.
- Pointer events on SVG elements enable rich interactions—hover, click, drag—without external libraries.
- Accessibility is not optional. Use `<title>`, `aria-label`, and `role="img"` to describe meaningful shapes.
- Extract scale logic and reusable chart components into separate modules for maintainability.
- For simple to medium visualizations, custom SVG outweighs the complexity and bundle cost of charting libraries.






Comments
Loading comments…