Experiments are a fun excuse to learn the latest tricks, think of new ideas, and push your limits. "Pure CSS" demos have been a thing for a while, but new opportunities open up as browsers and CSS itself evolves. CSS and HTML preprocessors also helped the scene move forward. Sometimes preprocessors are used for hardcoding every possible scenario, for example, long strings of
:checked and adjacent sibling selectors.
In this article, I will walk through the key ideas of a Pure CSS Connect 4 game I built. I tried to avoid hardcoding as much as I could in my experiment and worked without preprocessors to focus on keeping the resulting code short. You can see all the code and the game right here:
I think there are some concepts that are considered essential in the "pure CSS" genre. Typically form elements are used for managing state and capturing user actions. I was excited when I found people use
<button type="reset"> to reset or start a new game. All you have to do is wrap your elements in a
<form> tag and add the button. In my opinion this is a much cleaner solution than having to refresh the page.
My first step was to create a form element then throw a bunch of inputs into it for the slots and add the reset button. Here is a very basic demonstration of
<button type="reset"> in action:
I wanted to have nice visual for this demo to provide a full experience. Instead of pulling in an external image for the board or the discs, I used a
radial-gradient(). A nice resource I often use is Lea Verou's CSS3 Patterns Gallery. It is a collection of patterns made by gradients, and they're editable too! I used
currentcolor, which came pretty handy for the disc pattern. I added a header and reused my Pure CSS Ripple Button.
Dropping discs onto the board
Next I enabled users to take their turns dropping discs onto the Connect 4 board. In Connect 4, players (one red and one yellow) drop discs into columns in alternating turns. There are 7 columns and 6 rows (42 slots). Each slot can be empty or occupied by a red or yellow disc. So, a slot can have three states (empty, red, or yellow). Discs dropped in the same column are stacked onto each other.
I started out by placing two checkboxes for each slot. When they're both unchecked the slot is considered empty, and when one of them is checked the corresponding player has its disc in it.
The possible state of having them both checked should be avoided by hiding them once either of them is checked. These checkboxes are immediate siblings, so when the first of a pair is checked you can hide both by using
:checked pseudo-class and the adjacent sibling combinator (
+). What if the second is checked? You can hide the second one, but how to affect the first one? Well, there is no previous sibling selector, that's just not how CSS selectors work. I had to reject this idea.
Actually, a checkbox can have three states by itself, it can be in the
indeterminate state. The problem is that you can't put it into indeterminate state with HTML alone. Even if you could, the next click on the checkbox would always make it transform into checked state. Forcing the second player to double-click when they make their move is unreliable and unacceptable.
I was stuck on the MDN doc of
:indeterminate and noticed that radio inputs also have indeterminate state. Radio buttons with the same name are in this state when they're all unchecked. Wow, that's an actual initial state! What's really beneficial is that checking the latter sibling also has an effect on the former one! Thus I filled the board with 42 pairs of radio inputs.
In retrospect, clever ordering and usage of labels with either checkboxes or radio buttons would have made the trick, but I didn't consider labels to be an option to keep the code simpler and shorter.
I wanted to have large areas for interaction to have nice UX, so I thought it's reasonable to let players make a move by clicking on a column. I stacked controls of the same column on each other by adding absolute and relative positioning to the appropriate elements. This way only the lowest empty slot could be selected within a column. I meticulously set the time of transition of disc fall per row and their timing function is approximating a quadratic curve to resemble realistic free fall. So far the pieces of the puzzle came well together, though the animation below clearly shows that only the red player could make their moves.
The clickable areas of radio inputs are visualized with colored but semi-transparent rectangles. The yellow and red inputs are stacked over each other six times(=six rows) per column, leaving the red input of the lowest row on top of the stack. The mixture of red and yellow creates the orangish color which can be seen on the board at start. The less empty slots are available in a column, the less intense this orangish color gets since the radio inputs are not displayed once they are not
:indeterminate. Due to the red input always being precisely over the yellow input in every slot, only the red player is able to make moves.
I only had a faint idea and a lot of hope that I can somehow solve switching turns between the two players with the general sibling selector. The concept was to let the red player take turn when the number of checked inputs was even (0, 2, 4, etc.) and let the yellow player take turn when that number was odd. Soon I realized that the general sibling selector does not (and should not!) work the way I wanted.
Then a very obvious choice was to experiment with the nth selectors. However attracting it was to use the
odd keywords, I ran into a dead end. The :nth-child selector "counts" the children within a parent, regardless of type, class, pseudo-class, whatever. The :nth-of-type selector "counts" children of a type within a parent, regardless of class or pseudo-class. So the problem is that they cannot count based on the
Well CSS counters count too, so why not give them a try? A common usage of counters is to number headings (even in multiple levels) in a document. They are controlled by CSS rules, can be arbitrarily reset at any point and their increment (or decrement!) values can be any integer. The counters are displayed by the
counter() function in the
The easiest step was to set up a counter and count the
:checked inputs in the Connect 4 grid. There are only two difficulties with this approach. The first is you cannot perform arithmetics on a counter to detect if its is even or odd. The second is that you cannot apply CSS rules to elements based on the counter value.
I managed to overcome the first issue by making the counter binary. The value of the counter is initially zero. When the red player checks their radio button the counter is incremented by one. When the yellow player checks their radio button the counter is decremented by one, and so on. Therefore the counter value will be either zero or one, even or odd.
Solving the second problem required much more creativity (read: hack). As mentioned counters can be displayed, but only in the
::after pseudo-elements. That is a no-brainer, but how can they affect other elements? At the very least the counter value can change the width of the pseudo-element. Different numbers have different widths. Character
1 is typically thinner than
0, but that is something very hard to control. If the number of characters change rather than the character itself the resulting width change is more controllable. It is not uncommon to use Roman numerals with CSS counters. One and two represented in Roman numerals are the same character once and twice and so are their widths in pixels.
My idea was to attach the radio buttons of one player (yellow) to the left, and attach the radio buttons of the other player (red) to the right of their shared parent container. Initially, the red buttons are overlaid on the yellow buttons, then the width change of the container would cause the red buttons to "go away" and reveal the yellow buttons. A similar real-world concept is the sliding window with two panes, one pane is fixed (yellow buttons), the other is slidable (red buttons) over the other. The difference is that in the game only half of the window is visible.
So far, so good, but I still wasn't satisfied with
font-size (and the other
font properties) indirectly controlling the width. I thought
letter-spacing would fit nicely here, since it only increases the size in one dimension. Unexpectedly, even one letter has letter spacing (which is rendered after the letter), and two letters render the letter spacing twice. Predictable widths are crucial to make this reliable. Zero width characters along with single and double letter spacing would work, but it is dangerous to set the
font-size to zero. Defining large
letter-spacing (in pixels) and tiny (
font-size made it almost consistent across all browsers, yes I'm talking about sub-pixels.
I needed the container width to alternate between initial size (
=w) and at least double the initial size (
>=2w) to be able to fully hide and show the yellow buttons. Let's say
v is the rendered width of the 'i' character (lower roman representation, varies across browsers), and
c is the rendered width (constant) of the
letter-spacing. I needed
v + c = w to be true but it couldn't be, because
w are integers but
v is non-integer. I ended up using
max-width properties to constrain the possible width values, so I also changed the possible counter values to 'i' and 'iii' to make sure the text widths underflow and overflow the constraints. In equations this looked like
v + c < w,
3v + 3c > 2w, and
v << c, which gives
2/3w < c < w. The conclusion is that the
letter-spacing has to be somewhat smaller than the initial width.
I have been reasoning so far as if the pseudo element displaying the counter value was the parent of the radio buttons, it is not. However, I noticed that the width of the pseudo-element changes the width of its parent element, and in this case the parent is the container of the radio buttons.
If you are thinking couldn't this be solved with Arabic numerals? You are right, alternating the counter value between something like '1' and '111' would also work. Nevertheless, Roman numerals gave me the idea in the first place, and they were also a good excuse for the clickbaity title so I kept them.
Applying the technique discussed makes the parent container of the radio inputs double in width when a red input is checked and makes it original width when a yellow input is checked. In the original width container the red inputs are over the yellow ones, but in the double width container, the red inputs are moved away.
In real life, the Connect 4 board does not tell you if you have won or lost, but providing proper feedback is part of good user experience in any software. The next objective is to detect whether a player has won the game. To win the game a player has to have four of their discs in a column, row or diagonal line. This is a very simple task to solve in many programming languages, but in pure CSS world, this is a huge challenge. Breaking it down to subtasks is the way to approach this systematically.
I used a flex container as the parent of the radio buttons and discs. A yellow radio button, a red radio button and a div for the disc belong to a slot. Such a slot is repeated 42 times and arranged in columns that wrap. Consequently, the slots in a column are adjacent, which makes recognizing four in a column the easiest part using the adjacent selector:
<div class="grid"> <input type="radio" name="slot11"> <input type="radio" name="slot11"> <div class="disc"></div> <input type="radio" name="slot12"> <input type="radio" name="slot12"> <div class="disc"></div> ... <input type="radio" name="slot16"> <input type="radio" name="slot16"> <div class="disc"></div> <input type="radio" name="slot21"> <input type="radio" name="slot21"> <div class="disc"></div> ... </div>
/* Red four in a column selector */ input:checked + .disc + input + input:checked + .disc + input + input:checked + .disc + input + input:checked ~ .outcome /* Yellow four in a column selector */ input:checked + input + .disc + input:checked + input + .disc + input:checked + input + .disc + input:checked ~ .outcome
This is a simple but ugly solution. There are 11 type and class selectors chained together per player to cover the case of four in a column. Adding a
div with class of
.outcome after the elements of the slots makes it possible to conditionally display the outcome message. There is also a problem with falsely detecting four in a column where the column is wrapped, but let's just put this issue aside.
A similar approach for detecting four in a row would be truly a terrible idea. There would be 56 selectors chained together per player (if I did the math right), not to mention that they would have a similar flaw of false detection. This is a situation where the :nth-child(An+B [of S]) or the column combinators will come handy in the future.
For better semantics one could add a new
div for each column and arrange the slot elements in them. This modification would also eliminate the possibility of false detection mentioned above. Then detecting four in a row could go like: select a column where the first red radio input is checked, and select the adjacent sibling column where the first red radio input is checked, and so on two more times. This sounds very cumbersome and would require the "parent" selector.
Selecting the parent is not feasible, but selecting the child is. How would detecting four in a row go with available combinators and selectors? Select a column, then select its first red radio input if checked, and select the adjacent column, then select its first red radio input if checked, and so on two more times. It still sounds cumbersome, yet possible. The trick is not only in the CSS but also in the HTML, the next column has to be the sibling of the radio buttons in the previous column creating a nested structure.
<div class="grid column"> <input type="radio" name="slot11"> <input type="radio" name="slot11"> <div class="disc"></div> ... <input type="radio" name="slot16"> <input type="radio" name="slot16"> <div class="disc"></div> <div class="column"> <input type="radio" name="slot21"> <input type="radio" name="slot21"> <div class="disc"></div> ... <input type="radio" name="slot26"> <input type="radio" name="slot26"> <div class="disc"></div> <div class="column"> ... </div> </div> </div>
/* Red four in a row selectors */ input:nth-of-type(2):checked ~ .column > input:nth-of-type(2):checked ~ .column > input:nth-of-type(2):checked ~ .column > input:nth-of-type(2):checked ~ .column::after, input:nth-of-type(4):checked ~ .column > input:nth-of-type(4):checked ~ .column > input:nth-of-type(4):checked ~ .column > input:nth-of-type(4):checked ~ .column::after, ... input:nth-of-type(12):checked ~ .column > input:nth-of-type(12):checked ~ .column > input:nth-of-type(12):checked ~ .column > input:nth-of-type(12):checked ~ .column::after
Well the semantics are messed up and these selectors are only for the red player (another round goes for the yellow player), on the other hand it does work. A little benefit is that there will be no falsely detected columns or rows. The display mechanism of the outcome also had to be modified, using the
::after pseudo element of any matching column is a consistent solution when proper styling is applied. As a result of this, a fake eighth column has to be added after the last slot.
As seen in the code snippet above, specific positions within a column are matched to detect four in a row. The very same technique can be used for detecting four in a diagonal by adjusting these positions. Note that the diagonals can are in two directions.
input:nth-of-type(2):checked ~ .column > input:nth-of-type(4):checked ~ .column > input:nth-of-type(6):checked ~ .column > input:nth-of-type(8):checked ~ .column::after, input:nth-of-type(4):checked ~ .column > input:nth-of-type(6):checked ~ .column > input:nth-of-type(8):checked ~ .column > input:nth-of-type(10):checked ~ .column::after, ... input:nth-of-type(12):checked ~ .column > input:nth-of-type(10):checked ~ .column > input:nth-of-type(8):checked ~ .column > input:nth-of-type(6):checked ~ .column::after
The number of selectors have increased vastly in the final run, and this is definitely a place where CSS preprocessors could reduce the length of the declaration. Still, I think the demo is moderately short. It should be somewhere around the middle on the scale from hardcoding a selector for every possible winning pattern to using 4 magical selectors (column, row, two diagonals).
Any software has edge cases and they need to be handled. The possible outcomes of a Connect 4 game are not only the red, or yellow player winning, but neither player winning filling the board known as draw. Technically this case doesn't break the game or produce any errors, what's missing is the feedback to the players.
The goal is to detect when there are 42
:checked radio buttons on the board. This also means that none of them are in the
:indeterminate state. That is requiring a selection to be made for each radio group. Radio buttons are invalid, when they are
:indeterminate, otherwise they are valid. So I added the
required attribute for each input, then used the
:valid pseudo-class on the form to detect draw.
Covering the draw outcome introduced a bug. In the very rare case of the yellow player winning on last turn, both the win and draw messages are displayed. This is because the detection and display method of these outcomes are orthogonal. I worked around the issue by making sure that the win message has a white background and is over the draw message. I also had to delay the fade in transition of the draw message, so it would not get blended with the win message transition.
While a lot of radio buttons are hid behind each other by absolute positioning, all of those in indeterminate state can still be accessed by tabbing through the controls. This enables players to drop theirs discs into arbitrary slots. A way to handle this is to simply forbid keyboard interactions by the
tabindex attribute: setting it to
-1 means that it should not be reachable via sequential keyboard navigation. I had to augment every radio input with this attribute to eliminate this loophole.
<input type="radio" name="slot11" tabindex="-1" required> <input type="radio" name="slot11" tabindex="-1" required> <div class="disc"></div> ...
The most substantial drawback is that the board isn't responsive and it might malfunction on small viewports due to the unreliable solution of tracking turns. I didn't dare to take the risk of refactoring to a responsive solution, due to the nature of the implementation it feels much safer with hardcoded dimensions.
Another issue is the sticky hover on touch devices. Adding some interaction media queries to the right places is the easiest way to cure this, though it would eliminate the free fall animation.
One might think that the
:indeterminate pseudo-class is already widely supported, and it is. The problem is that it is only partially supported in some browsers. Observe Note 1 in the compatibility table: MS IE and Edge do not support it on radio buttons. If you view the demo in those browsers your cursor will turn into the
not-allowed cursor on the board, this is an unintentional but somewhat graceful degradation.
Thanks for making it to the last section! Let's see some numbers:
- 140 HTML elements
- 350 (reasonable) lines of CSS
- 0 external resources
Overall, I'm satisfied with the result and the feedback was great. I sure learned a lot making this demo and I hope I could share a lot writing this article!