Let me share some code that may be of use to somebody. For my application I need a grid. Not the kind for responsive websites but like a grid intended for visual guidelines. Much like a notebook.

Writing block

I have some requirements that I want to support.

  • Zoom
  • Panning
  • It must be infinite. So no max height or width.
  • No background images

So, I came up with this.

See the Pen Infinite grid by Dion Snoeijen (@octopus11) on CodePen.

It’s best viewed from within codepen because the embed widget is ignoring the scrollwheel and mouse events. Or at least, it works wonky like this.

You can use the scrollwheel, or whatever that you are using to scroll to zoom in and out. And if you hold down space, you can click and drag the grid in any direction.

The solution is relatively simple. It’s using a css gradient to draw the grid. Then we update the css on events and then we set the gradient css on the grid element.

So, first let’s create the html and css.

<div class="grid">
</div>

We really don’t need anything fancy, let’s just focus on the grid and give it minimal styling.

body, html {
  padding: 0;
}

.grid {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
  bottom: 0;
}

.panning {
  cursor: grab;
}

I start with initializing some variables in JavaScript. Their use will be clear soon.

const grid = document.querySelector('.grid');
const translate = {
  scale: 1,
  translateX: 0,
  translateY: 0
};
let panning = 0;
const pinnedMousePosition = { x: 0, y: 0 };
const pinnedGridPosition = { x: 0, y: 0 };

Now I will introduce a doGrid() method so we can initialize the grid system.

const doGrid = () => {};
doGrid();

Right below that the method is called. The doGrid method will contain all event listeners.

Events for panning

keydown is needed to register if the spacebar is being pressed. With that info we can update panning with +1.

keyup will cancel the spacebar press to set panning back with 1.

mousedown will store all current parameters and will add +1 to panning.

mouseup will take 1 of panning.

mousemove to handle the changes made by panning.

Event for zooming

scrollwheel It’s for registering a zoom in / out action.

  grid.addEventListener('wheel', event => {
    translate.scale += (event.deltaY * (translate.scale / 5000));
    if (translate.scale > 3) {
      translate.scale = 3; 
    }
    if (translate.scale < 0.4) {
      translate.scale = 0.4;
    }
    update();
  });

  document.addEventListener('keydown', event => {
    if (event.key === ' ' && !panning) {
      event.preventDefault();
      grid.classList.add('panning'); 
      panning++;
    }
  });

  document.addEventListener('mousedown', event => {
    pinnedGridPosition.x = translate.translateX;
    pinnedGridPosition.y = translate.translateY;
    pinnedMousePosition.x = event.clientX;
    pinnedMousePosition.y = event.clientY;
    panning++;
  });

  document.addEventListener('mouseup', () => {
    panning--;
  });

  document.addEventListener('keyup', event => {
    panning--;
    grid.classList.remove('panning');
  }); 

  grid.addEventListener('mousemove', event => {
    if (panning === 2) {
      const diffX = (event.clientX - pinnedMousePosition.x) / translate.scale;
      const diffY = (event.clientY - pinnedMousePosition.y) / translate.scale;
      translate.translateX = pinnedGridPosition.x + diffX;
      translate.translateY = pinnedGridPosition.y + diffY;
      update();
    }
  });

It’s all about updating the translate object. Once updated, we need to call the method that’s doing all the real work. The update method which is also part of doGrid.

  const update = () => {
    let transform = '';
    Object.keys(translate).forEach(property => {
      transform += property + '(' + translate[property] + (property !== 'scale' ? 'px' : '') + ') ';
    });
    grid.style.transform = transform.trim();

    const vars = {
      a: 9 * translate.scale,
      b: 10 * translate.scale,
      c: -.5 * translate.scale,
      d: translate.scale,
      e: 99.5 * translate.scale,
      f: 100 * translate.scale
    };
    const backgroundPosition = grid.getBoundingClientRect();
    const colors = { a: 57, b: 75, c: 80 };

    const background = `
    repeating-linear-gradient(
      0deg,
      transparent 0,
      transparent ${vars.a}px,
      rgb(${colors.b}, ${colors.b}, ${colors.b}) ${vars.a}px,
      rgb(${colors.b}, ${colors.b}, ${colors.b}) ${vars.b}px
    ) 
    ${backgroundPosition.x}px ${backgroundPosition.y}px / ${vars.f}px ${vars.f}px repeat,
    repeating-linear-gradient(
      90deg,
      transparent 0,
      transparent ${vars.a}px,
      rgb(${colors.b}, ${colors.b}, ${colors.b}) ${vars.a}px,
      rgb(${colors.b}, ${colors.b}, ${colors.b}) ${vars.b}px
    ) 
    ${backgroundPosition.x}px ${backgroundPosition.y}px / ${vars.f}px ${vars.f}px repeat,
      repeating-linear-gradient(
      0deg,
      rgb(${colors.c}, ${colors.c}, ${colors.c}) ${vars.c}px,
      rgb(${colors.c}, ${colors.c}, ${colors.c}) ${vars.d}px,
      transparent ${vars.d}px,
      transparent ${vars.e}px
    ) 
    ${backgroundPosition.x}px ${backgroundPosition.y}px /  ${vars.f}px ${vars.f}px repeat,
    repeating-linear-gradient(
      90deg,
      rgb(${colors.c}, ${colors.c}, ${colors.c}) ${vars.c}px,
      rgb(${colors.c}, ${colors.c}, ${colors.c}) ${vars.d}px,
      transparent ${vars.d}px,
      transparent ${vars.e}px
    ) 
    ${backgroundPosition.x}px ${backgroundPosition.y}px /  ${vars.f}px ${vars.f}px repeat,
    rgb(${colors.a}, ${colors.a}, ${colors.a});
`;
    grid.setAttribute('style', 'background: ' + background);
  }

As you can see the update method is used to generate the background css attribute. It needs to be set to the element by using setAttribute. grid.style.background = background; does not work and I honestly don’t know why to be honest ;).

Please check the full example on codepen.