CodeTV announced a hackathon called “Build the Best Landing Page,” with the stack picked for you: Astro, GSAP, and Webflow Cloud. I started with a list of 60 ideas, then went with one that wasn’t on it. Over Fourth of July weekend and a few days after, I built a little OS-inspired browser experience and named it Loading Today.
It’s a calm page to keep open while you work: listen to a thunderstorm, watch the rain fall, all themed to one of seven moods. Mess around with silly little apps, or actually get things done with notes and to-dos. A quick escape between tasks and tabs.
The progress bar tracks how much of your day has “loaded.” When a digit in the percentage changes, the old one doesn’t just disappear. It falls. It tumbles off the bar, bounces, and lands in a pile at the bottom of your screen. Time, physically accumulating on your floor.
If a pile of expired time bothers you too, there’s a Magic Broom you can drag around to sweep the digits into a portal. When you’re done, the broom flies itself home: loop-the-loop, gold sparkles trailing from the bristles.
Here’s what’s powering that experience: Matter.js simulating, PixiJS rendering, GSAP choreographing, and a physics scene that can idle at 0% CPU. This isn’t a full build-along, just the decisions that made it work and the parts I’d reuse.
Falling digits, the broom, and the portal
One canvas to rule them all
Everything canvas-rendered in Loading Today (falling digits, the broom, the portal, sparkles) lives on a single full-screen transparent PixiJS canvas layered over the DOM. One WebGL context, one render loop, shared by every effect.
The canvas itself is straightforward. The part I focused on was keeping it idle when nothing was happening. The PixiJS ticker starts switched off and only wakes when something actually needs it. Systems register a “wake check,” a cheap boolean function, and the loop stops itself the moment every check returns false:
// The shared PixiJS app: the ticker only runs when a wake check asks for it
const wakeChecks = new Set();
export function registerWakeCheck(fn) {
wakeChecks.add(fn);
syncPixiTicker();
}
function needsTicker() {
if (document.hidden) return false;
for (const check of wakeChecks) {
if (check()) return true;
}
return false;
}
export function syncPixiTicker() {
if (!app) return;
if (needsTicker()) {
if (!app.ticker.started) app.ticker.start();
return;
}
if (app.ticker.started) {
app.ticker.stop();
renderPixiScene(); // paint one last settled frame
}
}
The falling-digits system’s wake check reads like a checklist of “is anything interesting happening”:
// The falling-digits wake check
function fallingDigitsNeedsTicker() {
if (introHoldDepth > 0) return true;
if (spawnKickFrames > 0) return true;
if (consumingBodies.length > 0) return true;
if (hasAwakeBodies()) return true;
if (isPortalActive()) return true;
if (extraWakeCheck?.()) return true;
return false;
}
When the pile settles and Matter.js puts every body to sleep, hasAwakeBodies() flips false, the ticker stops, and the page costs nothing until the next digit expires. That’s what lets it sit open all day for free.
I also limit how much time Matter can process in a single update, so returning to a tab that’s been in the background doesn’t send the pile flying.
Making time fall: the DOM-to-canvas handoff
The percentage shows one decimal place, so one of its digits changes roughly every 86 seconds. When that happens, I compare the old characters with the new ones. For every digit that changed, I ask the DOM where it was on screen, then spawn a physics body at the same coordinates:
// Spawning a falling digit when the percent changes
function updatePercentDisplay(value, previousChars) {
const nextChars = formatPercent(value).split("");
const digitEls = [...percentEl.querySelectorAll(".status-bar__progress-digit")];
const compareLength = Math.max(
previousChars?.length ?? 0,
nextChars.length,
digitEls.length,
);
for (let index = 0; index < compareLength; index += 1) {
const previousChar = previousChars?.[index];
const nextChar = nextChars[index];
const digitEl = digitEls[index];
if (
previousChar !== undefined &&
previousChar !== nextChar &&
digitEl &&
/\d/.test(previousChar)
) {
const rect = digitEl.getBoundingClientRect();
const x = rect.left + rect.width / 2 + (Math.random() - 0.5) * 10;
const y = rect.bottom + 2 + Math.random() * 4;
if (!dropCharAt(previousChar, x, y)) {
void dropChar(previousChar, x, y); // lazy-init physics, then drop
}
}
// …
}
percentEl.innerHTML = buildPercentMarkup(value);
return nextChars;
}
The DOM digit re-renders with its new value; the canvas spawns the old glyph a couple of pixels below where it just was. Your eye reads it as the same character detaching and falling. No portals between DOM and WebGL, no cloned elements, just getBoundingClientRect() as a handoff protocol.
I only ask the DOM where the digit is once, at the moment it falls. getBoundingClientRect() can force a layout, so it’s not something I want to call every frame. After that first measurement, the canvas owns the digit and the DOM’s part is over.
Each dropped glyph becomes a pair of objects, a PixiJS Text for looks and a Matter rectangle for behavior, sized from the actual rendered glyph:
// Each digit: a PixiJS Text for looks, a Matter body for behavior
const text = new Text({ text: char, style: getFallingDigitTextStyle() });
text.anchor.set(0.5);
text.position.set(x, y);
app.stage.addChild(text);
const body = Matter.Bodies.rectangle(x, y, Math.max(text.width, 6), Math.max(text.height, 8), {
restitution: 0.07, // barely bouncy: these are tired digits
friction: 0.93, // they should stack, not skate
frictionAir: 0.032,
density: 0.0045,
chamfer: { radius: 1 },
});
Matter.Body.setAngle(body, (Math.random() - 0.5) * 0.5);
Matter.Body.setAngularVelocity(body, (Math.random() - 0.5) * 0.12);
Matter.Body.setVelocity(body, {
x: (Math.random() - 0.5) * 2.5,
y: 1.2 + Math.random() * 2.2,
});
Each frame, the PixiJS text follows the position and rotation of its Matter body. Matter controls the physics, and PixiJS draws the result. That split makes the animation handoff later way simpler.
Most of the feel is in how the digits behave when they land. They settle fast and maybe tip over once, instead of pinballing around the screen. Each one also gets a little random spin on the way down, so the pile never looks too rigid.
A digit expires and joins the pile
The pile: making time stack up
Each body uses friction: 0.93 with near-zero restitution, so a digit lands where it falls and stays put. And because every digit drops from the same place, the day-progress percentage on the left of the status bar, they stack straight down into a single pile directly beneath it, on a floor that sits just below the visible edge.
Digits click when they land.
On the first floor collision, the body is added to a WeakSet and a tiny synthesized “tick” plays via Cuelume, which generates UI feedback with the Web Audio API instead of audio files. Any later collisions are ignored.
Matter.Events.on(engine, "collisionStart", (event) => {
for (const pair of event.pairs) {
const digitBody = pair.bodyA.label === "floor" ? pair.bodyB :
pair.bodyB.label === "floor" ? pair.bodyA : null;
if (!digitBody || landedBodies.has(digitBody)) continue;
landedBodies.add(digitBody);
playUiSoundEffect("tick");
}
});
The pile survives window resizes, with a bounce.
On resize I rebuild the floor, then nudge any nearby digits to match the change in position. Shrink the window fast enough and the pile bounces, like the floor turned into a trampoline for a second. On phones, opening the keyboard can make the viewport hundreds of pixels shorter, so I ignore those height-only resizes while a text field is focused.
The pile has a limit.MAX_BODIES caps the world at 280 digits; past that, the oldest bodies quietly retire. Between the cap, Matter’s body sleeping, and the wake-check ticker, a fully settled pile costs literally zero frames.
The portal: part animation, part physics
Open the Magic Broom app and a portal rises at the bottom of the screen: concentric elliptical rings with 52 ASCII characters (% · * + # @ ░ ▒ ▓ …) orbiting it. The portal has to do two jobs at once: act as a PixiJS animation and as a force field that Matter.js can react to.
Opening the portal
The portal lives in PixiJS, not the DOM, so there isn’t a node for GSAP to animate. I gave GSAP a plain object instead, tweened its scale, and used onUpdate to repaint the portal:
// Opening the portal by tweening a proxy object
export function openPortal() {
activeTimeline?.kill();
phase = "opening";
root.visible = true;
const state = { scale: openScale };
activeTimeline = gsap.timeline({
onComplete: () => { phase = "open"; },
});
activeTimeline.to(state, {
scale: 1,
duration: 0.42,
ease: "back.out(1.4)",
onUpdate: () => {
openScale = state.scale;
syncPortalPosition();
layoutOrbitChars();
},
});
}
That back.out(1.4) overshoot is what makes the open feel alive: the portal pops past full size, then settles back.
Closing has a little more going on: every orbiting character collapses into the center with a 12ms stagger while the ellipse crushes to zero with power4.in. That small stagger gives each character time to follow the others into the center, instead of having the whole portal disappear at once.
for (let index = 0; index < orbitChars.length; index += 1) {
const orbit = orbitChars[index];
const delay = index * 0.012;
activeTimeline.to(orbit.text, {
x: 0, y: 0, alpha: 0,
duration: 0.34,
ease: "power3.in",
}, delay);
}
activeTimeline.to(collapse, {
scale: 0,
duration: 0.48,
ease: "power4.in",
onUpdate: () => { /* repaint rings at new scale */ },
}, 0.12);
Suction: a force field with three zones
While the portal is open, I measure each digit’s distance from the portal and use that to place it in one of three zones:
- Consume (inside ~1.12 radii): you’re done, begin the swallow.
- Pull (inside the suction range): forces apply.
- Ignore (beyond it): friction restored, carry on being a pile.
I ended up layering a few techniques because each one looked wrong on its own:
// Inside the portal's per-frame suction pass
const falloff = 1 - suctionNorm / pullSuctionNorm;
// 1. Reduce friction near the portal so the digits can slide inward.
item.body.friction = Math.min(item.baseFriction, 0.65);
// 2. Apply a small force toward the center.
const pull = 0.000055 * falloff * portalScale * suctionStrength;
Matter.Body.applyForce(item.body, item.body.position, {
x: dirX * pull,
y: dirY * pull,
});
// 3. Guide the digit inward by adjusting its velocity.
Matter.Body.setVelocity(item.body, {
x: item.body.velocity.x * 0.985 + dirX * falloff * 0.42 * suctionStrength,
y: item.body.velocity.y * 0.985 + dirY * falloff * 0.58 * suctionStrength,
});
// 4. Near the center, move it directly toward the portal
// so it doesn't keep circling.
if (suctionNorm <= 2.0) {
const pullStep = (0.015 + falloff * 0.05) * frameScale * suctionStrength;
Matter.Body.setPosition(item.body, {
x: bodyX + dx * pullStep,
y: bodyY + dy * pullStep,
});
}
applyForce on its own was accurate but too slow; the digits drifted in. Pulling them straight toward the portal looked like a cheap tractor beam. Combining them worked better: physics near the edge, velocity steering in the middle, and direct position steps close to the center.
Handing off: from physics to animation
Once a digit is close enough to be swallowed, I remove it from Matter completely. From there, it’s no longer a physics object; it’s just an animation playing out.
function startConsumingBody(index) {
const item = activeBodies[index];
Matter.World.remove(world, item.body); // physics is over for you
activeBodies.splice(index, 1);
consumingBodies.push({
text: item.text,
progress: 0,
startX: item.text.x,
startY: item.text.y,
startScale: item.text.scale.x,
});
}
Each frame, it shrinks, spins faster and faster, fades, and pulls in toward the center of the portal:
entry.progress = Math.min(1, entry.progress + deltaSeconds * 3.6);
const eased = entry.progress ** 1.6;
entry.text.position.set(
entry.startX + (layout.x - entry.startX) * eased,
entry.startY + (layout.y - entry.startY) * eased,
);
entry.text.scale.set(entry.startScale * (1 - eased) * Math.max(portalScale, 0.15));
entry.text.alpha = Math.max(0, 1 - eased * 1.1);
entry.text.rotation += deltaSeconds * (4 + entry.progress * 8);
Matter handles the first part of the pull, including collisions with the rest of the pile. Near the center, I remove the digit from the simulation and animate the rest of the movement directly.
Digits pulled into the portal
The broom: a character, not a cursor
The broom is a spritesheet with poses: resting against the screen edge, held upright, leaning into a leftward or rightward swish. While you drag it, the sprite’s pose is chosen by your drag velocity:
// Choosing the broom's pose from drag velocity
function pickDragFrame(vx, vy) {
const absVx = Math.abs(vx);
const absVy = Math.abs(vy);
if (absVx < 50 && absVy < 50) return "idleCenter";
if (absVx < 160) return vx < 0 ? "idleLeft" : "idleRight";
// Swish frames show bristle trail, not travel direction, so invert vs velocity.
return vx > 0 ? "swishLeft" : "swishRight";
}
Note the inversion on the swish frames: the bristles trail behind the motion, like hair does when you run. It’s obvious once you see it, but you still have to write the code to flip the frame against the velocity.
Sweeping is mostly a radius query and a velocity blend, plus one little helper. While the portal is open, each sweep gets a gentle extra nudge toward it:
export function sweepWithBroom(broomX, broomY, radius, velocityX, velocityY) {
const speed = Math.hypot(velocityX, velocityY);
if (speed < 12) return; // resting a broom on the pile does nothing
for (const item of nearbyBodies) {
let towardPortalX = 0, towardPortalY = 0;
if (portalIsOpen) {
const pushStrength = Math.min(Math.max(speed * 0.005, 0.22), 0.85);
towardPortalX = dirToPortalX * pushStrength;
towardPortalY = dirToPortalY * pushStrength * 0.75;
}
Matter.Body.setVelocity(item.body, {
x: item.body.velocity.x * 0.88 + velocityX * 0.006 + towardPortalX,
y: item.body.velocity.y * 0.94 + velocityY * 0.004 + towardPortalY,
});
}
}
The nudge is deliberately gentle. In that final setVelocity, the toward-portal term is added on top of your swipe velocity, not substituted for it, so a firm push still sends digits wherever you aim, and when the portal is closed there’s no bias at all. It only really surfaces on slow, lazy sweeps, tilting the odds toward the portal so casual cleanup tends to go where you’d hope.
The return flight
Let go of the broom and it doesn’t just drop back into place. On release it launches into a loop-the-loop, flies home to its resting corner, rights itself, descends, and settles, trailing gold ASCII sparkles (· ✦ ✧ * +) from the bristles the whole way. It’s one GSAP timeline broken into four parts. On every frame, onUpdate figures out where the broom should be and moves it there:
// The broom's return flight, abridged
returnTimeline = gsap.timeline({
onComplete: () => snapToRest(),
});
// 1. First half of a loop, starting exactly at the release point
returnTimeline.to(loop, {
p: LOOP_SPLIT,
duration: 0.58,
ease: "none",
onUpdate: () => {
const point = getLoopPathPoint(loop.p, startX, startY);
flight.x = point.x;
flight.y = point.y;
flight.rotation = point.rotation;
applyFlight(); // position sprite + measure speed, then spawn sparkles
},
});
// 2. Finish the loop while flying toward home, blending to upright
returnTimeline.to(flyLoop, {
u: 1,
duration: 0.88,
ease: "power2.inOut",
onUpdate: () => { /* blended loop-to-home path */ },
});
// 3. Slow vertical descent above the landing spot
returnTimeline.to(flight, {
y: descendY,
duration: 0.46,
ease: "power1.inOut",
onUpdate: applyFlight,
});
// 4. Snap into the rest pose
returnTimeline.add(() => setFrame("rest"));
GSAP handles the timing, while getLoopPathPoint() handles where the broom actually is. Keeping those two separate means I can change the easing or the path without touching the other.
The path itself is just an ellipse traced with sin and cos, no SVG path and no motion-path plugin:
// The loop is a parametric ellipse; p runs 0 to 1
function getLoopPathPoint(p, startX, startY) {
const radiusX = 92 - p * 20; // radii shrink as p grows: it spirals, not circles
const radiusY = 70 - p * 12;
const centerX = startX + 64;
const centerY = startY + 32;
// startAngle is measured from the release point, so p = 0 lands exactly
// where the broom was let go, so there's no snap when the loop begins
const startAngle = Math.atan2(startY - centerY, startX - centerX);
const angle = startAngle + p * Math.PI * 2.05; // just past one full turn
const x = centerX + Math.cos(angle) * radiusX;
const y = centerY + Math.sin(angle) * radiusY;
// Rotation follows the path: sample a point just ahead and face that way
const ahead = /* the same trig, evaluated at p + 0.012 */;
const moveAngle = Math.atan2(ahead.y - y, ahead.x - x);
return { x, y, rotation: moveAngle + Math.PI / 2 };
}
Instead of animating the rotation on its own, I use the direction of the path to set the broom’s angle each frame. That keeps it aligned with the curve. Because the path is just a function, I could swap the loop for a figure-eight without changing the timeline.
The sparkle trail works the same way: applyFlight measures the broom’s speed between frames and emits glyphs from the bristles while it’s moving. Each one falls, slows down, wobbles slightly, and fades out.
Release, loop-the-loop, fly home
Ship it: Astro to Webflow Cloud
Loading Today is built from Webflow’s official astro-gsap starter, which already includes the Astro, GSAP, and Cloudflare setup. PixiJS and Matter load only when they’re needed, the few server-side features use Astro API routes, and Webflow Cloud handles the build and deployment whenever I push to the repo.
If this gives you an idea for the CodeTV hackathon, I’d love to see what you make. You can also find me and plenty of other people building web experiments on OKAY DEV.
Links
- Project: Loading Today
- Hackathon: GSAP + Webflow Cloud Hackathon by CodeTV
- Built with: GSAP, Astro, Webflow Cloud, PixiJS, Matter.js, and Cuelume
Stay cozy. Keep building. 🌿