Skip to content
Twenty One Media
webSeptember 21, 2026

One Canvas, Two Orientations

The homepage hero animation shows scattered data fragments converging on the Twenty1 mark and then resolving into steady, organized ribbons on the other side. On desktop it flows left to right. On phones below 700px it flows top to bottom.

We wanted one set of drawing code. Here is how we got it.

The u/v coordinate system

Instead of writing in terms of x and y, every bit of animation math uses u (the flow axis) and v (the cross axis). A single plot function converts those to actual canvas pixels at the end:

const plot = (u: number, v: number): [number, number] =>
  vertical ? [cross / 2 + v, u] : [u, cross / 2 + h / 2 + v - h / 2];

On desktop (vertical = false), u maps to x and v maps to a vertical offset from center. On phones (vertical = true), u becomes y and v becomes a horizontal offset. Everything else: spawn positions, speeds, wave equations, packet spacing, ribbon curves, uses u and v only.

The vertical flag is set on every resize based on canvas.clientWidth < 700. Change the viewport, everything reorients automatically.

Why not two separate draw functions?

The naive approach is a copy-paste: one drawDesktop() and one drawMobile(), each with its own x/y math. That works until the animation changes. Then you update one and forget the other, or the two drift apart. With the u/v abstraction, there is no drift: every visual rule lives once.

The wave formula, for example, reads the same in both orientations:

const wave = (u: number, k: number, amp: number) => {
  const d = u - hub;
  return ramp(d) * amp * Math.sin(d * 0.011 - t * 1.1 + k * 0.55);
};

hub is always the center of the flow axis. amp is always a fraction of the cross axis. Whether that is a horizontal center or a vertical one is plot's concern, not wave's.

The scatter bell curve

The chaotic left side of the animation is denser in the middle and thinner at the edges. We did not write a normal distribution function. We used a simpler approximation: sum three uniform random values and scale down.

const spread = () => (Math.random() + Math.random() + Math.random() - 1.5) / 1.5;

The sum of three uniform samples approximates a bell curve well enough for particles. It is a direct application of the central limit theorem. The - 1.5 re-centers the result around zero, and dividing by 1.5 keeps it in the -1 to 1 range. No external library, no lookup table.

The funnel effect

As fragments approach the mark, they narrow and calm down. The progress variable p runs from 0 to 1 as a bit travels toward the hub:

const p = Math.min(1, Math.max(0, b.u) / (hub - gap));
const narrow = 1 - 0.8 * p * p;

The quadratic makes the narrowing gentle at first and aggressive near the end. The same p value also fades the color from dark ink to blue as bits approach the mark, so the color shift tracks the convergence without a separate calculation.

Stopping the loop when off-screen

A canvas running requestAnimationFrame at 60fps when the user has scrolled past it is pure waste. An IntersectionObserver tracks whether the canvas is visible and stops the loop when it leaves the viewport:

const io = new IntersectionObserver(([e]) => {
  visible = e.isIntersecting;
  startRef.current?.();
});
io.observe(canvas);

The frame function checks visible before scheduling the next frame. If the animation is paused or the reduced-motion preference is set, the same early-exit applies.

The result is a canvas that only paints when it is on screen, responds to OS motion preferences live (via useSyncExternalStore on the media query), and reorients on every resize with zero conditional logic in the drawing code.

No new dependencies. No SVG. About 160 lines of TypeScript.