AI & ML

Inside the Radio State Machine: How Wireless Systems Manage Complex Communication Flows

· 5 min read

Managing state in CSS isn't exactly intuitive, and honestly, it's not always the right choice. When an interaction involves business logic, needs persistence, depends on data, or requires coordinating multiple moving parts, JavaScript is usually the better tool.

That said, not every piece of state needs JavaScript.

Sometimes you're dealing with purely visual UI state: whether a panel is open, an icon has changed appearance, a card is flipped, or a decorative interface element should shift between visual modes.

In these cases, keeping the logic in CSS can be not just possible, but preferable. It keeps behavior close to the presentation layer, reduces JavaScript overhead, and often yields surprisingly elegant solutions.

The Boolean solution

One of the most well-known examples of CSS state management is the checkbox hack.

If you've spent time working with CSS, you've probably seen it used for clever UI tricks. It can restyle the checkbox itself, toggle menus, control component internals, reveal hidden sections, and even switch entire themes. It's one of those techniques that feels slightly mischievous the first time you encounter it, then immediately becomes useful.

If you haven't used it before, the concept is simple:

  1. Place a hidden checkbox at the top of the document.
<input type="checkbox" id="state-toggle" hidden="">
  1. Connect a label to it, so users can toggle it from anywhere.
<label for="state-toggle" class="state-button">
Toggle state
</label>
  1. In CSS, use the :checked state and sibling combinators to style other parts of the page based on whether the checkbox is checked.
#state-toggle:checked ~ .element {
/* styles when the checkbox is checked */
}
.element {
/* default styles */
}

The checkbox becomes a small piece of built-in UI state that CSS can react to. Here's a simple example switching between light and dark themes:

We have :has()

Notice I've placed the checkbox at the top of the document, before the rest of the content. This was necessary before the :has() pseudo-class, because CSS only allowed selecting elements that come after the checkbox in the DOM. Placing it at the top ensured you could target any element on the page, regardless of where the label appeared.

Now that :has() has wide support, you can place the checkbox anywhere and still target elements that come before it. This gives much more flexibility in structuring your HTML. For example, you can place the checkbox right next to the label and still control the entire page.

Here's a classic checkbox hack theme selector with the checkbox placed next to the label, using :has() to control page styles:

<div class="content">
<!-- content -->
</div>
<label class="theme-button">
<input type="checkbox" id="theme-toggle" hidden="">
Toggle theme
</label>
body {
/* other styles */
/* default to dark mode */
color-scheme: dark;
/* when the checkbox is checked, switch to light mode */
&:has(#theme-toggle:checked) {
color-scheme: light;
}
}
/* use the color `light-dark()` on the content */
.content {
background-color: light-dark(#111, #eee);
color: light-dark(#fff, #000);
}

Note: I'm using the ID selector (#) in the CSS as it's already part of the checkbox hack convention, and it's a simple way to target the checkbox. If you're worried about CSS selector performance, don't be.

Hidden, not disabled (and not so accessible)

Notice I've been using the HTML hidden global attribute to hide the checkbox from view. This is common practice in the checkbox hack, as it keeps the input in the DOM and allows it to maintain state while removing it from the visual flow.

Unfortunately, the hidden attribute also hides the element from assistive technologies, and the label that controls it doesn't have any interactive behavior on its own. This means screen readers and other assistive devices can't interact with the checkbox.

This is a significant accessibility concern. To fix it, we need a different approach: instead of wrapping the checkbox in a label and hiding it with hidden, we can turn the checkbox into the button itself.

<input type="checkbox" class="theme-button" aria-label="Toggle theme">

No hidden, no label, just a fully accessible checkbox. To style it like a button, we can use the appearance property to remove the default checkbox styling and apply our own.

.theme-button {
appearance: none;
cursor: pointer;
font: inherit;
color: inherit;
/* other styles */
/* Add text using a simple pseudo-element */
&::after {
content: "Toggle theme";
}
}

This gives us a fully accessible toggle button that still controls page state through CSS, without relying on hidden inputs or labels. We'll use this approach in all the following examples as well.

Getting more states

The checkbox hack is great for managing simple binary state in CSS, but it has a clear limitation. A checkbox gives you two states: checked and not checked. On and off. That works when the UI only needs a binary choice, but it's not always enough.

What if you want a component to be in one of three, four, or seven modes? What if a visual system needs a proper set of mutually exclusive states instead of a simple toggle?

That's where the Radio State Machine comes in.

Simple three-state example

The core idea is similar to the checkbox hack, but instead of a single checkbox, we use a group of radio buttons. Each radio button represents a different state, and because radios let you choose one option out of many, they provide a surprisingly flexible way to build multi-state visual systems directly in CSS.

Here's how this works:

<div class="state-button">
<input type="radio" name="state" data-state="one" aria-label="state one" checked="">
<input type="radio" name="state" data-state="two" aria-label="state two">
<input type="radio" name="state" data-state="three" aria-label="state three">
</div>

We created a group of radio buttons. Notice they all share the same name attribute (state in this case). This ensures only one radio can be selected at a time, giving us mutually exclusive states.

We gave each radio button a unique data-state that we can target in CSS to apply different styles based on which state is selected, and the checked attribute to set the default state (in this case, one is the default).

Style the buttons

The styling for the radio buttons themselves is similar to the checkbox button we created earlier. We use appearance: none to remove the default styling, then apply our own styles to make them look like buttons.

input[name="state"] {
appearance: none;
padding: 1em;
border: 1px solid;
font: inherit;
color: inherit;
cursor: pointer;
user-select: none;
/* Add text using a pseudo-element */
&::after {
content: "Toggle State";
}
&:hover {
background-color: #fff3;
}
}

The key difference with multiple radio buttons is that each represents a distinct state in a sequence. We need to display only the button for the next state while keeping the others hidden. Rather than using display: none, which would break accessibility, we can hide the buttons visually while preserving their presence in the DOM.

  1. position: fixed; removes the buttons from normal document flow
  2. pointer-events: none; prevents direct interaction with hidden buttons
  3. opacity: 0; makes the buttons invisible

This approach keeps all radio buttons accessible to assistive technologies while hiding them from view.

To reveal the appropriate button, we use the adjacent sibling combinator (+) to target the next radio button when the current one is checked. This ensures only one button appears at any given time.

input[name="state"] {
/* other styles */
position: fixed;
pointer-events: none;
opacity: 0;
&:checked + & {
position: relative;
pointer-events: all;
opacity: 1;
}
}

For circular flows, we can loop back to the first button when the last one is checked. This is optional and depends on your use case—we'll explore linear and bidirectional patterns shortly.

&:first-child:has(~ :last-child:checked) {}

Since checked radio buttons are hidden, their focus outlines disappear too. Adding an outline to the container ensures keyboard users can still track their position.

.state-button:has(:focus-visible) {
outline: 2px solid red;
}

Style the rest

With the state mechanism in place, we can style elements based on which radio button is checked. The data-state attribute helps differentiate between states.

body {
/* other styles */
&:has([data-state="one"]:checked) .element {
/* styles when the first radio button is checked */
}
&:has([data-state="two"]:checked) .element {
/* styles when the second radio button is checked */
}
&:has([data-state="three"]:checked) .element {
/* styles when the third radio button is checked */
}
}
.element {
/* default styles */
}

This pattern extends well beyond simple toggles. It can power steppers, view switchers, card carousels, visual filters, layout modes, interactive demos, and CSS-only experiments. The applications range from practical UI components to creative explorations, several of which we'll examine later.

Utilize custom properties

Centralizing state inputs and leveraging :has() opens up another powerful technique: custom properties.

Rather than setting final property values directly for each state—which requires targeting elements repeatedly with increasingly specific selectors—we can assign state values to variables at a higher scope. These variables cascade naturally, allowing components to consume them wherever needed.

For instance, we can define --left and --top per state:

body {
/* ... */
&:has([data-state="one"]:checked) {
--left: 48%;
--top: 48%;
}
&:has([data-state="two"]:checked) {
--left: 73%;
--top: 81%;
}
/* other states... */
}

Then reference those values on the element:

.map::after {
content: '';
position: absolute;
left: var(--left, 50%);
top: var(--top, 50%);
/* ... */
}

This centralizes state styling, eliminates selector duplication, and makes component classes more readable since they consume variables rather than reimplementing state logic.

Use math, not just states

Once state lives in variables, we can treat it as a numeric value and perform calculations.

Instead of defining complete visual properties for every state, assign a single numeric variable:

body {
/* ... */
&:has([data-state="one"]:checked) { --state: 1; }
&:has([data-state="two"]:checked) { --state: 2; }
&:has([data-state="three"]:checked) { --state: 3; }
&:has([data-state="four"]:checked) { --state: 4; }
&:has([data-state="five"]:checked) { --state: 5; }
}

This value can drive calculations across any element. For example, deriving background color directly from the active state:

.card {
background-color: hsl(calc(var(--state) * 60) 50% 50%);
}

By defining an index variable like --i per item (until sibling-index() gains broader support), we can calculate each item's styling—position, scale, opacity—relative to both the active state and its sequence position.

.card {
position: absolute;
transform:
translateX(calc((var(--i) - var(--state)) * 110%))
scale(calc(1 - (abs(var(--i) - var(--state)) * 0.3)));
opacity: calc(1 - (abs(var(--i) - var(--state)) * 0.4));
}

This is where the technique becomes particularly elegant: a single --state variable powers an entire visual system. Rather than writing separate style blocks for every card in every state, you define the rule once, assign each item its index (--i), and let CSS handle the rest.

Not every state flow should loop

Unlike earlier examples, the previous demo doesn't loop back to the beginning. Once you reach the final state, you stay there. This is achieved by removing the rule that displays the first radio button when the last is checked, and instead adding a disabled placeholder button that appears at the end.

<input type="radio" name="state" disabled="">

This works well for progressive flows like onboarding sequences, checkout processes, or multi-step forms where the final step represents a true endpoint. The states remain keyboard-accessible, which is generally desirable unless your use case requires otherwise.

To create a truly linear flow, replace the position, pointer-events, and opacity properties with display: none by default, and display: block (or inline-block) for the visible, interactive button. This removes hidden states from the focus order entirely, making them unreachable via keyboard navigation.

Bi-directional flows

Forward-only navigation isn't always sufficient. Users often need to move backward through states, which we can enable by also displaying the radio button that points to the previous state in the sequence.

To enable bidirectional navigation between states, we expand the CSS selectors to reveal both next and previous radio buttons simultaneously. The next button is targeted using the adjacent sibling combinator (+), while the previous button uses :has() to detect when the following input is checked (:has(+ :checked)).

input[name="state"] {
position: fixed;
pointer-events: none;
opacity: 0;
/* other styles */
&:has(+ :checked),
&:checked + & {
position: relative;
pointer-events: all;
opacity: 1;
}
/* Set text to "Next" as a default */
&::after {
content: "Next";
}
/* Change text to "Previous" when the next state is checked */
&:has(+ :checked)::after {
content: "Previous";
}
}

This approach allows users to move forward and backward through the state sequence.

While this builds naturally on the earlier pattern, it significantly expands control over state flow and enables more sophisticated interactions without leaving CSS.

Accessibility notes

This technique should remain visual in implementation while maintaining accessible behavior. Since the foundation uses native form controls, we start with solid accessibility support, but several considerations require attention:

  • Ensure radio buttons appear clearly interactive through cursor styling, adequate sizing, and spacing, with explicit labeling.
  • Maintain visible focus indicators so keyboard navigation remains trackable.
  • When steps become unavailable, communicate that state through multiple visual cues beyond color alone.
  • Honor reduced motion preferences when state transitions involve animation, layout shifts, or opacity changes.
  • For state changes involving validation, data persistence, or asynchronous operations, delegate that logic to JavaScript while using CSS purely for visual presentation.

The pattern works best as an interaction enhancement rather than a replacement for semantic markup or application logic.

Closing thoughts

The radio state machine starts as a modest CSS technique but quickly reveals unexpected creative possibilities.

With strategically placed inputs and thoughtful selectors, you can build interactions that feel dynamic and expressive while keeping visual state management in the rendering layer where it belongs.

That said, it remains a specialized tool.

Apply it when state is primarily visual, localized, and interaction-focused. Avoid it when flows depend on business logic, external data sources, persistence requirements, or complex coordination.

The goal isn't demonstrating that CSS can handle everything technically possible, but rather identifying where it excels naturally.

Try this experiment: select one small UI component in your current project, rebuild it as a minimal state machine, and observe the results. If it improves clarity, adopt it. If it creates friction, revert without hesitation. Share your findings with the community.


The Radio State Machine originally handwritten and published with love on CSS-Tricks. You should really get the newsletter as well.