A 404 page that's actually fun to land on
Why I built a CSS-only animated 404 with tilting digits, an orbiting astronaut, and a few dad jokes — and what it taught me about Astro's static 404 conventions.
I built a proper 404 page this weekend. Not because analytics were yelling at me — nobody really hits dead links on a five-page portfolio — but because the default “Page not found” annoyed me every time I clicked through to it.
Why bother
If you’ve landed on a 404, you’re already mildly grumpy. You typed something wrong, followed a stale link, or an AI answer sent you to a path that never existed. The page has exactly one job there: don’t pile on, and give them a way out.
What I don’t like about most “fun” 404s: it’s either a wall of corporate apology, or a giant illustration with no button. I wanted the middle ground — a short joke, then two clear doors back.
The copy
The voice is the same one the rest of the site uses. The portfolio is direct and a bit playful, it’s not a bank — so the 404 sounds like that too:
- Eyebrow: TRANSMISSION LOST · ERROR 404
- Headline: This page got abducted by aliens.
- Body: We searched every route. Even peeked at
/api/secret-cookies. - Closer: Either the URL never existed, or you just discovered a wormhole. Honestly, pretty cool either way.
The German version isn’t a literal translation — it just had to land
the same energy. Diese Seite wurde von Aliens entführt. does the
job. /api/secret-cookies became /api/geheime-kekse, and Beam me
home turned into Zurück zur Erde — a bit more Star Trek on the EN
page, a bit more grounded on the DE one.
Both strings live under notfound.* in en.json / de.json, like
every other piece of copy on the site. They get the data-scramble
effect from the nav for free.
The animation
Pure CSS. No JS, no GSAP. The page already runs under
PortfolioLayout, which deliberately doesn’t load the Webflow IX2
runtime — and I wasn’t going to drag in a heavy animation library for
a page most users will never see.
What the scene is made of:
- A big
404inArchivo Black. The two4s tilt in opposite directions and breathe up-down on a five-second loop. - The
0isn’t really a digit anymore — it’s a div with a radial gradient running from amber through red into dark oxide, a layer of atmospheric bands on top, and a slow brightness breathe. Reads as a tiny planet. - A 2px ring sits at -18° on the zero, slightly squashed. Enough to hint at Saturn.
- An SVG astronaut (helmet, visor reflection, “404” chest patch, red antenna light) orbits the scene. The orbit container rotates clockwise, the astronaut counter-rotates so its head stays up, and a separate bob animation gives the zero-G float.
- Two starfields built from
radial-gradientwith offset twinkle timings (4s and 7s). Doesn’t cost a single asset.
@keyframes nf-orbit {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes nf-counter-spin {
from { transform: translateX(-50%) rotate(0deg); }
to { transform: translateX(-50%) rotate(-360deg); }
}
All of it sits behind a prefers-reduced-motion guard. Anyone with
that set in their OS gets the same layout, just frozen. Nobody should
get motion sick on a 404.
Astro and the 404 quirk
One thing that briefly tripped me up: Astro emits
src/pages/404.astro as dist/404.html (flat), even when
build.format is set to directory for the rest of the site.
Cloudflare Pages, Netlify, Vercel — they all pick that file up as the
site-wide 404 automatically.
src/pages/de/404.astro falls back to the directory rule and ends up
as dist/de/404/index.html. How each host handles nested 404s
varies — I’ll wire the German fallback through a _redirects rule
when I get to it. Until then, an unknown URL anywhere on the site
lands on the English 404, which is fine for v1.
What stuck
A small CSS-only scene reads as more considered than a giant Lottie, and ships in roughly 4 KB of style. Keep the joke as short as possible — one headline, one body line, one closer, done. And always ship the two buttons back, otherwise the joke stops being charming around the third read.
If you want to see it: just make up a path. Try
/abducted-by-aliens —
that’s exactly what it’s there for.