I’m thoroughly convinced that SVG unlocks a whole entire world of building interfaces on the web. It might seem daunting to learn SVG at first, but you have a spec that was designed to create shapes and yet, still has elements, like text, links, and aria labels available to you. You can accomplish some of the same effects in CSS, but it’s a little more particular to get positioning just right, especially across viewports and for responsive development.
What’s special about SVG is that all the positioning is based on a coordinate system, a little like the game Battleship. That means deciding where everything goes and how it’s drawn, as well as how it’s relative to each other, can be really straightforward to reason about. CSS positioning is for layout, which is great because you have things that correspond to one another in terms of the flow of the document. This otherwise positive trait is harder to work with if you’re making a component that’s very particular, with overlapping and precisely placed elements.
Truly, once you learn SVG, you can draw anything, and have it scale on any device. Even this very site uses SVG for custom UI elements, such as my avatar, above (meta!).
We won’t cover everything about SVGs in this post (you can learn some of those fundamentals here, here, here and here), but in order to illustrate the possibilities that SVG opens up for UI component development, let’s talk through one particular use case and break down how we would think about building something custom.
The timeline task list component
Recently, I was working on a project with my team at Netlify. We wanted to show the viewer which video in a series of videos in a course they were currently watching. In other words, we wanted to make some sort of thing that’s like a todo list, but shows overall progress as items are completed. (We made a free space-themed learning platform and it’s hella cool. Yes, I said hella.)
Here’s how that looks:
So how would we go about this? I’ll show an example in both Vue and React so that you can see how it might work in both frameworks.
The Vue version
We decided to make the platform in Next.js for dogfooding purposes (i.e. trying out our own Next on Netlify build plugin), but I’m more fluent in Vue so I wrote the initial prototype in Vue and ported it over to React.
Here is the full CodePen demo:
Let’s walk through this code a bit. First off, this is a single file component (SFC), so the template HTML, reactive script, and scoped styles are all encapsulated in this one file.
We’ll store some dummy tasks in data
, including whether each task is completed or not. We’ll also make a method we can call on a click directive so that we can toggle whether the state is done or not.
<script>
export default {
data() {
return {
tasks: [
{
name: 'thing',
done: false
},
// ...
]
};
},
methods: {
selectThis(index) {
this.tasks[index].done = !this.tasks[index].done
}
}
};
</script>
Now, what we want to do is create an SVG that has a flexible viewBox
depending on the amount of elements. We also want to tell screen readers that this a presentational element and that we will provide a title with a unique id of timeline
. (Get more information on creating accessible SVGs.)
<template>
<div id="app">
<div>
<svg :viewBox="`0 0 30 ${tasks.length * 50}`"
xmlns="http://www.w3.org/2000/svg"
width="30"
stroke="currentColor"
fill="white"
aria-labelledby="timeline"
role="presentation">
<title id="timeline">timeline element</title>
<!-- ... -->
</svg>
</div>
</div>
</template>
The stroke
is set to currentColor
to allow for some flexibility — if we want to reuse the component in multiple places, it will inherit whatever color
is used on the encapsulating div.
Next, inside the SVG, we want to create a vertical line that’s the length of the task list. Lines are fairly straightforward. We have x1
and x2
values (where the line is plotted on the x-axis), and similarly, y1
and y2
.
<line x1="10" x2="10" :y1="num2" :y2="tasks.length * num1 - num2" />
The x-axis stays consistently at 10 because we’re drawing a line downward rather than left-to-right. We’ll store two numbers in data: the amount we want our spacing to be, which will be num1
, and the amount we want our margin to be, which will be num2
.
data() {
return {
num1: 32,
num2: 15,
// ...
}
}
The y-axis starts with num2
, which is subtracted from the end, as well as the margin. The tasks.length
is multiplied by the spacing, which is num1
.
Now, we’ll need the circles that lie on the line. Each circle is an indicator for whether a task has been completed or not. We’ll need one circle for each task, so we’ll use v-for
with a unique key
, which is the index (and is safe to use here as they will never reorder). We’ll connect the click
directive with our method and pass in the index as a param as well.
CIrcles in SVG are made up of three attributes. The middle of the circle is plotted at cx
and cy,
and then we draw a radius with r.
Like the line, cx
starts at 10. The radius is 4 because that’s what’s readable at this scale. cy
will be spaced like the line: index times the spacing (num1
), plus the margin (num2
).
Finally, we’ll put use a ternary to set the fill
. If the task is done, it will be filled with currentColor
. If not, it will be filled with white
(or whatever the background is). This could be filled with a prop that gets passed in the background, for instance, where you have light and dark circles.
<circle
@click="selectThis(i)"
v-for="(task, i) in tasks"
:key="task.name"
cx="10"
r="4"
:cy="i * num1 + num2"
:fill="task.done ? 'currentColor' : 'white'"
class="select"/>
Finally, we are using CSS grid to align a div with the names of tasks. This is laid out much in the same way, where we’re looping through the tasks, and are also tied to that same click event to toggle the done state.
<template>
<div>
<div
@click="selectThis(i)"
v-for="(task, i) in tasks"
:key="task.name"
class="select">
{{ task.name }}
</div>
</div>
</template>
The React version
Here is where we ended up with the React version. We’re working towards open sourcing this so that you can see the full code and its history. Here are a few modifications:
- We’re using CSS modules rather than the SCFs in Vue
- We’re importing the Next.js link, so that rather than toggling a “done” state, we’re taking a user to a dynamic page in Next.js
- The tasks we’re using are actually stages of the course —or “Mission” as we call them — which are passed in here rather than held by the component.
Most of the other functionality is the same :)
import styles from './MissionTracker.module.css';
import React, { useState } from 'react';
import Link from 'next/link';
function MissionTracker({ currentMission, currentStage, stages }) {
const [tasks, setTasks] = useState([...stages]);
const num1 = 32;
const num2 = 15;
const updateDoneTasks = (index) => () => {
let tasksCopy = [...tasks];
tasksCopy[index].done = !tasksCopy[index].done;
setTasks(tasksCopy);
};
const taskTextStyles = (task) => {
const baseStyles = `${styles['tracker-select']} ${styles['task-label']}`;
if (currentStage === task.slug.current) {
return baseStyles + ` ${styles['is-current-task']}`;
} else {
return baseStyles;
}
};
return (
<div className={styles.container}>
<section>
{tasks.map((task, index) => (
<div
key={`mt-${task.slug}-${index}`}
className={taskTextStyles(task)}
>
<Link href={`/learn/${currentMission}/${task.slug.current}`}>
{task.title}
</Link>
</div>
))}
</section>
<section>
<svg
viewBox={`0 0 30 ${tasks.length * 50}`}
className={styles['tracker-svg']}
xmlns="http://www.w3.org/2000/svg"
width="30"
stroke="currentColor"
fill="white"
aria-labelledby="timeline"
role="presentation"
>
<title id="timeline">timeline element</title>
<line x1="10" x2="10" y1={num2} y2={tasks.length * num1 - num2} />
{tasks.map((task, index) => (
<circle
key={`mt-circle-${task.name}-${index}`}
onClick={updateDoneTasks(index)}
cx="10"
r="4"
cy={index * +num1 + +num2}
fill={
task.slug.current === currentStage ? 'currentColor' : 'black'
}
className={styles['tracker-select']}
/>
))}
</svg>
</section>
</div>
);
}
export default MissionTracker;
Final version
You can see the final working version here:
This component is flexible enough to accommodate lists small and large, multiple browsers, and responsive sizing. It also allows the user to have better understanding of where they are in their progress in the course.
But this is just one component. You can make any number of UI elements: knobs, controls, progress indicators, loaders… the sky’s the limit. You can style them with CSS, or inline styles, you can have them update based on props, on context, on reactive data, the sky’s the limit! I hope this opens some doors on how you yourself can develop more engaging UI elements for the web.
Thank you, Sarah!
I have been using SVG for a quite while.
Here is what I think-
A better approach would be first design the Component using visual tool such as Adobe illustrator or inkscape then manipulate it or add interaction to it using JS.
This way it is more convenience.
Great article Sarah, really enjoyed and appreciate what it might have took for your team to do this.
@vishal: I’ve also been creating components with SVG for years and years, and you’re not wrong, that sometimes creating something in Illustrator is faster. However in this instance, you need the component to be flexible to many items and so you will need to dynamically create and bind lengths with JS, so that way is not flexible enough for a lot of use cases. You will also still need to write some of it by hand to make it accessibile. I want to be really clear that my team didn’t write this component, I did, so this isn’t just a writeup of someone else’s work, the article comes with an understanding of why I chose this approach as well. Cheers!
Great write-up. I started your SVG book a while ago and keep seeing possibilites for it. Nice to see this and someone teach me some Vue along with it.
title
inside content – did I miss smth about using of this element?SVG element has a title tag of its own, not the document title tag you’re probably thinking about.
This is awesome!
I’m curious about something in your example code that made me scratch my head:
instead of
I expected the later arithmetic operations to act in a weird way when given an array, but they totally work! As long as you only have one element in the array. I guess something’s going on like type-coerce to string and then back to number.
Does making a single-element array with a number in it vs using a number make something easier or have some other benefit, or is it a stylistic choice? (Or something else?)
Hey! Actually this was vestige from an older iteration and I had updated but it had gotten reverted somehow, thank you for mentioning it! It should be fixed now :)
Thank you for the article, Sarah!
What are the pros and cons of creating this component in HTML/CSS instead of SVG?
Thanks!
Great article Sarah! :) Just a thought: beware of mutating state in React. It should always be avoided. When you do this:
You are actually mutating
tasks[index]
(since[...tasks]
is just a shallow copy of the array). You should do something like this instead:tasksCopy[index] = {...tasksCopy[index], done: tasksCopy[index].done}
May have been better to use
spacing
andmargin
instead ofnum1
andnum2
. I found that bit of the code confusing. I’m also not sure what the difference is between these two becausespacing
andmargin
sound very similar in terms of semantics. Otherwise, cool tutorial!thing is, the various vue and react extensions make the code barely recognizable as standard js.
Seems like some people are bikeshedding you here and I just wanted to say, thanks, nice example, and I find this inpsiring! I’ve been digging svg in React lately, I agree that they are a match made in UI heaven. Looking forward to more from you!