Choice-based stories using CSS
(Posted 2025-12-24 02:27 -0500)In this post, I’ll go over how to make a basic choice-based story on a single web page without using any JavaScript or server-side logic. Some knowledge of HTML, CSS, and basic file operations is assumed.
Thanks to Dij for beta reading this post!
Background
I’ve been playing Grundo’s Cafe for a little bit, having largely given up on Neopets due to its aggressive monetization. One of the things I remember wanting to do as a kid was finish making something with the Neopian Adventure Generator, which is nothing special if you’re at all familiar with Twine or pretty much any other tool for choice-based interactive fiction – the only state it tracks is what passage the player is on. All the same, it had its charm.
Grundo’s Cafe doesn’t have the Neopian Adventure Generator, but what it does have are pet pages.1 Pet pages don’t allow JavaScript, and only a limited subset of HTML and CSS. It might be tempting to look at this set of limitations and say there’s no way to make an adventure using a pet page, but would it surprise you if I said that, even with these limitations, we have just as much power as the Neopian Adventure Generator?
The Story File
First allow me to present the HTML file this post will work with, which will not need to change for the rest of the post.
Save the following in a file with any name that ends in .html. How about void-of-doors.html?
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>The Void of Doors</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div>
<p>You awaken in an infinite void. Around you are three doors, <a href="#red">red</a> and <a href="#green">green</a> and <a href="#blue">blue</a>.</p>
</div>
<div id="red">
<p>The red door was made of <strong>FIRE</strong>! AAAAAAAAAA!</p>
<p>You have been incinerated. <a href="#">Try again?</a></p>
</div>
<div id="green">
<p>The green door was a reverse green screen! It looked blank but was actually a monster!</p>
<p>You have been eaten. <a href="#">Try again?</a></p>
</div>
<div id="blue">
<p>The blue door was open air! You're falling! Which one of these was the parachute pullcord again?! Was it <a href="#this">this one</a> or <a href="#that">that one</a>?!</p>
</div>
<div id="this">
<p>As you pull the cord, your parachute deploys! You drift gently to the ground. Oh, it's your doorstep!</p>
<p>You have made it home. :)</p>
</div>
<div id="that">
<p>As you pull the cord, your pocket lawnmower revs up! Why do you carry that again?! You accidentally sever your parachute's ripcord!</p>
<p>You have splatted. <a href="#">Try again?</a></p>
</div>
</body>
</html>
It’s obvious I’ve mustered my great skills as a writer to have brought this diversion to life. But how does it play? Try opening the file in a browser.
… Well, it doesn’t play like much of anything! How are we going to turn this into something more like a game?
Making It Interactive
You may have noticed there are links but they don’t do anything. Or maybe you noticed they do do something – they change the URL hash, which causes the browser to try to scroll to the appropriate passage. But at least on desktop, it’s very unlikely to be able to do any scrolling because of how short it is, and even if you zoom way in it may not be possible to distinguish the passages at the bottom from each other.
So the first step is to make the active passage stand out somehow. This can be done with the css :target selector! If we make a rule using it, it will cause the element pointed at by the URL hash to change. Make a style.css in the same folder as the HTML file, with the following contents:
:target {
font-size: 200%;
}
Now, refresh the page. You should find that clicking the links does something, namely, it makes one of the passages bigger! Well, it makes most of the passages bigger, but the first one doesn’t get this treatment. Why’s that?
We didn’t give it an id, because it’s supposed to appear even if there’s no URL hash. There’s no id that corresponds to an empty hash, so this one seems to require special treatment.
When should the initial passage stand out? Well, when no other passage does. But how do we tell what the initial passage even is? There are many elements without an id!
It looks like we have to pick an arbitrary rule, so how about this: All the elements directly inside the body are passages, and so if an element is in the body and has no id, then it’s the initial passage.2
Now that that’s decided, let’s try replacing the CSS with this:
body > :target,
body:not(:has(> :target)) > :not([id]) {
font-weight: 200%;
}
Suddenly there’s a much more complicated selector (the body:not(:has(> :target)) > :not([id]) bit), so let me rephrase it into words: Match any element with no id if its parent is the body element and none of its siblings is the target.
I also changed :target to body > :target even though in this case it doesn’t change anything, to make it more parallel with the other selector. (It will also come in handy during the next step.)
Now, refresh the page, and you should now find that the initial passage is shown large when no other passage is!
Hiding Information
So it’s now possible for the player to tell what passage they’re on, which is a good start. But we’d also like that to be the only passage that’s visible. After all, right now you can cheat by seeing into the future. You can even click a link in any passage the same as if it were the active one!
To fix that, we’ll need to style all the passages that aren’t the active one, like so:
body:has(> :target) > :not(:target),
body:not(:has(> :target)) > [id] {
display: none;
}
What changed? Let’s go over each part:
body:has(> :target) > :not(:target)– Thebodypart is more specific than before. Actually, the reason it wasn’t this specific before is becausebody:has(> :target) > :targetis redundant. Of course any body element directly containing the target contains the target! But now that our condition has been inverted – we want any element that isn’t the target – it needs to be this specific or else the initial passage will always be hidden. This part of the selector could be worded as: Match any sibling of the target if their parent is the body element.body:not(:has(> :target)) > [id]– This part, on the other hand, was already specific enough in that regard, so all that was needed was to remove:notto invert it. In words: Match any element with anidif its parent is the body element and none of its siblings is the target.display: none– The matching elements are hidden and removed from the page flow.
Refresh the page and see how it plays. With any luck, you should be seeing only one passage at a time now!
Conclusion
With this latest iteration of the CSS, the goal of creating a basic choice-based story is accomplished! Each passage is shown when it should be, and not shown when it shouldn’t. But what else could we do with this?
For starters, the URL hash is not the only way a web page can track state without involving JavaScript. For instance, a radio button’s state can be matched with a CSS selector, :checked. If you think of the URL hash as a single “variable” of state, adding in input elements allows you to have multiple!3
Instead of going directly in the body, the passages could go in any element at all. To do so, change both occurrences of body in the CSS to whatever is appropriate. E.g., if you want to put them in a main element somewhere inside the body, change them both to main. That way, you can have a sidebar or the like that persists even as you change passages.
And of course, you probably want to style the page further to your tastes.
That’s all for now. Feel free to use the HTML and CSS in this post as a template for making your own diversions. Enjoy!
Footnotes
-
In fact, if you have access to Grundo’s Cafe, you can see I’ve already done this on Arity’s pet page :) That said, as of writing, it’s not a proper adventure yet. Please look forward to it. ↩
-
Alternatively, we could put a class on all of them and select based on that, but I prefer doing it structurally because it makes the HTML less noisy. On top of that, Grundo’s Cafe has a limit on how much HTML you can include on a pet page, so less redundant information is better. ↩
-
Regrettably,
inputelements are not part of the subset of HTML allowed on Grundo’s Cafe pet pages. ↩