Do you also love the vw unit in CSS? The Volkswagen of units? And did you know it's complete and utter shit?

The expression 100vw means one hundred percent of the viewport width. The entire browser canvas. Want your full-bleed cover to be exactly that wide? Just set width: 100vw on it.

And what if the page has a vertical scrollbar? Mac users barely notice it, because macOS auto-hides it when not in use. Windows shows it permanently. It's a (usually) 15-pixel strip on the right that logically doesn't belong to the viewport width. You can't render anything in it. On the contrary, it eats into the viewport.

Except 100vw is the width including the scrollbar!

What does that mean? You build a full-bleed cover on a Mac, width: 100vw, open it in Chrome on Windows, and look — the page scrolls horizontally. The cover overhangs the right edge, by exactly those 15 pixels of scrollbar.

A fundamental viewport-width unit that doesn't respect the viewport width.

This bug has been around for ages and nobody's moving it. The ticket in the CSS Working Group has been hanging in limbo since 2021, and similar discussions have been running even longer. Browser vendors say “the spec mandates it”, spec authors say “browsers interpret it wrong”, and meanwhile a web developer is writing a workaround for a perfectly trivial full-bleed image.

In the meantime, more units have arrived (svw, lvw, dvw) and none of them accounts for the scrollbar. All the new units are shit.

So how do you build a full-bleed cover that won't trigger horizontal scrolling on Windows? Several workarounds exist. Let's go through them, starting from the silliest.

Dead ends

First instinct: force the vertical scrollbar to be permanent and subtract its width.

html {
	overflow-y: scroll;
}

.cover {
	width: calc(100vw - 15px); // DOESN'T WORK
}

This would work if the scrollbar were really 15px. Which it isn't on a Mac, on mobile (0px), or for users with custom styling.

Second attempt: scrollbar-gutter: stable. Tells the browser “always reserve space for the scrollbar, whether you show it or not”. Consistent layout, no jumping when switching between short and long pages. But it doesn't fix 100vw — that includes the scrollbar width regardless of whether it's actually visible.

Conclusion: we have to measure the scrollbar.

Solution 1: measure it with JavaScript

The property document.documentElement.clientWidth returns the inner width of the HTML element, scrollbar excluded. We propagate it into a CSS variable and update it on every resize:

<script>
new ResizeObserver(() => {
	document.documentElement.style.setProperty(
		'--viewport-width',
		document.documentElement.clientWidth + 'px'
	);
}).observe(document.documentElement);
</script>

Then we just supply a default value in CSS and we can use the variable:

:root {
	--viewport-width: 100vw;  // default
}

.cover {
	width: var(--viewport-width);
}

Works everywhere. It even triggers when the scrollbar suddenly appears or disappears mid-life of the page. Like when the user expands a long list. A few lines of JS, done.

But there's also a pure CSS route.

Solution 2: forget vw, you have cqw

In 2022, container query units arrived in CSS. 100cqw means “100% of the inline-size of the nearest container-query container”. All you need is to declare body as that container — and its width doesn't include the scrollbar:

body {
	container-type: inline-size;
}

.cover {
	width: 100cqw;
}

No workaround, no horizontal scrolling. Just a unit that does what it promises.

The catch: nested containers

This holds only as long as body remains the only container-query container in the subtree. The moment you use container-type somewhere inside for a component (typically so the component is responsive independently of window width), cqw inside that component starts referring to the component, not to body.

Most sites are fine with this. You can keep container queries on components, you just usually don't need a full-bleed cover inside them. That belongs in the main layout, not in a product card or a sidebar.

But if you're building a truly generic framework, where full-bleed can be requested by any component anywhere, the limitation can bite. And for that case, there's one last trick.

Solution 3: registered @property

We make a CSS variable of length type. Important word: type. A registered @property is computed as a length already at declaration, and from then on it inherits as concrete pixels — regardless of how many containers anyone piled into the path along the way:

@property --viewport-width {
	syntax: '<length>';
	inherits: true;
	initial-value: 0px;
}

html {
	container-type: inline-size;
}

body {
	--viewport-width: 100cqw;
}

.cover {
	width: var(--viewport-width);
}

Without registration this wouldn't help, because the variable would be substituted textually and 100cqw would resolve at the point of use, i.e., inside the nearest container. With length-typed registration, the value is frozen and inherits independently.

One day someone will close that ticket in the CSS WG, and the sun will shine. Until then, forget that vw exists. 🙂