YouTube's new morphing play/pause SVG icon
As soon as I saw the new YouTube Player and its new morphing play/pause button, I wanted to understand how it was made and replicate it myself.
From my analysis it looks like YouTube is using SMIL animations. I could not get those animations to work on browsers other than Chrome and it appears that they are deprecated and will be removed. I settled for the following technique:
-
Define the icon
path
elements inside adefs
element so that they are not drawn. -
Draw one icon by defining a
use
element whosexlink:href
attribute points to one of thepath
s defined in the previous step. Simply changing this attribute to point to the other icon is enough to swap them out, but this switch is not animated. To do that, -
Replace the
use
with the actualpath
when the page is loaded. -
Use
d3-interpolate-path
to morph onepath
into the other when the button is clicked. Other SVG libraries like D3, Snap.svg or Raphaël can also be used for this effect.
Finally, it’s important to point out that the number and order of the points of each path
affect the way in which they morph into one another. If a path
is drawn clockwise and another is drawn anticlockwise or if they are not drawn using the exact same number of points, animations between them will not look smooth. This is the reason the play button — a triangle — is drawn using 8 points instead of just 3. There’s definitely more to be said on this subject.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
<!DOCTYPE html>
<meta charset="utf-8">
<style>
html, body {
height: 100%;
margin: 0;
}
.container {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.button {
flex: 1;
padding: 0;
height: 100%;
border: 0;
background-color: white;
outline: none;
-webkit-tap-highlight-color: transparent;
}
.button svg {
width: 100%;
height: 100%;
display: block;
}
</style>
<body>
<div class="container">
<button class="button js-button">
<svg viewBox="0 0 36 36">
<defs>
<path id="pause-icon" data-state="playing" data-next-state="paused" d="M11,10 L17,10 L17,26 L11,26 M20,10 L26,10 L26,26 L20,26" />
<path id="play-icon" data-state="paused" data-next-state="playing" d="M11,10 L18,13.74 L18,22.28 L11,26 M18,13.74 L26,18 L26,18 L18,22.28" />
</defs>
<use xlink:href="#play-icon" />
</svg>
</button>
</div>
<script src="https://unpkg.com/d3-interpolate-path@2.3.0/build/d3-interpolate-path.js" integrity="sha512-/KgDI+uHyEILdOip4a2TTY6ErNAg/jJtTZ67hG450/jvgrZFKm9YkF/Q+mAIs6y7VTG+ODbACY4V4Dj4wM6SUg==" crossorigin="anonymous"></script>
<script type="text/javascript">
"use strict";
/* global document */
// taken from https://github.com/chenglou/tween-functions/blob/master/index.js
// t: current time, b: beginning value, _c: final value, d: total duration
function easeInOutCubic(t, b, _c, d) {
var c = _c - b;
if ((t /= d / 2) < 1) {
return c / 2 * t * t * t + b;
} else {
return c / 2 * ((t -= 2) * t * t + 2) + b;
}
}
function clamp(val) {
return Math.max(Math.min(val, 1), 0);
}
class Transition {
constructor(duration, cb, easeFn) {
this.duration = duration;
this.cb = cb;
this.easeFn = easeFn || easeInOutCubic;
this.tick = this.tick.bind(this);
this.isRunning = false;
}
start() {
this.startTimestamp = performance.now();
if (this.isRunning === false) {
this.isRunning = true;
requestAnimationFrame(this.tick);
}
}
tick(timestamp) {
var timeElapsed = timestamp - this.startTimestamp;
var from = 0;
var to = 1;
this.progress = clamp(this.easeFn(timeElapsed, from, to, this.duration));
this.cb(this.progress);
if (timeElapsed < this.duration) {
requestAnimationFrame(this.tick);
} else {
this.isRunning = false;
}
}
}
class PlayPauseButton {
constructor(el) {
this.el = el;
this.replaceUseWithPath();
this.el.addEventListener("click", this.goToNextState.bind(this));
this.transition = new Transition(400, this.tick.bind(this));
}
replaceUseWithPath() {
var useEl = this.el.querySelector("use");
var iconId = useEl.getAttribute("xlink:href");
var iconEl = this.el.querySelector(iconId);
var nextState = iconEl.getAttribute("data-next-state");
var iconPath = iconEl.getAttribute("d");
this.pathEl = document.createElementNS("http://www.w3.org/2000/svg", "path");
this.pathEl.setAttribute("data-next-state", nextState);
this.pathEl.setAttribute("d", iconPath);
var svgEl = this.el.querySelector("svg");
svgEl.replaceChild(this.pathEl, useEl);
}
goToNextState() {
var iconPath = this.pathEl.getAttribute("d");
var nextIconEl = this.getNextIcon();
var nextIconPath = nextIconEl.getAttribute("d");
var nextState = nextIconEl.getAttribute("data-next-state");
this.pathEl.setAttribute("data-next-state", nextState);
this.pathInterpolator = d3.interpolatePath(iconPath, nextIconPath);
this.transition.start();
}
getNextIcon() {
var nextState = this.pathEl.getAttribute("data-next-state");
return this.el.querySelector(`[data-state="${nextState}"]`);
}
tick(progress) {
this.pathEl.setAttribute("d", this.pathInterpolator(progress));
}
};
var playPauseButton = new PlayPauseButton(document.querySelector(".js-button"));
</script>
</body>