Reactive SVG charts
Draw a bar chart as plain SVG: a viewBox that scales, scales as computed functions, gridlines from the data, a title named with useId(), colour from a class and motion from CSS.
What you'll learn
When you finish, you can do these.- 1Draw an SVG bar chart that fits any screen width
- 2Turn data into bar and gridline positions with computed()
- 3Name a chart for screen readers with an id from useId()
- 4Make bars glide with CSS and move labels with transform
Picture the dashboard of a small online shop, with a bar chart of visits per day, Monday to Friday. A charting library could draw it, but it brings its own look and often more JavaScript than the page itself. For one small chart that must match your design, SVG is enough: the browser's own way to draw shapes with tags, where each bar is a <rect>, a rectangle.
The Code tab holds that chart in two files: App.vue, the page, with five visit counts and a New numbers button, and BarChart.vue, the component that draws them. By the end you can say why the bars glide when the numbers change, and how a screen reader, a program that reads pages aloud, names the chart. You need one idea from Computed properties, in the Beginner track: a computed, a value Vue works out from other values and keeps up to date.
Draw in a box that scales
The chart starts as a box that fits any screen. The drawing sits inside an <svg> tag, whose viewBox="0 0 320 180" gives it its own coordinates: a space 320 units wide and 180 tall. CSS sets how big it looks: with width: 100%, it fits a phone and a laptop.
The drawing's top left corner is 0, 0, and y, the distance from the top, grows downward. A <rect> grows down from its y, so a bar standing on the floor has its top at the floor minus its height. BarChart.vue calls that floor baseline, and pad is the margin left for the axis labels:
const width = 320, height = 180
const pad = { top: 16, right: 12, bottom: 28, left: 32 }
const baseline = height - pad.bottom
const plotHeight = baseline - pad.top
const plotWidth = width - pad.left - pad.right
With these numbers the floor sits at y 152, and the plot inside the margins, plotWidth by plotHeight, is 276 by 136.
Scales are computed functions
A scale turns a value from your data into a position. The chart has two: y turns 22 visits into a y of 16, and x turns the fifth day into an x of about 253. Both change with the data, y with its largest value, max, and x with the number of days, so each is a computed that returns a function:
const max = computed(() => Math.max(...values, 1))
const y = computed(() => (v: number) => baseline - (v / max.value) * plotHeight)
const x = computed(() => (i: number) => pad.left + (i * plotWidth) / values.length)
const barWidth = computed(() => plotWidth / values.length - 6)
A computed keeps its result until something it read changes, so y is rebuilt only when max changes, and the template reuses it for every bar and gridline. The template unwraps a computed: you write y(t) there, with t a gridline's value, and y.value(v) in script. The diagram shows the same idea on a cart total, worked out from a price and a quantity:
The bars are one more computed, one object per day with its position and height. ticks holds the values of the gridlines behind the bars: 0, half of max and max, so 0, 11 and 22.
const bars = computed(() => values.map((v, i) => ({
label: labels[i], value: v, x: x.value(i) + 3, y: y.value(v), h: baseline - y.value(v),
})))
const ticks = computed(() => [0, 0.5, 1].map(f => Math.round(f * max.value)))
In the template, each tick becomes a <g>, SVG's group tag, holding a <line> and a <text> with the number. Each bar is a <g> with its <rect> and its day name:
<g v-for="(t, i) in ticks" :key="i">
<line :x1="pad.left" :x2="width - pad.right" :y1="y(t)" :y2="y(t)" class="grid" />
<text :x="pad.left - 4" :y="y(t)" text-anchor="end" dominant-baseline="middle" class="small">{{ t }}</text>
</g>
<g v-for="b in bars" :key="b.label">
<rect :x="b.x" :y="b.y" :width="barWidth" :height="b.h" rx="3" class="bar" />
<text :x="b.x + barWidth / 2" :y="baseline + 14" text-anchor="middle" class="small">{{ b.label }}</text>
</g>
Three gridlines and five bars appear, all placed by the same y, so they never disagree. Keying each bar by its day name, :key="b.label", lets Vue update Tuesday's <rect> instead of replacing it, so a CSS transition can make it glide.
Name it, colour it, move it
The chart still needs a name, a colour and motion.
For the name, role="img" makes a screen reader treat the whole <svg> as one picture, and aria-labelledby holds the id of a <title> inside it that says what the picture shows. That id must be unique on the page, so it comes from useId(), a Vue function that gives each chart its own. BarChart.vue keeps it in id and adds -title:
<svg :viewBox="`0 0 ${width} ${height}`" role="img" :aria-labelledby="`${id}-title`" class="chart">
<title :id="`${id}-title`">{{ title }}. The highest day has {{ max }}.</title>
A screen reader announces "Visits per day. The highest day has 22." The Show values checkbox under the chart builds its id from the same id, so its label toggles this chart's checkbox and never another's.
Colour comes from a class, never from a fill attribute on each shape: .bar { fill: var(--chart-bar) } reads a CSS variable that App.vue defines once, so a theme change touches one line.
Motion comes from CSS too. For a <rect>, y and height are CSS properties as well as attributes, so the .bar rule's transition makes the bars glide when Vue sets new values. A <text>'s y is only an attribute, so a transition on it does nothing. The starter keeps each value label, the number over a bar, on the floor and moves it up with transform, which CSS can animate:
<text v-if="showValues" :x="b.x + barWidth / 2" :y="baseline - 4" :style="{ transform: `translateY(${-b.h}px)` }" text-anchor="middle" class="small value">{{ b.value }}</text>
Click New numbers: the bars glide, and their numbers ride along.
What goes wrong
Both mistakes give a chart that looks finished, and no warning.
The first writes the scale as if y grew upward, with 0 at the bottom:
const y = computed(() => (v: number) => pad.top + (v / max.value) * plotHeight)
The chart turns upside down. The axis reads 0 at the top and 22 at the floor, Friday's 22 visits get no bar because their top lands on the floor, and Wednesday's 7 becomes the tallest. Subtract from the baseline instead, so a bigger value moves the top up:
const y = computed(() => (v: number) => baseline - (v / max.value) * plotHeight)
Friday's bar is the tallest again.
The second gives the title a fixed id, the same in every chart:
<svg role="img" aria-labelledby="chart-title">
<title id="chart-title">{{ title }}</title>
Uncomment the second chart in App.vue, Orders per day, and both titles carry the same id. aria-labelledby finds the first, so a screen reader announces "Visits per day" for both charts. An id from useId() is new for each chart:
<svg role="img" :aria-labelledby="`${id}-title`">
<title :id="`${id}-title`">{{ title }}</title>
The orders chart is now announced as "Orders per day".
What to remember
viewBoxsets the drawing's own coordinates and CSS its size on screen. In SVG y grows downward, so a bar's top is the baseline minus its height.- A scale is a computed that returns a function from data to position, rebuilt only when what it reads changes and called for every bar and gridline.
- Derive bars and gridlines from the same scales, key each bar by something that survives a data change, and colour them with classes.
- Name the chart with
role="img"and a<title>whose id comes fromuseId(). - CSS can transition a
<rect>'syandheight, but a<text>'syis only an attribute: move labels withtransform. Past a few thousand shapes, or for tooltips and zoom, use canvas or a library.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- In
BarChart.vue, changeticksto[0, 0.25, 0.5, 0.75, 1].map(f => Math.round(f * max.value)). Five gridlines appear, labelled 0, 6, 11, 17 and 22 with the starter numbers. Click New numbers: the lines follow the newmax, and the top line still meets the tallest bar. - In
App.vue, uncomment the second<BarChart>. Click the words Show values under the orders chart: only its value labels disappear. InBarChart.vue, replaceuseId()with'chart'and click the same words again: now the visits chart loses its labels, because both labels point at the first checkbox with that id. PutuseId()back. - In
BarChart.vue, change the value label to:y="b.y - 4"and remove its:style. Click New numbers: the bars glide but the numbers jump. Change the.valuerule totransition: y 0.4s easeand click again: still a jump, because<text>has noyproperty. Restore:y="baseline - 4"and the:style: the labels travel with their bars.