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
Button sits comfortably above the bottom edge.
✕ What iOS renders
Address bar covers the bottom of the hero — the button is unreachable.
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.
Content-driven vs. forced sizing
Section titled “Content-driven vs. forced sizing”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.
Block <div>
Inline <span>
the price is free today
ignores width & height
Flex item flex-1
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-contentis the narrowest the content can get without overflowing. For text, the width of the single longest word.max-contentis the widest the content wants, with no wrapping at all. For text, the whole thing on one line.fit-contentismax-contentcapped 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.
w-full<p>’s heighth-screenflex-1w-fit<span>’s widthw-[640px]max-contentForcing 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.
size-* for square things
Section titled “size-* for square things”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.
<img className="size-10 rounded-full" src={user.avatarUrl} alt="" /><Icon className="size-4" />size-* sets width and height together, so one utility keeps the element square.
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.
Capping reading width
Section titled “Capping reading width”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.
Typography is the craft of arranging type to make written language legible, readable, and appealing when displayed. A column that is too wide forces the eye to travel a long way back to find the start of the next line, and a column that is too narrow chops sentences into stubs. The comfortable measure for body text sits around sixty-five characters per line, which is what this control is sweeping across.
Removing a flex item’s intrinsic floor
Section titled “Removing a flex item’s intrinsic floor”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.
Bounding the page and tall panels
Section titled “Bounding the page and tall panels”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.
Viewport units and the dvh reflex
Section titled “Viewport units and the dvh reflex”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:
lvhis the largest viewport, with the chrome fully collapsed. This is whatvhhas always meant.svhis the smallest viewport, with the chrome fully shown.dvhis 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.
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-dvhon the page or section shell. This is your default: whatmin-h-screenshould 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. lvhalmost 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.
<section className="min-h-dvh">min-h-dvh tracks the live viewport, so the section always fills exactly what’s visible. The whole fix is screen → dvh.
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-autoto 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.
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.
<div className="aspect-video w-full">One utility. No wrapper, no magic number, no absolute positioning. aspect-ratio made the hack obsolete, and this is the only form you write now.
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, here50vw, half the viewport width, so it grows and shrinks with the window. - But it’s clamped: never smaller than
min(16rem), never larger thanmax(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.
Choosing the right unit
Section titled “Choosing the right unit”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:
| Unit | Job | Reach |
|---|---|---|
rem | Spacing and type, the default | The Tailwind scale (p-4, w-64, text-lg) |
dvh | Filling the viewport height | min-h-dvh on a shell; h-dvh for a hero |
ch | Reading / measure width | max-w-prose, max-w-[65ch] on text |
px | Hairlines that shouldn’t scale | Borders, focus rings, 1px dividers |
fr | Grid tracks only | grid-cols-[1fr_2fr] (from the grid lesson) |
% | Rare | A flex/grid 1fr or w-full is usually better |
em | Very rare | Sizing 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, nevermin-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?
Build a zero-shift media card
Section titled “Build a zero-shift media card”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.
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.
External resources
Section titled “External resources”Ahmad Shadeed's visual, demo-rich tour of min-content, max-content, and fit-content, the content-driven half of this lesson's model.
web.dev's deep dive on CLS and reserving space for late-loading media, the metric aspect-ratio exists to protect.
The CSS property reference, including how it interacts with explicit width and height and the auto behavior.
web.dev's walkthrough of lvh, svh, and dvh, and the mobile address-bar problem they solve.