Sizing, viewport units, and aspect-ratio
How CSS decides the width and height of a box, and the Tailwind sizing utilities a 2026 developer reaches 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. You open desktop devtools, toggle through a few device sizes, and it looks perfect every time.
Then someone opens it on their iPhone, and there’s a strip of dead space at the bottom. Worse, the call-to-action button is hiding behind the address bar, just out of reach. Nothing in your CSS hints at why. The math is exactly 100vh, and 100vh is supposed to mean “the height of the screen.” Here’s the gap between what you designed and what the phone actually renders:
✓ 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 reason, with the full mechanism coming 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 actually see. Your hero is taller than the screen, and the overflow is exactly the slice the address bar is sitting on.
The fix is a single change: screen becomes dvh. But the bug is a good way into the whole topic, because sizing is where a surprising amount of CSS confusion lives. “Why is my width auto?” “Why did my flex item blow out to the wrong size?” “Why does height: 100% do absolutely nothing?” Every one of those resolves to a single mental model, and once you have it, sizing stops being trial and error.
Here’s the plan for the lesson. We’ll start with the model, the one idea that explains every sizing surprise you’ll hit. Then we’ll cover the day-to-day utilities, grouped by the job you’re doing: forcing a size, constraining a size, deriving one dimension from another, and letting content decide. Along the way we’ll collect three concrete wins: the iOS fix you just met, media that never shifts the page when it loads, and sizes that scale smoothly with the screen instead of jumping.
You already have pieces of this. Earlier in this chapter, the box model lesson gave you w-* and max-w-* on the spacing scale, border-box, and mx-auto for centering, and the flexbox lesson gave you the min-w-0 trick to stop a flex item from overflowing. This lesson supplies the why underneath all of it, plus the mobile and media reflexes the box model lesson didn’t reach.
Content-driven vs. forced: the one model
Section titled “Content-driven vs. forced: the one model”Every box on a page has a width and a height, and each of those two dimensions gets its size in one of exactly two ways.
Either the content decides, so the box is as big as whatever is 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. That’s the whole model, content-driven or forced.
The proper names are intrinsic (content-driven) and extrinsic (forced), and you’ll see those words in articles and the occasional devtools label, so they’re worth knowing. But “content-driven” and “forced” are the words to think in.
One fact about default block elements clears up most sizing confusion once it clicks:
A block element is forced on width but content-driven on height.
Take a <div>, a <p>, or a <section>. By default each one stretches to fill its parent’s width (forced, because it takes whatever horizontal space the parent offers) and grows just tall enough to hold its content (content-driven, because the height is whatever the text and children add up to).
That asymmetry is the source of 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 the box had taken on its own.height: 100%on a<div>usually does nothing at all.100%means “100% of the parent’s height,” and the parent is itself content-driven on height, so it has no fixed height to be a percentage of. You ask for 100% of an unknown value, and nothing happens.
Block <div>
Inline <span>
the price is free today
ignores width & height
Flex item flex-1
This is what the diagram above shows. Look at how the coloring flips per element. The block is forced on width and content-driven on height. The inline <span> is content-driven on both axes, which is why, as the display-modes lesson covered, 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.
That’s the deeper pattern: 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. Write h-full on a flex item’s cross axis and it works, because that axis was free. Write w-1/2 on a block and it 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 content:
min-contentis the narrowest the content can get without overflowing. For text, that’s 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 CSS 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. Recognizing them is enough; the one you’ll actually reach for is w-fit, and we’ll meet it in context shortly.
Here’s the habit this whole section buys you: 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.
Before moving on, run that question over a handful of declarations. Sort each one 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”Start with the bluntest tool: the utilities that force a dimension to a value you pick.
w-* and h-* take their numbers from the same spacing scale that feeds p-* and gap-* (the box model lesson covered that scale, where every step is var(--spacing) * n). So w-64 is 16rem, h-10 is 2.5rem, and they 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 16rem, 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 the rare genuinely fixed pixel value there’s the arbitrary form, w-[640px], but reaching for it often is a warning sign that you’re fighting the layout instead of letting it size things for you. h-full exists too, with the caveat you already know from the model: it’s 100% of the parent’s height, so it only does something when the parent has a fixed height to take a percentage of.
One utility to put on your “recognize, rarely write” shelf is w-screen, which is 100vw. It looks like the obvious way to make something 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 gives you a horizontal scroll you didn’t ask for. The better default for “full width” is w-full inside a container you control, not w-screen.
size-* for square things
Section titled “size-* for square things”Here’s a small Tailwind v4 ergonomics win that pays off constantly. A lot of UI is square (avatars, icon buttons, status dots, the icons inside them), and squares mean 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, with the same value. Easy to update one and forget the other, and your eye has to check that they match.
<img className="size-10 rounded-full" src={user.avatarUrl} alt="" /><Icon className="size-4" />One utility, both dimensions, guaranteed square. size-* sets width and height together, so reach for it whenever the element is a square.
The rule of thumb is plain: square → size-*; rectangle → w-* and h-* separately. That’s the entire decision. 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, and it’s one of the most useful sizing tools you have, because 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 be content-driven or container-driven, whatever’s natural, and then you bound the result: “be as wide as your content wants, but never past this.” That’s why they compose so well with intrinsic sizing and with flex and grid. You’re not overriding the algorithm, you’re putting a fence around its output.
Three uses cover the vast majority of real cases.
Capping reading width
Section titled “Capping reading width”The single most common one is to stop a column of text from getting too wide to read comfortably. Long lines are genuinely hard on the eye: when a line runs past roughly 75 characters, your eye loses its place tracking back to the start of the next one. The comfortable measure is around 65 characters.
<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 whatever space is left. The article still fills a narrow parent and still shrinks on a phone, content-and-container-driven as always, but it refuses to grow past the cap on a wide screen. You’ll also see this written max-w-[65ch], and it’s the same idea: the ch unit is the width of the font’s “0” glyph, so 65ch is about 65 characters wide, a width measured in the thing you actually care about.
Slide the cap in the playground and watch what your eye does. Past about 75ch the lines feel like work; below about 45ch they’re choppy and cramped. The comfortable range in the 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 that holds long text, such as a filename, a URL, or a title, overflows its container instead of shrinking, and min-w-0 fixes it. The model explains why, and it’s worth seeing now that you have the vocabulary.
A flex item’s default minimum width isn’t zero, it’s min-content, its intrinsic floor. The flex algorithm isn’t allowed to shrink the item below the width of its longest unbreakable chunk. So the item refuses to drop below that floor, and the overflow you see is the item holding at its minimum.
<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 that floor to zero. Now the extrinsic flex sizing wins: the item can shrink to whatever slot the row gives it, and truncate adds the ellipsis. In the model’s terms, you removed the intrinsic minimum so the extrinsic flex track could take over. It’s the same recipe you already knew, now with the reason attached.
Bounding the page and tall panels
Section titled “Bounding the page and tall panels”The last use is two patterns you’ll write on most apps. min-h-dvh on the page shell means at least full height, taller if the content needs it; this is the iOS fix from the intro, which the next section explains 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 just 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, and it’s the practical use of the fit-content keyword from earlier. Put a button inside a flex-col and it stretches to the full width of the column, because, as you saw in the flexbox lesson, flex’s default cross-axis behavior is items-stretch. Often that’s fine. When it isn’t, and you want the button to hug its label and sit at the start, 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, regardless of how wide the column is. You overrode the forced stretch with content-driven sizing for that one element.
Viewport units and the dvh reflex
Section titled “Viewport units and the dvh reflex”Here’s the full story behind the opening bug.
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 the 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; it’s a range between two extremes. 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 exactly whatvhhas always meant.svhis the smallest viewport, with the chrome fully shown.dvhis the dynamic viewport, which tracks the current state live and re-measures as the bar moves.
The bug now 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 is showing, the real viewport is shorter than 100vh, and your 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.
Here’s how to choose, framed by what you’re trying to do:
- “Fill at least the screen; content can push it taller.” →
min-h-dvhon the page or section shell. This is the default you’ll reach for. It’s whatmin-h-screenshould have been from the start, and it’s the line you’ll write on nearly every full-height layout. - “Exactly the visible height, no more.” A full-bleed hero, a scroll-snap section. →
h-dvh. svhwhen something must stay visible even with the bar showing, such as a call-to-action that can never be hidden. Rare, but the right tool when you need the guarantee.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 too, but horizontal chrome is uncommon, so you’ll rarely need them.
The fix for the legacy trap is mechanical, and worth committing to muscle memory because h-screen and min-h-screen are everywhere in tutorials and older codebases. You will meet them constantly:
<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 does come with one caveat. dvh re-measures as the address bar animates in and out, which means 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, so you trade the perfect fill for stability. Reach for dvh by default, and svh when stillness matters more than filling the last few pixels.
aspect-ratio: sizing one dimension from the other
Section titled “aspect-ratio: sizing one dimension from the other”Here’s the second concrete win. Picture a card with an image at the top, where the image is loaded from a URL: a user upload, a CMS, an external API.
You write <img src={url} className="w-full" />. Before the image’s bytes arrive, the browser has no idea how tall it is, so the <img> occupies zero height. Then the bytes land, the browser learns the real dimensions, and the image suddenly snaps to its full height, shoving everything below it down the page. That jump is CLS , a measurable 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. If you tell the browser the shape of the image, its aspect ratio, it can compute the height from the width immediately, hold that space, and drop the image in when it loads with no shift at all:
<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 forced one dimension (width) and let aspect-ratio compute the other. object-cover is the companion that decides how the actual image fills that reserved box, cropping to cover it rather than stretching (image handling has its own depth elsewhere; object-cover is all you need here).
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 on card hero images, where every card’s image must be the same height no matter what dimensions the source happens to be.aspect-autofor the reset, back to the content’s natural size.
Drag the width and switch the ratio in the playground. The box’s height follows from the two: you set width and ratio, and the height is computed for you. That’s the whole point. One dimension plus a shape, and the other dimension is no longer your problem.
It’s worth seeing what this replaced, because you’ll run into 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, plus the real content absolutely positioned to fill it. The trick relied on a quirk: percentage padding resolves against the parent’s width, so padding-bottom: 56.25% is 9/16 of the width, which gives you 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. Same model, same fix.
clamp(): a size that breathes between two bounds
Section titled “clamp(): a size that breathes between two bounds”Sometimes you don’t want a fixed size or a content-driven one. You want 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 sudden jump at a breakpoint.
CSS has a function for exactly this, and it’s worth knowing by name even though the next chapter covers it in depth: 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 it can’t get narrower than 16rem on a phone or wider than 32rem on a billboard. One declaration, no breakpoints. (min() and max() are the one-sided cousins, a value with only an upper or only a lower bound.)
If that pattern feels familiar, it should. In the grid lesson you wrote repeat(auto-fit, minmax(...)) to get a column count that responds to the container with no media queries. clamp() is that same instinct, responsive without breakpoints, applied to a single value instead of a track list. Same idea, smaller target.
We’ll stop here on purpose. Fluid typography, and pairing clamp() with container query units, are the heart of a dedicated lesson in the next chapter, where clamp() gets a full treatment. For now, know it exists, 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 now met a handful of units. 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 reach 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 of those deserve a sentence. % is mostly a trap now: when you reach for it, a flex flex-1, a grid 1fr, or a plain w-full is almost always the cleaner answer, and the genuine case for % is narrow, like a width that must be an exact fraction of a specifically-sized parent. And em (relative to the element’s own font size, not the root’s) earns its keep only in tight spots, such as an icon that should grow with the button text it sits beside.
This is the same discipline the box model lesson drilled with spacing: stay on the scale. A stray mt-[37px] is a warning sign, a number nobody chose on purpose that won’t line up with anything else on the page. If the scale doesn’t have the value you need, the fix is to adjust --spacing in @theme, not to sprinkle arbitrary pixels.
Match each unit to its job:
Match each CSS unit to the one job it's the right tool for. Click an item on the left, then its match on the right. Press Check when done.
remdvhchfrpxThat’s 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”Now you’ll put four of those moves into one component. Below is a media card that’s been built wrong on purpose, where “wrong” means the exact bugs this lesson is about. The avatar is sized with raw width and height instead of size-*. The hero image has no reserved height, so it collapses and would shove the body down the moment a real image loaded. The body text runs the full width, which makes it hard to read. And the whole card floats at the top instead of centering in the viewport.
Match the target on the right. You’ll write the four headline primitives: size-* on the avatar, aspect-video on the image, max-w-prose on the body, and min-h-dvh grid place-items-center (the centering from the grid lesson) on the section that holds the card.
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.
The four headline moves from this lesson, in one component: size-12 makes the avatar a guaranteed square, aspect-video reserves the hero image’s height before its bytes arrive 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, using 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.