Responsive design
In short: Responsive design makes a page work on every screen: the viewport meta tag, mobile first and media queries that change the layout by the width.
Problem
The button is off the screen
The product page was made on a monitor, and it looks fine there. A customer on a phone writes: "I can't order, there is no button". Here it is at the width of a phone, 360px:
Console
The "Order" button is on the right, off the screen. The page slides sideways, and most people do not think of sliding it. The code has no error: the picture is 400px, the text column is 360px, and they sit side by side. On a monitor 1000px wide that fits. On 360px it does not.
The idea
Screens have different widths
The same page opens on a phone (360–430px), on a tablet (about 800px) and on a computer (1000px and more). A page that adapts to every width is called responsive.
The first step is in <head>:
<meta name="viewport" content="width=device-width, initial-scale=1">Without it the phone pretends to be a monitor: it lays the page out at 980px and shrinks it until it fits. The text gets tiny, and your CSS never sees the real 360px. With it, the width of the page is the width of the screen. In the sandbox we add this line for you, but in your own pages you write it.
The second step is the order in which you write the CSS. Mobile first means: the base rules are for the narrowest screen. There everything is one below the other, and that almost always fits. Then you add changes for wider screens with a media query:
.product { display: grid; gap: 16px;}@media (min-width: 700px) { .product { grid-template-columns: 1fr 1fr; }}@media (min-width: 700px) is a condition: "the width is at least 700px". The rules inside apply only when the condition is true. Otherwise the browser skips them.
The third step is pictures. width="400" is good for the HTML: the browser keeps space while the picture loads. On a narrow screen, though, it needs a ceiling:
img { max-width: 100%; height: auto;}max-width: 100% means "no wider than its box", and height: auto keeps the proportions so it is not squashed.
See it
See the queries switch on
The window grows from 360px to 1000px. Watch the ruler at the top and the lines in the code.
body { margin: 0; padding: 16px; }.cards { display: grid; grid-template-columns: 1fr; gap: 16px;}@media (min-width: 600px) { .cards { grid-template-columns: 1fr 1fr; }}@media (min-width: 900px) { .cards { grid-template-columns: repeat(4, 1fr); }}Step by step, in words
- A phone, 360px. The base rule is for the narrowest screen: one column. A card is 360 − 2 × 16 = 328px wide.
- The window gets wider and the column grows with it. At 599px the condition min-width: 600px is still false, and the browser skips the rules inside.
- At 600px the condition is true. Its rule comes after the base one and wins: two columns of 276px. The cards flow into the new grid.
- Wider again: the two columns grow to 425.5px each. min-width: 900px is still false.
- At 900px both queries are true. They set the same property, so the later one wins: four columns of 205px.
- At 1000px nothing new switches on. The four columns grow to 230px.
- The browser checks the queries again on every change of width: a turned phone, a resized window. Narrow it and the columns go back the same way.
A media query is not worked out once. The browser checks it on every change of width and lays the page out again at once. The two queries set the same property with the same specificity, so at 900px the later one wins. That is the cascade you already know.
Try it
Fix it for a phone
The page is 360px wide. Make it mobile first: the picture and the text one below the other, and nothing sticking out sideways. The picture is no wider than its box. From 700px up the picture and the text are in two columns. The check looks for sideways scrolling and for how many rows the picture and the text column sit on.
Console
Solution
index.html<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dobrudzha bread · The shop</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<main class="product">
<img src="img/bread.svg" width="400" height="400" alt="A round white loaf on a board">
<div class="info">
<h1>Dobrudzha bread</h1>
<p>White bread, 650 g, baked this morning.</p>
<p class="price">€1.80</p>
<button type="button">Order</button>
</div>
</main>
</body>
</html>
style.cssbody {
margin: 0;
font-family: system-ui, sans-serif;
}
.product {
display: grid;
gap: 16px;
padding: 16px;
}
img {
max-width: 100%;
height: auto;
}
.price {
font-size: 1.5rem;
font-weight: 700;
}
button {
background: #1a7f37;
color: #fff;
border: 0;
border-radius: 6px;
padding: 12px 20px;
font-size: 1rem;
}
@media (min-width: 700px) {
.product {
grid-template-columns: 1fr 1fr;
gap: 32px;
}
}
.product becomes display: grid with one column. img { max-width: 100%; height: auto; } shrinks the picture to the width of its box. The two columns are in @media (min-width: 700px), so they do not apply on the phone. overflow-x: hidden on body is not a fix: the scrolling goes away, and the button stays off the screen.
Try it
Now for a computer
This page is already made for a phone: everything is in one column. Here the width is 1000px. Add a media query so that from 700px up the picture and the text are in two equal columns.
Console
Solution
index.html<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dobrudzha bread · The shop</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<main class="product">
<img src="img/bread.svg" width="400" height="400" alt="A round white loaf on a board">
<div class="info">
<h1>Dobrudzha bread</h1>
<p>White bread, 650 g, baked this morning.</p>
<p class="price">€1.80</p>
<button type="button">Order</button>
</div>
</main>
</body>
</html>
style.cssbody {
margin: 0;
font-family: system-ui, sans-serif;
}
.product {
display: grid;
gap: 16px;
padding: 16px;
}
img {
max-width: 100%;
height: auto;
}
.price {
font-size: 1.5rem;
font-weight: 700;
}
button {
background: #1a7f37;
color: #fff;
border: 0;
border-radius: 6px;
padding: 12px 20px;
font-size: 1rem;
}
@media (min-width: 700px) {
.product {
grid-template-columns: 1fr 1fr;
gap: 32px;
}
}
@media (min-width: 700px) { .product { grid-template-columns: 1fr 1fr; gap: 32px; }}max-width: 700px is the opposite: "at most 700px". It switches on only on narrow screens. min-width: 700 without px is invalid, and an invalid query is never true.
Try it
Find the bug
On a computer there should be two columns, but the page stays in one. Why?
Pick the line with the bug.
The media query is true, but the base rule comes after it. Both are .product, with the same specificity, and the later one wins: grid-template-columns: 1fr on line 8. With mobile first the base rules come first and the media queries after them.
Try it
Fill in
Which word makes the two columns apply from 700px up?
@media (: 700px) {
.product {
grid-template-columns: 1fr 1fr;
}
}min-width: "the width is at least 700px". With mobile first, queries almost always use min-width.
The idea
The colour in one place
The green of the shop is in the CSS in four places: the price, the links, the button and the border. The shop changes its colour and someone has to find them all. Miss one and the page is in two colours.
A CSS variable (custom property) is a value with a name. The name starts with two dashes, and var() reads it:
:root { --accent: #1a7f37;}button { background: var(--accent);}:root is the topmost element, <html>. Variables are inherited like color, so whatever is in :root is seen everywhere in the page. The colour changes on one line.
The same gives a dark theme. @media (prefers-color-scheme: dark) is true when a person has chosen a dark theme on their phone or computer. Inside it you change only the values of the variables. The rules that use them stay the same.
See it
Light and dark
Console
The sandbox follows the theme of your browser, not the theme button of this site. Switch your system theme to dark and press "Run": the background, the text and the green change, while the CSS rules for .card, .price and button are the same. The green on dark is lighter, so it reads on the dark background.
Try it
A new colour for the shop
The shop changes the green #1a7f37 to orange #c2410c. Make a variable --accent in :root with the value #c2410c and use it in the four places where the green is now.
Console
Solution
index.html<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dobrudzha bread · The shop</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<main>
<article class="card">
<h1>Dobrudzha bread</h1>
<p class="price">€1.80</p>
<p>See the <a href="/bread">other breads</a></p>
<button type="button">Order</button>
</article>
</main>
</body>
</html>
style.css:root {
--accent: #c2410c;
}
body {
margin: 0;
padding: 16px;
font-family: system-ui, sans-serif;
}
.card {
max-width: 320px;
padding: 16px;
border-top: 4px solid var(--accent);
}
.price {
color: var(--accent);
font-size: 1.5rem;
font-weight: 700;
}
a {
color: var(--accent);
}
button {
background: var(--accent);
color: #fff;
border: 0;
border-radius: 6px;
padding: 12px 20px;
font-size: 1rem;
}
:root { --accent: #c2410c;}Then the four places say var(--accent). The check reads both the variable and the real colours. So orange typed by hand in four places does not pass. Neither does var(accent) without the dashes: the browser drops such a declaration and the button stays grey.
The idea
States: pointed at, focused, disabled
A button does not look one way only. It can be under the mouse, focused from the keyboard, or disabled. CSS catches these with pseudo-classes: a word with a colon after the selector.
button:hover { background: #116329;}button:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px;}button:disabled { background: #9ca3af; cursor: not-allowed;}:hover: the mouse is over the element. A phone has no mouse, so important information is not hidden behind:hover.:focus-visible: the element has focus and the browser decides the focus should show. From the keyboard it always shows. On a mouse click on a button it usually does not. That is whyoutline: noneon:focusis a bug: a keyboard user loses track of where they are.:disabled: the element has thedisabledattribute. The "Sold out" button should look disabled, not just be disabled.
And the size. On a phone you press with a finger, and it is less precise than a mouse. A button 24px tall gets missed. Give buttons a height of at least 44px: min-height: 44px.
Try it
The buttons on a phone
A colleague removed the focus outline, and the buttons are short. Make it so: the buttons are at least 44px tall (min-height); on keyboard focus there is a 3px solid outline in the colour --accent; the disabled button "Sold out" is grey (#9ca3af) with the cursor not-allowed. The check presses Tab and looks at the "Order" button.
Console
Solution
index.html<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dobrudzha bread · The shop</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<main>
<article>
<h2>Dobrudzha bread</h2>
<p>€1.80</p>
<button type="button">Order</button>
</article>
<article>
<h2>Yellow cheese</h2>
<p>€7.50</p>
<button type="button" disabled>Sold out</button>
</article>
</main>
</body>
</html>
style.css:root {
--accent: #1a7f37;
}
body {
margin: 0;
padding: 16px;
font-family: system-ui, sans-serif;
}
button {
min-height: 44px;
background: var(--accent);
color: #fff;
border: 0;
border-radius: 6px;
padding: 8px 16px;
}
button:hover {
background: #116329;
}
button:focus-visible {
outline: 3px solid var(--accent);
outline-offset: 2px;
}
button:disabled {
background: #9ca3af;
cursor: not-allowed;
}
Remove button:focus { outline: none; }. If it stays after :focus-visible, it wins: the specificity is the same and it comes later. The outline goes on button:focus-visible and the grey on button:disabled. The pseudo-class :disabled catches the disabled attribute from the HTML, so a .disabled class is not needed.
More precisely, if you are curious
Viewport, more precisely. Without the meta tag, mobile browsers lay the page out at 980px and shrink it. In Chromium with an emulated 360px phone, innerWidth is 980 and @media (min-width: 700px) is true: the phone gets the computer layout. initial-scale=1 means "no shrinking at the start". Do not turn zoom off (user-scalable=no, maximum-scale=1): people with weak eyesight enlarge the page with two fingers.
Breakpoints. 600, 700, 900 are not sacred numbers. Put a breakpoint where your layout starts to look bad, not at the model of some phone. Some write queries in em (min-width: 40em): then they also follow the font size a person has chosen.
Queries not only for width. (hover: hover) is true when the main pointing device can hover (a mouse), and (pointer: coarse) is true for a finger. (prefers-reduced-motion: reduce) says a person does not want motion: remove the animations. (orientation: landscape) is for a sideways screen.
Variables, more precisely. var(--accent, #1a7f37) has a fallback value in case the variable is missing. A variable can change for part of the page: .sale { --accent: #c2410c; } and everything in .sale is orange. The value is checked only when it is used: --accent: 12px is allowed, but then color: var(--accent) is invalid and the colour becomes the inherited one. The property color-scheme: light dark tells the browser to colour its own parts (fields, scrollbars) in the dark theme too.
Size for a finger. WCAG 2.2 (criterion 2.5.8) asks for a target of at least 24 × 24px or enough space around it. Apple recommends 44 × 44pt, and Material Design 48 × 48dp.
Exercise
The catalogue on a phone
The catalogue was made for a computer: four cards of 220px in one row. Here the width is 360px. Make it for a phone: the cards in two columns, no sideways scrolling, pictures no wider than their card, the "Buy" buttons at least 44px tall (min-height). Put the green #1a7f37 in a variable --accent in :root and make the buttons use it. If you like, add four columns from 700px up as well.
Console
Solution
index.html<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Catalogue · The shop</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<main>
<h1>Catalogue</h1>
<div class="cards">
<article class="card">
<img src="img/bread.svg" width="220" height="220" alt="">
<h2>Dobrudzha bread</h2>
<p>€1.80</p>
<button type="button">Buy</button>
</article>
<article class="card">
<img src="img/milk.svg" width="220" height="220" alt="">
<h2>Fresh milk 3%</h2>
<p>€2.40</p>
<button type="button">Buy</button>
</article>
<article class="card">
<img src="img/cheese.svg" width="220" height="220" alt="">
<h2>Yellow cheese</h2>
<p>€7.50</p>
<button type="button">Buy</button>
</article>
<article class="card">
<img src="img/apples.svg" width="220" height="220" alt="">
<h2>Apples, 1 kg</h2>
<p>€2.20</p>
<button type="button">Buy</button>
</article>
</div>
</main>
</body>
</html>
style.css:root {
--accent: #1a7f37;
}
body {
margin: 0;
font-family: system-ui, sans-serif;
}
main {
padding: 16px;
}
.cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.card img {
max-width: 100%;
height: auto;
}
.card button {
min-height: 44px;
background: var(--accent);
color: #fff;
border: 0;
border-radius: 6px;
padding: 8px 16px;
}
@media (min-width: 700px) {
.cards {
grid-template-columns: repeat(4, 1fr);
}
}
grid-template-columns: 1fr 1fr splits the width in two, while repeat(2, 220px) is fixed again and does not fit: two cards and the gap are 2 × 220 + 16 = 456px, more than 360px. The pictures need max-width: 100%, or with width: 220px they stretch the columns. The four columns for a computer are in @media (min-width: 700px) after the base rule. The check runs on the phone and does not see them, but the page is complete with them.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Selectors and the cascade · The box model · Flexbox: row and column · Grid: rows and columns
- Tasks on this topic: CSS: how the page looks