T://bendet

One Site, Two Brands (dressed to fork).

August 28, 2026A eleven-minute reading

on astro, css, view-transitions, accessibility, and static-sites

Here’s a confession about the site you’re reading: it has a second identity. Not a dark mode. A second brand. Different typefaces, different ornament, different layout in places. There’s a switch, and I’m not going to tell you where it is.

Portfolio websites, portfolio websites everywhere

I started this article with a confession. I’ll continue it with another one. Before I wrote a single line of this project, I spent a lot of time researching other developers’ portfolios, and I had endless groups of tabs and windows open with them.

There are some genuinely amazing developers out there. Plenty of those sites stood out: writing worth reading, animation that meant something, transitions that carried you between pages instead of interrupting you, and the list goes on.

But most of them I couldn’t tell apart: a hero, a card grid, a contact form, and some very recognizable UI libraries.

The technical parts I found were interesting and gave me some direction, but I knew I didn’t want to start there. I wanted the site to have character. To be more precise, I wanted it to have mine, exaggerated. By a lot. Sort of an imaginary diary, one with a second personality living in it and writing back.

That is where the two-brand idea came from. One site, one dual identity.

The framework I didn’t reach for

Now that that was decided, the next part was choosing an architecture.

React was the obvious answer: a library I knew, felt comfortable in, and trusted. It’s what I reach for, and five years of building a design system in it had made the reach automatic. There is nothing wrong with starting from what you know. But I wanted to start from first principles instead, and ask why I needed the library at all. How much reactivity does a portfolio with a blog on it really need?

So I wrote down what this one actually does. It renders text. It has a handful of routes, no server state and no live data, and its single piece of genuine interactivity is a switch that changes one attribute. Written down like that, the one question turned into four.

How much JavaScript should a site like that be running before I have asked for any? Where does the writing live, and what checks that a piece is shaped the way its template expects? What does a crawler get handed on the first response? And when one component really does want to be interactive, what does the rest of the page pay for it?

Astro answered all four.

  • Zero JS by default. You got it. The budget starts empty and I opt in, rather than starting full and trimming down.
  • Content collections. Out of the box, and typed.
  • Static HTML at build time. Which does most of what SEO wants before you’ve thought about SEO.
  • Islands. Still need some reactivity? There you go, scoped to the one component that asked for it.

What you give up is real. The moment a page wants to be genuinely interactive, you feel the friction. For this site, that was the point.

So the real question became: how much can two brands differ when all the runtime gets to do is flip an attribute on <html>?

The answer surprised me. It’s “almost everything”, and the interesting part is the cost curve on the way there.

The broken mental model: theming = colors

I started where most people start: theming means design tokens. Custom properties for color, radius, shadow; a data-brand attribute; swap the values. That gets you exactly as far as it sounds: two palettes on one design. Nobody mistakes that for two identities.

The corrective is twenty years old. CSS Zen Garden (opens in new tab) took one fixed HTML document and let designers restyle it into hundreds of complete, unrecognizable designs. The divergence lived entirely in CSS and imagery. The DOM never changed. If you’ve internalized “different look needs different markup,” Zen Garden is the counterexample that should bother you.

That reframes the problem. Markup divergence isn’t the first tool. It’s the last one, and everything before it is cheap.

The menu

While designing this I ended up with a five-level scale, each level adding a kind of divergence and a bill for it:

Level What diverges What it costs
L0: Token-only Colors, shadows, other token values via custom properties ~Nothing
L1: Decorative layer + fonts, pseudo-element ornaments, backgrounds, motion CSS roughly ×2 for skinned parts; every change QA’d across all brand × mode states
L2: Re-composition + layout: per-brand grid placement and order over the same children CSS only, but one source order must read correctly for both brands
L3: Forked zone + markup: both variants built into the page, CSS picks one HTML and DOM nodes ×2 per forked zone; both variants authored and kept in sync forever
L4: Free fork Everything, per page Honest only as a separate URL or a JS app. This level cannot hide behind a runtime switch (see below)

Two things about this table earned their place the hard way.

First, L1’s ceiling is far higher than it feels. Most of what reads as “a completely different site” (type, texture, ornament, color, density, motion) is decorative-layer territory. And the browser subsidizes it: @font-face fonts and CSS background images are fetched lazily, only when a rendered element actually uses them, so the hidden brand’s assets cost nothing until the moment you switch.

Second, L2 comes with a rule from the CSS Grid spec, not from taste. Grid placement and order are visual-only: they don’t move anything in the accessibility tree or the tab order. So if two brands want different layouts from the same children, there must exist one source order that reads correctly for both. When you can’t find one, when the brands genuinely want different reading orders, the section has outgrown re-composition. That failure is exactly the test for promoting it to L3.

The line you can’t cross

L3 works because Astro renders both variants at build time. Astro components run once, during the build; the client-side switch never constructs anything, it only changes which prerendered branch CSS displays. The page is still honestly static.

L4 is where that stops being possible. “The homepage is a terminal in one brand and a hand-drawn map in the other, sharing nothing” cannot be a CSS selection: at that point the switch has to build DOM at runtime, and you’re writing a client-side renderer by hand: hydration order, listener re-binding, scroll and focus restoration, and crawlers seeing only the pre-switch markup. That’s not a static site with a flourish anymore. It’s a JS app wearing a static site’s clothes.

The honest versions of L4 both already exist in the wild. Lynn Fisher archives every full redesign of her site at its own URL (opens in new tab); lite.cnn.com (opens in new tab) ships a whole alternate identity as a separate build. Total divergence ships as a separate URL, and the switch becomes navigation. Every hidden-theme easter egg I surveyed follows the same law: runtime switches ship CSS divergence; structural divergence ships as another page.

The fine print on forked zones

L3 is the level where the internet’s folk wisdom is mostly wrong, in both directions.

Accessibility: simpler than feared. Hide the inactive variant with display: none and it leaves the accessibility tree and the tab order entirely: no aria-hidden, no focus management. The rule has one edge: never hide the inactive branch with opacity or off-screen positioning, which leave it focusable and readable to screen readers. display: none or nothing.

SEO: the penalty is a myth, but there’s a real cost. Google’s duplicate-content machinery is about the same content on multiple URLs; two copy variants inside one page carry no penalty, and under mobile-first indexing, hidden-in-HTML content gets full weight. The real cost is subtler: both brands’ copy gets indexed on one URL, so a search snippet can surface the wrong brand’s voice for a query. Microcopy diverges here; body content doesn’t, partly for maintenance, partly for exactly this.

Performance: the budget is nodes, not bytes. The doubled HTML compresses away; what doesn’t is DOM size. A display: none element is still in the document, so it still counts, which means the number to watch is the combined count with both variants present, not the heavier brand’s. Get that wrong and you’ll measure a forked page at half its real weight. Lighthouse starts warning around 800 body nodes, and a page that forks wholesale eats that budget twice. It’s the thing that bounds forking in my policy, because “just one more forked zone” has no natural stopping point and a node budget does.

Maintenance: the honest bill. Every forked zone is authored twice and kept in sync forever, and every change to it is checked in four states: two brands × light and dark. This is the cost that compounds. The others you pay once.

The policy this site runs

Where I landed, as rules rather than vibes:

  1. L1 is the sitewide default. Shared skeleton, per-brand skin. The article prose you’re reading is the purest case: one DOM, with the brand delta applied to the rendered Markdown by selector.
  2. L2 where composition is the brand. Listing pages and project detail re-compose the same children per brand, under the hard source-order rule; design sign-off includes keyboard-tabbing every re-composed zone in both brands.
  3. L3 is uncapped in zone count but capped in nodes. Any page may fork any number of zones, provided the built page stays under the ~800-node warning line with both variants counted together, and every candidate zone has to survive a cheap prototype before its design locks. Three pages fork wholesale: home, about, the 404.
  4. L4 never at runtime. If a page ever earns a free fork, it gets built as its own URL and the switch navigates there. None planned.

And the switch itself: a same-document View Transition with an animated clip-path, so the new brand spreads across the page like ink instead of blinking into place. Feature-detected: browsers without View Transitions (and anyone with prefers-reduced-motion set) get an instant swap, which is the correct fallback, not a degraded one. The transition mechanic turned out to be completely neutral to divergence level: it snapshots the root and animates between states, whether the change under it was a token swap or a forked zone flipping. It never pressured the architecture once.

How it feels in practice

The genuinely surprising outcome: the static stack was never the constraint. I went in expecting to negotiate with Astro; instead every negotiation was with a budget. The framework question (“don’t I need a client renderer for this?”) dissolved the moment I understood that both variants can simply exist in the build and CSS can do the choosing.

Now that the site is built, I can check that against the thing itself, and the budgets I braced for turned out not to be the ones that mattered.

The node budget never bit. The heaviest wholesale-forked page in this build is the about page, at 178 body nodes with both brands present, a fifth of the warning line. I’d expected the cap to be the thing telling me no. It never had to. What it actually bought was a rule that can be checked instead of argued about, which is worth having even when the answer is always yes.

Two things I’d filed as brand levers turned out to be shared ladders. Spacing and corner radii diverge plenty (the two identities sit at visibly different densities), but not one value of either is brand-owned. Both come off one stock scale, and each brand simply takes a different rung of it. That distinction is worth more than it sounds: a shared ladder with per-brand rungs is still one design system, where a parallel brand-suffixed vocabulary would have been two.

Forking didn’t stay on the three pages I zoned for it. Home, about and the 404 fork wholesale, as planned, but there is a little of it on every other page too. The footer ended up with a per-brand root, the header forks its logo accent and its brand glyph, the contact page forks its heading block, and both listing pages carry ornament that only one brand has. Small, deliberate, each one a place where the two registers wanted different elements rather than different paint. That’s the pattern to expect: L3 leaks downward into components long before it threatens a page budget. It’s also the reason the maintenance bill, not the node count, is the number I’d watch on a bigger site.

The discipline that actually bites day-to-day is the prototype gate. It’s tempting to promote every expressive idea straight to a forked zone, because forking is the fun kind of work. Making each zone earn L3 through a throwaway prototype (could this be L2? could it be L1 with better ornament?) has demoted more zones than it has approved. Zen Garden keeps being right about that.

Somewhere on this page, tucked out of sight, lies a bit of mischief waiting on blank parchment. Uncover the switch, and the DOM will reveal its footsteps: the exact same code you’re reading now, just drawn in different ink. That’s the whole trick, and the trick is that there almost isn’t one. No grand illusions here, just code pretending to be magic.