Interactivity, scripting and framework integration
Handle pointer and focus events, use hit areas and measurement APIs, and render SVG from React, Vue and Angular without hydration mismatches.
Pointer, focus and hit areas
<svg viewBox="0 0 240 120" xmlns="http://www.w3.org/2000/svg" role="group" aria-label="Chart controls">
<!-- Only the painted part of a shape receives pointer events by default.
Add a transparent hit area when the mark is thin. -->
<g class="series" tabindex="0" role="button" aria-label="Revenue series">
<path d="M20 100 L80 40 L140 70 L200 20" fill="none" stroke="#6d28d9" stroke-width="2"/>
<!-- a transparent wide stroke makes the thin line easy to click -->
<path d="M20 100 L80 40 L140 70 L200 20" fill="none"
stroke="transparent" stroke-width="16" style="pointer-events: stroke"/>
</g>
<!-- pointer-events: none on decorative layers keeps them out of the way -->
<g style="pointer-events: none">
<text x="20" y="115" font-size="10" fill="#64748b">Jan</text>
<text x="200" y="115" font-size="10" fill="#64748b">Apr</text>
</g>
</svg>pointer-events | Effect | Use for |
|---|---|---|
visiblePainted (default) | Hit only where painted and visible | Filled shapes |
visibleStroke | Hit along the stroke only | Thin lines with a fill of none |
stroke | Hit along the stroke, even if transparent | Invisible hit areas |
all | Hit the whole bounding box | Groups used as buttons |
none | Never a target, but children can be | Decorative layers |
bounding-box | The untransformed box | Predictable targets under transforms |
fill | The interior, painted or not | Shapes with a transparent fill |
// Events on SVG elements behave like DOM events, with extra properties.
const path = document.querySelector('.series path');
path.addEventListener('pointerenter', (event) => {
event.currentTarget.style.strokeWidth = '3';
});
path.addEventListener('pointerleave', (event) => {
event.currentTarget.style.strokeWidth = '2';
});
path.addEventListener('focus', () => { /* keyboard users get the same feedback */ });
// Convert a client coordinate into the SVG's user space: getScreenCTM is
// the reliable way, because it accounts for the viewBox, CSS size and any
// transform on the element itself.
function toUserSpace(svg, clientX, clientY) {
const point = svg.createSVGPoint();
point.x = clientX;
point.y = clientY;
return point.matrixTransform(svg.getScreenCTM().inverse());
}
svg.addEventListener('click', (event) => {
const { x, y } = toUserSpace(svg, event.clientX, event.clientY);
console.log('clicked at user coordinates', x.toFixed(1), y.toFixed(1));
});💡
A shape is only focusable in a browser tab order if it can receive focus. SVG elements support
tabindex="0" and focus(), so a chart's marks can be keyboard navigable — but you must add the attribute and a visible focus style. Without them, an onclick on a path is mouse-only.Measuring geometry
const path = document.querySelector('.series path');
const group = document.querySelector('.series');
const text = document.querySelector('text');
// getBBox: the geometry of the element in its own user space, ignoring
// transforms and strokes.
const box = group.getBBox();
console.log(box.x, box.y, box.width, box.height);
// getBoundingClientRect: the screen rectangle, after every transform.
const rect = group.getBoundingClientRect();
console.log(rect.top, rect.left, rect.width, rect.height);
// getTotalLength and getPointAtLength: the path's own geometry.
const length = path.getTotalLength();
const midpoint = path.getPointAtLength(length / 2);
console.log('midpoint', midpoint.x, midpoint.y);
// Placing a label at a point along the curve:
const marker = document.querySelector('.marker');
const point = path.getPointAtLength(length * 0.75);
marker.setAttribute('cx', point.x);
marker.setAttribute('cy', point.y);
// isPointInFill: hit testing in user space, without a pointer event.
const svg = path.ownerSVGElement;
const userPoint = toUserSpace(svg, 300, 200);
console.log(path.isPointInFill(userPoint), path.isPointInStroke(userPoint));getBBox()ignores the element's owntransformand ignores stroke width, so a thick stroke can extend beyond it. UsegetBoundingClientRect()for anything involving what the user actually sees.- Both measurement APIs force a layout flush. Read once, store the values, then write — do not interleave reads and writes in a loop.
getBBox()throws on an element that is not rendered (insidedisplay: none). Check visibility or tolerantly catch the error.getPointAtLengthis the standard way to place a label, a marker or a tooltip anchor on a curve without doing the maths yourself.
// A reusable pattern: place a marker at the maximum of a series.
function placePeakMarker(svg, path, data) {
const peakIndex = data.reduce((best, d, i) => (d.value > data[best].value ? i : best), 0);
const total = path.getTotalLength();
// Approximate the fraction along the path by the data index. For a straight
// polyline this is exact; for a curve it is a good visual approximation.
const fraction = data.length === 1 ? 0 : peakIndex / (data.length - 1);
const point = path.getPointAtLength(total * fraction);
const marker = svg.querySelector('.peak');
marker.setAttribute('cx', point.x);
marker.setAttribute('cy', point.y);
marker.setAttribute('r', 4);
marker.removeAttribute('hidden');
const label = svg.querySelector('.peak-label');
label.setAttribute('x', point.x);
label.setAttribute('y', point.y - 10);
label.setAttribute('text-anchor', 'middle');
label.textContent = data[peakIndex].value.toLocaleString();
}React, Vue and Angular
// React: SVG elements are created with the same JSX syntax, but the attribute
// names are the camelCase DOM property names, not the kebab-case attributes.
export function Bars({ data, width = 400, height = 200 }) {
const max = Math.max(...data.map((d) => d.value));
const barWidth = (width - 40) / data.length;
return (
<svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label="Revenue by quarter" width="100%">
{data.map((datum, index) => {
const barHeight = (datum.value / max) * (height - 40);
return (
<g key={datum.label}>
<rect
x={20 + index * barWidth}
y={height - 20 - barHeight}
width={barWidth * 0.7}
height={barHeight}
rx={3}
fill="#6d28d9"
>
<title>{`${datum.label}: ${datum.value}`}</title>
</rect>
<text
x={20 + index * barWidth + (barWidth * 0.7) / 2}
y={height - 6}
textAnchor="middle"
fontSize={11}
fill="#475569"
>
{datum.label}
</text>
</g>
);
})}
</svg>
);
}
// Attribute name mapping that catches people out:
// stroke-width -> strokeWidth
// text-anchor -> textAnchor
// dominant-baseline -> dominantBaseline
// xlink:href -> href
// class -> className
// style -> a style object, not a string// Hydration: numbers must be identical on the server and in the browser.
// `Math.random()`, `Date.now()` and locale formatting are the usual offenders.
// Wrong: the server and the client produce different strings.
// <text>{new Date().toLocaleTimeString()}</text>
// Right: compute once on the server, pass the value down, or render client-side only.
export function ChartTimestamp({ iso }) {
// iso comes from the server; formatting it is deterministic.
return <text x={8} y={16} fontSize={11}>{new Date(iso).toISOString().slice(0, 16)}</text>;
}
// And for anything genuinely client-only, gate it:
import { useEffect, useState } from 'react';
export function ClientOnly({ children }) {
const [ready, setReady] = useState(false);
useEffect(() => setReady(true), []);
return ready ? children : null;
}<!-- Vue: attribute names keep their kebab-case form, unlike React. -->
<template>
<svg :viewBox="`0 0 ${width} ${height}`" role="img" aria-label="Revenue by quarter">
<g v-for="(datum, index) in data" :key="datum.label">
<rect
:x="20 + index * barWidth"
:y="height - 20 - barHeight(datum)"
:width="barWidth * 0.7"
:height="barHeight(datum)"
rx="3"
fill="#6d28d9"
/>
<text
:x="20 + index * barWidth + (barWidth * 0.7) / 2"
:y="height - 6"
text-anchor="middle"
font-size="11"
fill="#475569"
>{{ datum.label }}</text>
</g>
</svg>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({ data: Array, width: { type: Number, default: 400 }, height: { type: Number, default: 200 } });
const max = computed(() => Math.max(...props.data.map((d) => d.value)));
const barWidth = computed(() => (props.width - 40) / props.data.length);
const barHeight = (d) => (d.value / max.value) * (props.height - 40);
</script>| Framework | Attribute style | Namespace handling | Hydration note |
|---|---|---|---|
| React | camelCase props | Automatic | Numbers must match on both renders |
| Vue | kebab-case attributes | Automatic | Same, plus no v-if divergence |
| Angular | kebab-case, with bindings | Automatic | attr. prefix for plain attributes |
| Plain DOM | createElementNS | You supply the namespace | Not applicable |
| Svelte | kebab-case attributes | Automatic | Same as Vue |
// Angular: bind attributes with attr. when the name is not a property.
@Component({
selector: 'app-bars',
template: `
<svg [attr.viewBox]="'0 0 ' + width + ' ' + height" role="img" aria-label="Revenue by quarter">
@for (datum of data(); track datum.label; let i = $index) {
<g>
<rect [attr.x]="20 + i * barWidth()"
[attr.y]="height - 20 - barHeight(datum)"
[attr.width]="barWidth() * 0.7"
[attr.height]="barHeight(datum)"
rx="3" fill="#6d28d9" />
<title>{{ datum.label }}: {{ datum.value }}</title>
</g>
}
</svg>
`
})
export class BarsComponent {
readonly data = input.required<Datum[]>();
readonly width = 400;
readonly height = 200;
readonly barWidth = computed(() => (this.width - 40) / this.data().length);
barHeight(datum: Datum) {
const max = Math.max(...this.data().map((d) => d.value));
return (datum.value / max) * (this.height - 40);
}
}FAQ
Why is my click handler not firing on a thin line?
Only the painted area receives pointer events, so a 2-unit stroke has a 2-unit hit area. Add a transparent duplicate path with a wide
stroke-width and pointer-events: stroke, or set pointer-events: all on a group.How do I get the SVG coordinates of a click?
Use
svg.createSVGPoint(), set x and y from the client coordinates, and transform by svg.getScreenCTM().inverse(). Do not subtract the bounding rectangle: that ignores the viewBox scaling and any transform on an ancestor.Related
Responsive, fluid and data-driven SVG Animation, embedding and accessibility
Last refreshed 2026-09-18.