Skip to main content
Dublin Library

The Publishing Project

The Browser Knows What It's Doing, Trust It.

 

The Ideal Viewport Doesn't Exist made me think about the CSS I write and how much it has changed since I started.

CSS has evolved from a simple styling language into a powerful system that adapts to devices and user preferences. Developers once controlled nearly every detail of layout and presentation, but now we can delegate much of that work to the browser and let it make smarter decisions based on the user's context.

This post explores what it means to "trust the browser" and how to use its built-in capabilities to create better web experiences.

Be the Browser's Mentor, Not Its Micromanager #

The video that inspired this section title is a great example of how we can guide the browser without micromanaging every detail.

When Andy Bell first did this presentation, he mentioned a series of concerns that are not available in the modern web environment but the underlying message is still relevant: trust the browser.

We should design for everyone, not just for ourselves or a specific set of devices. The content should not be exactly the same for every user, since we cannot account for every possible combination of extensions, corporate restrictions, and device/browser limitations. It should still be accessible and functional for everyone. We can trust the browser to make smart decisions about how to present content based on the user's device, preferences, and context.

There are new web technologies in CSS and HTML that help us build responsive layouts without complex media-query trees, separate mobile and desktop sites, or heavy JavaScript layout libraries. These tools let browsers adapt layouts based on available space instead of fixed breakpoints. Rather than handing browsers rigid instructions for every screen size, we define constraints and let the layout engine choose the best presentation for the user's device.

Flexbox #

Flexbox allows us to create 1-dimensional flexible layouts that adapt to the available space.

The HTML defines a simple wrapper with four items.

<div class="wrapper">
	<div class="item">Item 1</div>
	<div class="item">Item 2</div>
	<div class="item">Item 3</div>
	<div class="item">Item 4</div>
</div>

Without flexbox, the items would stack vertically by default. With flexbox, we can define how they should flow and wrap based on the available space.

The first round of CSS defines the wrapper as a flex container and the gap between items. When we don't define the flow, the default is row nowrap, which means the items will be laid out in a single row and won't wrap to the next line. If the container is too narrow to fit all items in a single row, they will overflow.

.wrapper {
	display: flex;
	gap: 4rem;
}

The second round of CSS adds the flex-flow property to define a row-based layout that wraps when necessary. At this point the items have no defined size, so they will shrink to fit the content and wrap as needed.

.wrapper {
	display: flex;
	flex-flow: row wrap;
	gap: 4rem;
}

In the third round of CSS, we define a fixed size for the items. This will cause them to wrap when the container is too narrow to fit them all in a single row.

.item {
  border: 2px solid red;
  block-size: 200px;
  inline-size: 200px;
}

The final round of CSS defines the properties of the children of the flex container. We set explicit dimensions for each child item, and we set the flex property to 1 1 200px, which means that the items can grow and shrink as needed, but they have a base size of 200px. This allows the items to adapt to the available space while maintaining a minimum size.

.item {
  border: 2px solid red;
  block-size: 200px;
  flex: 1 1 200px;
}

Each step we took on CSS produced a specific layout, and the browser made smart decisions about how to present the content based on the defined constraints. We didn't have to micromanage every detail of the layout; we just provided the rules and let the browser do its job.

Grid #

Grid is a two-dimensional layout system that allows developers to create complex layouts that adapt to the available space. It provides more control over both rows and columns, making it easier to create responsive designs without relying on media queries.

Defining grids #

Just like we did with Flexbox, we start with a simple HTML structure that defines a wrapper with six items.

<div class="wrapper">
	<div class="item item01">01</div>
	<div class="item item02">02</div>
	<div class="item item03">03</div>
	<div class="item item04">04</div>
	<div class="item item05">05</div>
	<div class="item item06">06</div>
</div>

The CSS first defines the wrapper as a grid container and sets up a grid with six equal columns (1fr each) and a gap between them. The rows will be automatically created based on the number of items and the defined columns without using explicit row definitions.

This creates the grid but the grid is not responsive yet. Items will be placed in the grid based on their document order, making the row expand based on the number of items, regardless of the number of columns.

.wrapper {
  display: grid;
  grid-template-columns: repeat(6, 1fr);
  gap: 2rem;
}

We can use template areas to define named areas of the grid and place items in those areas.

The template looks like an ASCII representation of the grid layout, where each string represents a cell in the grid and each row in the template corresponds to a row in the grid. The names in the template correspond to the grid areas that we will define for each item.

.page-layout {
  display: grid;
  grid-template-columns: 300px 1fr;
  grid-template-rows: auto 1fr auto;
  grid-template-areas:
    "header header"
    "sidebar main"
    "footer footer";
  min-height: 100vh;
}

We can then assign each item to a grid area using the grid-area property. This allows us to place items in specific areas of the grid based on the template we defined without having to specify the exact row and column lines.

.page-header { grid-area: header; }
.page-sidebar { grid-area: sidebar; }
.page-main { grid-area: main; }
.page-footer { grid-area: footer; }

Sizing and Placing Grid Items #

To set the size of columns and rows we use grid-template-columns and grid-template-rows.

The most basic setup is to explicitly define column and row sizes using fixed lengths such as px, flexible units such as fr, or keywords such as auto.

.wrapper {
	display: grid;
	grid-template-columns: 250px 250px 250px 250px 250px 250px;
	gap: 2rem;
}

We can use the repeat() function to simplify the syntax when we have repeating patterns. The first argument is the number of times to repeat, and the second argument is the value to repeat.

.wrapper {
	display: grid;
	grid-template-columns: repeat(6, 250px);
	gap: 2rem;
}

Placing grid items #

When you don't specify the position of grid items, they will be placed in the grid according to the order they appear in the HTML. The first item will be placed in the first cell of the grid, the second item in the second cell, and so on. If there are more items than cells, they will wrap to the next row.

The first modification to automatic placement is to specify how many cells (rows or columns) an item will span. In this case, item01 will span 2 columns and 2 rows, starting from the next available grid cell.

.item01 {
	grid-column: span 2;
	grid-row: span 2;
}

By default, the browser's packing logic is sparse (strictly sequential). It places Item 1, Item 2, Item 3, etc., in order. It will never look back to see if Item 4 might fit into the gap left by Item 3, leaving a gap in the layout.

This is solved by using the dense packing algorithm, which allows the browser to backtrack and fill in gaps with items that come later in the source order.

.wrapper {
	display: grid;
	grid-template-columns: repeat(6, 250px);
	gap: 2rem;
	grid-auto-rows: repeat(4, 200px);
	grid-auto-flow: row dense;
}

.item03 {
	grid-column: span 2;
	grid-row: span 2;
}

The final option is to specify the exact start and end line for the item. In this case, the first item will start at the first column and end at the third column, and it will start at the first row and end at the third row.

.item01 {
	grid-column: 1 / 3;
	grid-row: 1 / 3;
}

You can simplify the syntax by using the span keyword to indicate how many columns and rows the item should span, starting from its default position in the grid.

.item01 {
	grid-column: 1 / span 2;
	grid-row: 1 / span 2;
}

Both auto-fill and auto-fit are CSS Grid keywords used with the repeat() function to build responsive layouts without media queries. They allow the browser to automatically determine how many columns to create based on the available space.

They behave identically until the grid container becomes wider than the combined width of all the grid items. The core difference lies in how they handle that extra space.

  • auto-fill (Reserves space): The browser creates as many column tracks as possible within the container based on the minimum size provided. If there are not enough items to fill all the tracks, the empty tracks remain in the layout, reserving their space.
  • auto-fit (Collapses space): The browser also calculates the maximum number of tracks. However, if there are empty tracks left over, it collapses them to zero width. The remaining grid items are then allowed to stretch and consume the extra space (provided you are using a flexible unit like 1fr).

Use auto-fit when you want your items to always stretch to fill the available screen width. Use auto-fill when you want items to maintain their maximum size limits and align strictly to a grid, even if it leaves empty columns at the edge of your layout.

.wrapper {
  display: grid;
  grid-template-columns: repeat(
		auto-fill,
		minmax(200px, 1fr)
	);
  gap: 2rem;
}

.wrapper2 {
  display: grid;
  grid-template-columns: repeat(
		auto-fit,
		minmax(200px, 1fr)
	);
  gap: 2rem;
}

Now that track sizing is dynamic with auto-fill and auto-fit, let's look at how dense placement behaves in that specific setup.

By default, CSS Grid uses a "sparse" packing algorithm. It places items sequentially. If a larger item (e.g., grid-column: span 2) does not fit on the current line, the browser pushes it to the next line, leaving a gap.

Setting grid-auto-flow: dense changes this behavior. The browser will continuously look back at earlier gaps in the grid and attempt to backfill them with smaller items that appear later in your HTML source order.

.wrapper {
  display: grid;
	/* we can also use autofill */
  grid-template-columns: repeat(
		auto-fit,
		minmax(200px, 1fr)
	);
	grid-auto-flow: row dense;
  gap: 2rem;
}

Dense packing and auto-fit don't conflict. They control two different phases of the grid rendering engine: sizing tracks vs. placing items.

Here is how they interact:

Internal Gaps (Works with both): If you have items of varying sizes that create holes in the middle of your grid layout, grid-auto-flow: dense will backfill those holes regardless of whether you use auto-fill or auto-fit. The packing algorithm handles item placement before track collapsing occurs.

End-of-Row Gaps (The Key Distinction): The real interaction occurs at the end of a row when you have fewer items than the maximum possible columns:

  • With auto-fill and dense: The grid creates actual empty tracks at the end of the row. Because these tracks exist and have width, the dense packing algorithm could place a dynamically added element into them later.
  • With auto-fit and dense: The empty tracks at the end of the row are collapsed to zero width, and the existing items stretch to fill the row. Because the empty tracks effectively no longer exist, the dense algorithm has no space at the end of that specific row to backfill into.

CSS Grid Lanes and browser support (as of early 2026) #

Grid Lanes solves an old problem with CSS: how to create masonry layouts like Pinterest without relying on JavaScript libraries like Isotope or Masonry.js or on hacks like dense packing in CSS Grid.

With grid-lanes, we can create masonry-like layouts using pure CSS, without the need for JavaScript. To use it, define the grid container with display: grid-lanes and specify the number of columns and the gap between items. The browser will automatically place items in the grid, filling in gaps as needed to create a masonry layout.

.container {
  display: grid-lanes;
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
  gap: 1rem;
}

Support for grid lanes is still limited across browsers, even though implementation work is progressing. Here's a quick overview of current support for grid-lanes:

Browser Version Support Status
Safari 26.4+ ✅ Supported
Chrome 148+ ⚠️ Limited Support (Behind Flag)
Edge 148+ ⚠️ Limited Support (Behind Flag)
Firefox 151+ ⚠️ Limited Support (Behind Flag)

Container Queries #

Container queries let components adapt to their container size instead of the viewport, reducing reliance on viewport media queries for component-level layout changes.

This HTML represents a card component that contains an image and some content.

<div class="card-wrapper">
  <article class="card">
    <img src="thumbnail.jpg" alt="Article thumbnail" />
    <div class="card-content">
      <!-- Card text here -->
    </div>
  </article>
</div>

To set up container queries, we need to define a container on the parent element. This is done by setting the container-type property to inline-size (or size for both dimensions) and giving it a name with container-name.

We then set the default layout for the card component, which is a column-based layout. The card will stack the image on top of the content by default.

.card-wrapper {
  container-type: inline-size;
  container-name: card-wrapper;
}

.card {
  display: flex;
  flex-direction: column;
}

We can then use a container query to change the layout of the card when the container reaches a certain width. In this case, when the container is at least 400px wide, we change the layout to a row-based layout, placing the image next to the content.

@container card-wrapper (inline-size >= 400px) {
  .card {
    flex-direction: row;
  }
}

With container queries, we delegate layout decisions to the container rather than the viewport. This avoids the need to calculate breakpoints for all the devices we might encounter. The browser can handle designs for sizes we did not explicitly account for.

Delegating Color and Accessibility #

Historically, ensuring accessible text contrast against dynamic backgrounds required complex JavaScript calculations or intricate SASS functions. Managing dark and light modes meant maintaining duplicate variables wrapped in @media (prefers-color-scheme) blocks.

Two CSS functions allow us to delegate these decisions directly to the browser's internal engine.

The light-dark() function allows you to provide two color values and trust the browser to serve the correct one based on the user's system preferences. This eliminates the need to micromanage theme states.

Similarly, the contrast-color() function is the ultimate example of offloading mathematical labor.

This delegation becomes even more powerful when combined with CSS custom properties (variables) to create dynamic design tokens. You can assign a light-dark() evaluation to a CSS variable at the root level, and then pass that variable directly into contrast-color(). The browser will correctly resolve the theme color first, and then calculate the optimal accessible contrast.

Example: Theme-aware buttons

To understand why we pair these functions, it's important to distinguish what each one does:

  • The light-dark() function acts as a simple toggle: it returns your light theme color if the user's operating system is in light mode, and your dark theme color if it's in dark mode.
  • contrast-color() looks at the final resolved background color and automatically generates a new foreground color as a best-effort accessibility choice.

By using this function, you are making a conscious trade-off: you give up absolute, pixel-perfect design control (like forcing a specific dark navy text color) in exchange for mathematical accessibility. The browser strictly enforces this binary, typically returning pure black or pure white.

:root {
  color-scheme: light dark;
  --color-primary: light-dark(teal, cyan);
}

.smart-button {
  background-color: var(--color-primary);
  color: contrast-color(var(--color-primary));

  padding: 0.75rem 1.5rem;
  border: none;
  border-radius: 4px;
}

However, trusting the browser doesn't mean turning off your own judgment. For certain complex mid-tone backgrounds where neither pure black nor pure white mathematically passes WCAG 2.x requirements, the browser will simply pick the 'least bad' option with the highest relative contrast. Even the best mentors have limits, so always verify your final color pairs.

Browsers currently use the WCAG 2 contrast model, while APCA (Advanced Perceptual Contrast Algorithm) represents a newer approach in the WCAG 3 direction. As browser engines adopt newer models, the chosen foreground color may change for the same background, but the underlying goal remains readable contrast without rewriting your component logic.

You can also use light-dark() to create theme-aware images by defining the function with two images, one for light mode and one for dark.

.item01 {
  background-image: light-dark(
		url(light-pattern.png),
		url(dark-pattern.png)
	);
}

You must use two colors or two images with light-dark(). It doesn't work if you try to mix a color and an image, or if you only provide one value.

The sizes="auto" Attribute for Lazy-Loaded Images #

A newer capability in HTML is the sizes="auto" attribute for lazy loaded responsive images that addresses a big pain point in responsive images.

Responsive images have traditionally required developers to provide a srcset with multiple image sources and a sizes attribute that defines the layout size of the image at various breakpoints. This often involved complex calculations to determine the correct sizes for each breakpoint, which could be time-consuming and error-prone.

The sizes="auto" attribute allows the browser to automatically determine the appropriate image size to load based on the layout and available space, without requiring developers to manually calculate and specify sizes for different breakpoints.

<img
  loading="lazy"
  sizes="auto"
  width="1200"
  height="800"
  srcset="small.jpg 300w,
          medium.jpg 600w,
          large.jpg 1200w"
  src="fallback.jpg"
  alt="A descriptive alt text">

While sizes="auto" eliminates massive amounts of manual calculation, it can feel contradictory to use it alongside hardcoded HTML width and height attributes (as shown in the example above). To understand why this isn't micromanaging, you must distinguish between three concepts:

  • Layout Size (The CSS problem): How wide is the image right now on the user's screen? (e.g., 345px on a phone). The sizes="auto" attribute entirely solves this.
  • Intrinsic Size (The file's physical reality): What are the actual physical pixel dimensions of the source image file? (e.g., 1200 pixels wide by 800 pixels tall).
  • Intrinsic Aspect Ratio (The HTML problem): What is the inherent shape derived from that intrinsic size? (e.g., a 3:2 ratio).

Providing explicit width and height attributes isn't about setting rigid dimensions; it's about providing a blueprint. You are telling the browser the intrinsic aspect ratio ahead of time, allowing it to reserve the correct amount of vertical space and prevent layout shifts before the image even begins to load.

Conclusion: Let the Browser Do Its Job #

In Designing Without Breakpoints: Is It Possible? established that we can create responsive designs without relying on fixed breakpoints, even though media queries remain necessary in some cases.

In this post, we explored how to trust the browser to make smart layout and design decisions with Flexbox, Grid, container queries, modern color functions, and HTML attributes like sizes="auto" for responsive images. When we let the browser do its job, we build adaptable, accessible, and efficient experiences for more devices and user preferences without micromanaging every design detail.

Edit on Github