In a world with , chapter 2.3.
THE PATH · SESSION 8 OF 66 · UNIT 2 · ACT I
Formation Magic
Flexbox, CSS Grid, and Responsive Design
At a glance
- PACE
- Session 8 of 66 · suggested week 3. Continue when you are ready.
- UNIT
- Unit 2 · The First Skill I Learned in This World Was Making Things Pretty
- ACT
- Act I · Foundation
- BEFORE
- 2.2 Armor and Cosmetics
- AFTER
- 2.4 Enchantments
- ARTIFACT
- layout-demo.html with a flex navigation bar and a card grid that wraps by itself; holy-grail.html whose named areas restack at 768px and 1024px; and formation.html, a responsive quest portfolio with a hero, six cards, fluid type, and no horizontal scrollbar, committed and pushed
- READ
- 65 min · 5 diagrams · 19 code blocks
The hook#
The pages you shipped last session have color, type, space, and a hover. They also have one shape. Everything stacks, top to bottom, as wide as the window. That is what a browser does with a document when nobody has told it otherwise. Open any site you actually use. The navigation is a row. The cards sit beside each other and drop to the next line when they run out of room. A sidebar keeps the main story company. Shrink the window and the row becomes a stack, on purpose, not by accident. That rearrangement is layout, and it is the difference between a styled document and a page.
The company had learned to stand in a line. Then the captain drew a shape in the dirt: a front, two flanks, a reserve behind. The same people. A different formation. When the ground got narrow, the flanks folded in behind the front and the company was a line again, and nobody had to be told twice, because the narrow shape had been the first one they practiced.
Two systems do this work, and they are not rivals. Flexbox lays items out along one direction, a row or a column, and is how you build a bar, a centered hero, a card's insides. Grid lays items out along both directions at once, and is how you build the page: header, navigation, story, sidebar, footer, each in a named place. Before either existed, people stretched floats and tables into jobs they were not made for, and wrote articles about centering a box. Centering a box is three declarations now. You will measure them.
Then the window. Phones, tablets, laptops, and the occasional ultrawide. A page that only works at the width of your monitor is a page that works for you. The approach is mobile-first: the rules you write with no query are the small screen, and a query adds rules for a wider screen. The narrow formation is the one you practice first. By the end of today the potion shop's cards sit in a grid, the about page survives a phone, and you have a formation drill of your own.
The stages apply these declarations as inline styles, the same constraint as last session, and then they measure. A gap, a shared top edge, a header that spans its container. The numbers are the layout. The CSS you save is in the blocks.
The idea#
One direction#
Flexbox is for a single line of items. You turn it on at the parent.
.navbar { display: flex; flex-direction: row; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem;}display: flex is the switch. The element becomes a flex container, and its direct children become flex items. Grandchildren are unaffected. Nothing visible happens until a second property, because the defaults are a row, packed at the start, stretched on the cross axis.
flex-direction names the main axis. row, the default, runs left to right. column runs top to bottom. row-reverse and column-reverse flip the direction without changing your HTML, which you will almost never want, because the visual order then disagrees with the order a screen reader walks.
justify-content distributes items along the main axis. flex-start packs them at the start. flex-end packs them at the end. center puts the group in the middle. space-between pins the first item to the start and the last to the end and shares the free space between the items. space-around puts equal space on both sides of each item, which means the outer ends get half of what the gaps get. space-evenly makes every gap, including the ends, the same. If you remember one, remember space-between for a bar with a name on the left and links on the right, and center for a group that should sit in the middle.
align-items does the same job on the cross axis, the one perpendicular to the main axis. stretch is the default, and it is why a row of cards becomes equal height when one of them has more text. center is the other one you will type constantly. flex-start, flex-end, and baseline are there when the stretch is wrong.

Fig 1 is the whole memory trick. In a row, justify moves left and right and align moves up and down. In a column, those sentences swap. When a layout is wrong, say the axis out loud before you change a property. Most "align-items did nothing" bugs are a direction you forgot you set.
flex-wrap: wrap lets items drop to the next line when they do not fit. The default is nowrap, which is why a nav of eight links overflows the phone and grows a horizontal scrollbar. gap is space between items, and it does not collapse the way margin does. Prefer it. Margin on flex items is still useful for one trick: in a column, margin-top: auto on the last child eats all the free space above it and pins that child to the bottom. That is how a card's price sits on the same line across cards of different heights.
The children's properties are about size, not about the group. flex-grow is a share of leftover space. All of them at 1 means equal shares. One of them at 2 means that one gets twice the leftover the others get. flex-shrink is the same idea when there is not enough room; 0 means "do not get smaller than your basis." flex-basis is the size before growing or shrinking, a suggestion, not a promise. The shorthand is flex: grow shrink basis, and flex: 1 is a common way to say "share the space, and start from nothing." align-self overrides the container's align-items for one child. order changes the visual order and not the source order. Leave it alone unless you have a reason a screen reader should hear a different sequence than a sighted reader sees. The source order is the real order.
The pattern that replaced a decade of hacks:
.hero { display: flex; justify-content: center; align-items: center; min-height: 50vh;}Both axes centered. min-height gives the cross axis somewhere to center in. Without a height, a row is only as tall as its content, and centering on the cross axis has no free space to spend.
Both directions#
Grid is for rows and columns together. You turn it on the same way, on the parent.
.page { display: grid; grid-template-columns: 200px 1fr 200px; grid-template-rows: auto 1fr auto; gap: 1rem;}grid-template-columns is the list of column sizes. 200px is fixed. 1fr is a fraction of the remaining space, after the fixed tracks have taken theirs. 200px 1fr 200px is a fixed flank, a flexible middle, a fixed flank. 1fr 2fr gives the second column twice what the first gets of whatever is left. fr is not a length you can measure until the other tracks are placed. That is why a column of 1fr next to a column of 300px does not mean "equal," and why people who treat fr as a synonym for percent get a layout that will not add up.
Named areas are why grid is pleasant to read.

.page { display: grid; grid-template-columns: 200px 1fr 200px; grid-template-rows: auto 1fr auto; grid-template-areas: "header header header" "nav main aside" "footer footer footer"; min-height: 100vh; gap: 1rem;} .page-header { grid-area: header; }.page-nav { grid-area: nav; }.page-main { grid-area: main; }.page-aside { grid-area: aside; }.page-footer { grid-area: footer; }The string is the picture. Repeating header across the first row makes the header span three columns. min-height: 100vh gives the middle row somewhere to grow, so the footer sits at the bottom of the viewport even when the story is short. 100vh is the viewport height. 100vw is the width, and you will use it rarely, because a horizontal scrollbar plus 100vw is a classic way to be 15 pixels too wide.
repeat(3, 1fr) is 1fr 1fr 1fr. minmax(280px, 1fr) is "at least 280, and then a share of the rest." Put those together and you get the line that retires a pile of media queries:
.card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr)); gap: 1.5rem;}
As many 280-pixel columns as will fit, each one growing to share the row, and the next card moving down when another 280 will not. Fig 3 draws the plain form, minmax(280px, 1fr), and that is the one to say out loud. The min(280px, 100%) wrapped around the minimum is the guard for a phone: a plain 280 cannot shrink, so on a viewport narrower than 280 plus the container's padding the one column a phone gets is wider than the phone, and the page grows a horizontal scrollbar. min() takes the smaller of its two values, so the minimum is 280 where there is room for 280 and the full width of the container where there is not. auto-fit collapses tracks that have no card, so two cards on a wide screen grow to fill the row. auto-fill keeps the empty tracks, so two cards stay 280-ish and the rest of the row is blank columns. For a card grid you want auto-fit. You will feel the difference the first time a single card stretches the full width and you did not expect it; that is fit, doing what it was told.
Which one, then. A navigation bar, a centered hero, a cluster of badges in a row: flex. The skeleton of the page, equal columns that stay equal, a region that must sit in a particular cell: grid. A wrapping row of cards can be either; the auto-fit line is grid, and it is the one to reach for. The honest answer is both, on the same page. Grid decides where the regions are. Flex decides how the things inside a region line up. Fig 5, in the demonstration, is that sentence drawn.
The narrow shape first#
Responsive means the page is good at every width you can drag the window to, not tolerable. The way to get there is to write the narrow layout as the base, with no query, and to add wider layouts inside @media (min-width: …).
The viewport line from Session 2.1 is what makes this true on a phone. Without <meta name="viewport" content="width=device-width, initial-scale=1.0">, a phone pretends to be a desktop and shrinks the result, and your carefully stacked layout never gets a chance to run.

.page { display: grid; grid-template-columns: 1fr; grid-template-areas: "header" "nav" "main" "aside" "footer";} @media (min-width: 768px) { .page { grid-template-columns: 200px 1fr; grid-template-areas: "header header" "nav main" "nav aside" "footer footer"; }} @media (min-width: 1024px) { .page { grid-template-columns: 200px 1fr 200px; grid-template-areas: "header header header" "nav main aside" "footer footer footer"; }}Read it from the top. Every screen gets the stack. A viewport at least 768 pixels wide also gets the two-column version, and keeps getting it as the window grows, until 1024, where the three-column version replaces the template. The query does not say "only tablets." min-width: 768px means "768 and up," and the next query overrides it when the window gets there. That is why the order of the queries matters. Write them smallest first.
max-width queries are the other direction, desktop rules undone for phones. They work, and you will see them, including in demos that stack a nav by overriding a row. Prefer min-width. A base that is already the phone does not need to be undone.
Breakpoints are where your layout breaks, not a catalog of devices. 768 and 1024 are useful defaults because that is where a 200-pixel sidebar starts to deserve its place. If your cards collide at 900, the query goes at 900.
clamp(1.5rem, 4vw, 3rem) is a font size that is 4 percent of the viewport width, never smaller than 1.5rem, never larger than 3rem. It scales without a query. Use it for headings. Keep body text in rem, so a reader who enlarged their default type still gets the enlargement. vw on body text ignores that preference, which is why the clamp for a paragraph should keep its minimum in rem.
The demonstration#
Three stages and the file you will actually save. Fig 5 is the target: a bar that is a flex row, and a grid of cards whose insides are flex columns.

Walkthrough 1: the bar. Create layout-demo.html and layout-demo.css in html-css-foundations. The head is the boilerplate you know, plus the stylesheet link. The body starts here.
<nav class="navbar"> <a class="navbar-brand" href="#board">Quest Board</a> <ul class="navbar-links"> <li><a href="#board">Home</a></li> <li><a href="#board">Quests</a></li> <li><a href="#board">Party</a></li> <li><a href="#board">Inventory</a></li> <li><a href="#board">Profile</a></li> </ul></nav><main id="board" class="main-content"> <h1>Welcome to the Quest Board</h1> <p>Select a quest. The layout is the lesson; the quests can wait.</p></main>The base of the stylesheet is last session's reset and tokens. Then the bar. Written mobile-first, the stack is the default and the row is the upgrade.
*,*::before,*::after { box-sizing: border-box;} :root { --color-bg: #08080a; --color-surface: #16161a; --color-accent: #00a868; --color-text: #f5f2ee; --color-muted: #8a8a94; --space: 1rem;} body { margin: 0; font-family: "Segoe UI", Tahoma, sans-serif; background: var(--color-bg); color: var(--color-text);} .navbar { display: flex; flex-direction: column; align-items: center; gap: var(--space); padding: var(--space); background: var(--color-surface);} .navbar-brand { color: var(--color-accent); font-size: 1.5rem; font-weight: 700; text-decoration: none;} .navbar-links { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; list-style: none; margin: 0; padding: 0;} .navbar-links a { color: var(--color-muted); text-decoration: none;} .navbar-links a:hover { color: var(--color-accent);} @media (min-width: 768px) { .navbar { flex-direction: row; justify-content: space-between; padding: 1rem 2rem; } .navbar-links { flex-direction: row; gap: 1.5rem; }}Two flex containers, one inside the other. The nav places the brand and the list. The ul places the links. On a phone both are columns, centered. At 768 and up the nav becomes a row with the brand pinned left and the list pinned right, and the list itself becomes a row. Nested flex is the normal way a bar is built. Change justify-content in DevTools to center, then flex-end, then space-around, and watch only the main axis move.
The stage measures that difference. space-between opens a gap between the brand and the links. center closes it, because the two groups are now one centered cluster with nothing telling them to separate.
<nav id="bar"> <a class="brand" href="#board">Quest Board</a> <ul> <li><a href="#board">Home</a></li> <li><a href="#board">Quests</a></li> <li><a href="#board">Party</a></li> </ul></nav><main id="board"><p>The board sits under the bar.</p></main>const bar = document.getElementById("bar");const brand = bar.querySelector(".brand");const list = bar.querySelector("ul");bar.style.display = "flex";bar.style.alignItems = "center";bar.style.padding = "12px 16px";bar.style.background = "#16161A";brand.style.color = "#00A868";brand.style.fontWeight = "700";brand.style.textDecoration = "none";list.style.display = "flex";list.style.gap = "16px";list.style.listStyle = "none";list.style.margin = "0";list.style.padding = "0";function split() { return Math.round(list.getBoundingClientRect().left - brand.getBoundingClientRect().right);}bar.style.justifyContent = "space-between";console.log("space-between, pixels between brand and links:", split());bar.style.justifyContent = "center";console.log("center, pixels between brand and links:", split());console.log("ok: navbar");Walkthrough 2: the cards. Under the main content, six quests. The grid line is the whole responsive story. Each card is a column, and the reward carries margin-top: auto.
<section class="card-section"> <h2>Available quests</h2> <div class="card-grid"> <article class="card"> <h3>Slay the Dragon</h3> <p class="card-difficulty">Legendary</p> <p>The northern village is done waiting.</p> <span class="card-reward">500 gold</span> </article> <article class="card"> <h3>Gather Moonflowers</h3> <p class="card-difficulty">Easy</p> <p>Ten flowers, and only at night.</p> <span class="card-reward">50 gold</span> </article> <article class="card"> <h3>Escort the Merchant</h3> <p class="card-difficulty">Medium</p> <p>Bandit Gorge, then the capital.</p> <span class="card-reward">150 gold</span> </article> <article class="card"> <h3>Clear the Dungeon</h3> <p class="card-difficulty">Hard</p> <p>Five floors. Bring back the crystal.</p> <span class="card-reward">300 gold</span> </article> <article class="card"> <h3>Decode the Runes</h3> <p class="card-difficulty">Medium</p> <p>The library grew writing overnight.</p> <span class="card-reward">200 gold</span> </article> <article class="card"> <h3>Win the Tournament</h3> <p class="card-difficulty">Hard</p> <p>Three rounds. One title.</p> <span class="card-reward">400 gold</span> </article> </div></section>.card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr)); gap: 1.5rem;} .card { display: flex; flex-direction: column; gap: 0.75rem; padding: 1.5rem; background: var(--color-surface); border-radius: 8px; border-left: 4px solid var(--color-accent);} .card-reward { margin-top: auto; color: #e8d800; font-weight: 700;} h1 { font-size: clamp(1.5rem, 4vw, 2.5rem);}Resize the window slowly. Columns appear and disappear with no query anywhere near .card-grid. Change 280px to 150px and watch the wrap happen later. Change auto-fit to auto-fill with only two cards in the grid and watch the empty tracks stay. Put the two cards back on auto-fit and watch them grow. That is the difference, and it is the reason the requirement later in this session says auto-fit.
The stage forces the widths so you can see the count without dragging the window.
<div id="quests"> <article>Dragon</article> <article>Flowers</article> <article>Escort</article> <article>Mine</article> <article>Runes</article> <article>Tournament</article></div>const grid = document.getElementById("quests");grid.style.display = "grid";grid.style.gridTemplateColumns = "repeat(auto-fit, minmax(140px, 1fr))";grid.style.gap = "8px";function columns() { const counts = {}; grid.querySelectorAll("article").forEach((card) => { const top = Math.round(card.getBoundingClientRect().top); counts[top] = (counts[top] || 0) + 1; }); return Math.max(...Object.values(counts));}grid.style.width = "640px";console.log("container 640px, columns on the first row:", columns());grid.style.width = "300px";console.log("container 300px, columns on the first row:", columns());console.log("ok: cards");Walkthrough 3: the holy grail, measured. The file is holy-grail.html, and building it is Exercise 1; the stage here only proves the shape. Five regions, one parent, the areas from Fig 2, and the CSS is the named-areas block from the idea, the one with .page-header { grid-area: header; } and its four siblings. On your machine the parent is min-height: 100vh and each region has its own background so the shape is obvious. The stage is the same shape in a shorter frame, and the script checks the two facts that matter: the header is as wide as the page, and the nav's right edge meets the main's left edge.
<div id="page"> <header>Guild</header> <nav>Nav</nav> <main>Main</main> <aside>News</aside> <footer>Footer</footer></div>const page = document.getElementById("page");page.style.display = "grid";page.style.gridTemplateColumns = "90px 1fr 90px";page.style.gridTemplateRows = "auto 1fr auto";page.style.gridTemplateAreas = '"header header header" "nav main aside" "footer footer footer"';page.style.minHeight = "240px";page.style.gap = "6px";page.style.width = "100%";["header", "nav", "main", "aside", "footer"].forEach((name) => { const el = page.querySelector(name); el.style.gridArea = name; el.style.padding = "8px"; el.style.background = name === "main" ? "#16161A" : "#0D0D10";});const pageBox = page.getBoundingClientRect();const headerBox = page.querySelector("header").getBoundingClientRect();const navBox = page.querySelector("nav").getBoundingClientRect();const mainBox = page.querySelector("main").getBoundingClientRect();console.log("header width", Math.round(headerBox.width), "page width", Math.round(pageBox.width));console.log("header spans the page:", Math.abs(headerBox.width - pageBox.width) < 8);console.log("nav sits left of main:", navBox.right <= mainBox.left + 1);console.log("ok: grail");The responsive version of this file is Exercise 2. Do not start it from the desktop string. Start it from the single column, and let the queries add the flanks.
Where it breaks#
Your turn#
The quest#
Before Session 2.4, on html-css-foundations:
layout-demo.html, the bar and the wrapping card grid.holy-grail.html, correct at the three widths, footer at the bottom of a short page.formation.html, the ten requirements, checked at 320, 768, and 1024.potion-shop.html: the three cards in arepeat(auto-fit, minmax(min(280px, 100%), 1fr))grid instead of a stack, checked at 320. One rule on the container that holds the cards, and the cards sit next to each other.about-me.htmlresponsive: a flex or a grid where a column used to be, and at least onemin-widthquery. It has to look meant on a phone.
Play Grid Garden through. The frogs were flex. The garden is grid, and the properties it drills are the ones in Fig 2. Then read the transitions guide and the BEM overview. Transitions are how a hover stops being a snap. BEM is how a stylesheet stays readable once more than one person, including you next month, has to find a rule. Both are the next session. Twenty-five minutes for the two readings.
Budget two to three hours if the drill did not get finished. Push before you stop.
Side quests#
Three pages in the library look further down roads you walked today. They are written for later units, so read the opening and the first picture, not the code.
Side quests from this session
- CompositeA page is a container of containers: the grid holds regions, a region holds a flex row, the row holds links. That is the composite, a tree where a branch and a leaf answer to the same idea of 'a node with children.' The page names the DOM. Read the intent and the first picture; the class code is for later.Design Patterns
- Trade OffsFlex or grid is not a rule to memorize, it is a choice with a context, which is the only lesson on this page that survives contact with a real stylesheet. There is no always. Read the mindset section and bring it back to the table in this session: a bar is one axis, a page is two, and a wrapping card row can honestly be either.Algorithms
- Facadegrid-template-areas is one door in front of tracks, lines, and placement. You write a picture made of names, and the grid does the bookkeeping. That is a facade: a simpler interface over a subsystem that does not know you simplified it. Read the intent. The order-and-inventory example is the same shape.Design Patterns
- The fixed navReproduce a fixed navigation bar, a growing underline, and a mobile menu. Screenshots show the states; complete CSS and JavaScript connect the controls. Revisit the event handlers after Session 3.5.Atelier
- The heroBuild a split hero with oversized gradient type, a cutout portrait, and a soft background glow. Compare each visual layer with its actual CSS and test what changes when you edit it.Atelier
- Cards and the link gridBuild project cards that adapt to the available width, lift on hover, and remain usable from the keyboard. Trace the grid, surface, spacing, and response separately.Atelier
Before next session#
Finish the garden. If a level asks for a line number and an area name, you already have both: Fig 2 is that level.
Spend ten minutes resizing a site you respect, with the device toolbar, from 320 to 1440. Watch the nav. Watch the cards. Decide whether they used a query or whether the columns simply stopped fitting. You can tell: a query changes the shape at a specific width, all at once, and auto-fit changes the count whenever another minimum fits.
Next time: Enchantments. Transitions, transforms, images that do not break the grid you just built, and a naming scheme so the stylesheet can grow. The formation stays. It learns to move.
Same company. A formation, and a narrower one they already knew. The ground can change width now. The line still holds.
See also