Last year, I hired a design firm to work on the marketing site for my side hustle. Here’s what it used to look like:
And here’s what it looks like now (note that it’s highly animated and looks better when you view it directly):
There’s a lot that still hasn’t even been implemented, and it’ll get better with time as we add more of the things from the designers’ vision. As you can tell, it’s a LOT more exciting.
I can’t believe you’ve done this.
I can already smell the comments from people who are byte misers, complaining that I’m loading a bunch of videos for a site that ostensibly needs no video at all—the service doesn’t even have video support. “You are scroll-jacking!” “You’re making my Apple II catch on fire!” “I’ve used up my whole data plan visiting your website a single time!”
Yeah, I get it. I understand. I’ve been on the orange website and I’ve heard it all, but I promise this was a careful and measured choice. Also it’s just one page.
How I built it
First off, I got some help writing the code for the site from my old buddy @cvan, one of my old work buddies. Chris and I were interns together at Mozilla. I left and he did games and WebVR stuff while I was off building enterprise software and violating intellectual property rights or whatever.
Times Two (the design firm) created a real masterpiece in Figma, and while I’m perfectly capable of writing all of the code myself, I lack the time and motivation to get a project of that size and scope finished in any reasonable timeframe. I paid Chris to step in and fill in some of the gaps in my ability to do work.
For the purposes of this post, I want to talk about the topmost two sections: the hero and the “globe”. The hero features the “mosaic” tiles with animated illustrations in them (shown above). The globe is the “scroll jacked” section below it that shows three product features as you scroll (shown below).
Between the two, there’s a transition where the globe section slides up and fades into the hero through the bottom center tile.
Where I started
I started with a version based on the DOM. The tiles had images in them and were positioned with CSS Grid. The radial lines you see clipping the tiles in the hero were simple <div>
tags with a border-radius
set to make them circles. The positioning of the circles and the spacing of the grid was done entirely with CSS using variables. This was a great first attempt, and let me prove out the layout.
In the screenshot above, I’ve highlighted the innermost radius (as I call it) with red and the bottom center tile with blue. The upper corners of the tile intersect the radius. And that’s true at all screen sizes! Doing that is surprisingly easy: the math is straightforward.
The first step is to position the tile. Based on the mosaic grid, we can calculate its width. Vertically, it takes up all the space below the text above it. We actually don’t care about the vertical positioning—you’ll see why in a minute.
The radius gets positioned so that the bottom edge dips down a fixed number of pixels into the tile. Let’s call that X. If we know the width of the tile (W) and the position of the bottom of the circle, we can calculate the radius of the circle and the position of its vertex.
In the drawing above, I’ve labeled the edges. This looks like a great place to use trigonometry (and you can!) but we actually have no use for the angle here, we just care about the value of R. R gives us the vertical position of the circle and its radius. Calculating it is simple Pythagorean theorem, but instead of taking the square root, we just do polynomial expansion and simplify until we get R:
X and W/2 are both known (one is a constant and the other is the width of a thing on the page.
Another solution
Once I had this working and positioned the circles, it looked good, but it didn’t move. Adding the videos proved challenging. Using animated WebP was a no-go1. Adding video elements into each tile was even more challenging, since they needed to be scaled correctly (which meant clipping them so they maintained the right aspect ratio).
Moreover, looking towards the transition, it was going to be a nightmare to animate the whole damn page in a way that worked with the transition. The DOM just doesn’t work that way!
I made the choice to ditch the DOM and use a Canvas element instead. The canvas is drawn by calculating the size and shape of the grid. Each tile is drawn by creating a clip path for the rounded rectangles, then drawing the video element for each section into them. For the “joined” columns to the left and right of the center, the clip path is simply two rounded rectangles, and the video is drawn into the combined area. The radiuses are simply circles filled on top of the tiles, drawn at the right moment.
But how about that center tile and the transition? Hear me out.
First, create the clip path. Then, call clearRect
to “punch out” the background of the canvas. Then draw the video in.
Now, set the canvas to be position: fixed
, so it’s attached to the viewport. As you scroll, it stays in the same spot on the screen. When you draw the video, set the opacity to some percentage based on scroll position (the further you scroll, the less opaque the video becomes). Completely hide the canvas when the transition is complete.
All that’s left is adjusting the sizing of the grid and height of the center tile to also take the scroll position into account. The rendering function (ignoring helpers for doing the clip paths and sizing the videos) is only about 400 lines, and that covers all of the variants for different screen sizes.
One of the great properties of this is that you have one code path for rendering. Regardless of what the scroll position is or how much time has passed, the same drawing operations get performed. There’s essentially no branches in the code that get triggered conditionally, which means the hero renders at a pretty consistent FPS.
The globe
I also started with a DOM-based solution for the globe, as well. Chris helped me out by getting the layout more well-situated, accounting for the somewhat variable-size of the copy on the page and odd screen sizes.
I didn’t actually realize the globe itself would be animated until I saw the final designs and got the handoff folder from the design team. My first take was to use a <div>
rounded into a circle, then to adjust the background (a long image) along horizontally into the correct position. This simply won’t cut it for the final product, though:
The content of the globe “bleeds out” around it.
A single giant video that’s moved horizontally won’t work because the world inside the globe’s circle doesn’t just slide, it changes shape/perspective.
There are predefined transitions between each major position.
I said “fuck it” and did this one in a Canvas also.
Animating the globe
The globe is made up of a number of looping videos and transitions. The transitions only play “forwards” (not in reverse), so we only use them when scrolling “downward” on the page. I concatenated the videos together in order, then made a note of the start and end timecodes for each segment.
The first attempt was to just render the video into the canvas. When the timeupdate
event fires, check if the video is at the end of the current segment and if so, reset the playhead to the start of the segment to make it loop. timeupdate
, as it turns out, doesn’t fire often enough for this to work. The fix is to set your own timer to when you roughly expect the clip to end.
This technique worked alright, but the video was enormous. I transcoded it to AV1 to save some space, but a new problem emerged: setting currentTime
to seek was taking a hundred milliseconds or more, which broke the illusion and made the video loop poorly. A fastSeek
method exists, but it’s not high-precision enough and it’s not supported in Chromium (Chrome, Edge, Arc, etc.), so it’s not an option.
The fix was to create two video elements playing the same video. The first is what’s actually being rendered. The second is pre-seeked to the correct timecode of where the video will loop from. When the video gets to the loop point, they’re swapped and the video that hit the end of the segment is seeked to the correct position.
The white line that sits under the globe
The next challenge was making the video appear to be transparent.
There’s a thin white line that cuts across the page. That’s the only thing that’s “under: the video (everything else can be drawn over the top).
My first attempt was using the Canvas’s compositing options to draw the video such that only the black-ish pixels were replaced, but I simply couldn’t get this to work. The best solution I came up with was pulling the pixels out of the canvas with getImageData for the row of pixels along where the white line sits. I iterate from the center outward until I find the first decidedly black pixel, then draw one line out to each edge of the page from there.
The orbs
Around the globe are these little orbs that swing in like they’re in orbit. There were a few challenges to make these look good.
The first was loading them. On desktop there’s six per segment, which means 18 images total. I wanted them to be SVG to maximize resolution. But loading 18 SVGs just feels silly. The solution was to inline them. But when you’re rendering these in a Canvas, you can’t just slap some XML on the page.
Instead, I have React render each of these into hidden <div>
s on the page2 and slurp out the generated XML code into data URIs, which I create image elements from (at the correct DPI). I can then draw those image elements into the canvas.
Animating them is another tricky challenge. They are positioned with a radius (0, 1, or 2) and an angle (rotation from zero degrees at the far left). As you scroll, I increase the radius, drawing the images at the appropriate position with simple trigonometry (thank you, SOH-CAH-TOA). I made their movement elastic instead of animating linearly to make them feel more organic.
You’ll notice that they sort of wiggle a little bit. The design had them sit static, but I felt like they needed a tiny bit of motion to feel like they’re not just boring accents. To do that, I used 3D Perlin noise.
A quick primer on Perlin noise
First, in 2D. Imagine a grid. Each cell in the grid gets a random number from zero to one. Of course, each cell in the grid has integer coordinates. Here’s an example:
Getting the value of a coordinate is simple: (2, 1)
is 0.303.
Now, imagine I give you non-integer coordinates. What’s the value at (1.2, 0.8)
? Perlin noise simply smoothes the values at (1, 0)
, (1, 1)
, (2, 0)
, and (2, 1)
to get the value. If you imagine each cell represents the altitude of some terrain at those coordinates, Perlin noise is simply extrapolating the smooth in-between altitudes.
We can either fill in a pre-defined grid of numbers like this, or we can use a pseudorandom number generator (PRNG) to deterministically get the random numbers at a particular point. With a PRNG, we feed in a seed value, then tell it to return a random number. With Perlin noise, the seed value is some hash of the coordinates: one pair of integer coordinates deterministically returns a random number.3
Knowing just the floating point coordinates for a value you want, you can figure out the integer coordinates for the cells that you’ll smooth out. With the integer coordinates you can get the deterministic random numbers, and a little bit of number crunching later, you’ve got your smoothed out value.
Now, to do this in 3D, we just add another dimension. Not only do we have cells on the X-Y plane, we also have cells on the Z axis. Given 3D coordinates, we smooth out the values in three dimensions instead of two. Instead of imagining a 2D map where the cells represent altitudes, consider a 3D star map of a nebula, where the cells (cubes instead of squares) contain the brightness of the nebula’s gases at that location. The math is a little more verbose, but it’s the same exact principle.
Perlin noise doesn’t care about the numbers we feed in for the coordinates. It doesn’t know if it’s a nebula or an ocean or a tank of gas, or something else entirely. In my case, to make my SVG orbs wiggle, I feed in coordinates in the form (angle, radius, time)
. The angle is the angle around the globe that the orb is supposed to be positioned at. The radius is how far from the globe it is. And time is literally just the number of seconds (scaled slightly) since the animation started.
If we use the nebula metaphor, the position of the orb gives us a 2D location at the bottom of the nebula. From there, we’re flying a spaceship vertically:
The X-Y coordinates (the angle and radius) remain the same, but as time progresses, we increase along the Z-axis. The cells represent the brightness of this nebula, and we’ll see the brightness smoothly increase and decrease as we fly through the cells going upwards.
And that’s our wiggle: the “brightness” represents how much we should nudge the angle that the orb gets drawn at. Scaling the time, angle, and distance values makes this more dramatic or subtle.
Why make this big mess?
It’s a bit of a mess, but it’s a beautiful, striking mess.
My audience is podcasters. Mostly amateurs. I’m not selling the sourcehut of podcasting, I’m putting out into the world that I’ve got some cool ideas, and it’s a fun weird place to publish your show. I want people to feel excited by my service.
When I hired the design firm, I told them this: I’m just one guy. There’s no director of product design or VP of Branding or teams that need to rubber stamp decisions. I wanted them to do things that no other company would go for because it’s not a safe choice.
Sure, there’s bound to be some folks out there who just want something that looks like it was outsourced to SAP in 2012. Those aren’t the people I’m in the market to sell software to. I never was.
Podcasters are funny sorts of folks to market to, because they’ll either love you or hate you. And by their very nature, they have strong opinions and a microphone4. In my seven-ish years of making Pinecast, I’ve learned to lean into the weird and interesting stuff, and customers tend to love it.
It would have been convenient, though! Unfortunately the file size was too large. Additionally, getting the second and fourth columns of the mosaic grid to be “joined” with a fixed-size gap between the top and bottom tiles was technically no good.
I could pre-render these as strings, but I’d rather have the SVGs as JSX so they can be used elsewhere.
This is (sort of) how Minecraft worlds are infinite but the same if you use the same seed: random numbers are deterministically found for each block in the world, and different flavors of Perlin noise compute what goes in each location.
Both a blessing and a curse.