A dashboard card needs a small chart: a week of sign-ups as bars, or a trend as a line. You can draw both as SVG in a Vue template, with no chart library. The data goes in as a prop, the geometry comes out of computed(), and Vue redraws the chart when the data changes. Each chart below is one component of under 80 lines, styles included.
SVG suits this because it is a vector format whose shapes are ordinary elements: described by attributes (x, y, width, d), styled with CSS, inspectable in developer tools. Vue binds them like any other element. :x, :height and :d take reactive values, v-for draws one shape per data point, and @mouseenter works on a <rect> exactly as it does on a <button>.
Set the coordinate space with viewBox
The one idea that makes SVG charts easy: draw in a fixed coordinate space and let the browser scale it. The viewBox attribute declares that space. With viewBox="0 0 320 160", every x runs from 0 to 320 and every y from 0 to 160, whatever size the element is on screen. Give the element a CSS width of 100% and a height of auto, and the chart fills its container on a phone and a monitor alike, keeping its aspect ratio.
The mistake is to size the SVG with width and height attributes instead:
<template>
<svg width="320" height="160">
<!-- 320 CSS pixels wide, always: it overflows a narrow card -->
</svg>
</template>
<template>
<svg viewBox="0 0 320 160" class="chart">
<!-- scales with the container -->
</svg>
</template>
<style scoped>
.chart {
width: 100%;
height: auto;
display: block;
}
</style>
display: block removes the small gap an inline SVG leaves below itself.
Remember that y grows downwards. A bar of height h on a 160-high chart starts at y = 160 - h, not at 0. Forget that and the bars hang from the top edge of the chart.
Build a bar chart from computed values
The component takes an array of { label, value } and a title. Everything else is derived.
<script setup lang="ts">
import { computed, ref, useId } from 'vue'
interface Bar {
label: string
value: number
}
const props = defineProps<{ data: Bar[]; title: string }>()
const width = 320
const height = 160
const pad = 20
const gap = 8
const max = computed(() => Math.max(...props.data.map(d => d.value), 1))
const barWidth = computed(
() => (width - pad * 2 - gap * (props.data.length - 1)) / props.data.length,
)
const bars = computed(() =>
props.data.map((d, i) => {
const h = ((height - pad * 2) * d.value) / max.value
return { ...d, x: pad + i * (barWidth.value + gap), y: height - pad - h, h }
}),
)
const active = ref<number | null>(null)
const titleId = useId()
</script>
<template>
<svg
:viewBox="`0 0 ${width} ${height}`"
role="img"
:aria-labelledby="titleId"
class="chart"
@mouseleave="active = null"
>
<title :id="titleId">{{ title }}</title>
<g v-for="(bar, i) in bars" :key="bar.label" @mouseenter="active = i">
<rect
:x="bar.x"
:y="bar.y"
:width="barWidth"
:height="bar.h"
class="bar"
:class="{ active: active === i }"
/>
<text :x="bar.x + barWidth / 2" :y="height - 6" text-anchor="middle" class="label">
{{ bar.label }}
</text>
</g>
<text v-if="active !== null" :x="width - pad" :y="pad - 6" text-anchor="end" class="label">
{{ bars[active]?.value }}
</text>
</svg>
</template>
<style scoped>
.chart {
width: 100%;
height: auto;
display: block;
}
.bar {
fill: #42b883;
transition: height 300ms ease, y 300ms ease;
}
.bar.active {
fill: #35495e;
}
.label {
font-size: 10px;
fill: currentColor;
}
</style>
max is the largest value, with a floor of 1 so an all-zero dataset does not divide by zero. barWidth shares the inner width between the bars with a fixed gap. bars turns each data point into a rectangle: its left edge, its top (measured from the top of the chart, hence height - pad - h) and its height. Apart from placing the labels, the template does no arithmetic: the bars bind what the computed produced. role="img" and titleId are for screen readers, covered further down.
Change data and the whole chain recomputes. Push a new item and a bar appears; double a value and its bar grows. Each bar is keyed by its label, so keep labels unique.
Highlight a bar on hover and animate its height
Hover is a ref holding the index of the bar under the pointer. @mouseenter on each group sets it; @mouseleave on the whole SVG clears it. The active bar gets a class, and a <text> near the top shows its value. No tooltip library, no positioning maths: it is a template with a v-if.
The ?. in bars[active]?.value guards a real case. If the data shrinks while the pointer is on the last bar, active points past the end of the array; without the ?., rendering fails and the chart disappears. It also keeps vue-tsc passing under TypeScript's noUncheckedIndexedAccess, which Nuxt 4 and @vue/tsconfig 0.8 turn on: that option types bars[active] as possibly undefined.
The bars animate with CSS alone. In SVG 2, height and y on a <rect> are geometry properties, which means CSS can set and transition them. Put transition: height 300ms, y 300ms on the bar, and when a value changes, Vue sets the new attributes and the browser animates from the old height to the new one. y is in the list because a taller bar's top edge moves up.
A <path> is different. Chrome and Firefox also treat its d as a CSS property, but they only animate between paths with the same commands, so adding a point makes the line jump. Safari parses d in CSS but ignores it. The line chart below therefore redraws without a transition.
Draw a line chart with a computed path
A line is one <path> whose d attribute is a string of drawing commands: M x y moves the pen, L x y draws a straight line to a point. Build that string in a computed and bind it. This component takes a plain array of numbers, plus a title and a description.
<script setup lang="ts">
import { computed, useId } from 'vue'
const props = defineProps<{ points: number[]; title: string; description: string }>()
const width = 320
const height = 120
const pad = 12
const max = computed(() => Math.max(...props.points, 1))
const stepX = computed(() => (width - pad * 2) / Math.max(props.points.length - 1, 1))
const coords = computed(() =>
props.points.map((value, i) => ({
x: pad + i * stepX.value,
y: height - pad - ((height - pad * 2) * value) / max.value,
})),
)
const d = computed(() =>
coords.value.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' '),
)
const titleId = useId()
const descId = useId()
</script>
<template>
<svg
:viewBox="`0 0 ${width} ${height}`"
role="img"
:aria-labelledby="`${titleId} ${descId}`"
class="chart"
>
<title :id="titleId">{{ title }}</title>
<desc :id="descId">{{ description }}</desc>
<path :d="d" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
<circle v-for="(p, i) in coords" :key="i" :cx="p.x" :cy="p.y" r="3" fill="currentColor" />
</svg>
</template>
<style scoped>
.chart {
width: 100%;
height: auto;
display: block;
}
</style>
coords maps each value to a point, spacing them evenly across the width (the Math.max(..., 1) in stepX stops a single point from dividing by zero). d joins the points into the path string; the first command is M, the rest are L. The circles reuse the same coordinates, so they always sit on the line. Using the index as the key is fine here because the points have no identity beyond their position.
Using currentColor for the stroke means the chart takes the text colour of wherever you put it, so it follows a dark theme for free.
Name the chart for screen readers
A screen reader cannot read a rectangle. Three pieces give the chart a name:
role="img"makes the whole SVG one image to assistive technology, so its shapes and<text>labels are not read out one by one.<title>is the image's name, like thealttext of an<img>.<desc>is a longer description, the place for "Sales rose from 12 in January to 31 in June".
In the line chart, aria-labelledby points at both ids, so the chart is named explicitly rather than left to each browser to find its <title> and <desc>. The ids come from useId(), added in Vue 3.5. It returns an id that is unique across the app and the same on the server and in the browser, which matters if you render on the server. Two charts on one page get two ids with no effort from you.
The bar chart has only a <title>; give it a description prop and a <desc> the same way. Its hover value never reaches keyboard or screen reader users, so the description is where the numbers belong. Keep it short and specific, and compute it from the data if you can, so it stays true when the numbers change.
When to reach for a chart library
The two components above leave out axes and assume values of zero or more. For a sparkline in a card, a bar chart on a dashboard or a progress ring, that is enough, and a library would add an API to learn and code to ship.
Reach for a chart library when you need axes with sensible ticks for arbitrary ranges, a legend that stays readable with eight series, zooming, or thousands of points. Each is a solved problem that is tedious to solve again. Below that line, two components like these are smaller than a library and entirely yours to style.