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 (web code in a nutshell). 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 very 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 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 resale the property's value. Interpolating by that value you can go from the start to the end of the of your transition Bézier smooth.

Conclusion

Not much else to say. Hopefully you can use the path based segment animation to do something else cool.