Responsive design

from Red Blob Games
30 Nov 2017

Table of Contents

I have web pages going back 22 years and I've been making layout changes less and less often. Every time I change something it takes a while to go back through all my pages to make sure it works on all of them.

My older pages like polygon map generation are designed for a 450 pixel width. I had designed that layout to support desktop computers back in the 1990s.

640x480800x6001024x7681280x10241600x1200

As I worked on more visual pages the 450 pixel width seemed limiting so I designed newer pages for 600 pixels.

640x480800x6001024x7681280x10241600x1200

For mobile, I told the phone to treat the page as 640 pixels wide, and scale it to fit (using viewport width=640). I haven't updated that layout in years. I experimented with wider layouts for the probability page and the hexagon page (both pages switch to a two column layout when the browser is wide enough) but for the most part I've tried to stick to the 600 pixel width. It's simple. It works.

Except it doesn't work well on tablets, because it applies the same size to tablets and phones:

The "scale to fit" behavior tries to use the same tiny format for a phone in portrait mode and a tablet in landscape mode. A tablet in landscape mode is closer to a laptop size, so why isn't it using a layout similar to a laptop?

To fix this I needed to learn “responsive design”.

#Goal

Instead of making the page a fixed width:

I want the contents to adapt to the size of the browser:

The main things that will vary are the paragraph width, font size, and the diagram size. The diagrams will be the hardest, as it's going to vary on each page, so I first worked on paragraph width, and then tackled font size, then finally diagrams.

#Visualization

The first thing I needed was a good way to think about the problem. I decided I should think about this as a function from browser width to content width. In my old layout, this function is constant. It always returns 450px or 600px, regardless of browser width.

I made a visualization of this function. The browser sizes are along the y-axis; the layout of the page is displayed along the x-axis. Here's what my old layout looks like — at any browser size, the layout is the same:

I want the content width to change as the browser width increases. I studied another site I greatly admire:

I like that. The site is beautiful and works really well at common phone and tablet sizes. I tried the visualization of their layout:

Huh, weird, that's unexpected!

That site is responsive but not monotonic: when going from 767px to 768px, or 1079px to 1080px, the paragraphs become narrower. Most people aren't resizing their browser, so they won't notice this.

For my site I decided I did not want to design for specific devices. Instead, I wanted these two properties:

  1. Monotonic: when the browser becomes wider, the paragraphs should stay the same or become wider, never become narrower.
  2. Continuous: when the browser changes size a tiny bit, the paragraphs should change size a tiny bit, not a large amount.

Here's my goal:

At narrow sizes, I give almost all the space to the content. At medium sizes, I give some space to content and some to margins. At larger sizes, I clamp the content size and give all the additional space to the margins.

How do I implement this in CSS?

#Content width

After several experiments and iterations I made these rules to set the content width:

body {
    --body-width: calc(100vw - 36px);
}
@media (min-width: 550px) {
    body {
        --body-width: calc(330px + 33vw);
    }
}
@media (min-width: 1000px) {
    body {
        --body-width: 660px;
    }
}

It's a piecewise linear function. The @media rules are breakpoints: at a certain browser width, they apply a new set of CSS rules. At 1000px, both the min-width:1000px and min-width:550px rules apply, but the last definition is what the browser will use.

In the first segment (up to 550px), 100vw - 36px, the 100vw represents the slope of the line. 100vw means 100% of the browser width. That means every time the browser width changes by X, the content width changes by X too.

In the second segment (550px to 1000px), the 33vw is the slope, meaning that every time the browser width changes by X, the content width changes by ⅓ X (the margins get the other ⅔ X).

In the third segment (past 1000px), there's no vw component, so the slope is 0, meaning the content does not get any wider as the browser gets wider.

#Font size

One of the guidelines for making paragraphs comfortable to read is to make them no more than 75 characters wide. As my paragraph width increases, the lines would become too long. To compensate, I increase the font size:

body {
    --font-size: calc(10px + 1vw);
}
@media (min-width: 1000px) {
    body {
        --font-size: 20px;
    }
}
  • When the browser is 600px wide, the paragraphs are 528px wide and the font is 16px (ratio 33).
  • When the browser is 1000px wide, the paragraphs are 660px wide and the font is 20px (ratio 33).

I chose these sizes by adjusting the font until it was approximately 66 characters at 600px and 1000px. Once I had 16px and 20px as the goals, I used the linear interpolation formula to come up with the expression 10px + 1vw. Below 550px, the ratio (affecting line length) doesn't stay exactly 33, but that's okay.

#Two column layout

My probability, visibility, and curved paths pages use a two column layout based on my 450px fixed layout. When the browser is narrow, it is one column, and as it becomes wider it splits into two columns:

My hexagon guide uses a two column layout based on my 600px fixed layout:

How should I handle a responsive two column layout?

5.1Text and images equal#

Once I have enough space for two columns with small text, make the two columns the same width, with a gutter in between, and grow both equally as I get additional space:

I ended up using this for the probability page, where the left column has text, code, and diagrams, and the right column has diagrams and code, and also on the curved path page, where the left column has text, and the right column has text and diagrams.

There are two breakpoints. At 18px leftmargin + 528px leftcolumn + 18px gutter + 528px rightcolumn + 18px gutter = 1110px, I can switch to two columns (both 528px wide):

/* from the probability page */
@media (min-width: 1110px) {
    body {
        --margin: 18px;
        --body-width: calc((100vw - 3 * var(--margin)) / 2);
    }
    body section > * {
        margin-left: var(--margin);
    }
    body section .float-container {
        width: calc(100vw - 2 * var(--margin));
        max-width: calc(2 * var(--body-width) + var(--margin));
    }
    .float-container > * {
        float: left;
        width: var(--body-width);
    }
    .float-container > .float-right {
        float: right;
    }
}

When both columns reach 660px, they should stop growing. That's at 18px leftmargin + 660px leftcolumn + 18px gutter + 660px rightcolumn + 18px gutter = 1374px:

@media (min-width: 1374px) {
    body {
        --body-width: 660px;
    }
    body section > * {
        margin-left: calc((100vw - 1374px) / 2);
    }
}

(Sorry, I haven't tried generalizing this to other sizes and breakpoints)

5.2Text prioritized over images#

Once I have enough space for the two columns with large text, switch to two columns with small diagrams, then grow the diagrams as I get additional space:

I used this style for the hexagon and visibility pages, where the left column has text and code, and the right column has diagrams. There are two breakpoints. At 40px marginleft + 660px body + 18px gutter + 400px diagrams + 40px marginright = 1158px, the text column remains full width, and the second column with diagrams can start at 400px:

/* from the hexagon page */
@media only screen and (min-width: 1158px) {
      body {
          --gutter: 18px;
          --diagram-width: calc(100vw - 758px);
          --margin-left: calc((100vw - var(--body-width) - var(--gutter) - var(--diagram-width)) / 2);
      }

      body section > * {
          margin-left: var(--margin-left);
          max-width: var(--body-width);
      }
      body section > .float-container {
          width: calc(var(--body-width) + var(--gutter) + var(--diagram-width));
          max-width: inherit;
      }
      body section > .float-container > .float-right {
          width: var(--diagram-width);
      }
}

When the diagrams reach 600px, I stop increasing their size:

@media only screen and (min-width: 1358px) {
      body {
          --diagram-width: 600px;
      }
}

A variant of this would be to start the two column layout earlier, as soon as I can fit 528px paragraphs and 400px diagrams. That would require three breakpoints: two column, two column with paragraphs at full size, and two column with both columns at full size. I haven't tried this yet.

#Sizing elements

With the fixed size version of the page I picked a px size for each svg, img, canvas, iframe. When the browser width varies, I want the sizes to vary. For some elements, I want them to be at a "native" size unless the browser is narrow; and then I want it to shrink. For other elements, I want them to occupy the same width as the paragraphs.

Since my existing content doesn't specify which of these behaviors applies, I can't automatically convert all old pages to the new format. I need to do it one page at a time.

It's slightly trickier with interactive elements.

6.1svg#

To automatically resize an svg to the full width of the container, use viewBox="0 0 w h" instead of width="w" height="h".

For interactive elements: mouse event positions will have to be handled in code using createSVGPoint and getScreenCTM; d3-drag handles these automatically, but I had some of my own code that I had to adjust.

To start an SVG at 400px, allow it to shrink but not expand, use style=max-width:400px.

Since my stylesheet uses margin:auto to center things on the screen, and that only works with block elements, I put all the top level SVGs into a <figure> which is a block element:

<figure>
  <svg viewBox="0 0 600 300">
     ...
  </svg>
  <figcaption>Figure 2: lorem ipsum<</figcaption>
</figure>

6.2img#

To automatically resize an img, use style="max-width:100%;height:auto" or style="width:100%;height:auto".

As with SVGs, a combination of width and max-width can make the images shrink but not expand. And as with SVGs, I put top level images inside <figure>.

6.3canvas#

To automatically resize a canvas, use style="width:100%;height:auto".

For interactive elements, rescale the mouse event positions by multiplying by canvas.width / canvas.offsetWidth.

This gets a little tricky with window.devicePixelRatio. The standard code you find out there sets the css width and height to adjust for the device pixel ratio. For responsive design, we want the css style to be 100%. You can skip those two lines of code, and use:

if (window.devicePixelRatio && window.devicePixelRatio != 1) {
    canvas.width = width * window.devicePixelRatio;
    canvas.height = height * window.devicePixelRatio;
    ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
}

It gets more complicated if you want to use css max-width instead of width.

6.4iframe#

Unlike svg and img, the CSS rules do not automatically preserve aspect ratio, nor will they resize the content. That may be okay for some projects, but I left most of my iframe-using pages with the fixed layout. :(

I found a trick for iframe sizing on jameshfisher.com ; it seems to work but is kind of weird.

6.5Side by side#

When placing elements side by side, I had been using width= to set the pixel width of each, often with inline-blocks. I switched to using flexbox or percentage sizes. For example:

/* from my interactive A* page */
.side-by-side {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
}
.side-by-side > figure {
    flex: 0 1 250px;
    flex-basis: calc(var(--body-width)/2);
    margin: 0;
}
.side-by-side > figure > svg {
    margin: 0 11px;
}

For an example of this, see the A* page. In the section comparing A* to Dijkstra's Algorithm and Greedy Best First Search, I use a flexbox that exceeds the content width. On narrow browsers it will be two rows; on wide browsers it will be one row. I use a combination of flex-wrap and flex-basis. Another example is the offset grids on the hexagon page. On narrow browsers it will be two rows and on wide browsers it will be one row. It's not great though, as at some widths it will split with three small diagrams on the first row and one large one on the second row; I don't have a fix for this yet.

6.6absolute positioning#

My visibility page uses absolutely positioned DOM elements in a complicated way. I was unable to find an easy way to switch this to a responsive layout. I converted that page to a 600px fixed layout, and hope to revisit it later, when I make other updates to that page.

#Testing

For testing, Firefox's screenshotting tool and Chrome's screenshotting tool were both useful. I took screenshots before and after a change, and then reviewed the pages when screenshots changed.

# Firefox
/path/to/firefox \
    -screenshot \
    --window-size=$width,4000 "$url"

# Chrome
 /path/to/chrome \
     --headless --disable-gpu \
     --window-size=$width,4000 \
     --screenshot "$url"

I made a web page that showed the before-and-after screenshots for a set of test pages, to make it easier to spot differences. There are professional tools for this but this was quick & easy.

#Resources

  • Distill.pub site design - I learned a lot by studying Distill.pub's design, by Shan Carter.
  • Responsive typography - font size can be defined in terms of the vw unit
  • How to Scale SVG - svg size can be defined in terms of the container instead of using px sizes
  • CSS Locks - linear interpolation css properties between two values. I had worked out the math for making sizes monotonic and continuous, and then learned that other people do this too, and call it "locks".
  • The most used responsive breakpoints - if you are designing for specific devices - but I decided not to do this, instead using content size for breakpoints.
  • I used Flexbox cheatsheat and Flexplorer for quickly trying out flexbox configurations, and CSS-Tricks's guide to flexbox as a reference
  • I'm using px sizes for this design but I want to move towards rem units for accessibility, as described in EM vs REM vs PX – Why you shouldn't “just use pixels”. Until then, you'll have to use browser zoom to change the font size. I made some progress towards this, removing a lot of px sizes in my css, but there's a lot more to go. This is taking longer because I'm trying to keep backwards compatibility with all my existing html+css going back decades.
  • Along with the layout changes, I also used line-height to set up a vertical rhythm that is responsive.
  • Apple is adding min(), max() functions to their implementation of CSS in iOS 11.2; if those functions are more widely adopted, it will make some of the CSS for responsive layout simpler.
  • The minmax CSS feature seems like it'd be very useful, but it's only for CSS grids.

Email me at , or tweet to @redblobgames, or post a public comment: