Building a calculator in CSS
February 9, 2025I’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.
-
details.dialog > summary { list-style: none; } details.dialog[open] > summary { position: fixed; top: -1rem; left: -1rem; width: calc(100vw + 2rem); height: calc(100vh + 2rem); backdrop-filter: blur(0.4rem); z-index: 100; } details.dialog[open] > summary::before { background: var(--color-text); opacity: 0.4; position: fixed; top: 1rem; left: 1rem; width: 100vw; height: 100vh; content: ""; } details.dialog[open] > summary::after { content: "โ"; position: fixed; top: 2rem; right: 2rem; color: var(--color-text); } details.dialog:not([open]) { margin: 0.4rem; } details.dialog:not([open]) > summary::before { content: attr(text); padding: 0.4rem 0.8rem; border-radius: 0.4rem; border: 2px solid var(--color-text); background: var(--color-box); transition: background .2s, color .2s; } details.dialog:not([open]) > summary:hover::before { background: var(--color-text); color: var(--color-bg) } details.dialog[open] > summary ~ div { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); padding: 0.8rem; border-radius: 0.4rem; z-index: 110; background: var(--color-box); display: flex; flex-direction: column; align-items: center; } -
<details class="dialog"> <summary title="Toggle dialog" text="open"></summary> <div> <h4 style="margin-bottom: 0.8rem; margin-top: 0.2rem;">This is a simple dialog</h4> <span>You can close it by:</span> <ul> <li>Clicking the backdrop</li> <li>Clicking the X in the top right</li> <li>Pressing <code>ENTER</code> (if you are still focused on the <code>summary</code>)</li> </ul> </div> </details> -
This is a simple dialog
You can close it by:- Clicking the backdrop
- Clicking the X in the top right
- Pressing
ENTER(if you are still focused on thesummary)
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.
-
summary.close-button { list-style: none; padding: 0.4rem 0.8rem; border-radius: 0.4rem; border: 2px solid var(--color-text); background: var(--color-box); transition: background .2s, color .2s; margin: 0.4rem; user-select: none; } summary.close-button:hover { background: var(--color-text); color: var(--color-bg) } -
<details class="dialog" name="close-example"> <summary title="Toggle dialog" text="open"></summary> <div> <h4 style="margin-bottom: 0.8rem; margin-top: 0.2rem;">This is a simple dialog</h4> <span>You can close it by:</span> <ul> <li>Clicking the backdrop</li> <li>Clicking the X in the top right</li> <li>Pressing <code>ENTER</code> (if you are still focused on the <code>summary</code>)</li> </ul> <details name="close-example"> <summary class="close-button" title="Close Dialog">close</summary> </details> </div> </details> -
This is a simple dialog
You can close it by:- Clicking the backdrop
- Clicking the X in the top right
- Pressing
ENTER(if you are still focused on thesummary)
close
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.
-
.binary-input { counter-set: binary 0; } .binary-input input { --value: calc(pow(2, var(--i))); } .binary-input input:checked { counter-increment: binary var(--value); } .binary-input .num::after { content: counter(binary); } -
<fieldset class="binary-input"> <legend>Input number as binary</legend> <input type="checkbox" style="--i: 6;"/> <input type="checkbox" style="--i: 5;"/> <input type="checkbox" style="--i: 4;"/> <input type="checkbox" style="--i: 3;"/> <input type="checkbox" style="--i: 2;"/> <input type="checkbox" style="--i: 1;"/> <input type="checkbox" style="--i: 0;"/> <span class="num">Number: </span> </fieldset>
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.
-
#calc-sum { counter-set: sum 0; } #calc-sum input:checked { counter-increment: sum var(--value) binary var(--value); } #calc-sum .sum::after { content: counter(sum); } -
<div id="calc-sum"> <fieldset class="binary-input"> <legend>Addend 1</legend> <input type="checkbox" style="--i: 6;"/> <input type="checkbox" style="--i: 5;"/> <input type="checkbox" style="--i: 4;"/> <input type="checkbox" style="--i: 3;"/> <input type="checkbox" style="--i: 2;"/> <input type="checkbox" style="--i: 1;"/> <input type="checkbox" style="--i: 0;"/> <span class="num">Number: </span> </fieldset> <fieldset class="binary-input"> <legend>Addend 2</legend> <input type="checkbox" style="--i: 6;"/> <input type="checkbox" style="--i: 5;"/> <input type="checkbox" style="--i: 4;"/> <input type="checkbox" style="--i: 3;"/> <input type="checkbox" style="--i: 2;"/> <input type="checkbox" style="--i: 1;"/> <input type="checkbox" style="--i: 0;"/> <span class="num">Number: </span> </fieldset> <span class="sum">Sum: </span><br/> <div> -
Sum:
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--factvariable, which is positive for the first number, and negative numbr for the second one. But you could easily adapt this to match anyfieldsetand the firstfieldsetto allow more than two input numbers.
The--factvalue is then read and multiplied with the calculated field value.-
#calc-diff { counter-set: difference 0; } #calc-diff fieldset:nth-child(1) { --fact: 1; } #calc-diff fieldset:nth-child(2) { --fact: -1; } #calc-diff input:checked { counter-increment: binary var(--value) difference calc(var(--fact) * var(--value)); } #calc-diff .diff::after { content: counter(difference); } -
<div id="calc-diff"> <fieldset class="binary-input"> <legend>Minuend</legend> <input type="checkbox" style="--i: 6;"/> <input type="checkbox" style="--i: 5;"/> <input type="checkbox" style="--i: 4;"/> <input type="checkbox" style="--i: 3;"/> <input type="checkbox" style="--i: 2;"/> <input type="checkbox" style="--i: 1;"/> <input type="checkbox" style="--i: 0;"/> <span class="num">Number: </span> </fieldset> <fieldset class="binary-input"> <legend>Subtrahend</legend> <input type="checkbox" style="--i: 6;"/> <input type="checkbox" style="--i: 5;"/> <input type="checkbox" style="--i: 4;"/> <input type="checkbox" style="--i: 3;"/> <input type="checkbox" style="--i: 2;"/> <input type="checkbox" style="--i: 1;"/> <input type="checkbox" style="--i: 0;"/> <span class="num">Number: </span> </fieldset> <span class="diff">Difference: </span> <div> -
Difference:
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
counterfunction 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
radiobutton 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--factorset rules, because again, we cannot use counters for this.
Notice that I’m using the:hasselector, because I want the variable to be available to the entire element group and not only the specifc input element.-
#calc-prod { counter-set: prod 0; --factor: 1; } #calc-prod:has(#factor-1:checked) { --factor: 1; } #calc-prod:has(#factor-2:checked) { --factor: 2; } #calc-prod:has(#factor-3:checked) { --factor: 3; } #calc-prod:has(#factor-4:checked) { --factor: 4; } #calc-prod:has(#factor-5:checked) { --factor: 5; } #calc-prod:has(#factor-6:checked) { --factor: 6; } #calc-prod:has(#factor-7:checked) { --factor: 7; } #calc-prod input:checked { counter-increment: binary var(--value) prod calc(var(--value) * var(--factor)); } #calc-prod .prod::after { content: counter(prod); } #calc-prod .selected-factor::after { counter-set: variable var(--factor); content: counter(variable); } -
<div id="calc-prod"> <fieldset class="binary-input"> <legend>Factor 1</legend> <input type="checkbox" style="--i: 6;"/> <input type="checkbox" style="--i: 5;"/> <input type="checkbox" style="--i: 4;"/> <input type="checkbox" style="--i: 3;"/> <input type="checkbox" style="--i: 2;"/> <input type="checkbox" style="--i: 1;"/> <input type="checkbox" style="--i: 0;"/> <span class="num">Number: </span> </fieldset> <fieldset id="factor-select"> <legend>Factor 2</legend> <input type="radio" name="factor" id="factor-1" checked=""/> <input type="radio" name="factor" id="factor-2"/> <input type="radio" name="factor" id="factor-3"/> <input type="radio" name="factor" id="factor-4"/> <input type="radio" name="factor" id="factor-5"/> <input type="radio" name="factor" id="factor-6"/> <input type="radio" name="factor" id="factor-7"/> <span class="selected-factor">Number: </span> </fieldset> <span class="prod">Product: </span> <div> -
Product:
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
contenteditablevalue to true. You also have to set the display value of thestyleelement to make it visible.
Additionally, Iโve added thewhite-spacevalue 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.
-
#manual::after { counter-set: variable calc(var(--valueLeft) * var(--valueRight)); content: counter(variable); } -
<style contenteditable="" style="display: block; white-space: break-spaces;"> #manual { --valueLeft: 3; --valueRight: 5; } </style> <span id="manual">Result: </span> -
Result:
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
:checkedrules 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.
-
#calc-prod2 { --a0: 0; --a1: 0; --a2: 0; --b0: 0; --b1: 0; --b2: 0; --factor1: calc(var(--a0) + var(--a1) + var(--a2)); --factor2: calc(var(--b0) + var(--b1) + var(--b2)); --result: calc(var(--factor1) * var(--factor2)); } #calc-prod2:has(.f1 .factor-0:checked) { --a0: 1; } #calc-prod2:has(.f1 .factor-1:checked) { --a1: 2; } #calc-prod2:has(.f1 .factor-2:checked) { --a2: 4; } #calc-prod2:has(.f2 .factor-0:checked) { --b0: 1; } #calc-prod2:has(.f2 .factor-1:checked) { --b1: 2; } #calc-prod2:has(.f2 .factor-2:checked) { --b2: 4; } #calc-prod2 .f1 .num::after { counter-set: variable var(--factor1); content: counter(variable); } #calc-prod2 .f2 .num::after { counter-set: variable var(--factor2); content: counter(variable); } #calc-prod2 .prod::after { counter-set: variable var(--result); content: counter(variable); } -
<div id="calc-prod2"> <fieldset class="binary-input f1"> <legend>Factor 1</legend> <input type="checkbox" class="factor-2"/> <input type="checkbox" class="factor-1"/> <input type="checkbox" class="factor-0"/> <span class="num">Number: </span> </fieldset> <fieldset class="binary-input f2"> <legend>Factor 2</legend> <input type="checkbox" class="factor-2"/> <input type="checkbox" class="factor-1"/> <input type="checkbox" class="factor-0"/> <span class="num">Number: </span> </fieldset> <span class="prod">Product: </span> <div> -
Product:
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
resultvariable 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 calculatorbutton below and try it yourself.-
#calc-final { /* binary represenation of the input */ --a0: 0; --a1: 0; --a2: 0; --a3: 0; --a4: 0; --a5: 0; --a6: 0; --b0: 0; --b1: 0; --b2: 0; --b3: 0; --b4: 0; --b5: 0; --b6: 0; /* assemble numbers from selection */ --numA: calc(var(--a0) + var(--a1) + var(--a2) + var(--a3) + var(--a4) + var(--a5) + var(--a6)); --numB: calc(var(--b0) + var(--b1) + var(--b2) + var(--b3) + var(--b4) + var(--b5) + var(--b6)); /* fallback, will be overwritten by the selected operation */ --result: 0; display: flex; flex-direction: column; } /* apply operation */ #calc-final:has(#op-add:checked) { --result: calc(var(--numA) + var(--numB)); } #calc-final:has(#op-sub:checked) { --result: calc(var(--numA) - var(--numB)); } #calc-final:has(#op-mult:checked) { --result: calc(var(--numA) * var(--numB)); } #calc-final:has(#op-div:checked) { --result: calc(var(--numA) / var(--numB)); } #calc-final:has(#op-pow:checked) { --result: calc(pow(var(--numA), var(--numB))); } /* handle first value */ #calc-final:has(#a .b0:checked) { --a0: 1; } #calc-final:has(#a .b1:checked) { --a1: 2; } #calc-final:has(#a .b2:checked) { --a2: 4; } #calc-final:has(#a .b3:checked) { --a3: 8; } #calc-final:has(#a .b4:checked) { --a4: 16; } #calc-final:has(#a .b5:checked) { --a5: 32; } #calc-final:has(#a .b6:checked) { --a6: 64; } /* handle second value */ #calc-final:has(#b .b0:checked) { --b0: 1; } #calc-final:has(#b .b1:checked) { --b1: 2; } #calc-final:has(#b .b2:checked) { --b2: 4; } #calc-final:has(#b .b3:checked) { --b3: 8; } #calc-final:has(#b .b4:checked) { --b4: 16; } #calc-final:has(#b .b5:checked) { --b5: 32; } #calc-final:has(#b .b6:checked) { --b6: 64; } /* number previews */ #calc-final #a .num::after { counter-set: variable var(--numA); content: counter(variable); } #calc-final #b .num::after { counter-set: variable var(--numB); content: counter(variable); } #calc-final .res::after { counter-set: variable var(--result); content: counter(variable); } /* generic form style */ fieldset { border-radius: 0.4rem; border: 2px solid var(--color-box-light); margin: 0.4rem; } -
<details class="dialog" name="final-dialog"> <summary title="Toggle dialog" text="Launch calculator"></summary> <div id="calc-final"> <h4 style="margin-bottom: 0.8rem; margin-top: 0.2rem;">CSS Calc</h4> <span>Input the numbers as binary below and select an operation to perform</span> <fieldset class="binary-input" id="a"> <legend>Term 1</legend> <input type="checkbox" class="b6"/> <input type="checkbox" class="b5"/> <input type="checkbox" class="b4"/> <input type="checkbox" class="b3"/> <input type="checkbox" class="b2"/> <input type="checkbox" class="b1"/> <input type="checkbox" class="b0"/> <span class="num">Number: </span> </fieldset> <fieldset class="binary-input" id="b"> <legend>Term 2</legend> <input type="checkbox" class="b6"/> <input type="checkbox" class="b5"/> <input type="checkbox" class="b4"/> <input type="checkbox" class="b3"/> <input type="checkbox" class="b2"/> <input type="checkbox" class="b1"/> <input type="checkbox" class="b0"/> <span class="num">Number: </span> </fieldset> <fieldset> <legend>Operation</legend> <input name="operation" type="radio" id="op-add" checked=""/> <label for="op-add">+</label> <input name="operation" type="radio" id="op-sub"/> <label for="op-sub">-</label> <input name="operation" type="radio" id="op-mult"/> <label for="op-mult">*</label> <input name="operation" type="radio" id="op-div"/> <label for="op-div">/</label> <input name="operation" type="radio" id="op-pow"/> <label for="op-pow">^</label> </fieldset> <span style="margin-top: 0.8rem;" class="res">Result: </span> <details name="final-dialog"> <summary class="close-button" title="Close Dialog">close</summary> </details> </div> </details> -
CSS Calc
Input the numbers as binary below and select an operation to performResult:close
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 forattrto gain proper return-type support or for some way to read the numeric counter value. -
-
-
-