😱 Pure CSS! Neural Network / AI...it's easier than you think! 🤯

GrahamTheDev - Oct 30 '23 - - Dev Community

No, you didn't read that title wrong.

It is possible to build a neural network in pure CSS. I made a cool demo and everything!

However, training that neural network is next to impossible (with CSS) due to limitations on how many calc() statements you can have that rely on previous ones! So we won't do that here!

Now, the point of this article is not to advocate for building neural networks in CSS (you shouldn't), but instead to introduce you to a couple of CSS tricks I had to use to get this to work!

Read this first

Now, if you haven't already read it, I would recommend reading my previous article (or at least checking out the demo) where I built a neural network in vanilla JS.

The reason being, this is the exact same neural network, but without all the training abilities sadly.

Other than that, let's jump right in!

Show me the money Code!

Here it is:

:root{

    --inputX: 0.9;
    --inputY: -1;
    --output1: 0;
    --output2: 0;
    --output3: 0;
    --output4: 0;

    --output1bias: 0.006412765611663633;
    --output2bias: 0.007072853542676219;
    --output3bias: 0.0064746685639952214;
    --output4bias: 0.004851470988693036;

    --weights1-1: -0.9807254999119579;
    --weights1-2: 0.9813663133332142;
    --weights1-3: -0.9817224902785696;
    --weights1-4: 0.9817919593383302;
    --weights2-1: -0.9809082670731147;
    --weights2-2: -0.9816176935504328;
    --weights2-3: 0.9815336794202348;
    --weights2-4: 0.9815925299039976;


    /* propogate */
    --output1a: calc((var(--weights1-1) * var(--inputX)) + (var(--weights2-1) * var(--inputY)));
    --output2a: calc((var(--weights1-2) * var(--inputX)) + (var(--weights2-2) * var(--inputY)));
    --output3a: calc((var(--weights1-3) * var(--inputX)) + (var(--weights2-3) * var(--inputY)));
    --output4a: calc((var(--weights1-4) * var(--inputX)) + (var(--weights2-4) * var(--inputY)));

    --output1b: calc(max(0, var(--output1a)) + var(--output1bias));  
    --output2b: calc(max(0, var(--output2a)) + var(--output2bias));    
    --output3b: calc(max(0, var(--output3a)) + var(--output3bias));   
    --output4b: calc(max(0, var(--output4a)) + var(--output4bias)); 

    /* categorise */
    --maxOut: max(var(--output1b), var(--output2b), var(--output3b), var(--output4b));

    --output1c: max(calc(1 - ((var(--output1b) - var(--maxOut)) * (var(--output1b) - var(--maxOut)) * 1000000000)), 0);
    --output2c: max(calc(1 - ((var(--output2b) - var(--maxOut)) * (var(--output2b) - var(--maxOut)) * 1000000000)), 0);
    --output3c: max(calc(1 - ((var(--output3b) - var(--maxOut)) * (var(--output3b) - var(--maxOut)) * 1000000000)), 0);
    --output4c: max(calc(1 - ((var(--output4b) - var(--maxOut)) * (var(--output4b) - var(--maxOut)) * 1000000000)), 0);
}
Enter fullscreen mode Exit fullscreen mode

What, were you expecting thousands of lines of CSS? Are you disappointed?

Well fear not, in the next article in this series I will level that up and try and do Optical Character Recognition (OCR) using similar techniques! (might be a few weeks for that one though lol)

But, stick around, there are some interesting things we can learn from pushing CSS to it's limits even with this simple demo!

The Demo

The demo was actually harder to write than the Neural network (as I wanted the demo interface to be pure CSS / HTML too!).

As you select a square in the first section of our pre-trained neural network, it then calculates which quadrant it believes that value was within (shown in the "Neural Network Prediction" section).a

Each of the squares in the first section represents an x, y coordinate that lies between -1 and +1 on each axis. For example, the top left square is -0.8, 0.8 and the bottom right square is 0.8, -0.8.

Finally if you scroll down you will see some values from the neural network to show what is going on "under the hood".

Have a play and then we can have a look at some tricks I had to use to get the demo working!

Note: You may have to scroll up and down to see the inputs vs predictions. It is probably best viewed on PC to minimise scrolling.

A few interesting techniques

OK so first let's start with the neural network itself.

Sigmoid is out, say hello to ReLU!

Our first problem, was that we can't use exp() (exponent) if we want our solution to work on most browsers, as CSS exp() is only supported on FireFox and Safari.

This meant we can't create a sigmoid function for our outputs.

So instead we need another method to replace our sigmoid function.

Luckily there is another popular option here when working with Neural Networks...Rectified Linear Units (ReLUs).

These simply ignore all values less than 0 and only return positive values.

To implement this in CSS is (relatively) straight forward:

 --ReLU: calc(max(0, var(--output)) + var(--outputBias));  
Enter fullscreen mode Exit fullscreen mode

And technically we don't even need calc here (but I always like to include it).

What this does is say "give me the max value of "0" or our output + bias".

If our output + bias is less than 0 then we return 0 (as that is the max number), otherwise we return our output + bias!

Straight forward solution!

Getting a normalised result

There was another big problem to solve here, we need our Neural Network to be able to output it's guess as either a 1 (the guess) or a 0 (not the correct value).

Unfortunately this isn't how Neural Networks work. In fact they output a certainty for all values.

Something like:

  • x < 0, y < 0: 0.845 <- the highest probability
  • x > 0, y < 0: 0.283
  • x < 0, y > 0: 0.154
  • x > 0, y > 0: 0.319

And we need to turn that into:

  • x < 0, y < 0: 1 <- highest probability turns into a 1
  • x > 0, y < 0: 0
  • x < 0, y > 0: 0
  • x > 0, y > 0: 0

This is where this trick comes in:

    --maxOut: max(var(--output1b), var(--output2b), var(--output3b), var(--output4b));

    --output1c: max(calc(1 - ((var(--output1b) - var(--maxOut)) * (var(--output1b) - var(--maxOut)) * 1000000000)), 0);
Enter fullscreen mode Exit fullscreen mode

This might look complicated, so let's break it down.

First we need to find the largest value that is outputted by our 4 output neurons.

    --maxOut: max(var(--output1b), var(--output2b), var(--output3b), var(--output4b));
Enter fullscreen mode Exit fullscreen mode

So if our 4 output neurons were 20,11,16,4 then --maxOut would be 20.

Then we use that number to do the following:

  • subtract the max value from each output.
  • do this again and multiply them together (this accounts for negative values).
  • we then multiply this value by 1000000000 just to ensure that rounding is not a problem.
  • then we subtract this value from "1".
  • we then take the max value of either the output of that or 0.

This works as if the value is the same as the max value we are essentially doing the following:

  --output1c: 1 - ((20 - 20) * (20 - 20) * 1000000000)
  --output1c: 1 - ((0) * (0) * 1000000000) /* which is 1 - 0 */
  --output1c: max(1 - (0), 0) /* the max is 1 - 0 which is 1 */
Enter fullscreen mode Exit fullscreen mode

However if the value is less than the max value, the following happens (let's say max is 20 and our value is 14):

  --output1c: 1 - ((14 - 20) * (14 - 20) * 1000000000)
  --output1c: 1 - ((-6) * (-6) * 1000000000) /* which is 1 - (36 * 1000000000) */
  --output1c: max(1 - (36000000000), 0) /* the max is 0 as it is greater than 1 - 36000000000 which is -35999999999 */
Enter fullscreen mode Exit fullscreen mode

This solves our categorisation issue!

And that is all we needed to make a neural network (as the rest of the work is just multiplying biases together with inputs that should hopefully be self explanatory if you read my previous article).

Outputting some values

Here is one super useful trick I have not seen people use before.

We can use CSS counter() in order to debug our "application".

You may be wondering why we need this? Well if you try and get the value of a CSS calc expression you will soon run into problems. You will get the string back, not the actual value!

// --example-var: calc(20 * 3)
console.log(window.getComputedStyle(div).getPropertyValue('--example-var'))
// console will output "calc(20 * 3)" instead of 60!
Enter fullscreen mode Exit fullscreen mode

So this is why we can use the counter trick in a pinch!

Now, one thing to note is that counter() in CSS has some limitations. It only allows for integers.

This is a problem as we are dealing with a lot of decimals.

Luckily, as this is only for debugging, we have a workaround.

But before we tackle that, let's show you how you can use CSS counters to grab some values from your CSS.

#input1:after{
    counter-reset: input1 var(--input1);
    position: absolute;
    content: "input 1: " counter(input1);
}
Enter fullscreen mode Exit fullscreen mode

A couple of tricks here. First is that we need to actually output the value, so we use a pseudo element so we can utilise the content property (remember we can't use JS as it will just output the string value).

The second is that we initialise our counter with the value of our CSS variable using counter-reset:.

This means that if our --input1 has a value of 4, then our counter is (re)set to 4 as well.

Now, as I said, counters use integers. This is a problem when we have decimals. The answer is straight forward (although imperfect). We can multiply our decimal by a large value to make it an integer.

counter-reset: output1 calc(var(--output1b) * 100000);
Enter fullscreen mode Exit fullscreen mode

This quick hack moves our decimal place 6 positions to the right.

As I said, it is ugly (and we could probably do some fancy tricks to get our decimal place...that might be a future fun experiment!) but it does give us some outputs.

This is the technique used for the whole of the third section of the demo, if you are wondering how I outputted text there.

Important note: Unfortunately content: values are not exposed to assistive technology. And this is the main reason why "CSS only" is for nothing more than fun most of the time.

Do not use this trick in production, just save it for debugging when other methods fail.

Anyway, that is the hack, I hope that one day it will help you out!

Tricks for the demo itself

I wanted the demo itself to also be CSS only. So to achieve this there are two tricks here.

The input grid

The first is the input "grid" we have created.

Because we only want one value to be selected at a time (our x and y coordinates) I used a <input type="radio"> and then laid it out in a grid shape using floats and clears!

But the main trick was that I wanted to still make this keyboard accessible, so I used a trick where I visually hide the input and then adjust the label appearance based on state.

We then use the labels themselves in order to create our grid shape.

/* our label is the actual grid square */
label{
    --wh: min(10vw, 60px);
    width: var(--wh);
    height: var(--wh);
    display: block;
    background-color: #bb0000;
    float: left;
    text-align: center;
    font-size: 0.2vw;
    outline: 1px solid #666;
}

input[type="radio"]{
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  height: 1px;
  overflow: hidden;
  position: absolute;
  white-space: nowrap;
  width: 1px;
}

input:checked + label{
    background-color: #00ff00;
    outline: 4px solid #000;
    outline-offset: -4px;
}

input:focus + label{
    outline: 8px solid #333;
    outline-offset: -8px;
    border-radius: 16px;
}


Enter fullscreen mode Exit fullscreen mode

To achieve this we use the + operator. This grabs the next sibling (item on the same "level") that matches that selector.

So by grabbing input:checked and then finding the next label to that input with the + operator, we are able to use our labels as the items we display, instead of the radio inputs themselves, all while making the radio inputs still accessible.

Now, speaking of the radio inputs, I did mention that we need to visually hide them!

input[type="radio"]{
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  height: 1px;
  overflow: hidden;
  position: absolute;
  white-space: nowrap;
  width: 1px;
}
Enter fullscreen mode Exit fullscreen mode

The CSS above means that the input is still accessible to assistive technology and still focusable, but it does not take up a single pixel visually.

Combining this with the previous trick we can create a grid of radio inputs!

The output grid

Ahhh the final trick here.

If you remember earlier we created a way to make our neural network output a 1 or a 0.

But how do we turn that into a "red" and "green" for each of the 4 quadrants depending on the output?

This is where we can use a neat trick with linear gradients and opacity for our fill and outline respectively!

--color1: #00ff00;
--color2: #990000;
--switch1: var(--color1) calc(100% * var(--output1c)), 
                 var(--color2) 0;
--switch1outline: rgba(0,0,0, calc(100% * var(--output1c)));
Enter fullscreen mode Exit fullscreen mode

For our --switch1, we want the square to either be colour red (if the value is 0) or coloured green (if the value is 1).

By toggling the percentage of our first colour between either 100% (cover the square) or 0% (our secondary colour will take over) we get a neat way of toggling the colour.

We use a similar technique on our outline, adjusting the alpha value (transparency) to either 100% (visible) or to 0% (invisible).

By applying these styles to each of the 4 squares that make up our output grid as follows:

#out-x-1y-1{
    background: linear-gradient(var(--switch1));
    outline: 4px solid var(--switch1outline);
    outline-offset: -4px;
}
Enter fullscreen mode Exit fullscreen mode

Then depending on which grid square has an output of "1" we either get a dark red square or a bright green square with a dark outline.

That's it!

As with many of these more silly CSS articles, the result is not very useful, but getting it to work results in some interesting workarounds and techniques that can be useful!

I used some other CSS techniques in my article on bubble sort in pure CSS, if you want to check that out:

What is next?

Oh, just me trying to see if I can build a neural network in CSS that can perform optical character recognition...no biggie! 😱🤣

If you enjoyed this article and like to see unusual applications of web technologies, then give me a follow, either here or give me a follow on Twitter.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .