Let's write an SPA in CSS

Daniel Schulz - Jul 2 '19 - - Dev Community

Let's build a website. Let's make it about food, because I like food. Also, we need cats, because the Internet is made of cats. Also, because it's 2019, it can't have page reloads.
What's that? All those small pages use way too much Vue and React and all of the Javascripts, you say? Alright, let's try without then, let's have it your way.

In order to have a website about food and cats, we need to have content. Here's some:

<main class="content">
    <section class="content__section" id="cats">
        <img class="content__entry" src="http://lorempixel.com/500/500/cats/1/">
        <img class="content__entry" src="http://lorempixel.com/500/500/cats/2/">
        <!--[...]-->
        <img class="content__entry" src="http://lorempixel.com/500/500/cats/9/">
    </section>
    <section class="content__section" id="food">
        <img class="content__entry" src="http://lorempixel.com/500/500/food/1/">
        <img class="content__entry" src="http://lorempixel.com/500/500/food/2/">
        <!--[...]-->
        <img class="content__entry" src="http://lorempixel.com/500/500/food/9/">
    </section>
    <section class="content__section" id="foodandcats">
        <img class="content__entry" src="http://lorempixel.com/500/500/food/1/">
        <img class="content__entry" src="http://lorempixel.com/500/500/cats/2/">
        <!--[...]-->
        <img class="content__entry" src="http://lorempixel.com/500/500/food/9/">
    </section>
</main>
Enter fullscreen mode Exit fullscreen mode

Mmh, just look at that Lorem Burger and Ipsum Salad! But it's all naked HTML. You are not going out like that, website, dress yourself up!

.content {
    &__section {
        display: grid;
        grid-template-columns: 1fr 1fr 1fr;
        grid-gap: 16px;
        width: 100%;
    }

    &__entry {
        width: 100%;
    }
}
Enter fullscreen mode Exit fullscreen mode

That's better, but we can still see all the content. That's no SPA!
Alright, let's hide our content then.

.content {
    position: relative;

    &__section {
        position: absolute;
        top: 0;
        left: 0;
        //[...]
        pointer-events: none;
        opacity: 0;
        transform: translateX(-5%);
        transition: opacity 0.3s ease-out 0s, transform 0.3s ease-out 0s;

        &:target {
            opacity: 1;
            pointer-events: all;
            transform: translateX(0);
            transition: opacity 0.3s ease-out 0.3s, transform 0.3s ease-out 0.3s;
        }
    }

    &__entry {
        width: 100%;
    }
}
Enter fullscreen mode Exit fullscreen mode

Great. Now everything's gone. So what happened here?
Our .content__section elements are now absolutely positioned within .content, so they overlay each other. You can't see or click them, because we also removed their opacity and their pointer events (Watch out! Pointer events are reversible, so any children with pointer-events: all; are clickable again). We could have simply gone with display: none, but our way allows us to transition the content sections in and out of view.
We restore the opacity and pointer events on the :target pseudoclass, so as soon as we target a .content__section, it shows up.
But all that talking is getting us nowhere, because we put it all behind that :target pseudoclass and don't even have a navigation to trigger that. Getting the user to type in hash-URLs isn't UX-ish enough?

<nav class="navigation">
    <ul class="navigation__list">
        <li class="navigation__item"><a class="navigation__link" href="#cats">🐱</a></li>
        <li class="navigation__item"><a class="navigation__link" href="#food">🍴</a></li>
        <li class="navigation__item"><a class="navigation__link" href="#foodandcats">🍴+🐱</a></li>
    </ul>
</nav>
<main class="content">
    <!--[...]-->
</main>
Enter fullscreen mode Exit fullscreen mode
.navigation {
    position: fixed;
    top: 0;
    box-sizing: border-box;
    width: 100%;
    margin-bottom: 16px;
    background: rgba(indigo, .95);
    box-shadow: 0 0 5px 0 rgba(black, .5);
    z-index: 2;

    &__list {
        box-sizing: border-box;
        display: flex;
        justify-content: center;
        width: 100%;
        margin: 0;
        padding: 16px;
        list-style: none;
    }

    &__link {
        height: 2em;
        margin: 0 24px;
        padding: 8px;
        font-size: 2em;
        color: white;
        text-decoration: none;
    }
}
Enter fullscreen mode Exit fullscreen mode

There you go. A nice topbar. It even has emojis, so it's hip and cool!
It has local links that target the corresponding sections. :target, remember? Click the link, content shows up.
Congratulations, you can now close this article, because you got all the important stuff and our website can pretend it's an SPA. Or you can stay and refine it some more.
Right now, we added everything to eliminate the need to reload, but we don't show anything useful on the first load. How rude! Say hi!

<nav class="navigation">
    <ul class="navigation__list">
        <li class="navigation__item"><a class="navigation__link" href="#hi">✌️</a></li>
        <!--[...]-->
    </ul>
</nav>
<main>
    <!--[...]-->
    <section class="content__section is--default" id="hi">
        <p class="hi">hi ✌️</p>
    </section>
</main>
Enter fullscreen mode Exit fullscreen mode
.content {
    &__section {
        //[...]

        &.is--default {
            opacity: 1;
            pointer-events: all;
            transform: translateX(0);
        }

        &:target {
            //[...]

            & ~ .is--default {
                pointer-events: none;
                opacity: 0;
                transform: translateX(-5%);
            }
        }
    }
}

.hi {
    display: flex;
    justify-content: center;
    align-items: center;
    width: 100%;
    height: 500px;
    font-size: 3em;
    grid-column-start: 2;
}
Enter fullscreen mode Exit fullscreen mode

.is--default reverses the logic of our normal sections, but stays hidden, whenever there's a targeted section before it. Here's a drawback: That means, it needs to be the last item in <main>. Boo, semantics, boo! You may need to watch out for tabindex here as well.

Last but not least, let's smoothen out the page transitions a bit more.

:root {
    scroll-behavior: smooth;
}
Enter fullscreen mode Exit fullscreen mode

Whenever we change the view, another id gets targeted and prompts the browser to jump there. That's how internal links work, after all. To conceal that jump, we can slowly scroll to top while we transition the page.

Now, glue it all together and put it into a codepen:

Awesome! So why do people still use Javascript?
Well, there are some cons to this technique.

  • It uses internal links to navigate between pages, so you can't use them anymore for its intended use, on-page navigation.
  • It opens up a whole bucket of a11y problems, from tabindex to screenreaders. However those seem manageable and not any worse then those of usual SPAs.
  • Because we only visibly hide the unseen content and don't remove it from the stacking context, the document height is defined by the longest page. You can see that clearly with our "Hi" page. We could work around that by using display: none;, but then we couldn't transition anymore. I like transitions.
  • it just feels kinda hacky

But hey, for a small business card-esque page, or to show off some food and cat content, it could just do the trick. No need to set up a whole build process here, no bundles downloaded and no JS needed.

. . . . . . . . . . . . . . . . . . . . . . . .