Animated Reel Menu
I ended up sharing my last post on how to secure application credentials with a couple friends and colleagues. While the feedback was positive and full of great suggestions, many made note of or asked me about the site's menu button. One even suggested I write a post about how it works. This is that post.
If your user agent supports it and you haven't clicked the menu button before, please do. I'll wait! It's the 3 white lines in the top left corner (often called the hamburger icon).
Theory of Operation
The core gimmick takes advantage of
CSS transitions.
More specifically, how they apply to SVG
<path>
elements'
stroke-dasharray
and
stroke-dashoffset
styles. You start by creating a long path with a short line segment along it. Now you can animate it
to travel along the length of the path and grow and shrink as desired.
<svg viewBox="0 0 32 16" width="0" height="0">
<path id="path" d="m 0,8 c 16,-16 16,16 32,0" />
</svg>
svg {
width: 100%;
height: 100%;
}
#path {
fill: none;
stroke: #000;
stroke-dasharray: 8 32;
animation: demo 1s linear infinite;
}
@keyframes demo {
from { stroke-dashoffset: 40; } /* 8 + 32 = 40 */
to { stroke-dashoffset: 0 ; }
}
The rest is mostly just implementation details. Seriously, like 4 lines of productive code smothered by 150 lines of orchestration, usability, and edge cases. That's kind of a lie, but you can totally go implement your own without reading further. In fact, I strongly encourage you to stop reading and try it. Solving your failures is orders of magnitude more educational than reading some blog post.
Progressive Enhancement
I pride myself on a site that's fairly lean and accessible. You can use just about any user agent
and it should look good. To achieve this we start with something simple and add features. This also
improves fault tolerance because bugs are more likely to leave the site degraded instead of broken.
To begin, we need to define the look of the
<nav>
when we don't have JavaScript. I like a navigation strip when there's few items, but you do you.
<header id="top">
<nav id="menu">
<a>Link 1</a> <span class="no-css">|</span>
<a>Link 2</a> <span class="no-css">|</span>
<a>Link 3</a> <span class="no-css">|</span>
<a>Link 4</a>
</nav>
</header>
We can now
defer
a script. When run, this script adds a .js
class to the menu elements. This indicates
they're under JavaScript control, after which we can define states with
CSS classes that we add and remove to control the view.
For example, the menu being open can be defined with .js-open
, and its absence
indicating the menu is closed.
"use strict";
(() => {
let top = document.getElementById("top");
let menu = document.getElementById("menu");
let btn = document.createElement("button");
top.style.setProperty("--items", menu.children.length);
btn.title = "Menu";
btn.innerHTML = "$lt;button title="Menu"><svg>...</svg>$lt;/button>";
function open() {
menu.classList.add("js-open");
btn.classList.add("js-open");
btn.onclick = close;
}
function close() {
menu.classList.remove("js-open");
btn.classList.remove("js-open");
btn.onclick = open;
}
btn.onclick = open;
menu.classList.add("js");
top.classList.add("js");
top.insertBefore(btn, menu);
})();
Notice that our script creates its own
<button>
with our SVG inside. Doing it this way instead of
adding the button directly to the page prevents having a broken button when a user's agent doesn't
support or allow JavaScript. This is much cleaner than using
CSS to hide the button because some user agents don't
support CSS. Amusingly, there's just no practical way to
know how an agent will render what we tell it to. All we can do is test in a bunch of them and hope
for the best. Pretty sad considering you and I are probably running one of the same two processor
ISAs but I can't render anything consistently for
you because we insist on developing everything for web browsers.
Animation
After we have our menu, the next challenge is getting the right feel when people open and close it. Animation is a subtle art, just like lighting and sound. When it's bad people will notice. They'll complain or even sometimes complement you. Both can be concerning. Most of the time, animations shouldn't be noticeable, if they are it's usually because they're distracting. When you nail it, people generally won't notice.
That's what worries me about people noticing the menu. It's intentionally a flashy animation, and adding it effectively lags the menu by 500ms. That's why you have to be careful. The wrong animation can destroy an otherwise decent experience by forcing people to sit through your unhelpful eye candy. Yet, you have to open the menu somehow (if you want a hidden menu). It could just pop into existence but I wanted something that gave the menu a snappy, sort of comic feel. I also wanted it to look like the menu was being essentially let down via the reel animation.
Animating The Menu
At first I thought I wanted to unroll/fold the menu. If you look around online for
CSS unrolling techniques, the common suggestions involve
either
max-height
or
transform
.
Unfortunately, neither really captured the animation I had in my head.
#height {
transition: max-height 1s linear;
overflow: hidden;
max-height: 0;
}
#height.js-open {
max-height: 12em;
}
With
max-height
,
the first problem is you need to know the initial height of the box. This means every time you add
something to the list of items you'll have to modify the style sheet. It's either that, or put up
with a large delay before the transition kicks in at high speed. The other problem is the animation
seems to slide the content behind the bottom border. This could work with a drop shadow inside the
box to really sell the cutout effect, however, that's not the feel I wanted. You also need to be
careful about your
box-sizing
to
include things like the
border
and
padding
properly.
#xform {
transition: transform 1s linear;
transform-origin: top;
transform: scaleY(0);
}
#xform.js-open {
transform: scaleY(1);
}
The big problem with
transform
is
it crushes the text into static before it winks out of existence. It makes a lot more sense if you
realize it's actually doing a 3D rotation of the element, but
3D is definitely not the look I'm aiming for. Another common
problem with transforms for this sort of thing is they explicitly don't modify the flow of the
document. This is good for performance, and doesn't impact our use (assuming absolute positioning),
but it is worth noting for other applications.
#slide {
transition: top 1s linear;
position: absolute;
top: -20em;
}
#slide.js-open {
top: 0em;
}
The slide under animation I went with based on
top
positioning also
has a number of problems, but really nailed the look I was going for. It still has the known size
problem
max-height
did. It now also has a scaling latency problem. To see this, quickly resize your browser or zoom in
while looking at the menu button at the top of this page. You should be able to
catch the bottom of the menu peaking out behind the top bar. We essentially have to leave the
transition on the menu; this is the result. Overall, I can live with those issues though.
Animating The Button
The button is often what gets the attention. The core to the feel of this animation includes principals like anticipation, follow through, overlapping action, slow in/out, arcs, secondary action, and exaggeration. These principals and some not used here were laid out by Ollie Johnston and Frank Thomas in their book, The Illusion of Life: Disney Animation. I'm not going to drill down on these here, but you should read that book and others on animation if you're interested in this sort of stuff.
One of the keys to the button's animation is the use of a
cubic-bezier
easing function on the transition. This alone provides follow through, slow in/out, and
exaggeration. Bézier functions and the curves they plot are a deep mathematical subject. Here I only
intend to cover just enough for you to better understand what a single line of
CSS is computing for you. If you're curious just how
much you can learn about these, Pomax has a great
primer on Bézier curves for you.
#top {
--item-height: 2em;
--duration: .5s;
--dash-length: 8;
--dash-spaces: 32;
}
#top {
height: var(--item-height);
z-index: 1;
}
#top button path {
stroke-dashoffset: 0;
stroke-dasharray: var(--dash-length) var(--dash-spaces);
transition: stroke-dashoffset var(--duration) cubic-bezier(.3, -.4, .8, 1.4);
}
#top.js-open button path {
stroke-dashoffset: calc(
var(--dash-length) +
var(--dash-spaces)
);
}
#menu.js {
position: absolute;
top: calc(-1 * var(--items) * var(--item-height));
transition: top var(--duration) ease;
}
#menu.js-open {
top: var(--item-height);
}
The TL;DR is that a cubic Bézier is just a Bézier
function where the largest term is cubic (t3). The Bézier function is defined by a number
of (x, y)
points equal to the function degree plus one, four in the cubic case (3 + 1),
and computes based on an input variable t
. The value of t
ranges from 0 to
1 and represents the percent along the Bézier from the start to the end. The four points define the
start point, the end point, and two control points.
When using a
cubic-bezier
you're only asked for two points (x1, y1)
and (x2, y2)
because the start
and end are always (0, 0)
and (1, 1)
respectively. You can only set the
x
values between 0 and 1 because they influence the time component. Sadly being able to
have things happen before they start or after they end wouldn't really make sense in the context of
a transition (no time travel allowed).
Finally, using some math called Cardano's method, you can derive an equation where instead of t (the distance along the line) you pass the transition's completion (from 0 to 1) and it calculates the amount (0 to 1) to rescale the property's value. Interpolating by that value you can go from the start to the end of the transition Bézier smooth.
Conclusion
Not much else to say. I hope you can use the path based segment animation to do something else cool.