Hamburger icon: buns+burger same size despite pixel alignment?

681 Views Asked by At

enter image description here

Problem: the middle bar is a pixel taller than the other bars. This is not because I got the css wrong, it's because browsers align the edges of rectangles to the pixel grid.

I've tried a few different ways of making a hamburger icon:

  1. absolutely position a few divs with background-colors.

  2. inline svg tag

  3. svg as a background image

  4. single div with top/bottom border and background-color for the bars, and padding and background-clip: content-box; for the spaces.

All of these methods have the same problem:

At some zoom-levels/sizes, the icon looks bad because the three rectangles aren't all the same size. They're different sizes sometimes because browsers like to align the edges of rectangles with the pixel grid.

I of course want the two spaces between the rectangles to be the same thickness too.

I get that this comes with the compromise that the icon must be a multiple of 5px tall. Or, if you can expand the spaces by one pixel at alternating breakpoints than when you expand the bars, then it can be 2X or 3X as many different sizes.

So what's the trick?

Can a webfont use hinting to make it so all three rectangles get the same number of pixels of thickness regardless of size/resolution/zoom?

Is there some sort of CSS math I can do?

I'm really hoping I don't have to have JavaScript that tries to detect changes in the zoom level.

EDIT: LIVE DEMO

var hamburgerIconEl = document.getElementById('hamburgerIcon');
var fontSizeEl = document.getElementById('fontSize');
fontSizeEl.addEventListener('input', function (e) {
    hamburgerIconEl.style.fontSize = '' + (8 + parseFloat(fontSizeEl.value) / 100) + 'px';
});
    .body {
        background: white;
    }
    #hamburgerIcon {
        box-sizing: border-box;
        display: inline-block;
        height: 1.7ex;
        width: 2.04ex;
        border-width: .34ex 0;
        border-style: solid;
        border-color: black;
        background-clip: content-box;
        padding: .34ex 0;
        background-color: black;
    }
<p>Change the font size, watch the hamburger icon, see how there are a lot of sizes in which the bars don't all have the same thickness and/or spacing.</p>
<div>
  <input type="range" id="fontSize" min="0" max="1000" value="0">
  <label for="volume">Font Size</label>
</div>
<div>
    <div id="hamburgerIcon" style="font-size: 8px"></div> Menu
</div>

4

There are 4 best solutions below

1
A Haworth On

Systems do struggle with what to do with part pixels - one CSS pixel can map to several screen pixels on modern devices.

When there is an attempt at showing a fraction of a pixel there has to be a decision on whether to leave a screen pixel 'behind' or not.

A slightly simpler way of drawing the hamburger, using background image linear gradient, removes the problem of the middle bar sometimes not appearing centered. There are however the odd occasions where the middle bar is not exactly the same height as the other two.

var hamburgerIconEl = document.getElementById('hamburgerIcon');
var fontSizeEl = document.getElementById('fontSize');
const label = document.querySelector('label');
fontSizeEl.addEventListener('input', function(e) {
  hamburgerIconEl.style.fontSize = '' + (8 + parseFloat(fontSizeEl.value) / 100) + 'px';
  label.innerHTML = 'Font Size = ' + hamburgerIconEl.style.fontSize;
});
#hamburgerIcon {
  display: inline-block;
  padding: 0;
  margin: 0;
  height: 1.7ex;
  width: 2.05ex;
  background-image: linear-gradient(black 0 20%, transparent 20% 40%, black 40% 60%, transparent 60% 80%, black 80% 100%);
}
<div>
  <input type="range" id="fontSize" min="0" max="1000" value="0">
  <label for="volume">Font Size</label>
</div>
<div id="hamburgerIcon"></div>

1
chrwahl On

Now, I haven't seen your inline SVG, but could the issue be that you placed the lines too close to the edge of the SVG? If so, part of the top and bottom lines will seam thinner because they are cute off.

var hamburgerIconEl = document.getElementById('hamburgerIcon');
var fontSizeEl = document.getElementById('fontSize');
fontSizeEl.addEventListener('input', function (e) {
  hamburgerIconEl.style.fontSize = '' + (8 + parseFloat(fontSizeEl.value) / 100) + 'px';
});
svg {
  height: 2em;
  width: 2em;
  font-size: 8px;
}
<div>
  <input type="range" id="fontSize" min="0" max="1000" value="0">
  <label for="volume">Font Size</label>
</div>
<div>
  <svg viewBox="0 0 10 10" id="hamburgerIcon">
    <path d="M 1 3 H 8 M 1 5 H 8 M 1 7 H 8" fill="none" stroke="black" stroke-width="1" />
  </svg>
</div>

0
MarcRo On

This is a super tricky issue. Different browsers round half-pixels in different ways and have different zoom-levels, so it will be hard to set a size that scales well on all browsers and zooms.

You can check out this question if you haven't found it already.

What you could do is try to use a <canvas> element to draw your shapes. This scales more evenly to my experience.

Example:

const canvas = document.getElementById('canvas')
if (canvas.getContext) {
    var ctx = canvas.getContext('2d');
    ctx.fillStyle = 'green';
    ctx.fillRect(0, 0, 30, 1);
    ctx.clearRect(0, 1, 30, 2);
    ctx.fillRect(0, 3, 30, 1);    
    ctx.clearRect(0, 4, 30, 2);
    ctx.fillRect(0, 6, 30, 1);
  }
.container {
  padding: 10px 20px;
  display: block;
}

.burger {
  background-color: green;
  background-clip: content-box;
  padding-top: 2px;
  padding-bottom: 2px;
  border-top: 2px solid green;
  border-bottom: 2px solid green;
  height: 2px;
  width: 30px;
}
<div class="container">
  <h5>Div with bordrder</h5>
  <div class="burger one"></div>
</div>


<div class="container">
  <h5>Canvas</h5>
  <canvas id="canvas"></canvas>
</div>

0
JasonWoof On

Here's a javascript solution. It works perfectly for me on Chrome, but fails in creative ways on my iPad.

var hamburgerIconEl = document.getElementById('hamburgerIcon');
var fontSizeEl = document.getElementById('fontSizeSlider');
var dppxEl = document.getElementById('dppx');
function updateHamburgerIcon () {
    var height = parseFloat(getComputedStyle(hamburgerIconEl).height);
    var physPx = height * window.devicePixelRatio;
    var sparePhysPx = physPx % 5;
    var sparePx = sparePhysPx / window.devicePixelRatio;
    hamburgerIconEl.style.setProperty('--sparePx', '' + sparePx + 'px');
}
fontSizeEl.addEventListener('input', function (e) {
    hamburgerIconEl.parentElement.style.fontSize = '' + (8 + parseFloat(fontSizeEl.value) / 50) + 'px';
    updateHamburgerIcon();
});
updateHamburgerIcon();
function onResolutionChange (cb) {
    dppxEl.innerText = '' + window.devicePixelRatio;
    matchMedia('(resolution: ' + window.devicePixelRatio + 'dppx)')
        .addEventListener("change",
            function (e) {
                onResolutionChange(cb);
                cb(e);
            }, { once: true });
}
onResolutionChange(updateHamburgerIcon);
.body {
    background: white;
    color: black;
}
#fontSizeSlider {
    width: 100%;
}
#hamburgerIcon {
    display: inline-block;
    height: .75em;
    width: .88em;
    background-color: transparent;
    background-position: 50% 100%;
    background-repeat: no-repeat;
    background-image: linear-gradient(black 0 20%, transparent 20% 40%, black 40% 60%, transparent 60% 80%, black 80% 100%);
    --sparePx: 0px;
    background-size: auto calc(.75em - var(--sparePx));
}
<p>Try the size slider below, and also changing your zoom (current dppx: <span id="dppx"></span>)</p>
<div>
    <input type="range" id="fontSizeSlider" min="0" max="1000" value="0">
</div>
<div style="font-size: 8px">
    <div id="hamburgerIcon"></div> Menu
</div>