State Machines Without JavaScript
State machines usually live in JavaScript, often via libraries like XState. But the pattern can be implemented with nothing but HTML and CSS, which proved useful on a recent project with a strict “no client JavaScript” requirement that still needed one interactive feature.
The mechanism relies on <form> elements and <input type="radio"> to hold state, with other radio inputs or reset buttons—placed anywhere on the page—changing that state. The CSS :checked selector does the visual work, similar to the Checkbox Hack but with more logical complexity. A templating language like Nunjucks keeps the approach manageable and configurable.
A Basic Example: The Traffic Light
Every state machine discussion needs the classic traffic light. Here’s how one works with this technique: clicking “Next” advances the light through its states, all without a line of JavaScript.
Practical Use: Table View Toggling
Traffic lights aren't a daily need. A more relevant case is toggling between two views of a <table>. Two states (A and B) can be changed from two different points in the page, and those changes affect multiple elements across the layout. By placing the empty <form> and state-holding <input> elements at the top of the markup, their state can be targeted with general sibling selectors and then affect descendants elsewhere. This loose coupling means almost anything on the page can change from anywhere else.
The General Four-State Component

This setup defines “page state” as the desired visible state, while “machine state” is the internal controller state. A generic four-state machine (A, B, C, D) is built from three “radio reset controller” blocks.

The machine states are represented as a string of three bits (e.g., M001 or M101), one per radio button. Each button defaults to checked. Moving from M111 to M011, for instance, means clearing one radio by clicking another input in the same radio group. To go back, a form reset button restores the default checked input. While the controller has eight internal states, transitions are limited: there's no direct M111-to-M100 jump because that flips two bits at once. By assigning two internal states per page state (A uses M111 and M000), you get a single-step path from any page state to any other.
Making It Reusable
For portability, the component is written as Nunjucks macros. Four pieces are required:
- Controller
- CSS logic
- Transition controls
- State classes
The Controller Markup
The controller consists of three empty <form> tags and three hidden radio buttons (display: none). Each radio has a different name and defaults to checked. These inputs, placed at the top of the page, are not directly manipulated.
{% macro FSM4S_controller()%}
<form id="rrc-form-Bx00"></form>
<form id="rrc-form-B0x0"></form>
<form id="rrc-form-B00x"></form>
<input form="rrc-form-Bx00" style="display:none" type="radio" name="rrc-Bx00" checked="checked" />
<input form="rrc-form-B0x0" style="display:none" type="radio" name="rrc-B0x0" checked="checked" />
<input form="rrc-form-B00x" style="display:none" type="radio" name="rrc-B00x" checked="checked" />
{% endmacro %}
CSS Transition Logic
The connection between controller state and page content is entirely CSS. It checks for the :checked state of each controller radio and hides any descendant with a corresponding class like .M000. The display: none !important rule hides elements during inactive states; the !important is optional but prevents other CSS from overriding the visibility toggle.
{%macro FSM4S_css()%}
<style>
/* Hide M000 (A1) */
input[data-rrc="Bx00"]:not(:checked)~input[data-rrc="B0x0"]:not(:checked)~input[data-rrc="B00x"]:not(:checked)~* .M000 {
display: none !important;
}
/* one section for each of 8 Machine States */
</style>
{%endmacro%}
User-Facing Controls
To advance a state, the user clicks a visible radio button tied to the same form and radio group as the relevant controller bit. To reset that bit, they click the form’s reset button. Each control is rendered conditionally—only the controls for currently active states are shown or enabled. Transition macros can be placed anywhere in the HTML, and any control belonging to an inactive state is hidden.
{%macro AtoB(text="B",class="", classBtn="",classLbl="",classInp="")%}
<label class=" {{class}} {{classLbl}} {{showM111_A()}} "><input class=" {{classInp}} " form="rrc-form-Bx00" type="radio" name="rrc-Bx00" />{{text}}</label>
<button class=" {{class}} {{classBtn}} {{showM000_A1()}} " type="reset" form="rrc-form-Bx00">{{text}}</button>
{%endmacro%}
Applying State to Elements
Any element that should appear only in a specific page state needs the required machine-state classes. This is handled by utility macros. To show an item only in page state A, the {{showA()}} macro inserts all the needed classes to hide the element in the other states.
{%macro showA() %}
M001 M010 M100 M101 M110 M011
{%endmacro%}
Assembly
All pieces come together in a template: macros are imported, CSS logic is added to the document head, and the controller markup is placed at the top of the body. Specific elements get the appropriate state classes; each traffic-light signal, for instance, uses {{showA()}} to display while its “off” state needs the relevant .M000 and .M111 classes for the A state.
{% import "rrc.njk" as rrc %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Traffic Light State Machine Example</title>
<link rel="stylesheet" href="styles/index.processed.css">
{{rrc.FSM4S_css()}}
</head>
<body>
{{rrc.FSM4S_controller()}}
<div>
<div class="traffic-light">
<div class="{{rrc.showA()}} light red-light on"></div>
<div class="M111 M000 light red-light off"></div>
<div class="{{rrc.showB()}} light yellow-light on"></div>
<div class="M100 M011 light yellow-light off"></div>
<div class="{{rrc.showC()}} light green-light on"></div>
<div class="M010 M101 light green-light off"></div>
</div>
<div>
<div class="next-state">
{{rrc.AtoC(text="NEXT", classInp="control-input",
classLbl="control-label",classBtn="control-button")}}
{{rrc.CtoB(text="NEXT", classInp="control-input",
classLbl="control-label",classBtn="control-button")}}
{{rrc.BtoA(text="NEXT", classInp="control-input",
classLbl="control-label",classBtn="control-button")}}
</div>
</div>
</div>
</body>
</html>
Can This Scale?
The four-state cap covers most use cases, especially since several independent controllers can coexist on a page. Building larger machines is possible by adding bits, but it's not efficient: an even bit count collapses poorly, which is why both 3-bit and 4-bit setups are limited to four page states each.
| Bits (rrcs) | Machine states | Page states |
|---|---|---|
| 1 | 2 | 2 |
| 2 | 4 | 2 |
| 3 | 8 | 4 |
| 4 | 16 | 4 |
| 5 | 32 | 6 |
The Underlying Trick: Radio Reset Controller
The key to decoupled show/hide controls is what I call a radio reset controller. It's just three tags and one CSS rule. It allows a controlling button and the element it affects to sit in different parts of the page—but both must come after the controller markup.
The implementation uses a hidden radio input, checked by default, connected to an empty <form> by its ID. That form holds a visible radio input and a type="reset" button.
<!-- RRC Controller -->
<form id="rrc-form"></form>
<label>
Show
<input form="rrc-form" type="radio" name="rrc-group" />
</label>
<button type="reset" form="rrc-form">Hide</button>
<!-- Controlled by RRC -->
<input form="rrc-form" class="hidden" type="radio" name="rrc-group" checked />
<div class="controlled-rrc">Controlled from anywhere</div>
The hidden radio and its controlled div must be siblings. Users never interact with the hidden input directly; it reverts to checked via the reset button after being cleared by the visible radio.
input[name='rrc-group']:checked + .controlled-rrc {
display: none;
}
.hidden {
display: none;
}
Two lines of CSS make the connection work. The :checked pseudo-selector targets the controlled sibling when the hidden input is on.
Should You Build This Way?
This is a functional pattern, not a recommendation to abandon JavaScript. Most interactivity is better served by JS. Accessibility is a real concern: hidden inputs paired with buttons that alter the page need careful labeling and real-world testing. This is an edge-case technique, but it's a clever one that broadens the toolkit when JS isn't an option.



