CSS-Only Graphing Calculator
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:
- Write your function in the form
--y: f(var(--x));
. For example:--y: 2 * var(--x) - 3;
- If your function is not working, try including spaces around
+
and-
. - Various functions are available, such as
sqrt()
andpow()
(a full list of functions supported by your browser can be found beneath the calculator). - Smooth transitions don't always work. Try ensuring that your function is always syntactically valid.
- The calculator relies on some modern CSS features that your browser may not support. It should look like this. If not, you might need to update your browser[1]. Using a desktop browser might also help and will make things easier to use, but shouldn't be necessary.
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.
abs(x)
cos(x)
clamp(min, x, max)
exp(x)
hypot(a, b, c, ...)
log(x, base)
max(a, b, c, ...)
min(a, b, c, ...)
mod(x, divisor)
pow(base, exponent)
rem(x, divisor)
round(x, interval)
orround(strategy, x, interval)
sign(x)
sin(x)
sqrt(x)
tan(x)
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);
This is computed as:
--y: calc(2 * var(--x));
--x: calc(1 + 1);
Instead of doing the calculation first, the tokens are substituted in literally. If we had
--y: calc(2 * calc(1 + 1));
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 (i.e. if to include "-" at the front)
- Displaying the integer part of the number
- Displaying the decimal point (or hiding it if the number of decimal places is set to 0)
- Displaying the fractional part of the number
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 by computing . Then the sign of is , 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 (the SPSN of this system), we shift everything over so that the decimal gets chopped off. We can then multiply by (or divide by ) to get the original number, but without the decimal.
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 */
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).
--is-32-bit: (1 - 1.401298464324817e-45 / 2 * 2 / 1.401298464324817e-45);
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.
- [1]
As of 13/06/2024, I have tested the graphing calculator in Firefox (126.0.1; 64-bit), Chrome (125.0.6422.142; 64-bit), Opera (110.0.5130.66; 64-bit), Safari on iOS (17.5) and Safari (also 17.5; thanks Jimmy).
- [2]
If we used the new
:has()
pseudo-class, we would not need the checkbox before any elements that refer to the state. Note that before this pseudo-class existed, you could not adjust styling based on subsequent elements. This is probably the more modern version of this hack. - [3]
A lot of pure CSS projects are simplified by a strong HTML or CSS preprocessor, such as HAML or SCSS[4]. Having access to features such as for-loops (to create extra elements or styles) eases many of the issues that arise when you attempt to use CSS as a workhorse. However, this is not the point of CSS-only shenangans; the limitations of CSS are what makes them interesting.
In the same spirit, I tried to reduce the amount of duplicate HTML. Some of it is unavoidable, but I was able to cut it in third (see Even More Points).
- [4]
In the end, I did use SCSS, but only to import constants that I use across this website for consistency.
- [5]
Safari does not support
counter-set
, butcounter-reset
does the exact same thing (bar some magic stuff with the inheritance of counters that we do not need to worry about). - [6]
Firefox and Safari supported all the maths functions I could test. I don't know why someone would need to use
pow()
within normal CSS, but it is very nice that they include it. - [7]
While it is hard to debug websites on Safari (the lack of any sort of tools at all; it is hard enough to determine the version of Safari running) from an iPhone, I did find a cool trick to force refresh the cache for a page. From my initial searches, it seemed like you could only refresh the cache for all pages (from the settings page for Safari). However, if you turn your phone on airplane mode, refresh the page, turn off airplane mode, then refresh again, the cache for a page is refreshed. I don't know why there is not a direct option (say, double tapping or long tapping the refresh button), but at least this works.
- [8]
I honestly don't know what exactly is different about how Safari handles floats. Even asking around, I did not find much, so I'm going to share everything I know.
Safari seems to use 64-bit floats (i.e. doubles) for CSS variables, as it can handle values up to . It also seems to be fine with calculations of very small values. By observing the result of
var(--x)/var(--x)
for small--x
, Safari correctly calculates this as 1 until--x
is smaller than some particular value, which I will call . For , the result is insteadInf
. This hard limit means our rounding shenanigans will never work in Safari, regardless of the magic number we use.The even stranger aspect is where this critical point occurs. One might reasonably expect that Safari just doesn't use subnormal numbers (say, by employing a certain rounding mode, such as flush-to-zero). However, the smallest positive normal number for 64-bit floats is approximately and is greater than , so Safari can handle some subnormal numbers. Instead, this phase transition occurs at approximately (I found this experimentally). This is exactly a quarter of the smallest positive normal number. I have absolutely no idea why this is the case; I don't even have a hypothesis. If you have any ideas, please reach out and let me know, as this continues to haunt me.
- [9]
And because I want to show off how I could detect if a system uses 32- or 64-bit variables.