Earlier this month, we released a page about Stripe’s involvement with open source that showcases some of the projects we’ve released over the years. I’m proud to be part of a team that understands the importance of open source and generously gives back to the community, so I felt enthusiastic about designing our communication around it. Thankfully, the overall response to this page has also been enthusiastic, so I thought I’d write a technical breakdown of some of its parts.

Game of Life

The Game of Life is a zero-player game created by John Conway. Its rules are simple, yet fascinating, in how they open endless patterns. We thought it’d be a nice and appropriate little touch to use it as the background for the header and content.

The implementation uses canvas, which is perfectly suited performance-wise for that kind of task. The script starts by grabbing the canvas and defining the shared constants:

const canvas = document.querySelector(“canvas”);

const ctx = canvas.getContext(“2d”); const cellSize = 10;

const cellMargin = 2;

const cellsPerLine = canvas.width / (cellSize + cellMargin); const cellColors = new Map();

cellColors.set(“dead”, “#eee”);

cellColors.set(“alive”, “#5be”);

Then each cell is defined and drawn to create the grid:

const cells = []; for (var row = 0; row < cellsPerLine; row++) {

const y = row * (cellSize + cellMargin); for (var col = 0; col < cellsPerLine; col++) {

const cell = new Map();

cell.set(“x”, col * (cellSize + cellMargin));

cell.set(“y”, y);

cell.set(“isAlive”, false);

cell.set(“willLive”, false);

cells.push(cell); ctx.fillStyle = cellColors.get(“dead”);

ctx.fillRect(cell.get(“x”), cell.get(“y”), cellSize, cellSize);

}

}

Each cell is a Map containing its position and state. It also references the surrounding cells to figure out if it’ll live or die on the next round. Most of the cells have 8 direct neighbors (3 top, 2 middle, and 3 bottom cells) though some of them won’t — a cell in the first line won’t have top neighbors, for example. Neighbors are added to each cell Map:

cells.forEach((cell, i) => {

const neighbors = [];

const isNotInFirstLine = i + 1 > cellsPerLine;

const isNotInLastLine = i < cells.length — cellsPerLine;

const isNotFirstInLine = i % cellsPerLine > 0;

const isNotLastInLine = (i + 1) % cellsPerLine > 0; // top

if (isNotInFirstLine) {

if (isNotFirstInLine) neighbors.push(cells[i-cellsPerLine-1]);

neighbors.push(cells[i-cellsPerLine]);

if (isNotLastInLine) neighbors.push(cells[i-cellsPerLine+1]);

} // middle

if (isNotFirstInLine) neighbors.push(cells[i-1]);

if (isNotLastInLine) neighbors.push(cells[i+1]); // bottom

if (isNotInLastLine) {

if (isNotFirstInLine) neighbors.push(cells[i+cellsPerLine-1]);

neighbors.push(cells[i+cellsPerLine]);

if (isNotLastInLine) neighbors.push(cells[i+cellsPerLine+1]);

} cell.set(“neighbors”, neighbors);

});

The implementation on stripe.com/open-source is slightly different as it uses an infinite canvas but the idea is roughly the same.

The cells now have all the data they need and the animation function that relies on this data can be created. The Game of Life requires all computations to be done before changes occur, so our function will loop over the cells twice to:

Define all the cells that will live and die on the next round based on each cell’s neighbors; Apply these changes.

This is how it looks in practice:

const anim = () => { // define

cells.forEach(cell => { const livingNeighbors = cell.get(“neighbors”).filter(el =>

el.get(“isAlive”)).length; // A live cell with 2 or 3 live neighbors stays alive

// A dead cell with 3 live neighbors becomes a live cell cell.set(“willLive”,

cell.get(“isAlive”)

? livingNeighbors > 1 && livingNeighbors < 4

: livingNeighbors == 3

); }); // draw

cells.forEach(cell => {

if (cell.get(“isAlive”) == cell.get(“willLive”)) return;

cell.set(“isAlive”, cell.get(“willLive”));

ctx.fillStyle = cellColors.get(

cell.get(“isAlive”) ? “alive” : “dead”

);

ctx.fillRect(cell.get(“x”), cell.get(“y”), cellSize, cellSize);

}); // repeat

setTimeout(() => requestAnimationFrame(anim), 100); };

Before launching the animation, we should obviously define some default living cells. The Open Source page uses various complex “spaceships” but, for the sake of the example, we’ll define 500 random living cells:

const randomInt = (min, max) =>

Math.floor(Math.random() * (max — min + 1)) + min; for (var i = 0; i < 500; i++) {

const cell = cells[randomInt(0, cells.length — 1)];

cell.set(“isAlive”, true);

ctx.fillStyle = cellColors.get(“alive”);

ctx.fillRect(cell.get(“x”), cell.get(“y”), cellSize, cellSize);

}

We’re now ready to launch our animation and watch the Game of Life happens!

requestAnimationFrame(anim);

Line Drawing Animation

The category icons use a simple technique to create a color-filling animation as you hover over the navigation links.

The color doesn’t progressively fill, it’s faked by moving a stroke. Here’s the idea:

Each SVG icon is cloned and placed above its model. The stroke of these cloned icons is then modified: stroke-dasharray and stroke-dashoffset are set to the icon’s path length, resulting in a single big dash located outside the path. On hover, each dash’s offset is animated from its current value to zero, creating the line-drawing effect.

I’m using Animate Plus for these animations but the principle would remain pretty much the same no matter what tool you use. Here’s how it works:

// The default grey icon

const inactive = document.querySelector(“svg”); // The cloned icon we'll use for the animation

const active = inactive.cloneNode(true); // A reference to all the paths composing the icon

const paths = […active.getElementsByTagName(“path”)]; // Calculate the path lengths and make the stroke dashed

const pathLengths = paths.map(path => {

const pathLength = path.getTotalLength();

[“array”, “offset”].forEach(attr =>

path.setAttribute(`stroke-dash${attr}`, pathLength));

return pathLength;

}); // Change the stroke color and append the cloned icon

active.querySelector(“g”).setAttribute(“stroke”, “#0CB”);

document.body.appendChild(active); // Animate the offset of all the paths

active.addEventListener(“mouseenter”, () =>

paths.forEach((path, i) =>

animate({

el: path,

easing: “easeOutQuart”,

duration: 500,

“stroke-dashoffset”: [pathLengths[i], 0]

})));

A different animation is then added for mouseleave using a similar workflow and the opacity and offsets are reset to their initial values at the end of the animation:

active.addEventListener(“mouseleave”, () =>

paths.forEach((path, i) =>

animate({

el: path,

easing: “easeOutQuad”,

duration: 500,

opacity: 0,

complete() {

path.setAttribute(“stroke-dashoffset”, pathLengths[i]);

path.style.opacity = 1;

}

})));

Elastic Scrolling

Scrolling the page programmatically to a specific position is a common behavior on the web. The Open Source page uses a similar pattern for its 6 sub-categories but introduces a subtle yet significant difference: the page itself doesn’t scroll, the elements in the page move independently in order to emulate a natural and arguably interesting behavior.

Just like with the previous section, we’re going to use a simplified example:

<nav>

<a href=#category1>Category 1</a>

<a href=#category2>Category 2</a>

</nav> <h1 id=category1>Category 1</h1>

<ul class=cards>

<li><li><li><li><li><li><li><li>

</ul> <h1 id=category2>Category 2</h1>

<ul class=cards>

<li><li><li><li><li><li><li><li>

</ul>

Our script will start by selecting the elements we’ll use:

const nav = document.querySelector(“nav”);

const navLinks = […nav.querySelectorAll(“a”)]; const animatedElements =

[…document.querySelectorAll(“h1, .cards li”)];

The navLinks and animatedElements NodeLists are expanded into arrays using the spread operator in order to access Array.prototype methods.

The last element that still needs to be referenced is the scrolling root. Despite the specification being clear on it, browsers disagree on which element should handle the scroll position. Firefox and Edge use html, while Safari and Chrome consider body as the scrolling element. A new DOM API has been introduced to deal with this issue (document.scrollingElement) but we can’t rely on it yet as it’s not widely supported. Thus, fetching the correct scrolling element without relying on UA sniffing is unfortunately verbose and inelegant:

const scrollRoot = (() => {

if (“scrollingElement” in document)

return document.scrollingElement; const initial = document.documentElement.scrollTop;

document.documentElement.scrollTop = initial + 1;

const updated = document.documentElement.scrollTop;

document.documentElement.scrollTop = initial; return updated > initial

? document.documentElement

: document.body;

})();

This scrollRoot is used to calculate and set the scroll position correctly at the end of the custom scrolling animation which works like this:

When a category link is clicked, the delta is calculated between the current scroll position and the position of the section we reach. Animate all the elements in the animatedElements array using a vertical translation of delta. At the end of the animation, cancel all the translations and set the correct scroll position.

We start by looping over the navigation links and fetch the position of each title which marks the beginning of the sections:

const navHeight = nav.getBoundingClientRect().height; navLinks.forEach(link => {

const title = document.querySelector(link.getAttribute(“href”));

const pos = title.getBoundingClientRect().top — navHeight;

});

This loop adds an event listener to each link that triggers the animations. The animations are created again with Animate Plus:

link.addEventListener(“click”, e => {

e.preventDefault();

const delta = scrollRoot.scrollTop — pos;



// Reversing the array if it scrolls to the top so the delays are

// applied in the right order.

const elements = delta < 0

? animatedElements

: animatedElements.slice().reverse(); elements.forEach((el, i) => {

const params = {

el,

translateY: delta,

easing: “easeOutExpo”, // Increase the delay for each element to create the elastic

// scroll effect.

delay: i * 40

}; // When the last element finishes to animate, cancel all the

// transforms and set the correct scroll position.

if (!elements[i + 1])

params.complete = () => {

elements.forEach(el => el.removeAttribute(“style”));

scrollRoot.scrollTop = pos;

}; animate(params);

});

});

And that’s it! Keep in mind this simplified example skips quite a lot of details (for example, the actual implementation only animates the elements visible during the scroll) but it illustrates the core principles behind a custom elastic scroll.

3D Cards

The 3D effect that occurs as you click and drag on the project cards is slightly overkill, but it was also extremely fun to build!

The example below is pared down from the original. We’ll be recreating the effect for a single element only. Let’s start by selecting the element and calculating its center coordinates:

const card = document.querySelector(“.card”);

const cardCoordinates = card.getBoundingClientRect();

const cardCenter = {

x: cardCoordinates.left + cardCoordinates.width / 2,

y: cardCoordinates.top + cardCoordinates.height / 2

};

The center of the card is used to define the angle of the rotation, as the angle should increase proportionally to the distance between your cursor and the center of the card. The angle limit and the perspective of the rotation are defined arbitrarily.

const angle = {

max: 15,

perspective: 800

}; const setAngle = e => {

angle.x = angle.max *

(cardCenter.y - e.clientY) / (cardCoordinates.height/2);

angle.y = angle.max *

(e.clientX — cardCenter.x) / (cardCoordinates.width/2); card.style.transform =

`perspective(${angle.perspective}px)

rotateX(${angle.x}deg)

rotateY(${angle.y}deg)`;

};

setAngle is called on mousedown to set the initial rotation and on mousemove to update the rotation accordingly to the cursor position.

We need to account for two additional events to complete our behavior: mouseup and mouseleave. In both cases, the card will animate from its current rotation to zero. A callback will be defined to be used by both events:

const end = () => {

animate({

el: card,

perspective: [angle.perspective, angle.perspective],

rotateX: [angle.x, 0],

rotateY: [angle.y, 0]

});

};

The tricky part is that the mousemove, mouseup and mouseleave events must be added and removed dynamically: you start listening for them as soon as you start clicking on the card and stop when you release the mouse or leave the card. A toggleDynamicEvents function will deal automatically with the dynamic events:

const toggleDynamicEvents = (() => {

var added = false;



const events = {

move: setAngle,

up: end,

leave: end

}; const toggle = event =>

card[`${added ? “remove” : “add”}EventListener`]

(`mouse${event}`, events[event]); return () => {

Object.keys(events).forEach(toggle);

added = !added;

};

})();

toggleDynamicEvents is then called when mousedown fires and inside the end function we previously defined:

const start = event => {

setAngle(event);

toggleDynamicEvents();

}; const end = () => {

animate({

el: card,

perspective: [angle.perspective, angle.perspective],

rotateX: [angle.x, 0],

rotateY: [angle.y, 0]

});

toggleDynamicEvents();

}; card.addEventListener(“mousedown”, start);

And voilà! Just like with the previous sections, the code shown here purposely ignores some technical details to keep the example concise and focused. If you use similar techniques in your own projects, make sure you take the edge cases into account in order to provide a robust and consistent user experience.