Responsive design

 from Red Blob Games
30 Nov 2017

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”.

 1  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.

 2  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?

 3  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.

Alternatively, use rem units:

body {
    --body-width: calc(100vw - 2.25rem);
}
@media (min-width: 34.375rem) {
    body {
        --body-width: calc(20.625rem + 33vw);
    }
}
@media (min-width: 62.5rem) {
    body {
        --body-width: 41.25rem;
    }
}

Note that as of mid-2020 we can start using clamp() to simplify this calculation. I wrote this page in 2017 and wasn't able to use it yet. See this article[1] to see how to set font sizes with clamp().

 4  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;
    }
}

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.

Alternatively, use rem units:

body {
    --font-size: calc(0.625rem + 1vw);
}
@media (min-width: 62.5rem) {
    body {
        --font-size: 1.25rem;
    }
}

However, inside diagrams, I use a fixed font size in pixels! This is because the diagram itself is scaling, including all its contents. I don't want to scale the diagram and then scale the font again, or it will apply scaling twice. That means I can't use tools like px_to_rem[2].

 5  Two column layout#

My probability[3], visibility[4], and curved paths[5] 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[6] uses a two column layout based on my 600px fixed layout:

How should I handle a responsive two column layout?

 5.1. Text 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[7], 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);
    }
}

At some point I'd like to generalize this to calculate the breakpoints using SASS, and also switch to rem units.

 5.2. Text 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.

 6  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.1. svg

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.2. img

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.3. canvas

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.4. iframe

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[8] ; it seems to work but is kind of weird. Also see Aspect Ratio Boxes[9].

 6.5. Side 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[10]. 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[11]. 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.6. absolute positioning

My visibility page[12] 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.

 7  Testing#

For testing, Firefox's screenshotting tool[13] and Chrome's screenshotting tool[14] 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.

 8  Resources#

Email me , or tweet @redblobgames, or comment: