Isaac Beh

CSS-Only Graphing Calculator

FunCodingCSS

The following graphing calculator is written entirely in CSS and HTML. That's right; there is no Javascript anywhere on this page. Quite a few shenanigans are involved, from hidden checkboxes, to recursive variables, and even bit hacks. But let's be honest, using CSS often requires a few hacks.

The lack of Javascript comes with a few quirks:

Supported Functions

These are the functions supported by your browser. Check out Mozilla for comprehensive notes on which functions work with which browser versions. Note that inverse trigometric functions are unavailable, as they output an angle and there is no known way to turn an angle into a number in CSS.


Behind the Scenes

There are quite a few unrelated features of CSS being used here. Given the scope, I'm going to describe the main techniques I used one-by-one.

Function Input (i.e. Editable CSS)

The function input box is just a <style> block that modifies .graph .pt elements (among others to be discussed later). Just like any other element, these blocks can be made visible by using display: inline-block. Additionally, to make the text of the style block editable, we add the contenteditable attribute to the HTML. Any styling you enter will be used throughout the page.

Note that you need to use Shift + Enter to insert a new line within contenteditable elements, like the one below. Refresh the page to revert any changes.

For the graphing calculator, I also placed the style element in a <div> with overflow: clip. By shifting the style element upwards and fixing the div's height, I could hide the selector and braces, making it look less like CSS. The same techniques were used with the axis limits box as well.

This trick to make live-editable CSS is somewhat widely known (at least from what I have seen), with Mathias Bynens describing and using it in 2010 and Chris Coyier writing about it for CSS Tricks in 2011. It also gives me similar vibes to "This page is a truly naked, brutalist html quine". However, I have only noticed it being used for interactive styling. Given that contenteditable style blocks are a CSS-only way to create nuanced editable state, there's the opportunity for many other weird and unconventional projects to use them as well.

Graph Controls

The infamous checkbox hack is the most common way to have modifiable state in a CSS-only project. By using the :checked peudo-class, we can determine the state of an element which the user can toggle. Further more, by using the subsequent-sibling combinator ~, such as input:checked ~ div, we can then modify subsequent elements.

This does require the checkbox to be above all the elements we want to change[2], but if we give the checkbox an id and create a label with the for="id-here" attribute, any clicks on the label will act as if they were on the checkbox. Usually we hide the checkbox with display: none and only use the label, which simplifies the user interface.

This is the bread and butter of most pure CSS projects; I even use it for the filters on my homepage. Chris Coyier also has a more in-depth explanation of some of the use cases of this hack.

The Graph Points

The points on the graph are probably the most essential part of a graphing calculator. Sadly, it's hard to add many DOM elements via CSS, so the points are a series of nested divs[3]. With fixed positioning and the --y value set by a contenteditable style block, the biggest struggle is setting the --x variable for each div. Whilst this could be set individually for each div, I wanted to reduce any tedium. Instead, I tried to get a CSS variable (which I will call --i) to increment with each additional div.

Initially, I tried something along the lines of --i: var(--i) + 1, where the inner --i was supposed to refer to the parent's value of --i. This does not work. The CSS specifications make it very clear that cyclic variable dependencies are invalid. I also tried using counters and counter-increment, but you can only get the value of a counter as a string, so you can't use it to position elements.

I was lucky enough to stumble across this Stack Overflow post asking the same thing — with a solution as well. The issue with my approach is that, within --i: --i + 1, you cannot indicate which --i is the child's and which is the parent's. To get around this, we can have two different variables, --a and --b that oscillate between being the variable that is increased. Alternating classes a and b for each div makes this quite easy. We can then enumerate the divs and store the value in the variable --i.

There are a few subtleties that should be noted. We don't need to use calc() at each step, as variables are substituted (at the level of tokens). This is most easily explained by the following example: --x: calc(1 + 1);
--y: calc(2 * var(--x));
This is computed as: --x: calc(1 + 1);
--y: calc(2 * calc(1 + 1));
Instead of doing the calculation first, the tokens are substituted in literally. If we had calc() within each div, we would end up with --i: (calc(calc(calc(calc(-1 + 1) + 1) + 1) + 1)); which is slower to execute than: --i: (-1 + 1 + 1 + 1 + 1); We will use calc() at the last possible moment for this reason.

The way CSS substitutes variables is also the reason that extra brackets are included when we assign to --i. We need to ensure that the order of operations is correct: --x: 1 + 1;
--y: calc(2 * var(--x));
/* Evaluated as: */
/* --y: calc(2 * 1 + 1); */
/* So --y will be 3, not 4 */

This also causes some issues when entering functions into the calculator. Expressions like -var(--x) get expanded to -(1 + 1), which is invalid CSS. This is a flaw with the current calculator, but I can't think of any solution other than suggesting users to add spaces around all operators.

Putting this all together, we get something like:

Even More Points

Despite having the same number of divs as the original calculator, this demo doesn't look as good. By using pseudoelements (i.e. ::before and ::after), we can increase the point density. Each of these is positioned in between the existing points, effectively tripling the total number of points.

I could have just increased the number of divs, but using pseudoelements turned out to be faster! Testing the speed at which the graph updated to adjustments, I found that using pseudoelements caused a significant boost in performance (up to ~30% faster). While in general pseudoelements are slightly faster than DOM elements, the true speed increase in this case is due to the recursion. When --i is set within the scope of the rightmost point, it consists of thousands of copies of "+ 1". By reducing the number of divs, we reduce the levels of nesting, thus decreasing the size of this expression. This also reduces the size of many other variables that also reference --i, such as --x and --y.

Grid Lines

Grid lines were easy to implement and didn't even require any new HTML elements. By creating a repeating background linear gradient that only has 1px of a solid colour, stripes can be created. This can be overlayed by a rotated gradient in the other direction. By changing the background size, we create 8 lines.

Markers

Surprisingly, despite their innocuous appearance, the markers next to the grid lines were the worst part to implement. The big issue is how to display the value of a CSS variable. This doesn't seem to get discussed often, but as you saw in the previous example, it is possible using CSS counters.

The counter-reset property lets you set the value of a counter[5] (this is often used for list numbers). Then, by using the counter() function, you can get a string representation of its value to use for the content of a pseudoelement. We can reset multiple counters in a single statement, and strings can be concatenated by writing them one after each other, separated by whitespace. This provides us with most of the tools we need to print variables however we like.

Sadly, this is only most of the tools we need. Notice that the value is rounded towards the nearest integer. This is why calc() is required, despite the variable --x already being a number. Counters can only have integer values, it's just lucky that calc() will round a float to an integer (if an integer is required for a property).

This limitation of counters means we cannot natively display a decimal value, so some chicanery is required. This is further exacerbated by the lack of math functions available in Chrome, Edge and Opera[6]. When I started creating the calculator, Chrome and Opera did not support round() (this changed only a few weeks ago) and they still do not support abs() nor sign() as of the time of writing (18/06/2024). If you are to follow these instructions in the future, hopefully this part will be a lot easier.

We will break up the task of displaying decimal numbers into multiple parts:

Displaying the Sign

Displaying the sign uses a trick that is needed for every step: custom counter styles. These gained wide support across browsers in late 2023, and allow custom number formats for counters (again, usually for lists, but when has that stopped us before). They don't allow for floats (as the counters they are displaying are restricted to integers), but they do provide us with some power. The inspiration for using custom counter styles comes from Gurveer Arora's NoJS Calculator.

First we will calculate the sign of our variable by hand (as Chrome does not support the sign() function). Mathematically, we can get the absolute value x by computing \max(x,-x) . Then the sign of x is \frac{x}{\max(x,-x)} , where I will ignore division by 0, as things turn out to just work.

We can then create a custom counter style that is base 2, where each digit is the empty string. This results in only the sign of this number being printed. You can see this implemented below.

Displaying the Integer Part

Displaying the integer part would be quite easy... if we could actually calculate the integer part. The problem is that we want to round the variable (for further calculations), not just the value of the counter which cannot be further manipulated. Gurveer Arora did this by using the syntax descriptor of a custom property, which ensures that when a certain variable is set, it is rounded. Sadly this is not supported in Firefox.

This is probably the most cursed part of this project, but it turns out that you can round variables in CSS using bit-hacks. By multiplying a variable by a very small number, we can chop off everything past the decimal place (lost to the void of floating-point inprecision). Then by dividing by the same number, we get back our scale, but all the decimal values have been lost. The specific very small number that we need is called "the smallest positive subnormal number" (which I will refer to as SPSN) and is the smallest possible positive number representable in floating-point.

To give an analogy, suppose that you were storing a number with 8 digits, 4 before the decimal place and 4 after (this isn't exactly how floating-point numbers work, but it explains the general idea). If we multiply by 10^{-4} (the SPSN of this system), we shift everything over so that the decimal gets chopped off. We can then multiply by 10^4 (or divide by 10^{-4} ) to get the original number, but without the decimal.

A demonstration of how multiplying by the smallest positive subnormal number and the dividing again can floor a number.

However, this method presents another problem: we need to find this smallest positive subnormal number. It just so happens that this is not consistent between browsers, as it changes if 32- or 64-bit floating numbers are used for variables. So before we can do our bit-hacks, we need to determine if the variables we are working with are 32- or 64-bit.

This isn't too hard. We can take the smallest positive subnormal number for 32-bit floats, divide it by 2 and multiply it by 2. If we have 32-bit floats, this will round to 0 during this procedure. Using some maths, we can turn this into a 1 or 0: /* 1.401298464324817e-45 is the SPSN for 32-bit floats */
--is-32-bit: (1 - 1.401298464324817e-45 / 2 * 2 / 1.401298464324817e-45);
This technique provides us with a valid way of differentiating between Chrome (which uses 64-bit variables within CSS) and Firefox (which uses 32-bit variables, even in the 64-bit version).

When multiplying by our SPSN, any precision lost is still rounded. To ensure we always get the floor of our number, we will subtract 0.499999 from the original number (as then rounding to the nearest number becomes flooring the original value).

There is also one more thing to note: this approach does not work with Safari. Given that I have only been able to do testing on my partner's iPhone, it has been difficult to diagnose the cause[7]. From some testing, it seems that Safari does not support subnormal numbers, at least in the same way other browsers do[8]. A quick fix is to just check if the browser supports round() and use that instead. However, out of stubbornness[9], I also check that the -apple-system-font exists, ensuring that this is just a fix for Safari (inspired by Wojtek Witkowski's blog post).

Putting this all together, we get:

Displaying the Decimal Point

When displaying 0 decimal places, we don't want to print out a decimal point. Thus we need a method to test if a variable is 0, and print something accordingly. The fallback on a @counter-style is the toll we need. We use a cyclic system to print a decimal point for any positive number (which cycles through the one possible option), and fallback to a nothing style if the variable is less than 1:

Displaying the Fractional Part

Lastly we get to displaying the fractional part. Firstly, to calculate the fractional part, we can just subtract the integer part from the absolute value. By multiplying by a power of 10, we can shift this fractional part, so that it becomes an integer. We also clamp it to ensure that it does not go above ".999⋯" (to avoid rounding 1.999 to 1.10). If we want no decimal places, we also need to hide the counter with a fallback, rather than including a 0.

We also need to provide padding with 0's, so that 1.02 doesn't become 1.2. To achieve this, we will calculate the number of 0's needed and then use a symbolic counter style (this repeats a symbol the specified number of times). Again, we will use fallbacks to hide the counter if no zeros are necessary. Note that using the padding descriptor within the @counter-style does not work, as it cannot reference the variable containing the number of decimal places.

Finally Displaying a Full Number

Putting together all the previous parts, we get something like:

Altogether, this is a somewhat robust method for displaying floating-point variables using just CSS. Given the bit hacks, there are occasionally rounding issues, and if you set the number of decimal places to be too large, it will look like nonsense. Hopefully in the near future, we will not only have round() in all browsers, but also a way to print variables directly.

Function Support Testing

Testing if a function is supported did not involve any hacks at all. I did not know this, but the @supports query not only checks that the property exists, but also that the value is supported. This did involve a bit of duplicate CSS to test for each function, but each case was quite simple.

Conclusion

That's about it. There's some extra glue to make it all come together and give it some polish, but nothing worse than your typical webpage. Feel free to inspect-element and see the details.

While I doubt any of these techniques are suitable for "serious websites", working with limited tools is always a fun puzzle and something I'd highly recommend. They have also given me a much greater understanding of the nuances of CSS under the hood. I don't want to make "serious websites" anyway.