Skip to main content

Horizontal Scrolling in a Centered Max-Width Container

Applying modern CSS techniques on horizontal scroll section layouts

Posted on Mar 11, 2022

Takes about 7 minutes to read

The layout challenge

When I had first assembled a gallery of CodePen projects to include on my personal site redesign in the summer of 2021, I imagined the following layout and interaction:

This was a tough layout to get right! Ultimately, I decided to go with a slightly different homepage design that didn't rely on aligning the start position inside the page content area.

In case my site design has been updated, this is for my future friends reading: You can see the aforementioned version of my site in this tweet from August 2021.

Revisiting the desired result

The altered design worked well. But I still couldn't shake it. There had to be a way to build that original layout. Turns out nearly anything is possible with CSS these days. Here's a CodePen containing some gallery examples:

See the pen (@hexagoncircle) on CodePen.

I shared this solution on Twitter back before I had a blog space. Now that I do have one, I thought I'd take a deeper dive into how I achieved the final result.

Using a full-bleed layout

Josh Comeau's Full-Bleed Layout Using CSS Grid is an article I reference often. It's a solid, modern approach to limit the maximum width of page content while allowing "full-bleed" elements such as images to stretch across the viewport width. This style of layout has been achieveable by other means as discussed at length in Full Width Containers in Limited Width Parents on CSS-Tricks but I agree with Josh's sentiment about negative margin approaches being a bit hacky in comparison.

The CodePen above follows Josh's padding example but adds some named template areas which I'll explain:

.content {
display: grid;
grid-template-columns:
[full-start] 1fr
[content-start]
min(var(--content-max-width), 100% - var(--space-md) * 2)
[content-end]
1fr [full-end];
}

.content > * {
grid-column: content;
}

.gallery {
grid-column: full;
/* other gallery code */
}

The first and third columns are set to 1fr, causing them to fill the space surrounding either side of the second. The value of the second column is calculated by a CSS min() function, which selects the smaller of its two values depending on the window size. On screensizes smaller than --content-max-width, padding is created on either side by doubling a space value and subtracting it from 100% to suppress any unwanted page overflow.

Something to note is that calc() can be used but is not necessary for calculations written inside min(), max(), and clamp() functions.

A noticeable difference in this code are the named grid lines declared in grid-template-columns. Appending -start and -end creates a named area (or custom-ident) that can be referenced in a child element's grid-column property. When applied, an element will span the area between these two lines.

This approach removes the need for a "full-bleed" utility class on HTML elements. Instead, full and content become reusable values in the CSS for child elements when grid-column is declared. If the columns template should change at all (adding additional values, adjusting sizes) the named areas stay the same.

Creating the gallery styles

With the page layout finished, we can move on to the gallery component, starting on the top-level gallery element:

.gallery {
grid-column: full;
display: grid;
grid-template-columns: inherit;
padding-block: var(--gap);
overflow-x: scroll;
overscroll-behavior-x: contain;
scroll-snap-type: x mandatory;
scrollbar-width: none;
}

This is where scroll behavior and scroll snapping are handled, as well as stretching the viewport width. It inherits the grid-columns-template from the parent grid, acquiring the same column values and named grid lines.

inherit works as expected since the gallery spans the full row of the parent grid, so its column dimensions match. However, its grid is independent of the parent one, unlike subgrid which allows nested elements to utilize the parent grid. This article by Anna Monus explains it well. CSS Subgrid support is very low at the time of writing.

In browser developer tools, we can enable layout grid lines visually and get a sense of how it's all working. I'm using Chrome dev tools in the screenshot below but Firefox and Safari share similar steps.

[object Promise]

The inner wrapper

In order to align the initial project item to the page content area, a wrapper element surrounds the project items and has grid-column: content declared. Remember that the gallery inherits grid-template-column from its parent so the named area identifiers are available.

.gallery .wrapper {
grid-column: content;
display: flex;
align-items: center;
gap: var(--space);
}

.gallery .wrapper::after {
content: "";
align-self: stretch;
padding-inline-end: max(
var(--space),
(100vw - var(--content-max-width)) / 2 - var(--space)
);
}

A flex display is applied to the wrapper so that its children line up in a single row. The gap property adds the gutters between each child.

The wrapper also introduces a pseudo element as a spacer after the last project item to keep it from stopping right on the viewport edge. To make my original spacer code even better, Maarten Bruggink shared a fantastic suggestion that supports scrolling until the last element aligns to the right side of the page content area, even on larger screensizes. 👏

The projects

Adding flex-shrink: 0 on project items keeps them from collapsing to fit within the gallery wrapper. I've applied a combination of inline-sizing and aspect-ratio to projects in this demo so that they share the same responsive dimensions. It's not required though! Depending on what the gallery intends to accomplish, some project items could be wider, some tighter, and the layout would work as you'd expect. In the CodePen demo, scroll down a bit for an example.

A fun scroll snap tidbit

Something that I found really interesting: scroll-snap-align can be declared on nested elements! Notice that scroll-snap-align: center is set on project items. Although, while this works nicely for the center value, the result is not what you might hope for when using start or end. The elements are aligning to the scroll container edges of the gallery, which handles the scroll snap positioning, not the wrapper.

Reverse scroll direction

Scroll direction is handled quite gracefully. For languages that read from right to left, project items will be flipped appropriately thanks to their parent wrapper's flexbox display. The first item aligns to the right edge of the page content area and the gallery scrolls in from the left. Check the CodePen demo for an example of this further down the page.

For more information on this, RTL Styling 101 is an excellent guide. I recommend the Flexbox Layout Module section to learn more about flexbox and right-to-left styling.

CSS is awesome

CSS Grid and Flexbox open the doors to so many layout patterns that, not long ago, were nothing but impossible to produce without leaning into JavaScript. There are so many more exciting new features coming to CSS that will continue to push the boundaries of what we can create. If CSS Cascade Layers are any indication, browser teams are working hard on implementing these features faster than ever.

Helpful resources