Pure CSS single-page app routing

· 2 min read

You’re probably a busy person, so here’s the CSS:

section:not(:target) {
  display: none;
}

Demo: Open in a new tab Open in a new tab

Explanation

The :target CSS selector selects the element that is targeted by the URL fragment.

Combined with :not, we can hide sections that are not referenced by the URL fragment.

Just as JS routers use the fragment to hide/show sections in the DOM, this “CSS router” uses the same fragment to hide/show sections in the DOM.

Experiment: Default section

Notice that the example above doesn’t start with the Home section. The content is blank initially. This is because on initial page load we don’t have a URL fragment to begin with.

We need to make an exception for the Home section.

Let’s start by not hiding the #home section by default. Only hide #home if there’s a specific :target section.

- section:not(:target) {
+ section:not(#home, :target),
+ :root:has(:target) #home {
    display: none;
  }

Demo v2: Open in a new tab Open in a new tab

Experiment: Nested routes

One thing that makes most client-side routers modular is the ability to nest routes. We can do the same with CSS.

- section:not(:target) {
+ section:not(:target, :has(:target)) {
    display: none;
  }

Demo v3: This demo is best when you view it in a separate tab Open in a new tab

Parameterised routes?

The ultimate feature for client-side routers is to dynamically catch routes with parameters like for example /post/:id.

Since HTML is static, there’s no real way to do this with CSS. ☹

Unless… you could render all possible :id values in the markup and use it like you would nested routes.

<!-- ... -->
<section id="post/128"><!-- ... --></section>
<section id="post/129"><!-- ... --></section>
<section id="post/130"><!-- ... --></section>
<!-- ... -->

But that’d be like putting the entire database in HTML. And if you had multiple parameters in the route, it would be combinatorial explosion. So, nope. 👋