0x7F4A 48 54 42 2F 70 69 6C 67 72 69 6D 61 67 65 2E 68
0x0040 6E 6D 61 70 20 2D 73 43 20 2D 73 56 20 2D 70 2D
0x7FFD 67 69 74 2D 64 75 6D 70 65 72 20 2E 67 69 74 0A
0x2501 62 69 6E 77 61 6C 6B 20 2D 65 20 73 68 65 6C 6C
0x1A2B 2F 65 74 63 2F 70 61 73 73 77 64 20 72 6F 6F 74
0x4F6E 70 6E 67 20 74 45 58 74 20 70 72 6F 66 69 6C 65
0x9C3D 73 71 6C 69 74 65 20 64 75 6D 70 20 61 64 6D 69
0x55E8 73 73 68 20 75 73 65 72 40 31 30 2E 31 30 2E 31
0x2E5F 63 76 65 2D 32 30 32 32 2D 34 34 32 36 38 20 20
0xF7A1 72 6F 6F 74 20 73 68 65 6C 6C 20 6F 6B 21 20 20
0x0601 30 78 36 36 20 30 78 36 35 20 30 78 36 31 20 20
0x06B5 70 72 65 73 65 6E 74 20 64 61 79 20 6E 6F 77 21
0x7F4A 48 54 42 2F 70 69 6C 67 72 69 6D 61 67 65 2E 68
0x0040 6E 6D 61 70 20 2D 73 43 20 2D 73 56 20 2D 70 2D
0x7FFD 67 69 74 2D 64 75 6D 70 65 72 20 2E 67 69 74 0A
0x2501 62 69 6E 77 61 6C 6B 20 2D 65 20 73 68 65 6C 6C
0x1A2B 2F 65 74 63 2F 70 61 73 73 77 64 20 72 6F 6F 74
0x4F6E 70 6E 67 20 74 45 58 74 20 70 72 6F 66 69 6C 65
0x9C3D 73 71 6C 69 74 65 20 64 75 6D 70 20 61 64 6D 69
0x55E8 73 73 68 20 75 73 65 72 40 31 30 2E 31 30 2E 31
0x2E5F 63 76 65 2D 32 30 32 32 2D 34 34 32 36 38 20 20
0xF7A1 72 6F 6F 74 20 73 68 65 6C 6C 20 6F 6B 21 20 20
0x0601 30 78 36 36 20 30 78 36 35 20 30 78 36 31 20 20
0x06B5 70 72 65 73 65 6E 74 20 64 61 79 20 6E 6F 77 21
code

I kinda learned Astro by overbuilding everything

This web is my first Astro project, with lots of stuff: islands, content collections, four themes I couldn't choose between, the components I borrowed from MagicUI, and the CPU fire I started

#astro#webdev#magic-ui#performance

This is the first real code post on here, and it’s the most on-brand one I could’ve picked: it’s about the site you’re reading it on. I’d never touched Astro before this. I picked it for a blog, then proceeded to build four of them. Here’s how that happened, what Astro actually taught me along the way, and the part where I set my Ryzen on fire. Well, not exactly, but goddammn, some MagicUI components were too much for any kind of CPU when used multiple times. Anyway… there’s code in here, all of it lifted straight out of this site, so if you want to retrace any of it you can

Why Astro at all

I wanted a blog, not a single-page app pretending to be a blog. Most of what I write here is going to be static: words, code blocks, screenshots, the occasional diagram, etc. The kind of thing that has no need to have a MB of JavaScript just to render some paragraphs. Also, I talk a lot, I’m really used to make some text files writing my notes, my write ups, and it would be nice to have everything here

Astro’s whole thing is “static by default, bring your own framework when you actually need it”, and that sounded right for what I wanted, and also I’ll be honest… I also just wanted to learn something new. So I went in completely fresh, no idea what I was doing, which is the best way to find out

The main thingie I needed to learn about: islands

Coming from React-land, my mental model was “everything is a component, everything renders on the client, everything is alive”, but Astro quietly broke that on day one

In Astro, a component is just static HTML by default. You can drop a full React component into a page and it’ll render to HTML at build time and ship zero JavaScript. None. The interactive bits, the “islands”, only come alive if you explicitly ask them to. The rest of the page is just… HTML, as plain as it gets, and perfect for a static lightweight page

That took a second to sink in. I kept waiting for the catch, for the part where it secretly hydrates everything anyway, like those React re-renders because of any tiny thingie. But it doesn’t, so if you don’t opt a component in, the browser never even knows React was involved. For a blog that’s mostly text, that’s exactly what I needed

Opting in on purpose

So how do you wake up an island? You tell it when. Astro calls these client directives, and the moment they clicked was the moment I realized I get to choose what anything costs:

<Particles client:visible color="#3b82f6" quantity={50} />

That client:visible means the particle background doesn’t load its JavaScript until you actually scroll it into view. The main ones I had to learn about:

  • client:load hydrates immediately, for stuff that has to be interactive the instant the page is up
  • client:idle waits until the browser’s done with the important work, then quietly hydrates
  • client:visible waits until it scrolls into the viewport, which is perfect for anything below the fold

Suddenly every interactive thing on the page is a choice instead of a default tax. Coming from “everything’s on all the time and pray to not have too many components in a chain” having a thingie to choose what runs, and when, felt almost too generous

The blog is just data

Here’s the bit that sold me on Astro as more than a static-site toy: the posts themselves aren’t pages I hand-wire. They’re a typed collection. I describe the shape of a post’s frontmatter once, in a schema, and Astro validates every markdown file against it at build time:

const blog = defineCollection({
	loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/blog" }),
	schema: z.object({
		title: z.string(),
		description: z.string(),
		pubDate: z.coerce.date(),
		category: z.enum(BLOG_CATEGORIES),
		tags: z.array(z.string()).default([]),
		heroImage: z.string().optional(),
		draft: z.boolean().default(false),
	}),
});

If I typo a category, or forget a description, or write a date that isn’t a date, the build fails and tells me which file. I can even post something with draft: true, which means it’s real to my dev server but invisible to the live build until I toggle it. And because category is an enum tied to my actual category list, a post literally cannot exist in a category I haven’t defined. The whole blog is type-safe content, and I didn’t have to make up a database to get there

Turns out, I don’t even need React for most of it

I had another realization that actually changed how I built the site: a huge chunk of it doesn’t need React at all

Take the About page. It’s got scroll-reveal animations, a bento grid, hover states, the works. But it has zero React islands. It’s plain Astro components, a little CSS, and a single IntersectionObserver to trigger the animations. The reveal is just two classes and a transition:

[data-reveal] {
	transition: opacity 0.5s ease, transform 0.5s ease;
}
[data-reveal].reveal-init {
	opacity: 0;
	transform: translateY(16px);
}
[data-reveal].reveal-init.is-visible {
	opacity: 1;
	transform: none;
}
const els = document.querySelectorAll("[data-reveal]");
els.forEach((el) => el.classList.add("reveal-init"));

const io = new IntersectionObserver((entries, obs) => {
	for (const entry of entries) {
		if (entry.isIntersecting) {
			entry.target.classList.add("is-visible");
			obs.unobserve(entry.target);
		}
	}
});
els.forEach((el) => io.observe(el));

The hidden state is added by JavaScript, on purpose. If JS never runs, or you’ve asked your system for reduced motion, everything just renders visible from the start instead of being stuck invisible forever. No framework, no island, no hydration cost, and it degrades gracefully on its own. Just markup and CSS like it’s 2010 again (non-derogatory, I know that was on the line for some… questionable designs). Once that clicked the question flipped: instead of “how do I build this in React,” it became “do I even need to,” and the answer was usually no

Going overboard: the demos

Here’s where the “overbuilding” in the title was hiding. I couldn’t pick a look for the site. So instead of choosing, I built several full design directions as separate demos and let them fight it out

That sounds like a waste of time, and maybe it was, but it was also the best Astro tutorial I could’ve given myself. Building the same site four different ways drills the component model into your head fast. By the third one I wasn’t fighting the framework anymore. Repetition sometimes can help a lot on this, making projects, even if it’s kinda… the same project, but with a new layout and actually new whole theme

But as I couldn’t choose, here comes the theme switcher

I did the thing you’re not supposed to do. I refused to choose. All four looks ship in the final site as switchable themes

The trick that makes that not-insane is almost entirely CSS. The home page renders all four design variants into the HTML, and a stylesheet hides every one except the active theme’s:

[data-home-variant] {
	display: none;
}
html[data-theme="classic"] [data-home-variant="classic"],
html[data-theme="wired"] [data-home-variant="wired"],
html[data-theme="night-city"] [data-home-variant="night-city"],
html[data-theme="ghost-in-the-wired"] [data-home-variant="ghost-in-the-wired"] {
	display: block;
}

The three hidden variants are display: none, so their islands never hydrate. Maybe not the most elegant, but you’re not paying in resources for the themes you’re not looking at. Recoloring is the same idea, every accent is a CSS variable, and each theme just re-skins the tokens:

[data-theme="wired"] {
	--color-accent: #4ade80;
	/* ...the rest of the palette */
}

The one bit of actual JavaScript is making sure your pick doesn’t flash. The theme lives in localStorage, and a tiny inline script runs during head parsing, before the first paint, to set it:

<script define:vars={{ themes, storageKey, metaColors }}>
	try {
		const stored = localStorage.getItem(storageKey);
		if (stored && themes.includes(stored)) {
			document.documentElement.dataset.theme = stored;
		}
	} catch {
		/* storage blocked, default theme stands */
	}
</script>

That’s the whole switcher. All four designs in the HTML, CSS picks the winner, one inline script kills the flash, and the “default” is just a constant I can change my mind about whenever I want. Which, knowing me, I kinda will…

The borrowed thingies

I didn’t hand-roll every fancy effect. A lot of the eye-candy started as components from MagicUI, an open library of animated React bits, and then got pulled apart to fit

What I grabbed and mostly kept: the aurora text that does the purple-to-teal gradient on my name for example (configurable, as everything, of course), the flickering grid that turned out to be THE card-hover effect across the whole site, meteors streaking some backgrounds, plus smaller ones like number ticker, typing animation, text animate, hyper text, and a retro grid. Off-the-shelf parts that saved me a ton of time

But I also kinda deleted a whole pile of them after building. Terminal, marquee, shine border, magic card, border beam, light rays, grid pattern, scroll progress, animated shiny text, all gone. Not because they were bad, but because they belonged to demos that didn’t make the cut. No sense keeping a fancy border effect around for a look I’d already retired

And then there’s the ones I kept but completely gutted, which is its own story, because that’s where the trouble started

Boss fight! > Performance <

By far the worst part of this whole build was performance. Not even close

Here’s what happens when you build four themes’ worth of animated canvas backgrounds, particle fields, flickering grids and meteor showers, and then you’re kinda yoloing Magic UI and Astro, and who doesn’t yet know which of these are cheap and which are quietly melting a core. If your laptop fan sounds like a jet engine and your CPU graph looks like a heart attack, let me know, I might need more work to do. Still, I opened the site and felt it. Scrolled and felt it more. It drained CPU like crazy and it was entirely my own fault for putting motion on goddamn everywhere

So most of those “borrowed toys” didn’t survive contact in their original form. The particle background got rewritten basically end to end. The original redrew every single frame at 60fps. I throttled it to a target framerate (And I mean, the target animations was just pixels turning on and off in different places of it, and it looked like low-fps anyway) and just skip the frames in between:

const frameInterval = 1000 / fps;

function animate(time) {
	if (!isInView) return;
	rafId = requestAnimationFrame(animate);

	const elapsed = time - lastFrameTime;
	if (elapsed < frameInterval) return; // not time for the next frame yet
	lastFrameTime = time;

	drawFrame(elapsed / baseFrameMs); // scale movement so it looks the same at any fps
}

Notice the if (!isInView) return at the top. That’s the other half: the loop only runs while the canvas is actually on screen. An IntersectionObserver flips isInView on and off, so the second the particles scroll out of view the whole thing stops dead. No point animating pixels nobody’s looking at. That offscreen-pause became a rule that every animated thing on the site obeys now

A few more lessons from the same fire dumpster:

  • I stopped routing mouse movement through React state. Every mousemove was triggering a re-render of sorts, which is death by a thousand cuts. Now it writes to a plain variable the canvas reads directly
  • Every effect checks prefers-reduced-motion and just holds still if you’ve asked for it. That’s good for accessibility and it’s free CPU
  • The About page’s no React approach came straight out of this too. Transform and opacity only, both GPU-composited

Even before this post I tweaked some components a bit more. I wanted the particle field denser, but denser usually means more expensive. So instead of just adding particles I rewrote the draw step to be cheaper per particle, then added more of them. So, I got a busier effect that costs less than the old one did. That’s the whole performance arc in one move, more presence for a smaller bill. It only took me looking at my Ryzen’s temps to go real high to learn how to get there

What I’d tell past me

Astro’s mental flip, “nothing runs unless you say so”, is the one idea I wish I’d had on day one. Half my performance pain was me building like everything had to be alive, when the entire point of the framework is that it doesn’t

If you’re eyeing Astro for your first project: lean into static, treat every island as a cost you’re choosing to pay, let content collections carry your data, and assume any effect that looks cool is melting something until you’ve proven it isn’t

None of this is random examples, every snippet up there is real code from this site, and if you need more info about it, you can always ask me, I’ll try to put up a repo for issues and mostly to allow for comments. Either that, or I try to check how to add comments to Astro’s posts… as I go crazy again and start making wild easter eggs or more stuff. Already have some planned, not gonna lie

That last part’s optional. Probably don’t build four times the same page, either. But I don’t regret it!

Update, July 2026: about that React claim

A few sections up I said I don’t even need React for most of it. That held for about two weeks. Then I wanted a command palette (NAVI, press Ctrl+K, it’s real), and writing combobox keyboard handling and focus management by hand is the kind of thing I’ve done once and don’t need to do again. So the palette is a React island now, built on cmdk, and it was the right call. The boot screen you maybe saw on the way in? Plain Astro and one small script, no React