Six CSS Properties That Replace Half Your Layout Hacks
Image squashed inside a container? z-index set to 9999 but still covered? Background page scrolling when a modal reaches the bottom? A title line break that drives you crazy, with the last word hanging alone on the next line?
These problems aren't because you don't know CSS; it's because you don't know these properties.
1. object-fit: cover — The image distortion terminator
You've definitely written this kind of code: a user avatar placed in a 40x40 circular container, but the user uploads a rectangular photo, and the avatar gets squashed into an ellipse. Even worse are product images in a list, with all sorts of dimensions, uniformly stuffed into a 200x200 card—some images look fat, some look thin.
A common hack is to use background-image + background-size: cover instead of the <img> tag, but the semantics are poor, it's not SEO-friendly, and you can't right-click to save the image.
Actually, the <img> tag paired with object-fit solves it perfectly:
.avatar-img {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
<img
class="avatar-img"
src="user-uploaded-photo.jpg"
alt="User Avatar"
>
object-fit: cover maintains the image's aspect ratio, scaling and cropping the image to just fill the container, trimming any excess. It's the same effect as background-size: cover, but it's used for <img> tags.
It also supports other values:
/* Completely fill the container, possibly distorting */
.img-fill { object-fit: fill; }
/* Maintain aspect ratio, show the entire image, leaving empty space */
.img-contain { object-fit: contain; }
/* Maintain aspect ratio, crop overflow, most commonly used */
.img-cover { object-fit: cover; }
/* Maintain aspect ratio, use the image's original size, may also overflow */
.img-none { object-fit: none; }
There's also a partner property object-position, which controls from which part of the image the cropping starts:
.animal-photo {
object-fit: cover;
object-position: top; /* Start from the top, e.g., for a pet photo, crop the face, not the body */
}
Browser Support: All modern browsers. IE does not support it (who is still using IE?).
In one sentence: Add object-fit: cover to your <img> and never write background-image for image distortion again.
2. overscroll-behavior: contain — Lock the scroll, stop the background page from following
Open a full-screen modal, the user scrolls inside to see the content, and when they reach the bottom—bam, the background page scrolls along too. Even more awkward on mobile, pulling down to scroll to the top triggers the browser's "pull-to-refresh," reloading the entire page.
This is called "scroll chaining," an unavoidable pitfall in front-end development.
Previous solutions were troublesome: add overflow: hidden to the <body> when opening the modal, and remove it when closing. The problem is that overflow: hidden often doesn't work on iOS Safari, and you also need position: fixed to prevent sliding. The code is verbose and fragile.
Actually, one line of CSS solves it:
.modal-body {
overscroll-behavior: contain;
overflow-y: auto;
max-height: 70vh;
}
<div class="modal-overlay">
<div class="modal">
<h2>Details</h2>
<div class="modal-body">
<!-- Scrollable content below, won't affect the background page even when scrolled to the end -->
<p>Long content...</p>
<p>Long content...</p>
<p>Long content...</p>
</div>
</div>
</div>
overscroll-behavior has three values:
/* Default: when the scroll boundary is reached, the remaining scroll is passed to ancestor elements */
.scroll-default { overscroll-behavior: auto; }
/* Contain: stops at the scroll boundary, does not pass to any ancestor */
.scroll-contain { overscroll-behavior: contain; }
/* None: does not pass to ancestors. Unlike contain, this also prevents the "bounce" effect */
.scroll-none { overscroll-behavior: none; }
If you want to prevent the mobile "pull-to-refresh," add one line to the <body>:
body {
overscroll-behavior-y: contain;
}
Browser Support: All modern browsers, including mobile Safari. IE does not support it (again: who is still using IE?).
Pair this property with modals, drawers, and sidebars, and the user experience improves by a level.
3. text-wrap: balance — Title line breaks no longer drive you crazy
If your page title looks like this, you know the pain:
Building a High-Performance, Scalable
Microservices Architecture
"Microservices Architecture" sits alone on one line, while the line above is too long. Visually severely unbalanced, a designer would flip the table seeing this.
What was the old solution? Manually insert <br> tags in the title, adjusting line by line:
<h1>Building a High-Performance, Scalable<br>Microservices Architecture</h1>
The problem is that the line break position differs at different screen widths. The <br> you inserted might look even worse on a mobile phone.
text-wrap: balance is the standard answer to this problem:
.page-title {
text-wrap: balance;
max-width: 600px;
}
<h1 class="page-title">
Building a High-Performance, Scalable Microservices Architecture
</h1>
The browser automatically calculates the number of words per line, balancing the amount of text across lines as much as possible, preventing "orphan words" on the last line. The effect is as good as manually adjusting <br>, and it's responsive—automatically recalculating when the window is resized.
Note one detail: text-wrap: balance has a performance limit, calculating a maximum of 6 lines. So it's suitable for places with small amounts of text like titles and subtitles, not for large blocks of body text.
There's a sibling property text-wrap: pretty, which solves the "widow" problem at the end of paragraphs:
.article-content p {
text-wrap: pretty;
}
pretty tries to avoid a situation where the last line of a paragraph has only one word, suitable for body text layout. The difference from balance is: balance aims to "make line lengths even" (pursuing visual balance), while pretty aims to "not make the last line look too pitiful" (pursuing paragraph aesthetics).
Browser Support: text-wrap: balance Chrome 114+, Edge 114+, Safari 17.5+, Firefox 133+. text-wrap: pretty is currently only supported by Chrome/Edge, but Safari and Firefox are catching up. balance is already safe to use.
One line of CSS saves the time of debugging <br> positions.
4. isolation: isolate — z-index set to 9999 but still covered?
z-index is probably the most misunderstood property in CSS. Many people, when encountering an "element being covered," just keep increasing the number: 10 doesn't work, try 100; 100 doesn't work, try 9999; finally try 2147483647—still doesn't work.
The problem isn't the size of the number; it's the stacking context.
What is a stacking context? Simply put, when an element creates a new stacking context, the z-index values of all its internal child elements only compare within it, having no intersection with external elements. This is called "isolation."
For example: your page has a Modal (z-index: 100), and inside the Modal there is a Dropdown (z-index: 9999). But a Fixed button (z-index: 200) next to the Modal actually covers this Dropdown.
Why does this happen? Because the Modal's parent container might also have a z-index set, creating a new stacking context. No matter how large the z-index of the Dropdown inside the Modal is, it can only "climb up" within the Modal's stacking level, unable to climb out of this container.
isolation: isolate is specifically for this—consciously creating a stacking context to lock a component's z-index internally.
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 100; /* The Modal itself only occupies one layer externally */
isolation: isolate; /* Internal child elements play by themselves, not affecting the outside */
}
.modal-dropdown {
position: absolute;
z-index: 10; /* Relative to the inside of the Modal, 10 is enough */
}
<div class="modal-backdrop">
<div class="modal">
<div class="modal-dropdown">
Dropdown menu, z-index: 10 is enough, no need for 9999
</div>
</div>
</div>
The benefit of isolation: isolate is its clear semantics. It tells anyone reading your code: "This component has its own stacking world; things inside won't escape, and things outside won't intrude." It's more readable than the z-index: 0 hack (which also creates a stacking context but with unclear intent).
/* ❌ Unclear intent: Why suddenly set z-index: 0? */
.card { z-index: 0; }
/* ✅ Clear intent: I am isolating this component's stacking context */
.card { isolation: isolate; }
Browser Support: All modern browsers, without exception.
Next time, don't just keep adding numbers. Find that unisolated ancestor and add a line of isolation: isolate.
5. :has() — CSS finally has a "parent selector"
CSS selectors have always had a shortcoming: you can only select from ancestor to descendant, not the other way around. "If the current card contains a large-size image, the card's padding should increase"—this kind of requirement previously could only be done by using JS to add a class to the parent element.
:has() fills this gap.
Scenario 1: Parent-Child Linkage
/* If the card contains an image, increase padding */
.card:has(img) {
padding: 24px;
}
/* If the card contains an error state, make the border red */
.card:has(.error-message) {
border-color: red;
}
/* If the form has an input with an invalid format, highlight the entire fieldset */
fieldset:has(input:invalid) {
background: #fff3f3;
}
<!-- This card has an image, automatically gets 24px padding -->
<div class="card">
<img src="product.jpg" alt="">
<p>Product Description</p>
</div>
<!-- This card has no image, uses default styles -->
<div class="card">
<p>Product Description</p>
</div>
Scenario 2: Interactions without JS
:has() combined with form states can do many things that previously required JS:
/* When the checkbox is checked, the hidden config panel appears */
.panel:has(input[type="checkbox"]:checked) .config {
display: block;
}
/* When the search input has content, the clear button appears */
.search-group:has(input:placeholder-shown) .clear-btn {
display: none;
}
/* Adjacent sibling selection */
.item:has(+ .item:hover) {
background: #f0f0f0; /* When hovering over one item, its previous item also highlights */
}
<label class="panel">
<input type="checkbox"> Enable Advanced Config
<div class="config">
<!-- This area appears when checked -->
</div>
</label>
Scenario 3: Self-Adapting Layout
/* If the grid has more than 4 children, automatically switch to three columns */
.grid:has(:nth-child(5)) {
grid-template-columns: repeat(3, 1fr);
}
:has() supports relational selectors, so it can also do sibling selection and ancestor selection:
/* An h3 preceded by an h2 gets extra spacing */
h3:has(+ h2) {
margin-top: 2rem;
}
/* An input followed by a button loses its border-radius */
input:has(+ button) {
border-radius: 4px 0 0 4px;
}
Browser Support: Chrome 105+, Edge 105+, Safari 15.4+, Firefox 121+. Available on all platforms, safe for production.
:has() is the most exciting new CSS feature for me in recent years. It breaks the rule that "CSS can only select downwards," allowing many logics that previously required JS to be handled purely with CSS.
6. @container — Component-level responsiveness
How many years have you been writing responsive styles with @media? One problem has remained unsolved: @media can only listen to the viewport width, not the component's own width.
For example: the same product card component is 400px wide on the homepage and 200px wide in the sidebar. Responsive styles written with @media only look at the screen width. On a 1000px screen, the card in the sidebar still renders as a large layout—because @media doesn't know you've stuffed it into a 200px container.
@container solves this problem.
Basic Usage
First, define a "query container" for the container:
.sidebar {
container-type: inline-size;
container-name: sidebar;
}
Second, write styles based on the container's width:
/* When the sidebar width is < 300px, the card becomes smaller */
@container sidebar (max-width: 300px) {
.card {
display: block; /* Switch from horizontal to vertical */
padding: 12px;
}
.card-title {
font-size: 14px;
}
.card-image {
display: none; /* Too narrow, don't show the image */
}
}
/* When the sidebar width is > 500px, the card expands */
@container sidebar (min-width: 500px) {
.card {
display: grid;
grid-template-columns: 200px 1fr;
gap: 16px;
}
}
<aside class="sidebar">
<!-- When sidebar width is 200px, the card automatically switches to vertical and hides the image -->
<div class="card">
<img class="card-image" src="product.jpg">
<h3 class="card-title">Product Name</h3>
</div>
</aside>
Using Anonymous Containers Without Names
If you only want the container width to affect direct child elements, you don't need to name it:
.card-wrapper {
container-type: inline-size;
}
/* Anonymous container query, acts on direct children of .card-wrapper */
@container (min-width: 400px) {
.card {
flex-direction: row;
}
}
Real-world Scenarios
In a page layout, the same component might appear three times: in a full-width area, a two-column area, and a sidebar. Previously, these three layouts required three sets of CSS, or using JS to dynamically measure width. With @container, the component decides how to render based on its own container's width, working anywhere it's placed.
.product-card-grid {
container-type: inline-size;
}
@container (min-width: 350px) {
.product-card {
display: grid;
grid-template-columns: 120px 1fr;
}
.product-card img {
width: 120px;
}
}
@container (max-width: 349px) {
.product-card {
display: block;
}
.product-card img {
width: 100%;
}
}
Browser Support: Chrome 105+, Edge 105+, Safari 16.0+, Firefox 110+. Available across the board.
Used together with :has(), you can pretty much say goodbye to a large portion of CSS layout JS hacks.
Wrapping Up
Six properties, each a matter of one to a few lines of CSS, but before knowing them, you might have spent half an hour on StackOverflow searching for various workarounds. object-fit ends image distortion, overscroll-behavior locks down scroll chaining, text-wrap: balance saves the obsessive-compulsive from bad line breaks, isolation: isolate lets you bid farewell to z-index wars, and :has() and @container go a step further—moving work that previously had to rely on JS back into CSS.
What's the most outrageous CSS problem you've ever written? Have you ever had a moment where you debugged for ages only to find out "the browser actually has a native property that solves this"? The comments section awaits your venting.