Browser Zoom, CSS, Event Handlers, and Accessibility
Tags: webdev, javascript, css, a11y
Published: 2023-03-16 09:10:02

A few weeks ago I was working on some features for my color mapping site (https://paintmap.studio) and I ran into some very odd edge-case bugs.  I found that when I used Chrome's zoom feature (which also exists in any other modern browser), my seemingly-perfect code was _breaking_.  Specifically, CSS properties and React/JavaScript event handlers were simply _disappearing_ when I viewed the app at different zoom levels.

Here's the exact scenario (along with the associated fix):

First, the user selects an image, along with a number of different parameters that determine exactly how that image will be transformed.  The result is that you get a pixelated image that matches the colors in that image to a known set of colors from a palette of paints.  The result looks like this:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/12gzlm0iprwnuiwzcpga.jpg)

In theory, the app will process images of any size.  They _could_ be small images that are easily viewed onscreen.  But in practice, nearly all the images that I put through the app are fairly large.  Most commonly, they're 1440x1440 pixels.  

This means that it can be difficult to view the generated image in its entirety.  So when I'm using the app, I usually have my browser's zoom function set to 75%.  On my 4k monitors, this allows me to view the entire 1440x1440 image onscreen without having to scroll horizontally or vertically.

Another feature of the app is that it will then convert the transformed image into a huge "color map".  This is a massive HTML table where every cell in the table corresponds to one of the pixelated blocks from the transformed image.  But on the map feature, each of those cells contains a number, and each number corresponds to a given color of paint.

(In case your wondering, the whole purpose of this feature is that I can then copy-n-paste the entire HTML table into a spreadsheet. Once it's in a spreadsheet, I can then use it as a "paint by numbers"-style template.)

Unfortunately, converting the image into the massive HTML table makes the onscreen output even _larger_.  This is necessary because if I try to adhere to the size of the original image, you'll never be able to see all of the numbers in every individual cell.

So even with my browser set at a 75% zoom, the resulting display looks like this:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9udnhdy6cd0oiuvhpg29.jpg)

That may seem somewhat unwieldy because I still have to scroll horizontally and vertically to see the whole image.  But this isn't much of a concern because it's all just gonna get copied-n-pasted into a spreadsheet anyway.  Besides, when you're viewing an image in this format that has 144x144 table cells, there's only so much you can do to make it "presentable" on a single screen.
<br/><br/>


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/zy07yckogp94g2jpv4wm.png)
## Adding clickable elements
Here's the feature that I needed to add.  When I was looking at the big "color map", I found myself wondering: "How many _other_ places on the map use the same color as the one that's in _this_ particular cell?"

For example, when looking at the following section of the map:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/fsery7m63477fzcs8qb0.jpg)

I can see that there are certain blocks that use color #920.  But I wanted to be able to quickly see, at-a-glance, where color #920 occurs _in the entire color map_.  So I added some new logic to the code that renders the table cells.  That looked like this:

```javascript
const getTableCells = (cells = [rgbModel], rowIndex = -1) => {
   allow.anArrayOfInstances(cells, rgbModel).anInteger(rowIndex, is.not.negative);
   const { highlightedColor } = uiState;
   return cells.map((cell, cellIndex) => {
      const paintIndex = colors.findIndex(color => color.name === cell.name);
      const darkness = (cell.red + cell.green + cell.blue) / 3;
      let color;
      let backgroundColor;
      if (highlightedColor === cell.name) {
         backgroundColor = '#39ff14';
         color = 'red';
      } else {
         backgroundColor = `rgb(${cell.red}, ${cell.green}, ${cell.blue})`;
         color = darkness < 128 ? 'white' : 'black';
      }
      return (
         <td
            className={'cell'}
            id={cell.name}
            key={`cell-${rowIndex}-${cellIndex}`}
            onClick={handleCellClick}
            style={{
               backgroundColor,
               borderWidth: highlightedColor === cell.name ? 5 : 0,
               color,
            }}
         >
            {paintIndex}
         </td>
      );
   })
}
```

Here's what's happening in that function:

1. We grab the `highlightedColor` from `uiState`.<br/>

1. Then for each cell, if that cell represents the `highlightedColor`, we set the style to have a neon green background, with a red number, and a border width of 5.  If it's _not_ the `highlightedColor`, then we match the background color of the cell to the "normal" color, with a black number, and a border width of 0.<br/>

1. We set an `onClick` handler for each cell that will update the `highlightedColor`.<br/>

1. We also set the `className` for each cell to `cell`.<br/>

The `cell` CSS class looked like this:

```css
.cell {
    border-color: red;
    border-style: solid;
    cursor: pointer;
    font-weight: bold;
    height: 50px;
    min-height: 50px;
    min-width: 50px;
    text-align: center;
    transform-origin: 0 0;
    vertical-align: middle;
    width: 50px;
}
```

Notice that I've set the `cursor` attribute to `pointer` to give an extra visual cue that each individual table cell is indeed _clickable_.

And the `handleCellClick()` event handler looked like this:

```javascript
const handleCellClick = (event = {}) => {
   allow.anObject(event, is.not.empty);
   uiState.toggleHighlightedColor(event.target.id);
}
```

The end result is that, if I clicked on any cell that contained #920, the previous subsection would look like this:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/czqcgyi84fmw3us9eiri.jpg)

And if I zoomed way out, I'd get something that looked like this:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/y42tyg8z12pppxnffl0q.jpg)

This allowed me to quickly see all the other places in the image that used the same color.
<br/><br/>


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/94pthq6i2rdw068evi7q.jpg)
## The zoom problem
At first, I was quite pleased with my new color-highlighting feature.  But then I noticed something really wonky.

When I viewed the color map with no browser zoom applied, everything worked great.  But viewing it with no zoom meant that I could only see a tiny portion on the screen.  When I went back to my "normal" setting - browser zoom = 75% - something really strange happened.

_Most_ of the `onClick` handlers worked just fine.  But in _some portions of the map_, they did **nothing at all**.  Also, for _most_ of the table cells, the `cursor: pointer` CSS attribute was applied.  But on all of the cells where the `onClick` handler was failing, the cursor also reverted to its default value of `pointer`.

I couldn't find any rhyme-or-reason as to why the event/CSS values failed on _some_ cells, but worked perfectly fine on others.  Furthermore, the exact cells that failed-or-worked varied _depending upon the particular zoom setting that I'd chosen in the browser_.

At 75% zoom, a given region of cells would lose their event/CSS settings.  But at 66% zoom, entirely _different_ regions would lose the settings.  However, I also noticed that, _at 50% zoom_, everything worked just fine.  **This was a critical clue as to what was happening.**
<br/><br/>


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/080obsie3fl5zljmujie.png)
## The dangers of subpixel rendering
You may have noticed that, in most modern IDEs, if you try to manually set a CSS property to a subpixel value (e.g., `height: 50.33px`), you'll get some kinda warning.  They do this because browsers can get kinda wonky when you set pixel values that are not integers.

Of course in my example, I wasn't _manually_ setting any style attributes to subpixel values.  Everything was set to nice, clean, whole numbers.  But here's the "gotcha":
<br/>

> When the user manually alters the display with their browser's zoom feature, it forces everything onscreen to be re-rendered.  And those re-renderings will often turn your nice, clean, whole numbers into _subpixel calculations_.

<br/>For example, I was setting every table cell to have `height: 50px` and `width: 50px`.  Nothing wrong with that.  But if the user sets the zoom factor to 66%, now every cell height and width becomes `33.33px`.

Now I'm gonna be honest here.  I don't fully understand what happens inside the guts of the browser itself that makes a table cell with `height: 33.33px` and `width: 33.33px` suddenly _lose_ it's CSS properties or its event handlers.  But I was able to prove out through experimentation that those subpixel calculations were indeed the source of my problem.

This explains why, at different zoom levels, _some_ regions of the color map performed just fine, while _other_ regions failed.  Because when you're tiling successive table cells on the screen at, say, 66% zoom, the _first_ cell will be "off" from the screen's normal pixel grid (because of that pesky `.33px` modifier).  And the _second_ cell will also be "off" from the pixel grid (because now that one is essentially shunted by `.66px`).  But the _third_ cell will be able to "snap into" the screen's normal pixel grid.  Because by the time that the third cell is rendered, it's "offset" is `1/3 + 1/3 + 1/3 = 1` - meaning that it can snap into the monitor's "normal" display.

This also explains why my code worked perfectly at 100% zoom, _and 50% zoom_, but failed at zoom levels like 66% or 75%.  Because, at 50% zoom, all of my `height: 50px; width: 50px;` cells became... `height: 25px; width: 25px;` cells - meaning that at 50% zoom, everything was still rendered with nice, clean, whole-number pixel dimensions.
<br/><br/>


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/jt0txsl3226vmn664jp9.jpg)
## The fix
Luckily, once I realized what was going on and I did a bit of CSS googling, I was able to solve the whole problem with a _single_ line of CSS.  I altered my `cell` CSS class by adding this:

```css
transform: scaleX(1) scaleY(1);
```

This basically tells each cell to scale up to consume 100% of its boundaries.  This in turn seems to clear the problem that the CSS-and-event-handlers had with subpixel-styled elements.
<br/><br/>


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/83l5pf5lena6d4piflpw.jpg)
## But... who cares??
It's reasonable to look at this odd bug and wonder if you should even care about it at all.  At first, I just filed it away as a weird edge case that I never ran into before - and I'd probably never need to worry about it again.  

But... I'm actually pretty sure that I _have_ run into this issue before.  There have been times in the past when I was working on a site and I got tired of scrolling around to verify functionality.  So I'd shrink the screen down so I could see it all in one shot.  But then, after days, or even weeks, of using the site in this manner, I'd find some odd glitch in the app.  

After investigating it further, and after realizing that none of my colleagues could reproduce the error, I'd realize that the issue only happened _for me_ because I was the only one using the site on a non-100% zoom level.  So what did I do?  I just flipped my zoom back to 100% and went about my business.  

Afterall, no one _else_ had reported the error or was complaining about it in any way.  So I didn't want to burn dev cycles troubleshooting a problem that no one else cared about.

The only reason that I felt a need to _fix_ the bug, in this particular case, was because it was important _to me_.  Specifically, it "mattered" because it's hard to view those large color maps onscreen unless I'm shrinking it down to a workable size.

Additionally, even if someone _had_ complained in the past about features being broken in non-100%-zoomed formats, I'm pretty sure that I would've been fairly dismissive about it.  It's not that I don't care about the user experience.  But my reasoning would have been as such:
<br/>

> We spend a lot of time painstakingly ensuring that the site works optimally _in its default resolution_.  If you feel compelled to change that resolution (for some odd reason), I can't be bothered to test-and-troubleshoot every far-edge-case that results from people using the site _in unintended ways_.

<br/>Maybe that sounds flippant.  But there are many things you can do/use in a browser that subvert the devs' original intent.  And we can hardly be expected to devote loads of troubleshooting time to your aberrant way of using the app.

For example:  I've received "bug" tickets from users who said that something was broken on the site.  When I investigated it further, I couldn't recreate the error.  Nor could anyone else on our team.  When I contacted the user directly, I realized that the "bug" was caused because they're using some _third-party browser plugin_.  And it's interfering with the site's normal functionality.

In such scenarios, my answer has always been the same:  Stop using that third-party browser plugin on our site.  Sure, some people have whined about this response.  But I've always received support in this stance from my management chain.  Because trying to ensure that your app works properly _no matter which third-party plugin someone chooses to use_ is a horrific rabbit hole that yields almost no discernible value.

Finally, issues caused by browser zoom functionality feel like _extreme_ edge cases.  I mean, 99.99% of the time when I'm viewing/using _any_ website, I'm viewing/using it at the default 100% zoom level.  For the vast majority of users out there, they probably use most/all sites at the same 100% zoom level as well.
<br/><br/>


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/j2h8gkldbgu5xvdjc85w.jpg)
## An accessibility issue
But the more I thought about this, I began to realize this is _an accessibility issue_.  And it's one that I don't think many dev teams are really thinking much about.

I've worked on apps where accessibility was no one's real concern.  We had a limited userbase and management put no priority on accessibility standards.  So we happily cranked out code without worrying about things like A11Y compliance.

I've also worked on teams where accessibility was a critical concern.  In those shops, we constantly ran auditing tools and preached the importance of accessibility standards in everything we built.

But even in those "woke" environments, I don't _ever_ remember _anyone_ saying that we needed to validate the site's features _at different zoom levels_.  But what subset of your userbase is most likely to be using your site/app at varying zoom levels?  That's right.  People with impaired vision.  And if key features of your site simply _break_ at, say, 125% zoom, you could be alienating those users.

To be clear, I'm not even sure exactly how you test for this.  Like, if you're writing Jest unit tests, you won't be able to validate functionality at different zoom levels.  You'd need to leverage a headless browser solution.  And I'm not sure if quick auditing tools (like Axe) even check for these things?  I'll have to research that some more.

At any rate, I'm not trying to upend your current accessibility practices.  And to be fair, maybe in _your_ dev shop, this is already something that's on your radar.  But I'm just highlighting it as one potential issue that you may need to keep in mind going forward.

Cheers!  And happy coding.