Skip to content
Chapter 20Lesson 5

Sizing, viewport units, and aspect-ratio

How CSS decides a box's width and height, and the Tailwind sizing utilities you reach for to control them.

You build a landing page hero: full screen, dark background, a headline centered in the middle. In Tailwind that’s a reflex, h-screen, which compiles to height: 100vh. In desktop devtools, every device size looks perfect.

Then someone opens it on an iPhone. There’s a strip of dead space at the bottom, and the call-to-action button is hiding behind the address bar, just out of reach. Nothing in your CSS hints at why: the height is exactly 100vh, and 100vh is supposed to mean “the height of the screen.”

What you designed

Get started

Button sits comfortably above the bottom edge.

What iOS renders

Get started
yoursaas.com
← button hidden

Address bar covers the bottom of the hero — the button is unreachable.

The hero is the same 100vh in both phones — its height never changed. Only the visible viewport did: with the address bar showing, 100vh runs past the bottom of the screen, so the button at the bottom of the hero ends up behind the bar.

The short version, with the full mechanism later in this lesson, is that vh is measured against the largest the viewport can ever be, with the address bar scrolled away. While the bar is showing, the page is shorter than 100vh, so a 100vh box runs past the bottom of what you can see, by exactly the slice the address bar sits on.

The fix is one change, screen becomes dvh, and you’ll see why it works once the lesson covers viewport units. The bug is a good way in because most sizing surprises trace back to a single mental model, the one this lesson builds first. From there you’ll pick up two more wins: media that never shifts the page as it loads, and sizes that scale smoothly with the screen instead of jumping.

Every box has a width and a height, and each dimension gets its size in one of exactly two ways.

Either the content decides, so the box is as big as what’s inside it and grows and shrinks to fit. Or something outside forces it: a fixed length you typed, a 100%, a flex or grid track that hands the box a slot.

The proper names are intrinsic (content-driven) and extrinsic (forced). You’ll meet those words in articles and the occasional devtools label, but “content-driven” and “forced” are the words to think in.

One fact about default block elements clears up most sizing confusion:

A block element is forced on width but content-driven on height.

A <div>, <p>, or <section> stretches to fill its parent’s width (forced, since it takes whatever horizontal space the parent offers) and grows just tall enough to hold its content (content-driven, since the height is whatever the text and children add up to).

That asymmetry explains two of the most common moments of confusion in CSS:

  • width: 100% on a <div> usually does nothing visible. The div was already filling its parent’s width, so you’re forcing a value it had taken on its own.
  • height: 100% on a <div> usually does nothing at all. 100% means “100% of the parent’s height,” but the parent is itself content-driven on height, so there’s no fixed height to be a percentage of. You ask for 100% of an unknown, and nothing happens.
Forced (extrinsic) — something outside sets it Content-driven (intrinsic) — the content sets it

Block <div>

width — forced (fills parent)
text grows the height
height — content-driven (fits content)

Inline <span>

the price is free today

both axes — content-driven

ignores width & height

Flex item flex-1

flex-1
·
·
main axis — forced (flex track) cross axis — content-driven
The display mode decides which axis is forced. A sizing utility only bites on an axis the layout algorithm hasn't already claimed.

Watch how the coloring flips per element. The block is forced on width, content-driven on height. The inline <span> is content-driven on both axes, which is why it ignores width and height entirely. A flex item is forced along the main axis, where the flex algorithm hands it a slot, but content-driven on the cross axis.

The pattern underneath: the display mode you chose already decided which axis is forced. A sizing utility only takes effect on an axis the layout algorithm hasn’t already claimed. h-full on a flex item’s cross axis works, because that axis was free. w-1/2 on a block works, because you’re overriding the “fill the parent” default on the axis you control.

The content-driven half has a small vocabulary worth recognizing. When a box sizes to its content, CSS lets you name how much:

  • min-content is the narrowest the content can get without overflowing. For text, the width of the single longest word.
  • max-content is the widest the content wants, with no wrapping at all. For text, the whole thing on one line.
  • fit-content is max-content capped at the space available: it sizes to content when there’s room and wraps when there isn’t.

You won’t write these keywords often, since you write Tailwind, but Tailwind ships w-min, w-max, and w-fit for them, and you’ll see the keywords in devtools’ computed panel. The one you’ll actually reach for is w-fit, which we’ll meet in context shortly.

So when a size comes out wrong, don’t guess at utilities. Ask which axis is being computed which way: is this dimension content-driven or forced right now? That single question turns nearly every sizing bug from a mystery into a one-line fix.

Run it over a handful of declarations. Sort each into how it gets its size:

Decide how each box gets its size. Drag each item into the bucket it belongs to, then press Check.

Content-driven The content decides the size
Forced Something outside imposes the size
w-full
A default <p>’s height
h-screen
flex-1
w-fit
A <span>’s width
w-[640px]
max-content

Forcing a size: w-*, h-*, and the size-* shortcut

Section titled “Forcing a size: w-*, h-*, and the size-* shortcut”

The bluntest tool is the set of utilities that force a dimension to a value you pick.

w-* and h-* read from the same spacing scale as p-* and gap-*, so w-64 is 16rem, h-10 is 2.5rem, and your widths stay on the same rhythm as your padding and gaps. Three families of value cover almost everything: a fixed step on the scale, the full-width keyword, and fractions.

<div className="w-64" />
<div className="w-full" />
<div className="w-1/2" />

w-64 is a fixed step. w-full is 100%, the workhorse for “fill the container I’m in.” Fractions like w-1/2 and w-1/3 are percentages too (50%, 33.333%), handy for splitting a row by hand when you’re not using flex or grid. For a genuinely fixed pixel value there’s the arbitrary form w-[640px], but reaching for it often is a sign you’re fighting the layout instead of letting it size things for you. h-full works the same way, with the caveat from the model: it’s 100% of the parent’s height, so it does nothing unless the parent has a fixed height to take a percentage of.

Keep w-screen on your “recognize, rarely write” shelf. It’s 100vw and looks like the obvious way to go full-width, but 100vw includes the space under the vertical scrollbar, so a w-screen element overflows the page by the scrollbar’s width and adds a horizontal scroll you didn’t ask for. For full width, reach for w-full inside a container you control.

Much of the UI is square (avatars, icon buttons, status dots, the icons inside them), and a square means writing the same number twice:

<img className="h-10 w-10 rounded-full" src={user.avatarUrl} alt="" />
<Icon className="h-4 w-4" />

Width and height set separately to the same value. Update one and forget the other, and they silently fall out of sync.

The rule: square → size-*; rectangle → w-* and h-* separately. An avatar is size-10, an icon is size-4, a square icon-button is size-9. The moment width and height differ, you’re back to two utilities.

Constraining a size: min-*, max-*, and the reading-width cap

Section titled “Constraining a size: min-*, max-*, and the reading-width cap”

The utilities so far set a size. The min-* and max-* family does something subtler: it works with the layout algorithm instead of fighting it.

min-* and max-* don’t pick a size, they clamp the size the algorithm already computed. You let the box stay content- or container-driven, whatever’s natural, then bound the result: be as wide as your content wants, but never past this. That’s why they compose so well with intrinsic sizing, flex, and grid. You aren’t overriding the algorithm, you’re fencing in its output.

The most common clamp stops a column of text from growing too wide to read. Past roughly 75 characters per line, the eye loses its place tracking back to the start of the next line; the comfortable measure is around 65.

<article className="mx-auto max-w-prose">
{/* long-form content */}
</article>

max-w-prose caps the width at a readable measure, and mx-auto (from the box model lesson) centers the column in the space that’s left. The article still fills a narrow parent and still shrinks on a phone, content- and container-driven as always; it just refuses to grow past the cap on a wide screen. You’ll also see max-w-[65ch], which means the same thing: the ch unit is the width of the font’s “0” glyph, so 65ch is about 65 characters, the width measured in the thing you actually care about.

Drag the cap below. Past about 75ch the lines feel like work; below about 45ch they turn choppy and cramped. The comfortable middle is why max-w-prose exists.

Drag the cap and feel where reading gets uncomfortable.

In the flexbox lesson you learned a recipe: a flex-1 item holding long text, a filename or URL or title, overflows its container instead of shrinking, and min-w-0 fixes it. Now that you have the vocabulary, here’s why.

A flex item’s default minimum width isn’t zero, it’s min-content, its intrinsic floor. The algorithm can’t shrink the item below the width of its longest unbreakable chunk, so the item holds at that floor and the rest overflows.

<div className="flex items-center gap-3">
<span className="min-w-0 flex-1 truncate">{veryLongFileName}</span>
<button>Download</button>
</div>

min-w-0 lowers the floor to zero. Now the extrinsic flex sizing wins: the item shrinks to whatever slot the row gives it, and truncate adds the ellipsis. You removed the intrinsic minimum so the extrinsic flex track could take over.

Two clamps you’ll write on most apps. min-h-dvh on the page shell means at least full height, taller if the content needs it; the next section explains dvh fully. And max-h-* plus overflow-y-auto caps a tall panel’s height and hands the overflow to a scrollbar:

<aside className="max-h-dvh overflow-y-auto">{/* a tall sidebar */}</aside>

Scroll containers get their own lesson later in this chapter; for now, notice the shape: a max plus overflow-y-auto.

w-fit: opting one child back to content-driven

Section titled “w-fit: opting one child back to content-driven”

One more constraint-flavored move, the practical use of the fit-content keyword from earlier. A button inside a flex-col stretches to the column’s full width, because flex’s default cross-axis behavior is items-stretch. Often that’s fine. When you want the button to hug its label and sit at the start instead, w-fit opts that one child back to content-driven:

<div className="flex flex-col items-start gap-2">
<p>Ready to publish?</p>
<button className="w-fit rounded-md bg-blue-600 px-4 py-2 text-white">
Publish
</button>
</div>

The button is now exactly as wide as “Publish” plus its padding, whatever the column’s width.

On a desktop the viewport height is a stable number, because the window doesn’t change size as you scroll. On mobile it isn’t. The browser’s chrome, meaning the address bar and bottom toolbar, slides away as you scroll down and slides back as you scroll up. So “the height of the viewport” isn’t one value but a range between two extremes, and the browser gives you three units to ask for different points in that range:

  • lvh is the largest viewport, with the chrome fully collapsed. This is what vh has always meant.
  • svh is the smallest viewport, with the chrome fully shown.
  • dvh is the dynamic viewport, which tracks the current state and re-measures live as the bar moves.

Now the bug has a name. h-screen compiles to 100vh, which equals lvh, the largest the viewport gets. So a 100vh box is sized for the chrome-collapsed state: the moment the address bar shows, the real viewport is shorter than 100vh and the box runs off the bottom by exactly the bar’s height. That’s the dead strip, or the button hidden behind the bar.

Toggle the address bar in the simulator and watch each unit react. vh and lvh are sized for the tall state, so they overflow when the bar appears. svh is sized for the short state, so it leaves a gap when the bar hides. Only dvh tracks the bar and always fills exactly what’s visible.

vh
= lvh
svh
dvh
yoursaas.com
Address bar

Flip it to scroll the bar away, like you would on a phone.

  • vh / lvh — the largest the viewport ever gets
  • svh — the smallest, with the bar showing
  • dvh — the live viewport, right now

Address bar shown vh overflows behind it, svh fits, dvh fits.

Choose by what you’re trying to do:

  • Fill at least the screen, but let content push it taller. Use min-h-dvh on the page or section shell. This is your default: what min-h-screen should have been, and the line you’ll write on nearly every full-height layout.
  • Fill exactly the visible height, no more, for a full-bleed hero or a scroll-snap section. Use h-dvh.
  • Keep something visible even with the bar showing, such as a call-to-action that can never be hidden. Use svh.
  • lvh almost never on purpose. It’s the value that causes the bug.

Tailwind ships the whole family: h-dvh, min-h-dvh, max-h-dvh, plus h-svh / min-h-svh, h-lvh, and the legacy *-screen forms. There are *-dvw cousins for the horizontal axis, but horizontal chrome is uncommon.

The fix for the legacy trap is mechanical, which matters because h-screen and min-h-screen fill tutorials and older codebases:

<section className="min-h-screen">

min-h-screen is 100vh, the largest viewport. On iOS this leaves dead space or hides content under the address bar. It’s the form every old tutorial reaches for.

The default has one caveat. Because dvh re-measures as the address bar animates in and out, a dvh-sized box changes height mid-scroll and can nudge the layout. On a hero that movement is invisible. For content that has to stay steady while the user scrolls, svh holds a single value and never moves, trading the perfect fill for stability.

aspect-ratio: sizing one dimension from the other

Section titled “aspect-ratio: sizing one dimension from the other”

Picture a card with an image at the top, loaded from a URL: a user upload, a CMS, an external API.

You write <img src={url} className="w-full" />. Before the bytes arrive, the browser has no idea how tall the image is, so the <img> occupies zero height. Then the bytes land, the browser learns the real dimensions, and the image snaps to full height, shoving everything below it down the page. That jump is CLS, a Core Web Vitals failure that drags down your search ranking and makes the page feel broken.

The fix is to reserve the box before the bytes arrive. Tell the browser the image’s shape, its aspect ratio, and it can compute the height from the width immediately, hold that space, and drop the image in on load with no shift:

<img src={url} alt="" className="aspect-video w-full object-cover" />

aspect-video is 16 / 9. The width is 100% of the card, and the height is now derived from it, width divided by the ratio, so the box has its full height from the first paint. This is the model again: you force one dimension (width) and let aspect-ratio compute the other. object-cover is the companion that fills the reserved box, cropping to cover it rather than stretching the image.

Tailwind’s utilities map straight to the jobs:

  • aspect-square (1 / 1) for thumbnails and avatars on a tight grid.
  • aspect-video (16 / 9) for video embeds, hero images, anything cinematic.
  • aspect-[4/3], aspect-[3/2] for arbitrary ratios, so every card’s hero image lands at the same height whatever the source dimensions.
  • aspect-auto to reset back to the content’s natural size.

Drag the width and switch the ratio in the playground: set width and ratio, and the height is computed for you.

Set a width and a ratio; the height is derived.

It’s worth seeing what this replaced, because you’ll meet it in old code. Before aspect-ratio was a CSS property, the only way to reserve a ratio-locked box was a hack: a wrapper with percentage padding, the real content absolutely positioned to fill it. It relied on a quirk: percentage padding resolves against the parent’s width, so padding-bottom: 56.25% is 9/16 of the width, a 16:9 box.

.ratio-box {
position: relative;
width: 100%;
padding-bottom: 56.25%; /* 9 / 16 */
}
.ratio-box > * {
position: absolute;
inset: 0;
}

Recognize it; never write it. A wrapper, a magic percentage, and absolute positioning, all to fake what one property now does directly.

One thing to watch for has the same root cause you met with flex items. An aspect-ratio box inside a flex or grid container can collapse: the container’s algorithm and the ratio compete over the size, and the box ends up squashed. The fix is the one you already know: min-w-0, or pinning the other dimension explicitly, lets the ratio win.

clamp(): scaling a size between two bounds

Section titled “clamp(): scaling a size between two bounds”

Sometimes you want neither a fixed size nor a content-driven one, but a size that scales smoothly with the viewport: a hero heading that’s 2rem on a phone and 4rem on a wide monitor, gliding between the two as the window resizes with no jump at a breakpoint. CSS has a function for exactly that, clamp().

<div className="w-[clamp(16rem,50vw,32rem)]" />

There’s one form to memorize, clamp(min, preferred, max):

  • The value wants to be preferred, here 50vw, half the viewport width, so it grows and shrinks with the window.
  • But it’s clamped: never smaller than min (16rem), never larger than max (32rem).

So this box is half the viewport wide, fluidly, but never narrower than 16rem on a phone or wider than 32rem on a billboard. One declaration, no breakpoints.

The next chapter gives clamp() a full treatment, pairing it with container query units for fluid typography. For now, know the one form and reach for it when a size should scale between two bounds.

You’ve met a handful of units now. The habit worth building isn’t memorizing all of them; it’s knowing that rem and the spacing scale are the default, and every other unit has one specific job. Reach for the scale first, and for a special unit only when its job is the one in front of you.

Here’s the whole shortlist a 2026 developer actually writes:

UnitJobReach
remSpacing and type, the defaultThe Tailwind scale (p-4, w-64, text-lg)
dvhFilling the viewport heightmin-h-dvh on a shell; h-dvh for a hero
chReading / measure widthmax-w-prose, max-w-[65ch] on text
pxHairlines that shouldn’t scaleBorders, focus rings, 1px dividers
frGrid tracks onlygrid-cols-[1fr_2fr] (from the grid lesson)
%RareA flex/grid 1fr or w-full is usually better
emVery rareSizing relative to the element’s own font size

Two deserve a note. % is mostly a trap: a flex flex-1, a grid 1fr, or a plain w-full is almost always cleaner, and the real case for % is narrow, like a width that must be an exact fraction of a specifically-sized parent. em (relative to the element’s own font size, not the root’s) earns its keep only in tight spots, like an icon that should grow with the button text beside it.

This is the discipline the box model lesson drilled with spacing: stay on the scale. A stray mt-[37px] is a number nobody chose on purpose, one that won’t line up with anything else on the page. If the scale lacks the value you need, adjust --spacing in @theme rather than sprinkle arbitrary pixels.

The lesson distilled into the moves you’ll reach for without thinking:

  • Square element → size-*.
  • Reading width → max-w-prose.
  • Fill the viewport → min-h-dvh, never min-h-screen.
  • Media that loads late → aspect-* so it never shifts the page.
  • A size that should scale smoothly → clamp().
  • A flex item with long text that overflows → flex-1 min-w-0.

And underneath all of them, the one question that makes sizing a decision instead of a guess: is this dimension content-driven or forced?

The card below is built wrong on purpose, with the exact bugs this lesson is about. The avatar is sized with raw width and height instead of size-*. The hero image reserves no height, so it collapses to nothing and shoves the body down the moment a real image loads. The body text runs full width, which hurts readability. And the card floats at the top instead of centering in the viewport. Fix all four to match the target on the right.

This media card is built wrong on purpose. Center it in the viewport, give the avatar a single square size, reserve the hero image's height with a fixed 16:9 ratio so it never shifts, and cap the body text at a readable width. Match the target.

Target
Your output LIVE

Four moves from this lesson, one component: size-12 makes the avatar a square, aspect-video reserves the hero image’s height before it loads so the body never jumps, max-w-prose caps the copy at a readable measure, and grid min-h-dvh place-items-center centers the card in the visible viewport with dvh, not screen.