Building a calculator in CSS

February 9, 2025

I’m amazed by what websites can achieve without the need for JavaScript. Most of the time, when I’m designing a website, I try to keep it JavaScript-enhanced. Meaning that it works perfectly fine without JavaScript but may use JavaScript for more advanced features (i.e., autocomplete).

You’d be impressed with how much can be done by using semantic HTML and CSS.
Well, today we are doing exactly that, by building a simple calculator widget, which can be used in a calculator popup.

A simple dialog

Before we get to the more interesting stuff (math), we have to design the dialog, that will display the elements later on.

If you know your way around HTML elements, you might think that we could use a dialog element. Unfortunately, the dialog element can only be closed using HTML forms and not opened.
This leaves us with a couple of options: We could use an anchor tag, which navigates to #dialog, because we can check if an element with the id dialog is targeted using :target. However, this would require automatically incremented IDs if you plan on putting multiple dialogs on the same page. Additionally, it would also mess with existing headline or footnote navigation.
Alternatively, you could also use a check if a checkbox is :checked, but it would require hiding the dialog with CSS.

Instead, we will be using the details element. As the expand and close actions, semantically mirror dialog behaviour.
Repurposing the details element for a dialog is fairly easy. The main element is kept in its default position all the time, while the summary is used as a toggle. The summary is made to look like a button when the details element is closed, and spanned across the entire screen as a backdrop button when opened. In addition to that, the ::before pseudo-element is used to display a backdrop color, and the ::after pseudo-element is used as a close button in the top right.

You can click the open button below to see this in action. To see the source, simply switch between the HTML and CSS tabs.

You might be wondering if it is possible to add a proper close button to the end of our dialog, or maybe move the X button inside the card.
We can’t use the summary pseudo-elements, as we have no way of knowing where the inner card is, so we can’t properly position it.
However, while finishing this blog post, I noticed that we can use the details element behaviour to do this.

Assigning multiple details elements the same name attribute, groups them together logically, meaning that only one of them can be open at any given point in time.
We can use this to our advantage by adding a details element with the same name to the dialog body, and making it to look like a button. Pressing the close button, will cause the inner details element to expand, however since only one of the two can be open at any given time, this will cause the dialog to close.
Similarly, when clicking the open button, it expands the dialog details element, closing the inner one and thus making it possible to click the close button again.

You’ll also find that the following CSS is incomplete. That is because it reuses the same classes as the earlier examples. This saves me from having to write the same code multiple times and makes it easier for you to identify the relevant code sections.

You can obviously adapt this to your liking.
Currently, hovering over the main summary shows Toggle dialog as a title text. Instead, you could hide the main summary when the dialog is shown and use a separate details element in the same group as a toggle, allowing you more control over hover hints.

Obtaining user input

Unfortunately, we are still not ready to perform arithmetics, as we are missing a way to input numbers.

Some of you might know, that CSS offers an attr function, which is supposed to support getting an attribute value for elements. However, neither Firefox nor Chromium currently support setting the return type, making it impossible to extract the value in a usable format. (Note: I’m not sure if it is even possible to get the current input field value)

Instead of using a normal number input field, we will use a different representation: binary. In binary, the entire number is represented using ones and zeros. Allowing us to use checkbox input elements, where a checked field represents a 1 and an unchecked field represents a 0.
This example uses seven checkboxes, limiting the maximum input value to 127.

To convert the binary representation into a human-readable decimal number, we can make use of CSS counters. Even if you have never heard of them before, you have probably seen them before, as they are intended for adding numbers to lists or headlines.
In this case, we use it to add the decimal value of every field to the counter using counter-increment with the counter name and the value to increment by.

The increment value is calculated by taking the element index assigned to it. The reason for this will become apparent later.
The summed value is then displayed as a pseudo-element (which isn’t selectable by default).
You can play around with it below, although it doesn’t do that much yet.

Adding two values

Most of you probably realized that calculating the sum of two values is as simple as creating a second counter and incrementing both the binary counter and the sum counter.
And that is exactly what I did in this case.

CSS overwrites the old counter increment instruction, which is why we have to increment both counters.

Subtracting two values

Calculating the difference of two numbers is nearly as easy as calculating the sum. We just have to add the negative of every field value of the second number to the global counter.
I’ve added a --fact variable, which is positive for the first number, and negative numbr for the second one. But you could easily adapt this to match any fieldset and the first fieldset to allow more than two input numbers.
The --fact value is then read and multiplied with the calculated field value.

Performing more advanced operations

Now that we have the two basic operations out of the way, we can move on to the other ones - should be easy, right?
Well, unfortunately not.

See, the CSS counter function only returns strings, which is why it can only be used in a context where string values are supported. CSS also does not support string-to-number conversions, basically making it impossible to reuse the counter value.
This is also the reason I provided manual index numbers in the examples above.

But this isn’t the end of the calculator project; that would be boring.
This only means that the code will become less readable and harder to write and scale up.

Hardcoded factors

My first idea to implement multiplication was using fixed-decimal-value inputs, where every radio button represents a specific decimal value. As you can see, this provides limited options, but allows us to simply multiply the bit-values with the selected factor.
This requires manually writing/generating the --factor set rules, because again, we cannot use counters for this.
Notice that I’m using the :has selector, because I want the variable to be available to the entire element group and not only the specifc input element.

Direct input

The only way I’m aware of that allows getting rid of the pregenerated rules, is to use the CSS itself as the input.

I saw this trick online, where you make the style element editable in the DOM, by setting the contenteditable value to true. You also have to set the display value of the style element to make it visible.
Additionally, Iโ€™ve added the white-space value to neatly wrap the lines.

But to be honest, I think this is not user-friendly at all. It also misses the point of being something cool, something hacky. And I would consider it cheating.

Pregenerating rules

As I can’t seem to find an alternative, let’s focus on the pregenerated rules route.
To keep myself from having to write and debug too many lines of CSS, I decided to limit this example to three fields, but I promise, there will be more fields later.

In the end, this wasn’t that hard at all; as you can see by looking at the CSS tab, I’ve added the same :checked rules as above, but this time the rules change a variable for every bit. These variables are then added together as the single factors and multiplied.

And I’d say it works pretty well.

Putting everything together

Now that we have all the parts, it is time to put them together.
As promised, I added the missing bits to the binary representation. I’ve also folded a lot of the CSS rules into a single line to group them by functionality and make them easier to copy and paste.

Instead of directly adding the two numbers together, the result variable is set depending on which operation radio button is selected.
This makes it easier to add support for new operations while also keeping the display and number aggregation code separated.

And I guess that is it. Click the Launch calculator button below and try it yourself.

I had a lot of fun researching CSS functions for this post. Hopefully you learned something about CSS or got inspired to build something cursed using only HTML and CSS yourself.
All there is to do now is wait for attr to gain proper return-type support or for some way to read the numeric counter value.