Pure CSS Games
1: Using CSS counters in creative ways
January 2025
Starting With Questions
Before you start, you can play a little with the CSS-only activities below. Ask yourself these questions:
- How can I create a countdown timer in CSS?
- How can I calculate a score in CSS?
- How is a counter used for the letters in the word game?
- CSS can do arithmetic?
- Yes, OK, it has
calc()
, but how can I get it to show me the results? - How do I cycle through all the numbers, using a single button?
- Really!? I can use counters to animate emojis? How soon can I use that in a project?
- How does a counter know where the mouse is?
If you start with questions, each time you find an answer, you get a little buzz of satisfaction. It’s like with jokes. If you hear the punchline first, you might think: “When am I ever going to need an answer like this?” If you hear the build-up first, the punchline becomes something you want to share with your friends.
These are not all complete games or puzzles. You can think of them as illustrations of the concepts that you will be reading more about below.
You might also have other questions, like:
- How do I detect when the player has won?
- How do I stop the game when the player’s time runs out?
- How do I make an animation glitch?
- How do I ensure that whichever button the player clicks last behaves differently from the others?
- Must I solve the word game to find a link to the repo?
- How do I create that neat revolving reel animation?
Keep these questions in mind. You’ll find answers to them in
forthcoming tutorials… unless you can create good punchlines
answers for yourself first : )
What I expect you to know already
I’m assuming that you already:
- Know how to create an HTML page and link a CSS file to it
- Understand how to write CSS selectors
- Know how to structure nested CSS.
Nested CSS
Nested CSS has been available in all major browsers since December 2023, and it’s available to over 80% of all users, so it should work in your development browser. It allows me to write shorter CSS code.
Here’s an example of how I use nested CSS to create numbered links:
ol {
list-style-type: none;
counter-reset: item 0;
li {
margin: 0.5em;
border: 1px solid black;
a {
color: inherit;
text-decoration: none;
display: inline-block;
padding: 0.5em;
width: 100%;
box-sizing: border-box;
&::before {
counter-increment: item 1;
content: counter(item) ". ";
}
&:hover {
text-decoration: underline;
}
}
}
}
The result would look something like this:

The nested CSS above will be treated exactly as if it were written in this standard expanded format:
ol {
list-style-type: none;
counter-reset: item 0;
}
ol li {
margin: 0.5em;
border: 1px solid black;
}
ol li a {
color: inherit;
text-decoration: none;
display: inline-block;
padding: 0.5em;
width: 100%;
box-sizing: border-box;
}
ol li a::before {
counter-increment: item 1;
content: counter(item) ". ";
}
ol li a:hover {
text-decoration: underline;
}
What you might not know yet
If you don’t yet know about the recently-introduced :has()
pseudo-class, don’t worry. You’ll see plenty of examples on how to
use it.
Learning To Count
The Origin Story: ordered lists
If you create an <ol>
ordered list, your browser
will automatically number the lines. Try it:
<h1>TO DO LIST</h1>
<ol>
<li>Buy a tortoise</li>
<li>Call it 'The Speed of Light'</li>
<li>Tell people I can run faster than The Speed of Light</li>
</ol>
You can change the number that the list starts with by using the
start
attribute in your HTML:
<ol start="10">
<li>Buy a tortoise</li>
<li>Call it 'The Speed of Light'</li>
<li>Tell people I can run faster than The Speed of Light</li>
</ol>

This works because under the hood, the browser uses a counter
called list-item
. You can control this counter through CSS.
With the following rule, all your <ol>
elements will
start from 101
… even those where you set the
start
property in your HTML file. The counter
property takes precedence…
ol {
counter-set: list-item 101;
}
or rather: “should take precedence”
… except that since Google released Chromium 126 on 11 June 2024,
this use of list-item
is broken in Webkit browsers such as
Google Chrome, Opera and Microsoft Edge. It still works in Firefox and
Safari, so you can test it there. It’s not a big deal, because you won’t
need to use exactly this feature for anything in your CSS-only games.
It’s just nice to know that ordered <ol>
lists were
where the counter
feature came from.
Free For All
Ordered lists don’t claim exclusive rights over counters. Browsers make counters available to any element. They give you full control of any kind of numbering process. Here’s what an ordered list does naturally:
<ol>
<li>For the money</li>
<li>For the show</li>
<li>To get ready and go cat go</li>
</ol>

Here’s how you could create a similar (not identical) result using a
<ul>
unordered list.
<ul>
<li>For the money</li>
<li>For the show</li>
<li>To get ready and go cat go</li>
</ul>
ul {
list-style-type: none;
counter-set: item;
li {
counter-increment: item;
}
li::before {
content: "Item " counter(item) ". ";
}
}
Here’s what happens:
- The CSS elves see that a
counter
calleditem
has been set for the<ul>
element. - By default, they give it the value
0
. - They apply the rule
counter-increment: item
each time they meet a new<li>
item in the list where theitem
counter was set. - They then read the current value of
counter(item)
and use this number as thecontent
of the::before
pseudo-element. - They apply the decorations
Item
and.
around the counter

Summary
Note that you
can provide several items in the value for the content
attribute, like "Item " counter(item) ". "
, and the CSS
elves will concatenate them together for you.
Summary
Note that I use counter-set
and not counter-reset
, because the current version of
Firefox (128.0.3) does not always handle counter-reset
as
you would expect it to.
Seeing How CSS Works
Warning
What I show below is not good HTML. You are not supposed to
use <b>
tags inside a <ul>
list,
and there’s not a real-life case for leaving them empty. But this
makeshift HTML is good enough to show you how the CSS elves work.
<b></b>
<ul>
<b><<b>
<li>For the money</li>
<b></b>
<li>For the show</li>
<b></b>
<li>To get ready and go cat go</li>
<b></b>
</ul>
In my first example, I gave no initial value for the
item
counter, so it started with the default value of
0
. The CSS elves then found the first list item (“For the
money”), and incremented the item
counter by the default
amount: 1
. So when the first list item was shown, the
content
of its ::before
pseudo-element was
“Item 1.”
In the CSS below, I create a custom counter
called
item
, with an initial value of 10
. I also use
counter-increment: item 5;
each time an
<li>
item is treated, so the first item starts with
an item
value of 15.
ul {
list-style-type: none;
counter-set: item 10;
li {
counter-increment: item 5;
}
li::before {
content: "Item " counter(item) ". ";
}
}
b::before {
content: "—[" counter(item) "]—";
}
Here’s what this looks like:

Notice the values of counter(item)
that are shown in the
<b>
elements.
- The
item
counter is not available to the first<b>
element (outside the<ul>
element whereitem
is declared, so it shows the default value of0
. - The
<b>
element that appears in the HTML page before the first<li>
item shows the initial value of10
. - The other
<b>
elements show the value foritem
that was accumulated by all the<list>
items that came before it.
White Space Matters
Try adding a new line to the ruleset for the b::before
selector:
b::before {
counter-increment: item+1;
content: "—[" counter(item) "]—";
}
You’ll see that the CSS elves also now happily add 1
for
each <b>
element they encounter. Notice that there is
no space between item
and +1
.

But what if you change the +
to a-
sign?
b::before {
counter-increment: item-1;
content: "—[" counter(item) "]—";
}
The CSS elves get confused. They think that you are referring to a
counter named item-1
. If you’d used item-one
,
it would have had the same effect. You didn’t declare a counter called
item-1
, so they ignore it. The result will be just the same
as what you saw in Figure 4.
If you add a white space, then everything is clear again.
b::before {
counter-increment: item -1;
content: "—[" counter(item) "]—";
}
No counter-decrement
Note that there is no
property. You have to increment by a negative number. And a space.counter-decrement
Each b
element decreases item
by 1, in each
and every case, even outside the <ul>
where the
item
counter is declared. In effect, the CSS elves are very
accommodating. You didn’t declare a counter
for this
particular element before you started using it? No worries, they will
create one for you implicitly. Browsers are designed to be forgiving, so
you can write sloppy code.
And yes, my code here is deliberately sloppy. I find that you learn more about how to write good code if you deliberately try to break the rules, and see what the effect is. If you always do things perfectly, you don’t find out what the rules are.
And, as before, each <li>
item increases
item
by 5.

Interacting with the User
A web experience cannot be considered to be a game if it has no user
interaction. In this section, you’ll be seeing how to use checkboxes,
radio buttons and the sibling combinators ( ~
and +
) to change what the CSS is counting, depending on what the user clicks
on.
Not on display
The CSS elves will only count what they can see. You can try to trick them with another sloppy line of HTML:
<ul>
<b></b>
<li>For the money</li>
<b></b>
<input type="checkbox"> <!-- Linters don't like this in a <ul> -->
<li>For the show</li>
<b></b>
<li>To get ready and go cat go</li>
<b></b>
</ul>
And you can add a new rule that will hide the <li>
item immediately after the checkbox when you check it.
(Note that I’ve also removed the
counter-increment: item -1;
from the b::before
ruleset, to keep things simple.)
ul {
list-style-type: none;
counter-set: item 10;
li {
counter-increment: item 5;
}
li::before {
content: "Item " counter(item) ". ";
}
input:checked + li {
display: none;
}
}
b::before {
content: "—[" counter(item) "]—";
}
What do you think will happen when you click on the checkbox?

If an element is not displayed, then no counter will count it.
However, if you simply hide a list item, like this…
input:checked + li {
visibility: hidden;
}
… then the CSS elves do count it:

So here’s a trick that you can use: You can set the
display
value of certain HTML elements to none
if particular checkboxes or radio buttons are checked
. The
CSS elves will not count the elements with display: none
,
so you can display the number of such elements that are visible.
Time to play: counting clicks
If all this makes sense to you, the perhaps you’d like to create a really simple activity. (If it doesn’t make sense, then you can go back and read the links that I provided for each new concept, and you try to make different mistakes to irritate your browser, until you can work out what rules it really wants you to follow.)
This time, you can leave lists behind, and count checkboxes directly.
You can create an new HTML file and give it these elements:
<label>
<input type="checkbox">
<span></span>
</label>
<label>
<input type="checkbox">
<span></span>
</label>
<label>
<input type="checkbox">
<span></span>
</label>
<p>You have clicked on <span>/</span> circles.</p>
Attach a CSS stylesheet with the following rules:
body {
background-color: #222;
color: #ddd;
counter-set: total;
}
label span {
--size: 20vh;
display: block;
width: var(--size);
height: var(--size);
border-radius: var(--size);
background-color: gold;
opacity: 0.25;
counter-increment: total;
}
input:checked ~ span {
opacity: 1;
}
p span::before {
content: "0"
}
p span::after {
content: counter(total)
}
Make a prediction
Before you launch your new web page, do your best to predict what you will see:
- Each
<label>
contains both a checkbox and a<span>
. This means that the<span>
is part of the<label>
, and a click on this<span>
inside the<label>
will toggle the checkbox. - The
~
sibling combinator in the selectorinput:checked ~ span
selects a span only when a preceding<input>
sibling has been checked. When a checkbox is checked, theopacity
of the associated gold circle will be set to1
. When the checkbox is not checked, the gold circle will appear dim. - There are different rulesets for the
<span>
elements inside a<label>
and for the<span>
inside the<p>
element. This is because they are used for different purposes. - The custom
CSS property
--size
allows you to set one value that is used for thewidth
,height
andborder-radius
of all the<span>
elements which are inside<label>
elements. This means that the<span>
s will be round. - There is a
counter
calledtotal
which starts with a default value of0
. Each<label>
element increments this value by the default increment of1
. There are three<label>
elements, so the final value oftotal
should be 3. - The
<span>
inside the<p>
element contains only a/
slash, but the CSS adds both::before
and::after
pseudo-elements to it.
You should see three pale gold circles, each with a checkbox at its top left, and text at the bottom of your page should say: “You have clicked on 0/3 circles.” You can click on the checkboxes and circles as much as you like. After the first click on any of the circles, it will become brighter, but the final sentence will not change.

The value 3
appears because of this rule:
p span::after {
content: counter(total)
}
Add another counter
Can you imagine how to add another counter
that will
count how many <input>
elements have been checked?
Can you imagine how to display the value of this new
counter
in the ::before
pseudo-element of the
<p>
element?
Can you do it on your own, without reading the solution below?
Solution
Here are the lines of CSS that I changed or added in my version, in order to make this feature work:
body {
background-color: #222;
color: #ddd;
counter-set: total clicked;
}
<!-- lines skipped -->
p span::before {
content: counter(clicked)
}
p span::after {
content: counter(total)
}
input:checked {
counter-increment: clicked;
}

Now when you click on a gold circle, or on its checkbox, the number
shown in the ::before
pseudo-element changes.
More than one counter declaration?
You might think that declaring each counter
on a
different line might be valid CSS…
body {
counter-set: total;
counter-set: clicked;
}
… and it is valid, but it doesn’t behave the way you might
expect. The second counter-set
property overwrites the
first, so counter-set: total;
will be ignored.
This is the way CSS works for all other properties. In this ruleset…
label span {
background-color: gold;
background-color: silver;
}
… the silver
background will overwrite the
gold
, just as you would expect.
If you want to declare different counter
properties on
different lines, just use a single semicolon at the end of the
multi-line rule:
body {
counter-set:
total
clicked;
}
One-way Clicking
If you click on the same gold circle a second time, the checkbox will be deselected, and the number of “clicked” circles will go down. What is the simplest way to ignore any subsequent clicks?
In a group of radio buttons, only one radio button can be checked at any given time. Clicking on that button again does not uncheck it. If you have a group with only one radio button in it, then you can switch it from unchecked to checked, but you can’t toggle it back. Initially, a group of radio buttons does not need to have any buttons checked. This means that when you use radio buttons, the initial state can have all the buttons unchecked, just like you can do with checkboxes.
To test this, change the type
of each input
from checkbox
to radio
and refresh your
page.
<label>
<input type="radio">
<span></span>
</label>
<label>
<input type="radio">
<span></span>
</label>
<label>
<input type="radio">
<span></span>
</label>
<p>You have clicked on <span>/</span> circles.</p>
Now only the first click on a gold circle will have any effect, and the number of “clicked” circles cannot decrease.
Not following orders
If you follow my instructions perfectly, your web page should work. But if it works, what exactly have you learnt? You have definitely learnt to follow instructions, but you probably knew how to do that already.
So what if you deliberately do something different, something disruptive, something that I didn’t ask you to do? For example: what would happen if you put the “You have clicked on … circles” message at the beginning of your HTML page?
And just as a double-check, you can add a new line between the second and the third label, like this:
<p>You have clicked on <span>/</span> circles.</p>
<label>
<input type="radio">
<span></span>
</label>
<label>
<input type="radio">
<span></span>
</label>
<p><span>/</span> so far</p>
<label>
<input type="radio">
<span></span>
</label>
Suddenly, your first message insists “You have clicked on 0/0 circles”, no matter how hard you click. The new line that you have inserted fails to count the circle in the layer above it, or any clicks on that circle.

But were you really being disruptive? Or were you just following instructions again? I wasn’t watching.
Above or below?
Notice how I use the word “above” in the question block above.
The new line that you have inserted fails to count the circle in the layer above it
The third circle is shown on the screen below the other two,
and lower down in the HTML file. However, it is drawn in a
layer that covers the elements that appear earlier in the HTML file. In
the z-direction —from the screen to your eye— it is
above the <p>
element and the other circles,
even if in the y-direction, it is closer to the bottom of your
screen.
You can see what I mean if you add this rule at the end of your file:
label:last-of-type span {
position: relative;
top: -30vh;
left: 10vh;
border: 10px solid red
}

So yes, in one sense, the bottom circle is “above” the others.
Order in HTML and CSS
From the test that you did in the last section, you can deduce that
the order in which elements appear in the HTML file
affects the order in which they can increment a
counter
.
But what about the order of rules in the CSS stylesheet? What happens
if you change the order in which the counter
properties are
set and read, as shown below?
/* Read the value of the counter properties... */
p span::before {
content: counter(clicked)
}
p span::after {
content: counter(total)
}
/* ...before you declare the counter properties */
body {
background-color: #222;
color: #ddd;
counter-set:
total
clicked;
}
/* The CSS below is unchanged */
label span {
--size: 20vh;
display: block;
width: var(--size);
height: var(--size);
border-radius: var(--size);
background-color: gold;
opacity: 0.25;
counter-increment: total;
}
input:checked ~ span {
opacity: 1;
}
input:checked {
counter-increment: clicked;
}

Does this solve the problem that you just created?
No. But if you place a copy of the element…
<p>You have clicked on <span>/</span> circles.</p>`
… back at the end (as I did for the screenshot above), then
everything works fine, at least in this final position. This
suggests that the order in which rules appear in the CSS
file does not affect the order in which a
counter
is incremented.
But don’t let this give you a false sense of security. As Mr Weasley said: “Never trust something that can think for itself if you can’t see where it keeps its brain.”
Checking it twice
Whenever I am following a tutorial to learn a new technique, I do the tutorial twice. The first time, I follow the instructions carefully, so that I can see that the results are what the writer promised.
Sometimes, tutorials contain errors — a missing step, a missing line, a missing semicolon — and sometimes the technology has changed since the tutorial was written, and instructions that used to work no longer do. When I encounter a tutorial like this, I find that it is often worth persisting, and searching online for solutions. The process of searching is where a lot of my learning happens.
The second time I do the tutorial, I try not to read it at all. I do as much as I can from memory, and inevitably I make mistakes because there is something that I did not understand, or did not pay attention to, or assumed that I could ignore. I don’t mean to be disruptive. I just allow disruption to happen.
It is the mistakes that I make the second time that show me how much I still have to learn. If I don’t make these mistakes, I can deceive myself into thinking that I have understood. Solving my mistakes takes effort (especially if I don’t look back at the tutorial). And something that you have made an effort to do is something that you will remember.
An app that works perfectly the first time is less helpful than an app where I have to struggle to get it to work.
Traffic Lights, anyone?
A challenge for you
This arrangement of three circles, one above the other, might make you think of traffic lights. And you might be tempted now to get distracted and see if you can create a set of interactive traffic light with only CSS.
And why not? It’s fun to play, and play is great for learning.
The sequence of traffic lights is not the same in every country. Here’s a CSS-only illustration of the traffic light sequences in France and in the UK.
If you want to try this yourself, here’s a hint: You’ll be placing a
checkbox <input>
and a <span>
inside a <label>
for each light, as you have just
been doing, but the <span>
inside (at least) one
label will need a z-index
setting, so that a click on this
<span>
will check a radio button in a lower
layer.
The GitHub octocat logo in the bottom right corner will take you to the repository where you can see how this CSS-only activity works.
But this article is about counting and you won’t need to count anything with traffic lights. So when you are ready, perhaps you would like to continue?
Custom CSS Properties
You can also use custom CSS
properties to hold numbers. To create a custom CSS property, you use
a property name that starts with --x
, where “x” is any
letter.
I’ve already used a custom CSS property to set the diameter of the circles in the last example:
label span {
--size: 20vh;
display: block;
width: var(--size);
height: var(--size);
border-radius: var(--size);
/* all other lines skipped */
}
A custom property lets you define a value once, and use it in many
places. It helps you follow the DRY principle: Don’t Repeat Yourself. If
you decided that you wanted circles of a different size, you could
simply change the value of --size
, in just one place, and
the width
, height
and
border-radius
properties would all adjust their values.
My Opinion on the Term “CSS Variables”
Some people use the term CSS variables
as a synonym for
custom properties. But I don’t.
In a programming language, a variable can have various values at different times. And so can custom properties. But:
- A custom CSS property is often reset to its initial value, as you will see in a moment
- You can’t display the value of a custom CSS property in the
content
property of a::before
or::after
element - A custom CSS property cannot be used recursively, to reset its value to a value calculated from itself, as the next demonstration shows.
In JavaScript, you can do this, and the JavaScript elves know how to handle it:
var x = 1
x = x * 2
After these two lines of code have been executed, x
will
have the value 2
.
In CSS, you can set a custom property to a different value. This works fine:
label span {
--size: 25vh;
--size: 15vh;
display: block;
width: var(--size);
height: var(--size);
border-radius: var(--size);
/* more lines skipped */
}
But in the snippet below, the second rule causes the CSS elves to panic, as if they see themselves in an infinity mirror. They simply stop working for you.
label span {
--size: 25vh;
--size: calc(0.6 * var(--size));
display: block;
width: var(--size);
height: var(--size);
border-radius: var(--size);
/* more lines skipped */
}
Try both of these changes in the clicking-on-circles exercise that you have just done.
Custom CSS properties do not behave like variables do in other languages. So I always say “custom CSS properties”, to make this distinction clear. But if you call them “CSS variables”, if you want to. I won’t say anything. I’ll just sigh.
Here is the official explanation of this, without any reference to CSS elves: Resolving Dependency Cycles
Setting a starting value for a counter with a custom property
To explore how custom CSS properties can interact with counters, create a new HTML file with the following body:
<b></b>
<p>Tinker</p>
<p>Tailor</p>
<p>Soldier</p>
<p>Sailor</p>
<hr>
<label>
Add 10: <input id="add10" type="checkbox">
</label>
<label>
Add 100: <input id="add100" type="checkbox">
</label>
<label>
Add 1000: <input id="add1000" type="checkbox">
</label>
Write your own CSS
Here’s a challenge: Before you read on, can you use everything you have learnt so far about CSS counters to write the CSS that will make your page look like this?

Solution
Are you ready now? Or are you being disruptive, like I suggested you should be?
Did you write something like this?
body {
counter-reset: item
}
b::before {
content: "—[" counter(item) "]—";
}
p {
counter-increment: item;
}
p::before {
content: counter(item) ": ";
}
label {
display: block;
}
I imagine that:
- You chose a different name for your counter
- You might have given it an initial value and an explicit increment value
- Your rules might be in a different order.
That’s good. It means that you won’t be able to simply copy and paste the CSS that I provide below. You’ll have to adapt it to work with your property names. You’ll have to make an effort. 😈
Activating the inputs
To activate the checkboxes, can you do these three steps?
- Add a custom CSS property to the ruleset for the
body
selector, and set its value to0
. Below, I’ve used the name--start
, but you can use whatever name you like. - Use your custom CSS property as the initial value for your counter.
/* Create a CSS property and use it to initialize the counter */
body {
--start: 0;
counter-reset: item var(--start);
}
- Add three new rules that will change the value of your custom CSS
property. Note that I have placed the rule for the
#add1000
input first. Please do the same, even if you are feeling disruptive. You’ll see why in a moment.
/* Use the checkboxes to change the value of --start */
body:has(#add1000:checked) {
--start: 1000
}
body:has(#add10:checked) {
--start: 10
}
body:has(#add100:checked) {
--start: 100
}
Notice that I have wrapped the reference to the
<input>
elements with the new :has(...)
pseudo-class. Basically, this says: “if the body
has a
checked input, then set the value of --start
for the
body
and all its children”.
Why use :has()?
What would happen if you don’t use the :has()
pseudo-class, like this?
#add100:checked {
--start: 100
}
The rule above would say: “Set the value of --start
for
input#add100
and all its children.” But
<input>
elements don’t have any children, so the
value of --start
would change only for the
<input>
itself, and the <input>
itself ignores it. So nothing would happen.
Testing what the checkboxes do
Refresh your page, and then click on each of the checkboxes in turn. You should see the numbers change. Do they change the way you would expect them to?
When you check Add 10, you should see ——[10]——
. If you
check both Add 10 and Add 100, you should see ——[100]——
,
not 110
And if you check Add 1000 on its own, you
should see ——[1000]——
.
But if you check Add 1000 and one of the other checkboxes,
you do not see ——[1000]——
. You see
——[100]——
or ——[10]——
instead.

If you see something different, then check that your rule for
#add1000
does indeed appear first in your CSS file, as it
does in my code listing above.
Order does matter in CSS
I deliberately placed the rule for #add1000
first, so
that you can see that the order in which the CSS rules are written
does matter when you set the value of custom CSS
property. Even if you change the order of the <input>
elements in the HTML file, as shown below, the order of the CSS
rules still takes priority.
<label>
Add 1000: <input id="add1000" type="checkbox">
</label>
<label>
Add 100: <input id="add100" type="checkbox">
</label>
<label>
Add 10: <input id="add10" type="checkbox">
</label>
Try it and see.
Resetting A Counter
As you have just seen, you can set a counter
to a value
given by a custom CSS property. If you change the value of the custom
CSS property, the values of all the counters will be recalculated.
But you can also use counter-set
explicitly anywhere in
your CSS. Will there be a conflict of interests? Who will win? The
custom CSS property or the value given by counter-set
?
Here’s how you could test this.
- Add a new line to your HTML:
<p>Tinker</p>
<p>Tailor</p>
<b class="resetter"> <!-- new line -->
<p>Soldier</p>
<p>Sailor</p>
- Replace your current rule for
body
with these two rules… which may or may not be in a logical order. (That’s what we want to find out.)
b.resetter {
counter-reset: item 9999;
}
body {
counter-reset: item var(--start);
--start: 0;
}
/* The following lines do not change */
- Refresh your page. You should see something like this.
- Notice that the item number for
Soldier
is10000
(9999 + 1
), and that this value does not change when you check any of the checkboxes, although the numbers for the paragraphs before theb.resetter
element do change.

In the screenshot above, the custom CSS property --start
is set to 100
, and this determines what value the
item
counter has, until the
<b class=resetter>
element is displayed. At this
point, the rule…
b.resetter {
counter-set: item 9999;
}
… is applied, and the initial value set by the custom CSS property
--start
is forgotten. Once again, it is the order of
elements in the HTML source that decides what value a
counter
will have at any particular point.
To summarize:
- When you use CSS to alter the value of a custom CSS property, it is the precedence in the CSS which determines the value
- When you use CSS to alter the value of a CSS counter, it is the order of elements in the HTML file which determines the value.
This is, in fact, logical, but it can be confusing.
Counting Without Numbers
So far, every time you’ve seen a counter, it’s looked like a number. Well… that’s not quite true. Right at the beginning, in the little sneak-peek demos, you saw counters that look like letters and emojis. You may not even have realized they were counter tokens, although I did give a big hint.
Here’s a CSS-only presentation of how the @counter-style
at-rule can be used to change the tokens that are used to number
items in an <ol>
list. Click on the style names on
the right to see how it changes the counter tokens on the left.
The <ol>
list on the left contains only empty
<li>
items, so all you see are the
::marker
tokens.
The default value for list-style-type
is decimal. Your
browser has over 100
built-in alternatives, to cater to writing systems all around the
world. You can see them in action here.
In order to create so many styles, the @counter-style
at-rule became available in all
major browsers since September 2023.
Here, for example, is the built-in @counter-style
at-rule for Thai numeric symbols. This gives the Unicode code points for
each numeral:
@counter-style thai {
system: numeric;
symbols: '\E50' '\E51' '\E52' '\E53' '\E54' '\E55' '\E56' '\E57' '\E58' '\E59';
}
The @counter-style
below has exactly the same effect. It
uses the actual Thai
characters instead of their code points.
@counter-style thai {
system: numeric;
symbols: '๐' '๑' '๒' '๓' '๔' '๕' '๖' '๗' '๘' '๙';
}
And just like the counter
property has been made generic
so that all HTML elements have access to it, so the
@counter-style
has been made generic, so that you can
create your own styles. Here’s my rule for English weekday
abbreviations:
@counter-style weekdays {
system: cyclic;
symbols: "Mon" "Tue" "Wed" "Thu" "Fri" "Sat" "Sun";
suffix: "";
}
By default, each counter-style
has "."
as
its suffix. If you want to remove this (as I did for the weekdays), or
change it to something else (like ")"
), you can use the
suffix
property.
Because you can use any text, you can also use emojis. Or mathematical
symbols. Or Unicode shapes and arrows. The
W3C specifications also describe the use of images, but at the time of
writing, the major browsers have not implemented this for counters yet.
(You can set the list-style-image
as bullet points for a
whole list or for an individual list item, though, but this is outside
the current topic of counters.)
::marker Styling
In the example in the previous section, all the Thai list-items
appear in red, and the weekday abbreviations “Sat” and “Sun” appear in
gold. This is not an effect of the @counter-style
that is
used. This is achieved by styling
the ::marker
pseudo-element.
You can only change a few properties of the ::marker
pseudo-element. You can’t change its alignment or position, for
instance, but you can change the color
and even the
content
. Here’s the CSS that I’ve used to make weekends
stand out, and react to being rolled over by the mouse:
#weekdays:checked {
& ~ ol {
list-style-type: weekdays;
li:nth-child(7n + 6)::marker,
li:nth-child(7n)::marker {
color: gold;
}
li:nth-child(7n + 6):hover::marker,
li:nth-child(7n):hover::marker {
content: "Weekend!";
}
}
}
Note that the :hover
pseudo-class applies to the list
item itself; it cannot be applied to the ::marker
pseudo-element.

The ::marker
pseudo-element only applies to list
elements (<ul>
, <ul>
and
<dl>
). You can provide CSS rulesets for the
::before
and ::after
pseudo-elements of any
visible element, and these can be styled in many more ways than the
::marker
pseudo-element.
No-click Actions
So far, you’ve seen how to count checkboxes, radio buttons and other elements which are displayed on the page. This requires the user to interact physically with your game, and interaction is what gives your players the feeling that they are in control. (But you know that you, the game developer, you are really the one who is in control of their experience.)
Often, though, you want certain actions of your game to happen automatically: a countdown timer, for example, or a spinning reel on a fruit machine, or the expression on an emoji face. Or you may want to trigger something without the player performing an action as deliberate as a conscious click.
In the rest of this article, I’ll describe two other solutions: CSS
animations and the :hover
pseudo-class.
Animations
The easiest project for exploring how CSS animations work with CSS counters is a countdown timer. Here’s the kind of thing you’ll be creating in this exercise (with some extra contral buttons):
For this new topic, you can create a new folder with the name “Countdown” and create inside it an HTML file and a linked CSS file. Here’s enough HTML to get started:
<div></div>
Here is some equally minimal CSS:
body {
counter-set: countdown 10;
}
div::after {
content: counter(countdown);
font-size: 256px;
}
Connecting HTML and CSS
I’m assuming that you know how to create an HTML page with a linked CSS file, so that you can get this to work for you. If not, click on the GitHub octocat logo in the demo above, and check out how it works.
The font-size
is not strictly necessary, but it makes
the display more dramatic. Your page should now show a big number
10
.
The @keyframes
at-rule
CSS Animations are defined by a @keyframes
at-rule. Here is a rule that creates an animation with the name
timer
:
@keyframes timer {
0% { counter-increment: countdown 0; }
10% { counter-increment: countdown -1; }
20% { counter-increment: countdown -2; }
30% { counter-increment: countdown -3; }
40% { counter-increment: countdown -4; }
50% { counter-increment: countdown -5; }
60% { counter-increment: countdown -6; }
70% { counter-increment: countdown -7; }
80% { counter-increment: countdown -8; }
90% { counter-increment: countdown -9; }
100% { counter-increment: countdown -10; }
}
You can add this at the end of your CSS file.
Hacking with Emmet
Note that I did not type all of this out by hand. I used a hack. My code editor is VS Code, and this comes with a built-in plug-in called Emmet. (Emmet is an old or dialectical word for ant.) Emmet provides shortcuts for HTML and CSS code that you might need to type regularly. As of the time of writing, it does not provide shortcuts for generating CSS keyframe animations. So I cheated. I used a shortcut for creating a series of HTML elements with incrementing values. I typed this in my HTML page…
div{ $% counter-increment: countdown -$ }*10
… and then pressed Enter. (Tab also works.) This produced the following output:
<div> 1% counter-increment: countdown -1 </div>
<div> 2% counter-increment: countdown -2 </div>
<div> 3% counter-increment: countdown -3 </div>
<div> 4% counter-increment: countdown -4 </div>
<div> 5% counter-increment: countdown -5 </div>
<div> 6% counter-increment: countdown -6 </div>
<div> 7% counter-increment: countdown -7 </div>
<div> 8% counter-increment: countdown -8 </div>
<div> 9% counter-increment: countdown -9 </div>
<div> 10% counter-increment: countdown -10 </div>
The curly
{}
brackets define the text that I want to appear
inside the HTML element, the
dollar $
sign is replaced by a line number and the multiplication
*
sign tells Emmet how many elements I want.
The output is not exactly what I want, but I then used VS Code’s
built-in multi-cursor
selection to quickly remove the parts that I did not want, to add
curly {}
brackets where I did want them, and to add a
0
between the numbers on the left and the following
percentage %
sign.
Absolutely not a loop
Notice that each keyframe deducts a bigger number from
countdown
than the previous keyframe. This reveals a
critical difference between the way CSS applies a variety of rules to a
static page, compared with the way JavaScript can apply loops and
variables to create an endless range of possibilities.
In JavaScript, you could reduce the value of a variable by
1
on each iteration through a loop. In CSS, each time a new
keyframe uses counter-increment
to change the value of the
countdown
counter, it does this with reference to the
initial value of 10
that is set by this rule for the
<body>
element:
body {
counter-set: countdown 10;
}
Or to put it another way, CSS starts from the original static HTML page each time, and applies its rules in the appropriate order. There is never any question of looping.
The independence of
@keyframes
An @keyframes
rule simply gives instructions on what
should change at what point in the animation. It does not give any
information on which element this animation should be applied to, or how
fast it should run or whether it should repeat, or a number of other
things.
Playing an animation
To get the countdown timer to work, you can add a single line to your
CSS. This provides details that are not included in the
@keyframes
at-rule itself:
div::after {
content: counter(countdown);
font-size: 256px;
animation: timer 10s forwards;
}
This new line says:
- Apply the animation named
timer
to the element identified bydiv::after
- Play the animation over the span of
10s
- When the animation reaches the end, don’t rewind. Just play it
forwards
.
The animation should start as soon as you load the page, and continue
until the counter shows a big 0
. The word
forwards
is one of the values of the animation-fill-mode
property. It tells the CSS elves to leave everything the way they were
told to by the last keyframe they read.
Transition between frames
Reload the page to restart your animation.
Hard refresh in Firefox
In Firefox, when you simply refresh a page, Firefox remembers the state of all your checkboxes, radio buttons, details elements and so on, and restores their state after refreshing the page. To clear all these states, you need to use a hard refresh, which also clears the cache.
The keyboard shortcut for this, for all common browsers, is
Ctrl-Shift-R
(or Command-Shift-R
if you’re
working on a Mac.)
Do you notice that the step from 10
to 9
plays faster than the following steps? This is because, by default, CSS
applies a transition between each keyframe. If the property
that the animation changes can have intermediate values, then the
animation will move it smoothly from one keyframe to the next.
However, the value of your counter
changes by a whole
integer at each step. There are no intermediate values. With the default
settings, the CSS elves get half-way towards the next keyframe and they
think: “We’re closer to next waypoint now. Let’s change already!” As a
result, with your counter, the changes happen after 0.5s, 1.5s, 2.5s,
and so on. That’s why the change from 10
to 9
happens faster than you would expect.
You will see how to fix this in the coming sections.
What’s key about a keyframe?
The word keyframe is used to refer the precise values to use at precise times. The browser will interpolate other (non-key) frames between each keyframe.
Transitions
You can observe this interpolation process by adding a second
@keyframes timer
at-rule after the first:
@keyframes timer {
0% { background-color: #f00; }
10% { background-color: #f80; }
20% { background-color: #ff0; }
30% { background-color: #0f0; }
40% { background-color: #0cf; }
50% { background-color: #06f; }
60% { background-color: #33f; }
70% { background-color: #00f; }
80% { background-color: #80f; }
90% { background-color: #f0f; }
100% { background-color: #f00; }
}
Only the last timer animation is applied
Because this second at-rule appears later in the CSS file than the
other, the CSS elves will ignore the first
@keyframes timer
. They won’t increment the countdown
counter any more, they will only change the
background-color
.
Now when you refresh your page, you should see the
background-color
moving smoothly from one colour to the
next. The effect should be similar to what you see in the animation
below. (My animation has more bells and whistles than yours will have,
to illustrate the various points I’ll be mentioning).
animation-timing-function
Actually, the default animation-timing-function
is ease-in-out
, so there it slows down as it reaches each
intermediate background-color
then speeds up again If you
replace your animation
rule with this…
animation: timer 10s forwards linear;
… then the changes become even smoother.
Stepwise Animations
As I mentioned earlier, with the default animation-timing-function
of ease-in-out
(and even with the value
linear
), the changes in your countdown timer happen after
0.5s, 1.5s, 2.5s, and so on. For a countdown timer, this is not what you
want. Here’s how to fix that.
Keep the @keyframes timer
rule that changes the
background-color
, but change your animation
rule to this:
animation: timer 10s forwards step-end;
With the step-end
timing function, you should see an
abrupt change to the next background-color
at the end of
every second. Comment out the background-color
timer, so
that the original counter-increment
plays instead. You
should see that change from 10
to 9
now
happens the way you would expect.
Using otheranimation-timing-function
values
What happens if you use jump-end
,
or end
,
or step-start
as the timing function instead? Some of these have the same effect, one
does something different.
Whenever you meet a new idea, it is good to try a number of variants. This helps you to remember. The human brain is very sensitive to changes. Your eyes are constantly twitching, so that the signal they send to the brain is always slightly different. If the image on your retina does not change, your vision becomes poorer. When the pump on your refrigerator is running, your brain stops hearing it. When the pump motor stops, you suddenly become aware of the lack of noise.
If you give your brain the chance to compare and contrast ideas, your brain will pay more attention.
Here’s another demonstration of the timing of changes of a different
discrete animatable CSS property: z-index
:
This demonstration uses the following @keyframes
animation:
@keyframes z-left {
0% { z-index: 0; left: 0px; }
10% { z-index: 1; left: 0px; }
30% { z-index: 2; left: 50px; }
50% { z-index: 3; left: 100px; }
70% { z-index: 4; left: 150px; }
90% { z-index: 5; left: 200px; }
100% { z-index: 5; left: 200px; }
}
When you select step-end
as the
animation-timing-function
for this animation, the integer
steps for z-index
coincide with the stepwise change of the
left
property for the grey square. When you select
linear
as the animation-timing-function
, the
value of left
changes at a steady pace between keyframes.
The change in z-index
occurs when the grey square is
halfway between two keyframe positions.
You can find a list of all animatable CSS properties here. Most properties have an animation associated with them, and code that you can copy and paste.
Starting an Animation
For now, to restart your animation, you have to reload the page. Reloading the page will reset everything in your game, so you only want to do this if the player wants to start over from the beginning.
Here’s a change you can make so that the animation will restart when
you move your mouse over the <div>
element. I’ve
given the complete CSS code, so you can compare with what you have in
your project, but only a small part has changed.
Note that I have reduced the duration of the animation to just
1s
, because your time is precious, and you don’t want to
wait ten whole seconds to see the final result.
body {
counter-set: countdown 10;
}
div::after {
content: counter(countdown);
font-size: 256px;
}
/* animation rule moved to here... and made to run faster */
div:hover::after {
animation: timer 1s forwards step-end;
}
@keyframes timer {
0% { counter-increment: countdown 0; }
10% { counter-increment: countdown -1; }
20% { counter-increment: countdown -2; }
30% { counter-increment: countdown -3; }
40% { counter-increment: countdown -4; }
50% { counter-increment: countdown -5; }
60% { counter-increment: countdown -6; }
70% { counter-increment: countdown -7; }
80% { counter-increment: countdown -8; }
90% { counter-increment: countdown -9; }
100% { counter-increment: countdown -10; }
}
/* @keyframes timer {
0% { background-color: #f00; }
10% { background-color: #f80; }
20% { background-color: #8f0; }
30% { background-color: #0f0; }
40% { background-color: #0f8; }
50% { background-color: #08f; }
60% { background-color: #00f; }
70% { background-color: #80f; }
80% { background-color: #f0f; }
90% { background-color: #f08; }
100% { background-color: #f00; }
} */
Now, if you move your mouse over the text the
<div>
, the animation will start. It will stop again
and revert to showing 10
as soon as you move the mouse down
off the <div>
.
Restarting when the state changes
If you hold the mouse in a place where the animation will run to the
end, you should see a big 0
. If you then move the mouse
away from the link, it will revert to showing 10
.
Before, it played all the way to the end, and then stayed at
0
, regardless of where the mouse was. What has changed?
Before, the animation started as soon as the page was loaded. The
page remained loaded after the animation finished, so the CSS elves left
everything the way they were told to by the final 100%
keyframe of the animation. Now, when you move the mouse off the link,
they consider the animation to be switched off again, and reset
everything to its original state.
Pausing an Animation
Instead of :hover
, you can use the state of a checkbox
or a radio button to change the state of an animation. Edit your HTML
page so that it looks like this:
<label>
<input type="radio" name="play-state" id="play">
<span>Play</span>
</label>
<label>
<input type="radio" name="play-state" id="pause">
<span>Pause</span>
</label>
<label>
<input type="radio" name="play-state" id="stop">
<span>Reset</span>
</label>
<div></div>
Notice that there are three radio buttons which all appear before the
<div>
. These radio buttons share the same
name
property, so only one can be on at a time, but they
all have different id
values.
In your CSS page, comment out the rule for
div:hover::after
and add a new rule for the radio
buttons:
/* div:hover::after {
animation: timer 1s forwards step-end;
} */
input:checked {
animation: timer 1s forwards step-end;
}
There are three radio buttons. If you check any of them, the
countdown animation will play. The <div>
still has a
rule that tells its ::after
pseudo-element to display the
value of the counter(countdown)
, but it no longer
owns the animation. The animation belongs to whichever radio
button was last clicked. The countdown
counter belongs to
the <body>
element, and the div::after
element can listen for its current value, because it is a child of the
<body>
, which specifically appears in the HTML
after any of the :checked
radio
buttons.
Restoring ownership
To give ownership back to the div::after
element, you
can use the selector below:
body:has(input:checked) div::after {
animation: timer 1s forwards step-end;
}
The selector body:has(input:checked) div::after
says:
“Apply the following rule to any div::after
element when
the <body>
contains an <input>
that is :checked
.” All the radio buttons appear before the
<div>
element, so if any of them is checked, the
animation
rule will apply.
And now, with the body:has(...)
function, it doesn’t
actually matter whether the <div>
appears before the
radio buttons or after.
Reload your page so that no radio buttons are checked, and then click
any one of them. The animation plays to the end, and stops on
0
. Now click on any of the other radio buttons.
Nothing happens.
The rule says: “If any of the radio button
<input>
s is :checked
, play the
animation. If not, stop the animation.” So it doesn’t matter which
button is checked. They all agree with each other. The
<div>
receives the same message from each radio
button: “Go on! Play your animation!” But after the first message, the
<div>
simply shrugs: “I have played it already. I’m
good.”
What happens if you specify that only the button with an
id
of play
is to make the animation play? Try
this:
body:has(input#play:checked) div::after {
animation: timer 1s forwards step-end;
}
Now, if you click on the first button, the animation plays to the
end. If you click on one of the other buttons, the display reverts to
showing 10
.
Activating the Pause button
Add a ruleset for the #pause
button, as shown below.
Note that I have reset the duration for both buttons to the same
value of 10
, so that you have time to click on different
buttons while the animation is in progress.
body:has(input#play:checked) div::after {
animation: timer 10s forwards step-end;
}
body:has(input#pause:checked) div::after {
animation: timer 10s forwards step-end;
animation-play-state: paused;
}
The new input#pause
selector also plays the animation
when its button is selected, but it sets the animation-play-state
to paused
. So if you refresh your page and then click the
middle radio button, nothing happens.
The CSS elves are in fact busy, but they are busy doing nothing.
If you click on the Play radio button, the animation will start. If
you click on the Pause button, it will … pause. You can continue the
animation from this by clicking on the Play button again. Or you can
reset the counter to 10
by clicking on the Reset
button.
Who Cares Who Owns the Animation
What happens if you comment out the line that makes the
#paused
play the animation?
body:has(input#pause:checked) div::after {
/* animation: timer 10s forwards step-end; */
animation-play-state: paused;
}
If you start the animation with the first button and then click on
the Pause button, the animation reverts to its initial state, just as if
you had clicked on the Reset button. The
animation-play-state
no longer knows whose animation it
refers to.
Here is a more elegant version of the working CSS:
body:has(input#play:checked) div::after,
body:has(input#pause:checked) div::after {
animation: timer 10s forwards step-end;
}
body:has(input#pause:checked) div::after {
animation-play-state: paused;
}
Why is this more elegant? DRY! (Don’t Repeat Yourself)
The duration of 10s
is defined in only one place. In the
earlier version, you could set a different duration in each of the
rulesets, and unexpected things could happen. (I’m guessing that they
did, and you had to puzzle about what had gone wrong for a while. I did
warn you, but I’ve also suggested that you should be disruptive. I can’t
have it both ways, can I?)
The selector that you use for the different rules must apply to exactly the same element. You can say that the animation “belongs” to that element. The selectors used can look very different, so long as they refer to the same animated element. These rules would also work:
label:has(input#play:checked) ~ div::after,
body:has(input#pause:checked) div::after{
animation-name: timer;
animation-duration: 10s;
animation-fill-mode: forwards;
animation-timing-function: step-end;
}
label:has(input#pause:checked) ~ div::after {
animation-play-state: paused;
}
Triggering a Timeout
Now that you can create a countdown timer, the next step is to do
something when the player’s time runs out. If CSS were a programming
language, you might do something when the value of the
counter
reaches 0
. But CSS is a styling
language. It simply applies a series of rules to a basically static
page. It uses counters but it does not give you any way to
read the value of a counter, and certainly no way to compare
one value with another.
Off at a tangent
One main focus of this article is counters, but to bring this part of the story of a countdown to its logical conclusion, I’m going to have to leave counters aside for a moment, and talk about animations and other CSS properties, including custom properties that do not have a numerical value.
You’ve used the forwards
value of the
animation-fill-mode
property, so whatever CSS rules are
given by the last keyframe will persist after the animation has
finished. To see this, add a new rule at the end of the
@keyframes timer
:
@keyframes timer {
/* 10 lines skipped */
100% { counter-increment: countdown -10;
color: red;
}
}
Click on the Reset radio button and then on the Play button. Wait 10
seconds, and watch how the number 0
is now shown in red
when the animation ends.
Can you see where this is going? Instead of simply changing the
color
of the animated element itself on the last keyframe,
you can change something more radical which will affect other elements.
It would be best to start a new project for this.
Time to play: A Race Against Time
I want to use CSS to tell a story of how one person can save the
whole world as we know it browser from a terrible
simulated threat, by clicking on a button, in a race against time.
You can play this mini game below. The chances are that it’s already game over for you by the time you have read this far, but you can simply click on the Replay link:
Creating your own version of the game
Create a new HTML file with a linked CSS file. Here’s the HTML that you can use:
<form>
<label>
<input type="checkbox">
<span></span>
</label>
<div>It was always too late.</div>
<button type="reset">Reset</button>
</form>
You can make both the label
and the div
fill the entire browser viewport, with the div
in
front:
label,
div {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
font-size: 7vmin;
}
div {
background-color: #900;
}
The text “It was always too late” should now appear in dramatic black characters on an ominous red background. The mini-game that you can create will give the player the chance to prevent this terrible news from appearing.
The <label>
with a transparent background is
hidden behind this. However Reset button is also hidden behind the
<div>
, because of the use of
position: fixed
, which creates a new stacking
context. To raise the button above the fixed
elements,
you have to give it a position
other than
relative
or static
:
button {
position: absolute;
}
Hiding and showing the message of doom
To hide the red <div>
you can set its
display
to none
. But you don’t need to do this
directly. You can use a custom CSS property with the value of
none
. Add a new rule, and a new line to the ruleset for the
div
:
body {
--display: none;
}
label,
div {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
font-size: 7vmin;
}
div {
background-color: #900;
display: var(--display);
}
button {
position: absolute;
}
You should now see a little lonely checkbox in the centre of your
page. If you click on it, it becomes selected but nothing else happens.
You can add a rule to change the value of the --display
custom property. This is not the rule that you are looking
for:
input:checked {
--display: flex;
}
You can try it anyway, but it won’t work. Why not?
Which element is this custom property applied to? Which element
should it be applied to, so that the div
rule can
use it?
Try this instead:
body:has(input:checked) {
--display: flex;
}
Do you see the difference? In the first rule, --display
was set for the <input>
element. The
<div>
is not a child of the
<input>
element, so it cannot inherit the value of
--display
.
In fact, <input>
elements cannot have any
children. They are written with a single self-closing tag, so there is
nowhere for a child to be added. And for this reason, they cannot have
::before
or ::after
pseudo-elements either.
That’s why I added an empty <span></span>
element inside the label. A <span>
can have
children —and ::before
and ::after
pseudo-elements— and that’s good because that’s what I’ll use for the
storytelling.
The second rule, which uses the :has()
pseudo-class, allows me to select the <body>
element,
only if there is an <input>
which is
:checked
as one of its children. When you check the
checkbox, the <body>
gives its
--display
custom property the new value of
flex
, and the <div>
is a child of the
<body>
, so it inherits this new value. And the red
message fills the screen.
You can click on the Reset button to make it disappear again.
Ten Seconds until the End
Actually, so far you have created the exact opposite of the story I want to tell. I want to create a countdown where clicking this button will stop the red message from appearing. This is the ending I want your triumphant player to see:
span::before {
content: "My hero!";
display: block;
}
span::after {
content: "You saved us!"
}
You can use the same countdown timer that you used in the last exercise, with one little difference…
@keyframes timer {
0% { counter-increment: countdown 0; }
10% { counter-increment: countdown -1; }
20% { counter-increment: countdown -2; }
30% { counter-increment: countdown -3; }
40% { counter-increment: countdown -4; }
50% { counter-increment: countdown -5; }
60% { counter-increment: countdown -6; }
70% { counter-increment: countdown -7; }
80% { counter-increment: countdown -8; }
90% { counter-increment: countdown -9; }
100% { counter-increment: countdown -10;
--display: flex;
}
}
Instead of making the little harmless 0
turn red, this
will turn the whole page red, as --display
is set to
--flex
on the last keyframe.
If you add this to your CSS file, nothing will happen… yet. First you have to start the animation.
Where to add an animation
rule
Here’s a question for you. The following rule will start the animation. Which element should it be applied to?
animation: timer 1s step-end forwards;
I’ve used a very short duration (1s
) for now, so that
you will see immediately if the animation is working or not. Remember,
the final value of --display
needs to be applied to the
<div>
. Would this work?
div {
animation: timer 1s step-end forwards;
background-color: #900;
display: var(--display);
}
You can try it. You can try putting the new animation
rule at the end of the ruleset. But, no, it doesn’t work.
It’s not crystal clear why not. The official documentation talks about “animation-tainted” custom properties, and discussions on the W3C GitHub Issues pages show just how complex the interaction of custom variables and keyframe animations is. I use the principle of Occam’s Razor here. To paraphrase: if it doesn’t work, don’t look for anything more complex. Find a simple solution that does work.
Solution
What happens if you apply the animation to the
<body>
instead?
body {
--display: none;
animation: timer 1s step-end forwards;
}
Now the animation works. Now that you know that, you can set its
duration to 10s
to give yourself time to stop the animation
before it ends in tragedy.
Averting the Impending Disaster
My story needs two more elements to make it even a little bit interesting:
- An indication that the end is coming
- An action that will stop the animation.
You can use the counter(countdown)
to indicate how much
time is left:
input:not(:checked) {
& ~ span::before {
content: "Click me! Before it's too late...";
}
& ~ span::after {
content: "We have only " counter(countdown) " seconds left!";
}
}
The selector input:not(:checked)
adds greater
specificity to the selector that acclaims the hero
(span::before
), so the span will show this text as long as
the checkbox is not clicked.
But counter(countdown)
starts at 0
and then
goes negative. How can you get it to start at 10
? You’ll
need to add the following rule:
body {
--display: none;
counter-reset: countdown 10;
animation: timer 10s step-end forwards;
}
With this rule the countdown will be shown, and at 0
,
the “It was always too late” message will be shown.
However, if you click on the button to stop it, you just bring the end closer. That’s because of the rule you added a while back. It’s time to remove this:
/* body:has(input:checked) {
--display: flex;
} */
Now, if you click (anywhere) the checkbox will be checked, and you will be praised as a hero.
Unavoidable disaster?
But… wait for the 10-second countdown to end, and the terrible red message will appear anyway.
You need to stop the animation.
Only you can do this. You are the hero.
You need to tell the <body>
to pause the animation
when the checkbox is checked.
And here comes the twist in my story. You are not the hero after all. You just set up the situation so that you can look like the hero.
Does that give you a clue as to how to solve this?
You need to set the animation-play-state
to
paused
by default, and only set it to whatever
the opposite of paused
is while the checkbox is
not checked. This means that you need to create a selector that
selects the <body>
only if the checkbox is not
checked.
Your secret Bond-villain plan
Here’s one way to look like a hero:
body {
--display: none;
counter-reset: countdown 10;
animation: timer 10s step-end forwards paused;
}
You make the world safe. And then you create the sense of danger with this new rule:
body:has(input:not(:checked)) {
animation-play-state: running;
}
And since you are now the villain, you can deselect the checkbox again, and let countdown continue. You can both save the world and let the end come anyway.
Yes, but while you were doing this, the real hero (played by your
non-evil twin) crept unseen into the HTML file and changed the
type
of the <input>
from
"checkbox"
to "radio"
. Once a solitary radio
button is checked, it cannot be unchecked. The good guys win again!
One person can save the world!
<form>
<label>
<input type="radio">
<span></span>
</label>
<div>It was always too late.</div>
<button type="reset">Reset</button>
</form>
But… Reset Spells Disaster!
Ha! Your non-evil twin has missed a trick! Press the Reset button, and the countdown timer continues!
This is because resetting the form resets the state of the
input
elements inside the <form>
, and
this unchecks the radio button, just as if it were a checkbox.
You need something more radical than simply resetting the form. The
simplest solution is to reload the whole page, using a link whose
href
is /
. Notice that you don’t need the
<form>
element any more. Here’s the final HTML:
<label>
<input type="radio">
<span></span>
</label>
<div>It was always too late.</div>
<<a href="/" draggable="false">Replay</a>
To prevent the link from moving when dragged, just like a button,
I’ve added draggable="false"
.
Here’s the final CSS, where I’ve replaced the ruleset for
button
with a nested ruleset for a
, to make
the link look as much like a standard button as possible.
body {
--display: none;
counter-reset: countdown 10;
animation: timer 10s step-end forwards paused;
}
body:has(input:not(:checked)) {
animation-play-state: running;
}
label,
div {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
font-size: 7vmin;
}
div {
background-color: #900;
display: var(--display);
}
a {
position: absolute;
padding: 0.1em 0.2em;
background-color: #eee;
border: 1px outset #999;
border-radius: 0.25em;
text-decoration: none;
color: #000;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-o-user-select: none;
user-select: none;
&:hover {
background-color: #ddd;
&:active {
background-color: #ccc;
border-style: inset;
}
}
}
span::before {
content: "My hero!";
display: block;
}
span::after {
content: "You saved us!"
}
input:not(:checked) {
& ~ span::before {
content: "Click me! Before it's too late...";
}
& ~ span::after {
content: "We have only " counter(countdown) " seconds left!";
}
}
@keyframes timer {
0% { counter-increment: countdown 0; }
10% { counter-increment: countdown -1; }
20% { counter-increment: countdown -2; }
30% { counter-increment: countdown -3; }
40% { counter-increment: countdown -4; }
50% { counter-increment: countdown -5; }
60% { counter-increment: countdown -6; }
70% { counter-increment: countdown -7; }
80% { counter-increment: countdown -8; }
90% { counter-increment: countdown -9; }
100% { counter-increment: countdown -10;
--display: flex;
}
}
Final Tweaks
The online versionof this mini story game contains some cosmetic improvements. In particular, if you let the countdown reach one second, your version of the game will say “We have only 1 seconds left!”, with “seconds” in the plural even though there is only one.
Just 1 second
In the online version, this infelicity has gone. How do you think I fixed it?
Hint: counters can count.
Also, if the text wraps, then the line break will not occur between
the number and the word “second”. For this I’ve used a non-breaking
space, which for CSS needs to be written as "\00a0"
. You
can click on GitHub’s Octocat logo to visit the repository where you can
find the complete source. Or you can look at the solution below.
Solution
body {
--display: none;
counter-reset:
countdown 10
second 2 /* used with the seconds counter-style */
;
animation: timer 10s step-end forwards paused;
}
/* Create a counter-style to use "2 seconds" and "1 second"
* correctly. Use a non-breaking space between the number
* and "seconds" so that the whole chunk "X seconds" will
* wrap to the next line together.
*/
@counter-style seconds {
system: cyclic;
symbols: "\00A0second" "\00A0seconds";
}
/* lines skipped */
span::before {
content: "My hero!";
display: block;
}
span::after {
content: "You saved us all... with only " counter(countdown) counter(second, seconds) " to go!"
}
input:not(:checked) {
& ~ span::before {
content: "Click me! Before it's too late...";
}
& ~ span::after {
content: "We have only " counter(countdown) counter(second, seconds) " left!";
}
}
Animating a counter-style
Now you are ready to combine animating a counter and using a custom
@counter-style
at-rule. Here’s a little puzzle where the
challenge is not finding the combination of letters that opens the lock,
but finding the order in which you need to change the letters. If you
get the right word but don’t get the right order, the CSS equivalent of
an alarm goes off when you press Enter.
But you should know a little about me by now. I do like words to be spelt correctly, and I do like to move the shortest distance between points. Of the 24 possible orders in which you can change each letter only once, only one meets my high standards.
This article is about counters, so I won’t show you how to create the entire puzzle. I’ll just show you how to animate the way the letters change. I’ll get you to make a much simpler activity that uses this technique. If want to see how the order-of-execution logic was done, you can check out the GitHub repository.
Another new project
It’s time to start a new project with a basic HTML page linked to a
CSS file, just like you’ve done before. Create a new folder called
Animated Counters
, with and index.html
file
linked to a styles.css
file, And then put on your Thinking Cap.
A checkbox and a span (or two)…
You’re going to be animating two counters, using letters instead of numbers. The animation will start when you check a checkbox input. The image below shows what I want your page to look like in a browser. Do you think you can create this on your own, without looking at the HTML and CSS below?

As a clue, here’s how your page will will look like after the animation (but you don’t have to think about the animation yet):

OK, I do not normally spell “Bye” this way, and if you do you might accuse me of cultural appropriation, but please forgive me. Animating just two letters is all you need to understand the techniques.
“H” is the eighth letter in the alphabet, and “i” is the ninth. Later
(but not yet), you’ll create one @keyframes
animation to
count from 8 to 2 (to replace “H” with “B”), and
another@keyframes
animation to count from 9 to 25 (to
replace “i” with “y”).
Do your best with creating the “Well Hi! page! You can compare your version with mine later, and see if yours was in fact more elegant : )
Hints
Here are some things to think about:
- How to show the value of a counter
- How to set the initial value of a counter
- How to display a counter using upper-case letters
- How to display a counter using lower-case letters
- How you will (eventually) hide the checkbox input
- How you can make a label look like a button
- The final exclamation mark is not going to change. It’s not part of either animation.
Solution
Here’s the HTML that I used:
<label>
<input type="checkbox">
Well
</label>
<span></span>!
And you’ll see below the CSS that I started with. Note that the names
for built-in counter styles is case-insensitive. I could use
upper-alpha
or Upper-Alpha
and it would work
just as well.
body {
counter-set:
letter-one 8
letter-two 9
;
}
label {
padding: 8px 16px;
border: 1px outset #888;
border-radius: 8px;
display: inline-block;
}
input {
position: absolute;
left: 0vw;
}
span::before {
content: counter(letter-one, UPPER-ALPHA);
}
span::after {
content: counter(letter-two, lower-alpha)
}
I imagine that you will have used different names for your classes
and counters. You might have used a different selector to hold the
counter-set
rule. You might have used two elements to show
the letters Hi
, each with its own ::before
or
::after
pseudo-element.
That’s good. If your CSS creates a similar effect to mine, don’t change it at all. If your CSS is different now, that means that you will have to adapt the CSS that I provide below so that it works with yours. And that means that you might make mistakes which you will have to correct. And each mistake that requires effort to correct helps you to remember what you have learnt.
I’ve used left: 0vw
for the absolute position of the
input element. That means that it remains visible on the page for now.
In production, I would set it to -999vw
which will move it
far off-screen to the left. The value I use is arbitrary; all that
matters is that it is greater than the screen width of a checkbox.
If you want your label to behave more like a button when you hover your mouse over it or click, you can add a couple of cosmetic rules:
label {
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-o-user-select: none;
user-select: none;
}
label:has(:checked),
label:hover:active {
border-style: inset;
background-color: lightgrey;
}
With these changes, the label appears in a different state when the
<input>
that it contains is
:checked
.
Treating the animation
Figure 14 below shows your web page should look like after you check the checkbox.

So here’s a second challenge.
Create two @keyframes
at-rules
Can you create two @keyframes
at-rules, one to change
the letter H
into the letter B
, and one to
change the letter i
into the letter y
?
And can you find a CSS selector and ruleset that will do the following?
- Detect when the
<body>
element contains a checked input - Tell each of the
::before
or::after
pseudo-elements which@keyframes
animation to use
Solution
Here are the @keyframes
at-rules that I used in my
version:
@keyframes letter-1 {
0% { counter-increment: letter-one +0 } /* H */
5% { counter-increment: letter-one +1 } /* I */
10% { counter-increment: letter-one +2 } /* J */
15% { counter-increment: letter-one +3 } /* K */
20% { counter-increment: letter-one +4 } /* L */
25% { counter-increment: letter-one +5 } /* M */
30% { counter-increment: letter-one +6 } /* N */
35% { counter-increment: letter-one +7 } /* O */
40% { counter-increment: letter-one +8 } /* P */
45% { counter-increment: letter-one +9 } /* Q */
50% { counter-increment: letter-one +10 } /* R */
55% { counter-increment: letter-one +11 } /* S */
60% { counter-increment: letter-one +12 } /* T */
65% { counter-increment: letter-one +13 } /* U */
70% { counter-increment: letter-one +14 } /* V */
75% { counter-increment: letter-one +15 } /* W */
80% { counter-increment: letter-one +16 } /* X */
85% { counter-increment: letter-one +17 } /* Y */
90% { counter-increment: letter-one +18 } /* Z */
95% { counter-increment: letter-one -7 } /* A */
100% { counter-increment: letter-one -6 } /* B */
}
@keyframes letter-2 {
0% { counter-increment: letter-two +0 } /* i */
6% { counter-increment: letter-two +1 } /* j */
12% { counter-increment: letter-two +2 } /* k */
19% { counter-increment: letter-two +3 } /* l */
25% { counter-increment: letter-two +4 } /* m */
31% { counter-increment: letter-two +5 } /* n */
37% { counter-increment: letter-two +6 } /* o */
44% { counter-increment: letter-two +7 } /* p */
50% { counter-increment: letter-two +8 } /* q */
56% { counter-increment: letter-two +9 } /* r */
63% { counter-increment: letter-two +10 } /* s */
69% { counter-increment: letter-two +11 } /* t */
75% { counter-increment: letter-two +12 } /* u */
82% { counter-increment: letter-two +13 } /* v */
88% { counter-increment: letter-two +14 } /* w */
94% { counter-increment: letter-two +15 } /* x */
100% { counter-increment: letter-two +16 } /* y */
}
I could have used the same names for the animations as for
the counters. CSS will understand that
@keyframes letter-one
refers to something different from
counter(letter-one)
, but if I give them different names
then you can see that the name of the animation and the name of the
counter it increments can be different.
I’ve used intervals that are not exactly the same between each frame, just because I like to keep things as simple as possible.
Also, I made both animations run forwards through the alphabet, even though running backwards would have required fewer changes. It’s my homage to the pre-digital flight information display boards in airports, I suppose.

Back to the @keyframes
at-rules, notice how the “H to B”
animation flips from positive increments to negative increments when it
goes from “Z” round to “A”.
Here’s the CSS rule that I used to trigger the animations:
body:has(:checked) {
span::before {
animation: letter-1 2.0s linear forwards;
}
span::after {
animation: letter-2 1.6s linear forwards;
}
}
I used 0.1s
for each letter change, because I just want
to see that the animation is working without wasting precious
seconds.
To be honest, if I really wanted not to waste time, I should run both animations backwards. Here is another way of achieving the same overall effect, this time with one animation finishing before the other begins:
@keyframes letter-a {
0% { counter-increment: letter-one -0 } /* H */
16% { counter-increment: letter-one -1 } /* G */
33% { counter-increment: letter-one -2 } /* F */
50% { counter-increment: letter-one -3 } /* E */
67% { counter-increment: letter-one -4 } /* D */
84% { counter-increment: letter-one -5 } /* C */
100% { counter-increment: letter-one -6 } /* B */
}
@keyframes letter-b {
0% { counter-increment: letter-two -0 } /* i */
10% { counter-increment: letter-two -1 } /* h */
20% { counter-increment: letter-two -2 } /* g */
30% { counter-increment: letter-two -3 } /* f */
40% { counter-increment: letter-two -4 } /* e */
50% { counter-increment: letter-two -5 } /* d */
60% { counter-increment: letter-two -6 } /* c */
70% { counter-increment: letter-two -7 } /* b */
80% { counter-increment: letter-two -8 } /* a */
90% { counter-increment: letter-two +17 } /* z */
100% { counter-increment: letter-two +16 } /* y */
}
body:has(:checked) {
span::before {
animation: letter-a 0.6s linear forwards;
}
span::after {
animation: letter-b 1.0s 0.6s linear forwards;
}
}
A temporary alternative
Note that if you add this alternative solution, you don’t need to
comment out the previous body:has(:checked)
ruleset. CSS
will apply the last rules given for this selector.
letter-1
and letter-2
@keyframes.
In animation: letter-b 1.0s 0.6s
, the second time
(0.6s
) refers to the animation-delay
property, which tells the animation how long it should wait before
it starts. (You can even use negative values for this, and the animation
will play as if it had begun in the past.)
Notice that in the @keyframes letter-1
at-rule above,
the increment
value jumps from +18
for
Z
to -7
for A. The same is true for the
@keyframes letter-1
rule, which flips from to
-8
for a
to +17
for
z
.
@counter-style systems
The built-inlower-alpha
and upper-alpha
counters uses system: alphabetic
in their definition. This is slightly different from
system: numeric
in that there is no value for zero. If you
ask use a content
declaration to show the value
0
for an alphabetic
counter, it fall back on
the @default counter-style
and show exactly
0
.
Because there are 26 letters in the English/Latin alphabet, the value
27
will be represented as AA
or
aa
. This is not what you want in this particular case, but
you might find it useful in other cases.
Try it and see. Change the values that you use in your
counter-set
rule to something like this:
body {
counter-set:
letter-one 0
letter-two 27
;
}

Fruit machine
The fruit machine animation that you saw at the beginning uses exactly the same technique, with a few extra tricks:
- I’ve used an
@counter-style
at-rule that shows chosen emojis - Each reel of the fruit machine shows several numbers
- The reels themselves are animated, to rotate about the x-axis at a regular speed, then to jump backwards each time the counter animation changes its value.
- The smiley face also uses a
counter
and an@counter-style
at-rule
There are also two Spin buttons for each reel. The second button is hidden until the first Spin button for two other reels has been triggered. This means that the third Spin button you press triggers a previously hidden button, which starts a different animation… which glitches.
So you can never win.
Running an Animation Backwards
If you’ve got to this sections, I’ll assume that you have solved the challenges in the last section, or have worked through the solutions that I proposed.
So now, if you deselect the checkbox, the display jumps back
immediately to showing Hi
. There’s no reverse
animation.
Playing animations smoothly in reverse
Nikola Đuza at Pragmatic Pineapple has some good suggestions on how to make animations play smoothly in reverse, but none of them are helpful here.
If you look at the documentation for animation-direction
,
you’ll see that one of the options for the value is
reverse
. You might think, like I did, that it would be easy
to use this value with the @keyframe
animations that you
have already created. After trying, trying and trying this again and
again, it seems that I can’t force browsers to change the
animation-direction
of an animation that has already been
applied. I have had to use a more brute-force solution. (If you find a
better one, let me
know.
My solution was to make an exact copy of my
@keyframes letter-1
and @keyframes letter-2
animations. I called them @keyframes letter-3
and
@keyframes letter-4
. (I won’t show them here, because they
are exactly the same as the animations you can find in the solution to
the last challenge, just with different names.)
I added the rule below to my CSS. Notice the word
reverse
used to set the
animation-direction
.
body:has(input:not(:checked)) {
span::before {
animation: letter-3 2.0s linear reverse forwards;
}
span::after {
animation: letter-4 1.6s linear reverse forwards;
}
}
You can restore the original values for counter-set
(8
and 9
), and try this for yourself.
An unwanted side effect
You’ll see that it has an unwanted side effect. Because the checkbox
<input>
starts off unchecked, this
reverse
animation plays immediately after the page is
loaded.
What you need is a third state, which will not trigger any animations at the start.
body:has(input…)
Did you notice that my earlier selector was simply
body:has(:checked)
, but this selector includes
input
?
body:has(input:not(:checked))
If you don’t include input
, the animation triggered by
the first selector you added will not run.
You might think that adding input
increases the specificity
of this second selector, but that’s not the reason. The selector
body:has(:not(:checked))
means that if any element
does not have a :checked
pseudo-class (even
elements that do not ever receive a :checked
pseudo-class), then this selector will apply to the
<body>
element.
If you place this rule after the previous one, it will take precedence because it is read later from the CSS file. You could use this less specific selector and put it before the one you added first, but a co-worker might think that logically it should be placed after, and move it, and break your CSS.
By adding the input
element into the selector, you tell
CSS to ignore any other kind of elements that have no
:checked
pseudo-class. Now your co-worker can reorder your
rules in the CSS file and they will still work.
An alternative would be to add input
only to your
first selector . Try this:
body:has(input:checked) {
span::before {
animation: letter-a 0.6s linear forwards;
}
span::after {
animation: letter-b 1.0s 0.6s linear forwards;
}
}
body:has(:not(:checked)) {
span::before {
animation: letter-3 2.0s linear reverse forwards;
}
span::after {
animation: letter-4 1.6s linear reverse forwards;
}
}
You see? This also works, and this time it is because of
specificity. Perhaps the best solution is to use input
in both selectors, so that your co-workers can’t complain of you taking
confusing shortcuts.
Preventing the unwanted animation on page load
So back to the question: How can you stop the reverse animation from playing automatically when the page is loaded? Can you think of any three-state solutions? There’s a hint in the subtitle below.
See if you can find a solution for yourself before you read on.
Radio buttons are off by default
In your HTML file, replace the single checkbox with these two radio button:
label>
<input type="radio" name="play" id="well">
Well
</label>
<label>
<input type="radio" name="play" id="good">
Good
</label>
<span></span>!
Both buttons share the same name
property, so only one
can be on at any given time. Two states. But… both buttons can
be off when the page is first loaded. Two buttons with a third
state, for free!
So what CSS would you use with this? Notice that the radio buttons
have different id
values. Comment out your existing rules
for body:has(...)
(so that they don’t also get triggered)
and add this new one:
body:has(#well:checked) {
span::before {
animation: letter-1 2.0s linear forwards;
}
span::after {
animation: letter-2 1.6s linear forwards;
}
}
This is how your page should look immediately after it’s loaded. There shouldn’t be any animation.

And this is how it should look after your check the Well button, and after the animation has finished.

Just for fun
On my screen, the Well label is grey, which shows that its
<input>
has been
:checked
, but the radio button beside it appears unchecked.
Can you explain why?
Hint: How many radio buttons are there? What position has each been given? Do you see just a trace of blue around the edge of the radio button that you can see?
If you click on the Good button, once again the display jumps back
immediately to showing Hi
.
Just a hint
Did you notice that the frontmost radio button beside Well appears checked. Does that help you answer the previous question?
There’s still no reverse animation. You’ll need to add a new rule for
#good:checked
.
body:has(#good:checked) {
span::before {
animation: letter-3 2.0s linear reverse forwards;
}
span::after {
animation: letter-4 1.6s linear reverse forwards;
}
}
The animation should play in reverse.

Kill the spare
It would be nice to make this radio button pair behave like a single toggle checkbox… but with a third state. When the page is first loaded, you should see a button named “Well”. When you click on it, its name should become “Good”. Or rather, the Well label should be replaced by the Good label.
The new rule below will hide whichever label contains a
:checked
radio button:
label:has(:checked) {
display: none;
}
Solution
However, both buttons both appear when the page is first loaded, when neither of them has been checked yet. Can you think of a second selector for the rule above to apply to? It should say something like: “If there is a label containing an unchecked radio button followed by another label containing an unchecked radio button, hide the second label.”
You’ll need to use a sibling combinator, like ~
or
+
to select the second label.
Solution
Here’s my solution. Can you adapt this to work with your version?
label:has(input:not(:checked)) + label:has(input:not(:checked)),
label:has(:checked) {
display: none;
}
Counting Clicks: Take 2
At the beginning, I hope you played with the Arithmetic in Pure CSS
calculator. Amongst other things, this allows you to cycle through the
digits from 0
to 9
and then round to
0
again.
One of the questions that I suggested you ask yourself was: “How do I cycle through all the numbers, using a single button?” Well, now it’s time to give you an answer.
In the last exercise, you saw how to use just two radio buttons to
cycle through just two different states (Well and Good). Can you imagine
how you could extend this technique to cycle through all the numbers
from 0
to 9
and then start again from
0
?
Not with animations
OK, so technically, this exercise should not be in the Animations section, because it doesn’t involve any animations, so it won’t help you to think of a solution that uses animations.
But I did. I went down the wrong path. I went a long way down the wrong path before I gave up. Only Firefox stayed with me all the way.
If you’re interested, you can read below the story of my failed attempt to use animations to solve this problem.
A failed attempt
When I first planned this part, I had imagined that I would be able
to use animation and two radio buttons, one on top of the other. My plan
was to start with an animation which would repeatedly cycle through the
digits 0
to 9
, but which started off
paused
at 0
.
The animation would start running
as soon as the top
radio button was checked. When the animation reached the next keyframe,
it would move the label for the other radio button in front of the
:checked
button, so the label for the :checked
button would no longer generate a :hover
pseudo-class. I
would have a rule that would pause the animation if there was no
:hover
pseudo-class on the :checked
button. My
plan was that the animation would stop at the current number, and stay
there until the radio button which was now at the front was
clicked…
Fiendishly clever, I thought. But only Firefox agrees with me. Safari
doesn’t update the :hover
class until you move the mouse,
which leads to chaos. And the Chrome CSS elves just shake their heads
and say: “Whaaat!?”
If you feel so inclined, you can try it for yourself in different browsers.
I have included this failed attempt for a good reason, and not just because it took a lot of effort to get it to work at all. All progress takes effort. The article you are reading now and the demos that accompany it took many days to write. As soon as you start writing articles to share your knowledge, you will know that it’s worth it. When you decide to explain something in detail, you discover that there are still many details that you are unsure of, and you have to make the effort to understand them before you can continue.
One of the lessons that I learned from this failure was that Safari,
Chrome and Firefox all treat :hover
in subtly different
ways. It’s not good enough to have a good idea. You have to test early,
test often, and test on all target platforms (Thank for that mantra,
John Dowdell!)
To repeat my earlier question: Can you imagine how you could
extend the label-and-radio-button technique that you have just seen, to
cycle through all the numbers from 0
to 9
and
then start again from 0
?
It’s time for new HTML and CSS files. Perhaps you’d like to call this project “Counting to 9”.
Counting without counters
Here’s the HTML for the first solution that I succeeded with:
<input type="radio" name="d" id="d0" checked>
<input type="radio" name="d" id="d1">
<input type="radio" name="d" id="d2">
<input type="radio" name="d" id="d3">
<input type="radio" name="d" id="d4">
<input type="radio" name="d" id="d5">
<input type="radio" name="d" id="d6">
<input type="radio" name="d" id="d7">
<input type="radio" name="d" id="d8">
<input type="radio" name="d" id="d9">
<label for="d1">1</label>
<label for="d2">2</label>
<label for="d3">3</label>
<label for="d4">4</label>
<label for="d5">5</label>
<label for="d6">6</label>
<label for="d7">7</label>
<label for="d8">8</label>
<label for="d9">9</label>
<label for="d0">0</label>
With no CSS, this creates a line of 10 checkboxes, followed by the
digits 1234567890
in the same order as on the keyboard.

Note, though that the first input is checked, and this is
the input withid="d0"
. The input for 0
is at
the beginning, but the <label for="d0">
is at the
end. Try clicking on the numbers to see which radio button
becomes selected.
Only two labels at a time
I want to show only two labels at any one time:
- The label which is
for
the:checked
input. This should be visible. - The label which is
for
the input with the next number in the cycle. This should be:- Invisible
- In front of the label
for
the:checked
input.
If I use position: absolute
for all the labels, they
will all appear one on top of each other, with the
<label for="d0">
at the top of the pile, immediately
under the mouse. I’ll make everything really big, so it’s easy to click
on the numbers:
label {
/* Cosmetic */
--size: 100vmin;
width: var(--size);
height: var(--size);
font-size: var(--size);
text-align: center;
/* Logical */
position: absolute;
}

I won’t show the /* Cosmetic */
CSS any more, but you
might like to keep it in your project anyway.
I need to put the <label for="d1">
in front of the
<label for="d0">
. My trick is to:
- Set the
z-index
for all the labels except for<label for="d0">
to1
- Hide all the labels _except
<label for="d0">
and<label for="d1">
- The label for
<label for="d1">
(almost) transparent. (I’ll leave just a little opacity for now, so you can see that the number is there.)
Now, a click on what seems to be the 0
will in fact be a
click on the1
.
label {
<!-- Cosmetic lines skipped -->
position: absolute;
z-index: 1;
display: none;
}
#d0:checked ~ [for=d0] {
display: block;
opacity: 1;
}
#d0:checked ~ [for=d1] {
display: block;
opacity: 0;
}
[for=d0] {
z-index: 0;
}
#d9:checked ~ [for=d0] {
z-index: 1
}
Troubleshooting
If you don’t see any numbers on your page, check that the first radio button on the left i selected.
With the CSS shown above, if you click on the 0
,
<input id="d0">
will no longer be checked, but
<input id="d1">
will be checked instead. And the rest
of the page will go blank.
I now want <label for="d1">
to be visible, with
<label for="d2">
in front of it, but invisible. I can
do this by adding an additional selector to each of the rulesets that
set both display
and opacity
.
#d1:checked ~ [for=d1],
#d0:checked ~ [for=d0] {
display: block;
opacity: 1;
}
#d1:checked ~ [for=d2],
#d0:checked ~ [for=d1] {
display: block;
opacity: 0;
}
Indeed, I can apply the same logic all the way up to 9.
label {
position: absolute;
z-index: 1;
display: none;
}
#d9:checked ~ [for=d9],
#d8:checked ~ [for=d8],
#d7:checked ~ [for=d7],
#d6:checked ~ [for=d6],
#d5:checked ~ [for=d5],
#d4:checked ~ [for=d4],
#d3:checked ~ [for=d3],
#d2:checked ~ [for=d2],
#d1:checked ~ [for=d1],
#d0:checked ~ [for=d0] {
display: block;
opacity: 1;
}
#d9:checked ~ [for=d0],
#d8:checked ~ [for=d9],
#d7:checked ~ [for=d8],
#d6:checked ~ [for=d7],
#d5:checked ~ [for=d6],
#d4:checked ~ [for=d5],
#d3:checked ~ [for=d4],
#d2:checked ~ [for=d3],
#d1:checked ~ [for=d2],
#d0:checked ~ [for=d1] {
display: block;
opacity: 0.1;
}
[for=d0] {
z-index: 0;
}
When I click on a digit, the next digit becomes fully opaque, and shows the label for its next digit very transparently in front of it.
This works until I click on the 8
and the label for
9
becomes fully opaque. But there is no sign of a
semi-transparent 0
.
I can follow what is happening in the Developer Tools Inspector:

When I get to 9
, I want the next click to apply to the
<label for="d0">
. In the Inspector, this label
appears to be in a higher layer than the
<label for="d9">
, but just now, I set the
z-index
of every label except the
<label for="d0">
to 1
, so that I could
click on the <label for="d1">
instead. Now I’ll need
one last rule to fix this for this one specific case:
#d9:checked ~ [for=d0] {
z-index: 1
}
Try it! You should be able to cycle through all the numbers.
Detecting which number is currently checkd
This system has some advantages. At any time, I can use a selector
which contains (say) #d3:checked
, to check if the player is
currently looking at a 3
. This makes it easy to apply other
rules to other elements when you select the answer that I want you to
select.
Plus and minus
A major disadvantage is that you must click on the current number itself if you want it to increase. This is not an intuitive action.
It would be better to have a specific button that says
+
. And if you add a +
button, why not add a
–
button, too?
But, wait a minute… How can you make two buttons that work in opposite directions?
A Label with Two Wings
The task now is to create buttons Plus and Minus buttons that allow you to cycle through all the numbers.
Keeping it small
Whenever I am working in a big project and I come across a new problem, I create a new baby project to test my ideas and make all the mistakes I need to find a solution to this new problem. When I finally understand what I am doing, then I go back to the big project and apply my new solution.
Treating a new problem in isolation frees up your mind and makes it much easier to think in new ways.
So. New problem. New HTML and CSS files. You know the drill. “Plus and Minus” might be a good name for your project folder.
Preview
Here’s how the page you are about to create should work, but you can just create a proof-of-concept which doesn’t have all the pretty styling.
Here’s some HTML to get you started.
<label class="d0">
<span class="minus">–</span>
<input type="radio" name="d" id="d0" checked>
<span class="plus">+</span>
</label>
<label class="d1">
<span class="minus">–</span>
<input type="radio" name="d" id="d1">
<span class="plus">+</span>
</label>
<label class="d2">
<span class="minus">–</span>
<input type="radio" name="d" id="d2">
<span class="plus">+</span>
</label>
<!-- Something is missing here -->
<span class="display"></span>
Reading the code
Take a good look at it and see if you can make sense of all the labels, inputs and spans. Why does each label have two spans? Can you see how this will generate something that looks a little like the Preview above?
Here’s how the HTML looks in a browser.

This short extract only gives you three labels. They have radio
buttons with the id
s d0
, d1
and
d9
, but this is enough for you to check if this creates
some kind of cycle. You can add more labels with the same structure
later.
Each label has two spans: one with the class "minus"
,
one with the class "plus"
.
Showing the right spans
Suppose <input id="d1">
is checked.
Can you write a CSS rule that will show only:
- The
.minus
span for inside the<label class="d0">
- The
.plus
span for inside the<label class="d2">
?
Note that if any child of <label class="dX">
is
clicked, the associated <input id="dX">
will become
checked.
Solution
Here is the strict minimum that you need:
label span {
display: none;
}
body:has(#d1:checked) {
.d0 span.minus { display: block }
.d2 span.plus { display: block }
}
**Note that I have used label span
for the first
selector, and not just span
. because I don’t want to hide
span.display
.
This is what it will look like if you check the checkbox for
#d1
:

If you click either the –
or the +, a different checkbox
will be checked, and the –
and the + will disappear.
Not just cosmetic
I’m going to suggest, as a hint that you can use later, that
you make the -
and +
elements bigger.
span {
--size: 64px;
font-size: var(--size);
}
Showing the value
The HTML I gave you includes this:
<span class="display"></span>
, but nothing is
displayed in this span yet. I think you can guess that it’s meant to
display a digit.
Perhaps, if you think about it, this digit could a
counter
used in a content
declaration?
You could add a line to the ruleset for the #d1:checked
button, and a new rule for the span.display
:
body:has(#d1:checked) {
counter-set: digit 1;
.d0 span.m { display: block }
.d2 span.p { display: block }
}
span {
--size: 64px;
font-size: var(--size);
}
span.display::after {
content: counter(digit)
}
This would work. It would definitely work. But there’s a problem.
CSS won’t let you read the value of a counter
,
except to display it in a ::before
or ::after
element. So if you want to use the value in actual calculations, you’ll
need to think of something else.
A different solution?
Is there a different CSS property that you could set… and then display the value of this different property?
Hint: how did I set the size of the spans that show the
–
and +
buttons?
How about this:
body {
/* Declare a custom property and use it to set a counter */
--counter: 0;
counter-set: digit var(--counter);
}
label span {
display: none;
}
body:has(#d1:checked) {
/* Set the custom property to a new value */
--counter: 1;
.d0 span.minus { display: block }
.d2 span.plus { display: block }
}
Custom properties have the advantage that they can be read from any child of the element in which they are declared. They can even be read by JavaScript. Try this in the Developer Console:
getComputedStyle(document.body).getPropertyValue("--counter")

JavaScript in custom CSS properties
And if you are really sneaky, you can even store JavaScript code inside a custom CSS property. The official specifications for Custom Property Value Syntax explicitly requires browsers to make this possible. ( See “EXAMPLE 7”)
The trick
You can’t show the value of a custom property directly, but
if it is a number, then you can use it with counter-set
,
and then get a counter
to display it for you. You
can’t read the value of a counter
, but you can read the
value of a custom property. Win-win.
Repeating a Pattern
You want to be able to cycle through the numbers 0
to
9
, but for now, you can only show the number 1
(if the right radio button is checked). You need more labels with spans
and radio buttons in your HTML page.
Apply the pattern
For brevity, I’m not going to list them all. You should be able to see the pattern and the missing labels.
<label class="d0">
<span class="minus">–</span>
<input type="radio" name="d" id="d0" checked>
<span class="plus">+</span>
</label>
<label class="d1">
<span class="minus">–</span>
<input type="radio" name="d" id="d1">
<span class="plus">+</span>
</label>
<label class="d2">
<span class="minus">–</span>
<input type="radio" name="d" id="d2">
<span class="plus">+</span>
</label>
<!-- Add a label for 3 with the appropriate class and id -->
<label class="d3">
<span class="minus">–</span>
<input type="radio" name="d" id="d3">
<span class="plus">+</span>
</label>
<!-- Continue the sequence up to 9 (4-8 are skipped here) -->
<label class="d9">
<span class="minus">–</span>
<input type="radio" name="d" id="d9">
<span class="plus">+</span>
</label>
<span class="display"></span>
More CSS
You’l also need more CSS rules, each with a similar pattern.
Cheating
In the CSS below, I’ve cheated to make the .d3
label
jump directly to .d9
, to match the HTML entries above and
to keep the code short.
body {
--counter: 0;
counter-set: digit var(--counter);
}
label span {
display: none;
}
body:has(#d0:checked) {
--counter: 0;
.d9 span.minus { display: block }
/* Notice that the "minus" span belongs to 9 */
.d1 span.plus { display: block }
}
body:has(#d1:checked) {
--counter: 1;
.d0 span.minus { display: block }
.d2 span.plus { display: block }
}
body:has(#d2:checked) {
--counter: 2;
.d1 span.minus { display: block }
.d3 span.plus { display: block }
}
body:has(#d3:checked) {
--counter: 3;
.d2 span.minus { display: block }
/* I've cheated: I used .d9 instead of .d4 */
.d9 span.plus { display: block }
}
/* And so on, up to 9 ... */
body:has(#d9:checked) {
--counter: 9;
.d3 span.minus { display: block } /* <<< .d3/.d8 cheat */
/* Notice that the "plus" span belongs to 0 */
.d0 span.plus { display: block }
}
span {
--size: 64px;
font-size: var(--size);
}
span.display::after {
content: counter(digit);
}
I suggest that you add the following cosmetic rulesets, so the
-
and +
spans don’t keep changing places.
span {
position: absolute;
top: 30px;
}
span:first-child {
left: 10px;
}
span:last-child {
left: 60px;
}
Make these changes to your project, and then check if the plus and minus buttons work as expected.
Cycling from 9 to 0
Did you notice that the “plus” for 9 and the “minus” for 0 are different? That’s what makes the system cycle.
No repeat loops
This exercise demonstrates one of the frustrations of writing CSS-only activities. There are no repeat loops or Boolean logic in CSS. As a result, every pattern has to be written out methodically, chunk by chunk, and every variant on the pattern needs to be manually edited.
If you are familiar with SASS, you can use its flow control features to generate patterns in CSS. You can use JavaScript in NodeJS to generate corresponding patterns for HTML.
Nonetheless, in many cases, your CSS-only activities will require many many lines of code, most of which you will have to write by hand.
Next step: making a calculator?
The technique that I have described here is exactly the same as the one I used to create the Arithmetic in Pure CSS activity that you tried out at the beginning.
It’s true that the span “buttons” are a little more discreet there.
There’s also a little calculation that is done with the values of the
custom CSS properties that hold the digit values, and a little trick to
show NaN
if you try to divide by 0
. But you
should be able to reverse engineer everything that I did there… and if
you get stuck you can always visit the GitHub repo and see all the CSS
for yourself.
Incrementing with :hover
Your browser generates a :hover
pseudo-class for every
element that is currently under the pointer. If the pointer is a mouse,
then there will always be a :hover
pseudo-class created
somewhere while the mouse is over the page.
If the pointer is a finger or a stylus on a touch screen, than a
:hover
pseudo-class is created when there is the first
actual contact with the screen… and then (on Chrome for Android, at
least), the :hover
pseudo-class remains, even though no-one
is touching the screen.
Here’s the simplest “game” I could think of that illustrates my point, inspired by “The Floor is Lava”. On a computer screen, you have to move your mouse as the green safe space gets smaller and floats away. I haven’t found any way to check that touchscreen players doesn’t just simply lift their fingers and uncheck the checkbox. And yes: I’ve tried. If you can find a solution, please, send me a link to your articles so that I can learn from you.
In other words: if your game is intended for playing on touchscreens,
using :hover
as a major feature of your game is likely to
cause you headaches. If you use it for inessential features, then you
can have fun with it.
Also, even in a game with a mouse, a custom :hover
feature will stop working as soon as the mouse moves over a button or
other element that is not a child of the element whose
:hover
you are tracking. It can be tricky to get buttons
and :hover
to work together… but it can be tricky
in a good way.
So, with those warnings out of the way, would you like to have some
fun with :hover
… and with counters?
For the last time (in this article at least), I’ll ask you to create a new HTML file with CSS file linked to it. Here’s what you’ll be making (except that yours will have a white background.):
Here’s some HTML…
<div title="one">
<div title="two">
<div title="three">
<div title="four"></div>
</div>
</div>
</div>
… and some CSS:
div {
--size: 15vmin;
--line: 1vmin;
--delta: calc(var(--size) + var(--line) * 2);
position: relative;
top: var(--delta);
left: var(--delta);
width: var(--size);
height: var(--size);
border: var(--line) solid grey;
&:hover {
color: red;
border-color: orange;
counter-increment: divs;
}
&::after {
content: attr(title) " " counter(divs);
}
}
Open your page in your browser and move your mouse over the different
<div>
s:

The nested <div>
elements are clearly not
overlapping. In my screenshot above, the mouse is clearly over
div.three
. And yet three of the <div>
elements have an orange border. This must have been caused by the
rule:
&:hover {
color: red;
border-color: orange;
counter-increment: divs;
}
And the text of all of the elements is red. And yes, the CSS
elves are busy with some counter-increment
activity as
well. There is no counter: divs 0
declaration, but they
have understood that they need to behave as if the declaration were
there.
As you move your mouse around, the elements which are given a
:hover
pseudo-class will change. On a touchscreen, you can
tap different parts of the screen and see how the :hover
effect remains even after you remove your finger.
Where does ::after go?
Notice that the ::after
pseudo-element for the first
three <div>
s is shown below the
<div>
, but the ::after
pseudo-element
for the fourth <div>
appears inside it.
That’s because the first three have children. The ::after
pseudo-element is shown after the children… or rather, after
where the children would be if they had not been given a different
relative
position
.
The border of the <div>
under the mouse and all
its parents goes orange, because hovering, like the credit for a child’s
achievements, is claimed by its parents, all the way back to the first
generation.
The text of all the elements goes red when any one of them is under
the mouse, because color
, like eye-colour and debt for
humans, is inherited from an element’s parents.
How The Map Works
You’ve already seen the Map activity. Now that you have an
understanding of how CSS counters and @counter-style
works,
you can make sense of the tricks I have used.
Counting nesting depth
The Map uses counters to detect how deeply nested the element immediately under the touch point is.
The relief pattern for the hill and the sea is created by nested
<div>
elements:
<div class="hill-1 contour">
<div class="hill-2">
<div class="hill-3">
<div class="hill-4">
<div class="hill-5"></div>
</div>
</div>
</div>
</div>
<div class="sea-1 contour">
<div class="sea-2">
<div class="sea-3">
<div class="sea-4">
<div class="sea-5"></div>
</div>
</div>
</div>
</div>
Suppose the mouse is over <div class="hill-4">
. As
you saw in the last section, four of the div
s with a class
whose name begins with hill
will have a :hover
pseudo-class: div class="hill-4">
itself and all its
parents.
@counter-style for height or depth
I’ve created an @counter-style
at-rule to display a
string that represents a height or a depth for each of its values:
@counter-style relief {
symbols: "100m" "200m" "300m" "400m" "500m";
}
I increment a relief
counter, for each div
s
with a class whose name begins with hill
which has a
:hover
class.
div[class|=hill]:hover {
counter-increment: relief;
}
counter and @counter-style with the same name
The CSS elves are smart enough to distinguish between a
counter
and an @counter-style
that have the
same name, so it makes sense to use the same name for both, to indicate
that they work together.
And I use a ::before
pseudo-element on the
<p>
element centred at the top of the page to show
the height. But I hide this ::before
element…
p.relief::before {
content: "Height: " counter(relief, relief);
display: none;
}
…unless the lowest div.hill-1
element has
:hover
applied to it:
.hill-1:hover ~ p.relief::before {
display: inline;
}
Dealing with the grid
The grid pattern is even more complex: ten vertical rectangular
<div>
s, one for each of the columns, all nested
inside each other, and inside each column.
In this composite image below, you’ll see how the mouse is hovering
over a <div class="row-3">
, which is a child of all
the following:
<div class="row-2">
<div class="row-1">
<div class="col-2">
<div class="col-1">

In the CSS, a counter called col
is incremented for each
div
whose class name begins with col
…
div[class^=col]:hover {
counter-increment: col;
}
… and a counter called row
is incremented for each
div
whose class name begins with row
…
div[class^=row]:hover {
counter-increment: row;
}
Each div
whose class name begins with row
also has its own ::before
element, whose
content
is determined by the col
and
row
counters. However, these ::before
elements
acquired not shown…
div[class^=row]::before {
content: counter(col, upper-alpha) counter(row);
position: absolute;
width: 30px;
line-height: 30px;
text-align: center;
display: none;
}
… except for the div
which is immediately under
the mouse:
/* Show a label for the hovered cell... */
div[class^=row]:hover::before {
display: inline-block;
}
/* ...but not for all the cells in the parent rows */
div[class^=row]:has(:hover)::before {
display: none;
}
The Heist
So here’s a challenge that I set for myself: How much can I do using
just :hover
as the trigger? How much can I do using no
checkboxes, no radio buttons, no details elements.
Here’s what I came up with:
Each action is performed in a nested <div>
and the
nesting gets deeper as the story progresses. As a result, the innermost
element with a :hover
pseudo-class maintains a :hover
pseudo-class on all its parents. In this way, any changes made at one
level continue to apply to the inner levels.
However, if you move your mouse off the animation, it will reset and the story will start over from the beginning.
On touchscreen devices, the innermost element with a :hover
pseudo-class retains this class even after the contact with the
touchscreen ceases. If a child <div>
is activated by
a touch on its parent, the child <div>
does not
immediately receive a :hover pseudo-class. (With a mouse, the child
<div>
will receive its pseudo-class as soon as the
mouse moves, if not sooner.)
The numbered instructions at the top are set using a
counter
for the number of <div>
which
have a :hover
pseudo-class. The text is set by an
@counter-style
at-rule.
Click on the GitHub octocat icon to visit the GitHub repository.
Enjoy!
Complete a Game by Adding Counters
I wrote at the beginning of this article that I would not be showing you, step by step, how to build a particular game. However, I do want to give you the chance to test whether my explanations have made good sense to you.
I’ve created a pure CSS game that uses the techniques that I have described above. You can find the GitHub Repository here.
Adding counters
If you clone the repository and launch the game on your own computer, you will find that the scoring and timing system is missing. Using the knowledge that you have just acquired, can you get your local version of the game to behave the same as the online version?
The logic of the game (starting and stopping it, picking up the gold
tokens, showing the result when you reach the end) is already written.
You’ll find the CSS for this in a file called styles.css
.
You should not need to change anything in this file or in the
index.html
file.
There is a second CSS file attached to the HTML page. It’s called
counters.css
, and it currently contains no CSS at all. You
should now be able to write CSS of your own that:
- Shows the score
- Shows an emoji that gets happier as the score increases
- Shows how many seconds have passed since the player clicked on the green Start button
- Stops the timer when the game is over.
And of course, you can use this as the starting point for your own remixed version of the game. I’m sure that, artistically, your CSS power does exceed my own…
What I Hope You Know Now
If you have worked your way through all the explanations and the exercises up to here then you should have a clearer idea about:
- Using checkboxes and radio buttons to store state
- Using animations to change state
- Using
::before
and::after
pseudo-elements - Using
z-index
to reorder elements - Using the
:hover
pseudo-class to intercept the actions of the mouse - Writing selectors that control the logic
- Using custom CSS properties
Specifically, you should have a deeper understanding about how you
can use and abuse CSS counter
s,
@counter-style
s, list-style-type
and styling
the ::marker
pseudo-element for lists.
You can apply this new knowledge not just to creating CSS-only activities, but to the CSS that you use in any project.
Thinking outside the box
More importantly, I hope that you have picked up some ideas on how to explore a topic, how to test its limits, how to take it in new directions.
I’ve suggested that you have fun making mistakes and testing unorthodox approaches. Be sure, though, to test in all the major browsers as you go, so that you do not create something that only works in one browser when the stars are correctly aligned.
Tell me about your own creations
I’m hoping that you will be inspired to create some CSS-only activities of your own. If you do, please send me a link to your creations, so that I can learn from you.
Introduction
CSS is not a programming language. Not officially. But it is possible to create games with only HTML and CSS. And there are even some tutorials on how to make a specific game: a click ’em all, a jigsaw, a maze, and maybe more that I have not found.
My aim here is different. In this series of articles, I want to get you to experiment with the different techniques that you can use to create your own games. By the end of each article, you won’t have created a game, but you will have understood why some things work and some things don’t.
In this article, you’ll be looking at the following ideas:
- Using checkboxes and radio buttons to store state
- Using animations to change state
- Using
::before
and::after
pseudo-elements - Using
z-index
to reorder elements - Using the
:hover
pseudo-class to intercept the actions of the mouse - Writing selectors that control the logic
- Using custom CSS properties
- Working with counters
My main focus here will be on the last two ideas: understanding how CSS custom properties and counters work together. You need to use counters, after all, to number things: to show the score or to display a timer, for example. These are simple ways to add urgency and excitement to a game. And if you think laterally, you can also get counters to do some quite unexpected things for you, as you will see.
I won’t treat the other seven ideas is such great depth here. I’ll be writing other articles about them specifically.