As a developer with a passion for state machines, I’ve often found myself inspired by articles like “A Complete State Machine Made with HTML Checkboxes and CSS.” The power of pure CSS-driven state machines intrigued me, and I began to wonder: could I create something simpler, more interactive, and without the use of macros? This led to a project where I built an elevator simulation in CSS, complete with direction indicators, animated transitions, counters, and even accessibility features.
In this article, I’ll walk you through how I used modern CSS features — like custom properties, counters, the :has() pseudo-class, and @property — to build a fully functional, interactive elevator that knows where it is, where it’s headed, and how long it’ll take to get there. No JavaScript required.
Defining the State with CSS Variables
The backbone of this elevator system is the use of CSS custom properties to track its state. Below, I define several @property rules to allow transitions and typed values:
These variables allow me to compare the elevator’s current floor to its previous one, calculate movement speed, and drive animations and transitions accordingly.
A regular CSS custom property (--current-floor) is great for passing values around, but the browser treats everything like a string: it doesn’t know if 5 is a number, a color, or the name of your cat. And if it doesn’t know, it can’t animate it.
That’s where @property comes in. By “registering” the variable, I can tell the browser exactly what it is (<number>, <length>, etc.), give it a starting value, and let it handle the smooth in-between frames. Without it, my elevator would just snap from floor to floor, and that’s not the ride experience I was going for.
A Simple UI: Radio Buttons for Floors
Radio buttons provide the state triggers. Each floor corresponds to a radio input, and I use :has() to detect which one is selected:
--abs gives the absolute number of floors to move.
--relative-speed makes the animation slower when moving across more floors.
So, if the elevator jumps from floor 1 to 4, the animation lasts longer than it does going from floor 2 to 3. All of this is derived using just math expressions in the CSS calc() function.
Determining Direction and Arrow Behavior
The elevator’s arrow points up or down based on the change in floor:
While the delay runs, the --previous value lags behind the --current-floor. That lets me calculate direction and speed during the animation. Once the delay ends, --previous catches up. This delay-based memory trick allows CSS to approximate state transitions normally done with JavaScript.
Floor Counters and Unicode Styling
Displaying floor numbers elegantly became a joy thanks to CSS counters:
The \278A to \2783 characters correspond to the ➊, ➋, ➌, ➃ symbols and give a unique, visual charm to the display. The elevator doesn’t just say “3,” but displays it with typographic flair. This approach is handy when you want to go beyond raw digits and apply symbolic or visual meaning using nothing but CSS.
Accessibility with aria-live
Accessibility matters. While CSS can’t change DOM text, it can still update screenreader-visible content using ::before and counter().
This keeps the experience inclusive and aligned with accessibility standards.
Practical Applications of These Techniques
This elevator is more than a toy. It’s a blueprint. Consider these real-world uses:
Interactive prototypes without JavaScript
Progress indicators in forms using live state
Game UIs with inventory or status mechanics
Logic puzzles or educational tools (CSS-only state tracking!)
Reduced JavaScript dependencies for performance or sandboxed environments
These techniques are especially useful in static apps or restricted scripting environments (e.g., emails, certain content management system widgets).
Final Thoughts
What started as a small experiment turned into a functional CSS state machine that animates, signals direction, and announces changes, completely without JavaScript. Modern CSS can do more than we often give it credit for. With :has(), @property, counters, and a bit of clever math, you can build systems that are reactive, beautiful, and even accessible.
If you try out this technique, I’d love to see your take. And if you remix the elevator (maybe add more floors or challenges?), send it my way!
Editor’s note: This is a really clever idea that Preethi shared, but you will also see that it comes with accessibility drawbacks because it uses duplicated interactive elements. There are other ways to approach this sort of thing, as Preethi mentions, and we’ll look at one of them in a future article.
Two large pizzas for yourself, or twelve small ones for the kids party — everyone’s gone through the process of adding items to an online cart. Groceries. Clothing. Deli orders. It’s great when that process is simple, efficient, and maybe even a little quirky.
This post covers a design referred as infinite selection. Metaphorically infinite.
Here’s how it works:
That’s right, you click an item and it jumps right into the shopping cart, complete with a smooth transition that shows it happening. You can add as many items as you want!
And guess what: all of it is done in CSS — well, except the part that keeps count of selected items — and all it took is a combination of radio form inputs in the markup.
I’m going to walk you through the code, starting with the layout, but before that, I want to say up-front that this is just one approach. There are for sure other ways to go about this, and this specific way comes with its own considerations and limitations that we’ll get into.
The Layout
Each item (or product, whatever you want to call it) is a wrapper that contains two radio form inputs sharing the same name value — a radio group.
When you check one in a duo, the other gets unchecked automatically, leading to a see-saw of check and uncheck between the two, no matter which one is clicked.
Each item (or radio group) is absolutely positioned, as are the two inputs it contains:
The inset property is stretching the inputs to cover the entire space, making sure they are clickable without leaving any dead area around them.
Now we arrange everything in a layout. We’ll use translate to move the items from a single point (where the centered cart is) to another point that is a litte higher and spread out. You can code this layout anyway you like, as long as the radio buttons inside can make their way to the cart when they are selected.
So, yeah, a little bit of configuration to get things just right for your specific use case. It’s a little bit of magic numbering that perhaps another approach could abstract away.
Selecting Items
When an item (<input>) is selected (:checked), it shrinks and moves (translate) to where the cart is:
What happens under the hood is that the second radio input in the group is checked, which immediately unchecks the first input in the group, thanks to the fact that they share the same name attribute in the HTML. This gives us a bit of boolean logic a là the Checkbox Hack that we can use to trigger the transition.
So, if that last bit of CSS moves the selected item to the shopping cart, then we need a transition to animate it. Otherwise, the item sorta zaps itself over, Star Trek style, without you telling.
The whole point of this post is getting a selected item to the cart. There’s no “Cart” page to speak of, at least for the purposes of this demo. So, I thought it would be a good idea to show how many items have been added to the cart. A little label with the count should do the trick.
Basically, we’re selecting the cart object (the <output> element) and, for each click on a radio input, we increase an integer that represents the count, which is slapped onto the shopping card icon as a label. Sorry, no removing items from the cart for this example… you’re completely locked in. 😅
Accessibility?
Honestly, I wrestled with this one and there probably isn’t a bulletproof way to get this demo read consistently by screen readers. We’re working with two interactive elements in each group, and need to juggle how they’re exposed to assistive tech when toggling their states. As it is, there are cases where one radio input is read when toggling into an item, and the other input is read when toggling back to it. In other cases, both inputs in the groups are announced, which suggests multiple options in each group when there’s only one.
I did add a hidden <span> in the markup that is revealed with keyboard interaction as a form of instruction. I’ve also inserted an aria-label on the <output> that announces the total number of cart items as they are added.
Here’s the final demo once again:
Maybe Use View Transitions Instead?
I wanted to share this trick because I think it’s a clever approach that isn’t immediately obvious at first glance. But this also smells like a situation where the modern View Transition API might be relevant.
Adrian Bece writes all about it in a Smashing Magazine piece. In fact, his example is exactly the same: animating items added to a shopping cart. What’s nice about this is that it only takes two elements to build the transition: the item and the cart label. Using CSS, we can hook those elements up with a view-transition-name, define a @keyframes animation for moving the item, then trigger it on click. No duplicate elements or state juggling needed!
Alternatively, if you’re working with just a few items then perhaps a checkbox input is another possible approach that only requires a single element per item. the downside, of course, is that it limits how many items you can add to the card.
But if you need to add an infinite number of items and the View Transition API is out of scope, then perhaps this radio input approach is worth considering.