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-eventsEffectUse for
visiblePainted (default)Hit only where painted and visibleFilled shapes
visibleStrokeHit along the stroke onlyThin lines with a fill of none
strokeHit along the stroke, even if transparentInvisible hit areas
allHit the whole bounding boxGroups used as buttons
noneNever a target, but children can beDecorative layers
bounding-boxThe untransformed boxPredictable targets under transforms
fillThe interior, painted or notShapes 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 own transform and ignores stroke width, so a thick stroke can extend beyond it. Use getBoundingClientRect() 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 (inside display: none). Check visibility or tolerantly catch the error.
  • getPointAtLength is 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>
FrameworkAttribute styleNamespace handlingHydration note
ReactcamelCase propsAutomaticNumbers must match on both renders
Vuekebab-case attributesAutomaticSame, plus no v-if divergence
Angularkebab-case, with bindingsAutomaticattr. prefix for plain attributes
Plain DOMcreateElementNSYou supply the namespaceNot applicable
Sveltekebab-case attributesAutomaticSame 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.

Responsive, fluid and data-driven SVG Animation, embedding and accessibility

Last refreshed 2026-09-18.