In today's issue, I want to show you how to time travel — with JavaScript.
OK, it might not be "Doc Brown" levels of time travel.
But it's a powerful animation trick that feels like magic when the opportunity arises to use it.
We're gonna build something like this (a Vestaboard?). I've wanted to make one of these for some time. It was finally inspired by a colleague sharing this font site.
But before that, some context. I went down the rabbit hole to understand this technique back in 2021. The goal then was to create an infinite scrolling carousel without duplicating elements.
You can apply this technique to various use cases. All whilst avoiding DOM tricks like duplicating and moving elements around.
That's often one technique you will see when people create an infinite marquee/carousel effect (I should make more YouTube content). They duplicate the first and last element or the entire list. Then they position it so that it looks seamless as the translation happens.
But often you can avoid that altogether with today's technique.
CREATING A VESTABOARD
We’re going to create a split-flap display for this issue. A style popularized recently under the name “Vestaboard”. The trick to building something like this is to break it down into smaller tasks. You can start by creating a single flap, providing a foundation for the mechanics to follow.
How about the markup? You can use one container element and give it four child elements. Two children represent the current value, and the others represent the next value.
<div class="flip">
<!-- unfold top -->
<div>B</div>
<!-- unfold bottom -->
<div>B</div>
<!-- fold top -->
<div>A</div>
<!-- fold bottom -->
<div>A</div>
</div>
Using clip-path
, you can clip the children into the right shape. A small trick here is to use pseudo-elements of the container for the axle ends.
.flip div:nth-of-type(odd) {
clip-path: polygon(
0 0,
100% 0,
100% 40%,
calc(90% + 0.025em) 40%,
calc(90% + 0.025em) 48%,
calc(10% - 0.025em) 48%,
calc(10% - 0.025em) 40%,
0 40%
);
}
.flip div:nth-of-type(even) {
clip-path: polygon(
0 60%,
calc(10% - 0.025em) 60%,
calc(10% - 0.025em) 52%,
calc(90% + 0.025em) 52%,
calc(90% + 0.025em) 60%,
100% 60%,
100% 100%,
0 100%
);
}
Once you have all the pieces, you can determine how they will move. One way to think about it is that the current value will fold, and the new value will unfold. And that means you only need to move two of the four elements. You will rotate the bottom of the new value and the top of the current value on the x-axis.
@layer flip {
/* map 0-180 against a brightness range */
/* based on a --flipped value of 0-1 */
.flip div:nth-of-type(1),
.flip div:nth-of-type(2) {
/* 0-90 will be 0-1 */
filter: brightness(calc(1 / 90 * clamp(0, var(--flipped), 90)));
}
.flip div:nth-of-type(3),
.flip div:nth-of-type(4) {
/* 90-180 will be 1-0 */
filter: brightness(calc(1 - (1 / 90 * clamp(0, var(--flipped) - 90, 180))));
}
.flip div:nth-of-type(2) {
rotate: x calc(-180deg + (var(--flipped) * -1deg));
z-index: 2;
}
.flip div:nth-of-type(3) {
backface-visibility: hidden;
rotate: x calc(var(--flipped) * -1deg);
z-index: 3;
}
}
Throw in some perspective and dynamic brightness, you can get something like this:
Mechanics
It's time to add JavaScript to the mix. You have a few options for JavaScript animation. We will use GSAP for today's technique because of its API options. It also makes creating the breakdowns easier 😅. There are alternatives out there, but I didn’t have as much luck with them. And of course, you could also use WAAPI with a different approach (included later). The nice thing about GSAP is that you can get a lot from a small amount of code.
Onto the animation. Let's start by updating our flap from "A" to "B" to "C".
const [foldTop, foldBottom, unfoldTop, unfoldBottom] = Array.from(
flip.querySelectorAll('.flip > div')
)
await gsap
.timeline()
.fromTo(
unfoldBottom,
{ rotateX: 180 },
{ rotateX: 0 },
0
)
.fromTo(
foldTop,
{ rotateX: 0 },
{ rotateX: -180 },
0
)
// confirm the letters
gsap.set([unfoldTop, unfold], { innerText: 'B' })
gsap.set([foldBottom, fold], { innerText: 'C' })
// repeat the steps above to go from "B" to "C"
Here we simultaneously animate rotation on the x-axis for two parts. This would be a very verbose way of animating from letter to letter. This is one way of approaching things with WAAPI in a while loop.
With GSAP though, you have some powerful API features to tidy this up. Using onRepeat
makes this much cleaner.
gsap
.timeline({
repeat: 1,
delay: 0.4,
onRepeat: () => {
gsap.set([unfoldTop, unfold], { innerText: 'C' })
gsap.set([foldBottom, fold], { innerText: 'B' })
},
})
.fromTo(
unfold,
{ rotateX: 180 },
{ rotateX: 0, duration: 1, ease: 'none' },
0
)
.fromTo(
fold,
{ rotateX: 0 },
{ rotateX: -180, duration: 1, ease: 'none' },
0
)
That's a starting point, but how do we scale it? We need it to loop whatever characters we pass to it. All this takes is a minor tweak to our timeline logic.
const chars = Array.from(" SHIP_IT ")
gsap
.timeline({
paused: true,
repeat: chars.length - 2,
onRepeat: () => {
const index = Math.floor(timeline.totalTime() /
timeline.duration())
const next = chars[index]
const current = chars[(index + 1) % chars.length]
gsap.set([unfoldTop, unfold], { innerText: current })
gsap.set([foldBottom, fold], { innerText: next })
},
})
And now we can loop over whatever characters we want. Padding the character list with a space provides a way to return to a blank flap. Using Array.from()
lets us use emojis in the character string.
Now you have a generated timeline you can control. This is the "magic" (meta) part. The GSAP API allows you to set the time for a given timeline. But, you can also animate that time value.
const timeline = gsap.timeline... /* character timeline from above */
gsap.to(timeline, {
duration: 1,
totalTime: desiredTime, /* desired character index */
ease: 'power1.out',
})
To break down what's happening, consider this video. It shows both scrubbing and animating the timeline.
You may have noticed in the character timeline that the ease is set to “none” and the timeline duration is 1. A linear animation gives us better control of the effect when we animate the time.
The reality is that we aren't ever going to animate the timeline in reverse. It will only go forward. So, how do you animate beyond the duration of the timeline? Create a meta timeline and give it infinite iterations. We can use the modulo (%) operator to work out the correct character to show.
const duration = timeline.totalDuration()
const scrubber = gsap.to(timeline, {
totalTime: duration,
repeat: -1,
paused: true,
duration: duration,
ease: 'none',
})
Imagine we are animating through the characters of the alphabet. We are currently on "J" but want to get to "A". Instead of animating the time backward, we work out the time required to go forward past "Z" and back around to "A". When we select a character, we calculate the time to scrub forward by and animate that. With an eye on staggering a line of flips, we can pad out the time shift with extra loops.
const chars = Array.from(` abcdefghijklmnopqrstuvwxyz `)
const flipTo = (desired) => {
const currentIndex = chars.indexOf(chars[timeline.totalTime()])
const desiredIndex = chars.indexOf(desired)
// if the current index is greater, loop around
const shift =
currentIndex > desiredIndex
? chars.length - 1 - currentIndex + desiredIndex
: desiredIndex - currentIndex
// this is how you throw an extra loop in for the stagger
const pad =
currentIndex === desiredIndex ? 0 : config.pad * (chars.length - 1)
/* animate the time position of the scrubber */
gsap.to(scrubber, {
totalTime: `+=${shift + pad}`,
ease: 'power1.out',
duration: (shift + pad) * gsap.utils.random(0.2, 0.6),
})
}
Now we have a working slot on the board!
That's the trick to creating this split-flap display. You scrub the playhead of an animation that loops through the characters you want to use. And to make a board, you combine a bunch of slots. It's up to you how you go about this. The demos linked below use JavaScript classes to tidy things up. But you could create web components and expose parts for styling. Would you like me to make a web component and put it on GitHub? Let me know! I didn't want to get bogged down in API shape and other things here. But here's an example of using it.
const newSlot = new FlipSlot({
pad: 1,
characters: "abcdefghijklmnopqrstuvwyz",
color: "red",
})
newSlot.flip("J")
And here's a video playing with it.
Now I did mention WAAPI earlier. If you wanted to create a dependency-free version, you could. Instead of scrubbing an animation, you can repeat Element.animate()
inside a loop. The idea is that you repeat until you get the character you want. It isn't much code at all to get this working. The caveat is that you must write easing functions and extras to handle what GSAP does for you. For what it's worth, I love the control GSAP gives you to ease the time. I'd need more time to play with the WAAPI output.
/* repeat this inside a loop */
/* index tracks the current unclipped index */
/* run is the number of loops handled */
this.currentValue = chars[this.index % chars.length]
this.nextValue = chars[(this.index + 1) % chars.length]
unfoldTop.innerText = unfoldBottom.innerText = this.nextValue
foldTop.innerText = foldBottom.innerText = this.currentValue
await Promise.all([
unfoldTop.animate(
{
filter: ['brightness(0)', 'brightness(1)'],
},
{
duration,
delay: run === 0 ? delay : 0,
}
).finished,
unfoldBottom.animate(
{
rotate: ['x 180deg', 'x 0deg'],
},
{
duration,
delay: run === 0 ? delay : 0,
}
).finished,
foldTop.animate(
{
rotate: ['x 0deg', 'x -180deg'],
},
{
duration,
delay: run === 0 ? delay : 0,
}
).finished,
foldBottom.animate(
{
filter: ['brightness(1)', 'brightness(0)'],
},
{
duration,
delay: run === 0 ? delay : 0,
}
).finished,
])
this.index++
run++
But you get a similar result! (I still want to spend some time with this as I have a hunch we can do it a little better)
That’s a wrap for now—a fun little challenge to tinker with!
Also, a quick note: thanks for your patience. It’s taken me longer than I’d like to get this issue out. Between work, life, and everything in between, finding the time has been tricky. That said, I’ve also been working towards something exciting—a course for Craft of UI! It’ll be a home for all the demos, walkthroughs, and deeper dives. If that sounds like your kind of thing, feel free to join the waitlist at course.craftofui.com.
Demo link (GSAP): codepen.io/jh3y
Demo link (WAAPI): codepen.io/jh3y
On X: x.com/jh3yy
On BlueSky: bsky.app/jhey.dev
Stay awesome! ┬┴┬┴┤•ᴥ•ʔ├┬┴┬┴
THIS WEEK’S SPONSOR (THERE ISN’T ONE)
We don’t have one. But, if you want to buy a book. Buy this one from my wife, “Design for Developers: Master the Basics”
Learn essential design skills to elevate your code! Design for Developers simplifies design fundamentals for devs. Create beautiful, user-friendly interfaces with confidence.
Can't wait to walk through this one! It looks great 😍