Last year I put a tweet that showed some code refactoring using animation. Many people asked how I did it.
I think it's interesting why people find things like this interesting. I beleive it has to do with visual movement, it's a lot more compelling that reading text, which is code. When colorful things move we pay attention.
So how did I build it? I did it the hard way. It's just a bunch of <div>
s, with many setTimeout()
that advance thru a series of steps, placing a css class to hide or show something. It took a lot of time to put together.
So I thought about how I could make this easier to do, maybe I could create a tool where I could build a code animation in minutes instead of hours. Over the past few weeks I started putting together that tool.
This article is about how I created that tool, and how you can do it yourself. I share all the tricks so you can use it for your landing page, docs site, presentation or as GIF on twitter.
Why it works?
(show image comparison)
text tweet -> image tweet -> animation tweet
text tweet: Svelte's compiler writes the hook code for you
image tweet: diff
animation tweet: animated diff
If your app has an API, or is a developer tool, then demonstrating how your code works is vital to getting your software adopted.
Get noticed is hard, because developers are busy writing code all day, getting them to read even more code to see how your thing works can be hard to do.
One way, and the approach I'll talk about in this article, is using animation to demo code. It can be used on your home page, in your docs site or even in blog posts. Stripe is doing this.
The main advantages of animation are:
- It grabs attention. It's far more captivating than reading thru text.
- It conveys a lot of information quickly.
In this article, I'll show you how to you can roll your own, including syntax highlighting and animation tweening.
Setup
We're going to use Svelte for this example. It's ideal for this because it has built-in support for tweening animations, but React, Vue, etc will work well too.
Let's start by creating the project:
yarn init svelte@next example-project
We're going to use CodeMirror to display the code and take care of all the syntax highlighting for us. I've created a thin wrapper that reduces the boilerplate needed to setup CodeMirror.
yarn add -D codemirror @joshnuss/svelte-codemirror
Now let's add the component to our index page src/routes/index.svelte
<script>
// import syntax highlighting for js
import 'codemirror/mode/javascript/javascript'
// import wrapper component
import CodeMirror from '@joshnuss/svelte-codemirror'
// reference to the codemirror api
let editor
// editor options
// full list here: https://codemirror.net/doc/manual.html#config
const options = {
readOnly: true,
lineNumbers: true,
mode: "javascript",
value: ''
}
</script>
<CodeMirror bind:editor {options}/>
Animation loop
Let's say we want to animate several steps. It's a good idea to keep the steps as data, just in case we want to change things later.
We'll use an array:
const steps = [
{
type: 'append',
text: "function foo() {\n}"
}
]
Each step has a type
of animation (ie append
, insert
, delete
, scroll
, delay
, etc) has two phases: start
and stop
. We can organize that into a data structure:
const commands = {
// each function name maps to a command
append(step) {
// every command returns an object like this
return {
start() {
// stuff to run at the beginning of the step
},
stop() {
// stuff to run at the end of the step
}
}
}
}
Then use a series of setTimeout()
calls to advance thru each step:
const steps = [...]
onMount(() => next(0))
function next(index) {
// find the current step
const step = steps[index]
// find the command for this step type
// invoke the command function, passing the editor and step
const command = commands[step.type](editor, step)
// trigger the start of the animation
if (command.start) command.start()
// after 2 seconds, we'll move to the next step
setTimeout(() => {
// trigger the end of the animation
if (command.stop) command.stop()
// check that it's not the last step
if (index < steps.length - 1) {
// run next step
next(index + 1)
} else {
// it's the last step, so go back to step #1
next(0)
}
}, 2000)
}
Inserting
With the animation loop in place, we can start defining commands. The first is an insert command:
// in commands.js
insert(editor, step) {
return {
start() {
// move the cursor
editor.setCursor(step.pos)
// insert text
editor.replaceSelection(step.text)
// move the cursor to end
const end = step.pos + step.text.length
editor.setCursor(end)
}
}
}
Notice we didn't declare a stop()
function. That's because we didn't need it. Phases are optional.
Appending
The append logic can reuse the insert
command, by specifying the insert to happen after the last character.
// in commands.js
append(editor, step) {
// compute length of code
// getValue() returns the current code
const end = editor.getValue().length
// punt to `insert()` command
return insert(editor, {...step, pos: end})
}
Tweening & Easing
Currently transitions are linear. To make them feel more fluid, by using Svelte's tweened
store and setting up an easing function.
TODO
Cursors
Moving the cursor is fairly straightforward.
Defining a command would look like:
commands = [
{
type: 'cursor',
// set cursor at char 22
pos: 22
}
]
The command handler for cursor
would be:
function cursor(editor, step) {
return {
start() {
// move cursor to `.pos`
editor.setCursor(step.pos)
}
}
}
Highlighting text
Highlighting code can be done with a CSS class name or CSS attributes using the editor.markText()
.
Defining a command will look like this:
commands = [
{
type: 'select',
// start a char 0
from: 0,
// end at char 10
to: 0,
// set a class name (optional)
class: 'bg-green-300',
// set styles (optional)
style: 'background: green; color: white; text-decoration: underline'
}
]
Then the command handler would look like:
function select(editor, step) {
return {
// in start phase, mark text span
start() {
const from = editor.indexFromPos(step.from)
const to = editor.indexFromPos(step.to)
// mark a text span
editor.markText(from, to, {className: step.class, style: step.style})
},
// in stop phase, clear the selection
stop() {
// clear all marks
editor
.getAllMarks()
.forEach(mark => mark.clear())
}
}
}
Deleting
TODO
Scrolling
There are several ways we can scroll:
- To a line
- To a y position
- To an x position
x and y are easiest:
// in commands.js
function scroll(editor, step) {
return {
start() {
editor.scrollTo(step.x, step.y)
}
}
}
To find the y coordinate of a line number, we need to figure out how tall each line is, and then multiply by line:
// in commands.js
function scroll(editor, step) {
return {
start() {
let y = step.y
// check if scrolling to a line
if (step.line) {
// get total number lines in editor
const lines = editor.lineCount()
// get bounding box of editor
const scrollInfo = editor.getScrollInfo()
// compute the height of a line
const lineHeight = scrollInfo.height/lines
// y is line height multiplied by line number
y = lineHeight * step.line
}
// scroll
editor.scrollTo(step.x, y)
}
}
}
Scroll with tweening
TODO
Delays
This is the easiest one to implement, it's just a command that does nothing:
// in commands.js
function delay() {
// do nothing, no start() or stop() functions
return {}
}
Annotations
Showing code alone doesn't explain what's going on. It helps to show captions at the bottom of the editor.
Let's add another attribute caption
to each step in the timeline:
const steps = [
{
type: 'append',
// add a caption
caption: "adding a function"
text: "function foo() {\n}"
}
]
Now we can float the caption above the code
<div class="annotations">
{#if currentStep.caption}
<div class="text">
{currentStep.caption}
</div>
{/if}
</div>
It's handy to support markdown in captions so we can can bold or link things:
# install markdown parser
yarn add -D marked
Then wrap call marked()
on the currentStep.caption
<script>
import marked from 'marked'
</script>
<div class="annotations">
{#if currentStep.caption}
<div class="text">
{@html marked(currentStep.caption)}
</div>
{/if}
</div>
When the caption changes between step, it would be good to have a transition. We can use svelte's fly
animation for this:
<script>
import { fly, fade } from 'svelte/transition'
</script>
<div class="annotations">
<!-- use #key to make animation change every time caption changed -->
{#key currentStep.caption}
{#if currentStep.caption}
<!-- fly in, fade out -->
<div class="text" in:fly={{y: 100}} out:fade>
{@html marked(currentStep.caption)}
</div>
{/if}
{/key}
</div>
Publishing events
Often you want to synchronize the animation with other elements on the page. We accomplish that by firing events whenever a step starts or stops:
const dispatch = createEventDispatcher()
function next(index) {
const step = steps[index]
// fire start event, passing current step
dispatch('stepstart', step)
setTimeout(() => {
// fire end event, passing current step
dispatch('stepend', step)
if (index < steps.length - 1) {
next(index+1)
}
})
}
Client code can capture those events:
<script>
// declare event handlers
function start({detail: step}) {
// show/hide/scroll accompanying elements
}
function end({detail: step}) {
// show/hide/scroll accompanying elements
}
</script>
<CodeTimeline on:stepstart={start} on:stepend={end}/>
Conclusion
Animated code is a great way to showcase what your software does. It's captivating to the user, and you can pack in a lot of information quickly.
Building it a little tricky, but all you need is a sequence of steps and a loop that updates the UI at the start/stop of each step.
If you'd prefer to use a premade version of this, check out https://codinator.app.
P.S. This comes from experiments I've been doing over at https://1000experiments.dev, lot's more info and examples there.
Top comments (0)