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. 🙂
Leave a comment