Modern CSS Techniques
22. June, 2024 • 7 min read • Develop
Grid, Flexbox and the things around them
CSS spent about a decade being the part of the stack people apologised for. Then Flexbox landed, then Grid, then everything else arrived so quickly that I keep meeting developers who are still writing 2016 CSS out of habit. This is a tour of what I actually use.
None of this is exotic. All of it works in every evergreen browser as I write this. The point of the post is less “here is a new feature” and more “here is the syntax that catches people out”, because most of these tools are one small misunderstanding away from feeling broken.
CSS Grid
Grid is a two-dimensional layout system. Rows and columns at the same time, which is the thing Flexbox deliberately doesn’t do.
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: auto;
gap: 10px;
}
.grid__item {
background: #f0f0f0;
padding: 20px;
border: 1px solid #ccc;
}Three equal columns, each taking one fraction of the free space. repeat() saves you typing 1fr 1fr 1fr, and gap puts space between the tracks without the negative-margin nonsense we all used to write.
Once the tracks exist, you can place items across them by line number:
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: 100px 200px;
gap: 10px;
}
.grid__item1 {
grid-column: 1 / 3; /* span two columns */
grid-row: 1 / 2; /* occupy the first row */
}
.grid__item2 {
grid-column: 3 / 4;
grid-row: 1 / 3; /* span both rows */
}The numbers are grid lines, not tracks. A three-column grid has four lines. That off-by-one is responsible for a genuinely embarrassing share of the time I have spent debugging layouts.
Template areas
Named areas are the feature I’d show someone first, because they make a layout readable by people who don’t know CSS.
.container {
display: grid;
grid-template-areas:
"header header"
"sidebar content"
"footer footer";
grid-template-rows: 100px 1fr 50px;
grid-template-columns: 200px 1fr;
}
.header {
grid-area: header;
}
.sidebar {
grid-area: sidebar;
}
.content {
grid-area: content;
}
.footer {
grid-area: footer;
}You can see the page in the stylesheet. Rearranging it inside a media query is a matter of rewriting those three strings, which is about as close to pleasant as responsive layout gets.
Subgrid
Subgrid lets a nested grid use its parent’s tracks instead of defining its own. The classic use is a row of cards whose headings, bodies and footers should line up across the row even though the text lengths differ.
.cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, auto);
gap: 20px;
}
.card {
display: grid;
grid-row: span 3;
grid-template-rows: subgrid;
}Two things to get right, because I got both of them wrong the first time. There is no display: subgrid; the child is a normal display: grid and subgrid is a value of grid-template-rows or grid-template-columns. And the child has to actually span the parent tracks it wants to adopt, which is what grid-row: span 3 is doing. Miss that and the subgrid silently behaves like an ordinary grid.
Firefox has had subgrid since 2019, Safari since 16, and Chrome and Edge since 117 in late 2023. So it is finally usable without a fallback.
Flexbox
Flexbox is one-dimensional. Items in a row, or items in a column, with control over how the leftover space is distributed.
.container {
display: flex;
justify-content: space-between;
align-items: center;
}
.item {
background: #f0f0f0;
padding: 20px;
border: 1px solid #ccc;
}justify-content: space-between spreads the items along the main axis, align-items: center centres them on the cross axis. The old joke about vertical centring being impossible in CSS died right here.
For sizing, there are flex-grow, flex-shrink and flex-basis, and in practice you almost always want the shorthand:
.item {
flex: 1;
margin: 10px;
}flex: 1 expands to flex-grow: 1; flex-shrink: 1; flex-basis: 0%. That last value is the one people miss. A basis of 0% means the item’s own content width is ignored when space is divided, so every item ends up the same size. If you want the content to influence the result, you want flex: auto, which uses flex-basis: auto instead. I have watched this exact distinction cause a two-hour argument about a navigation bar.
Scroll snap
Scroll snap turns a scrollable area into something that settles on defined points rather than wherever the momentum happened to stop.
.container {
scroll-snap-type: x mandatory;
overflow-x: scroll;
display: flex;
}
.item {
scroll-snap-align: start;
flex: 0 0 100%;
}That is a carousel, in seven declarations, with no JavaScript and with native keyboard and touch behaviour intact. Use proximity rather than mandatory if the content varies in size, because mandatory will fight the user on a long item.
Filters
Filters apply graphical effects without touching the source image.
.image {
filter: blur(5px) contrast(1.2);
}They compose left to right, and they are cheap enough to animate on the compositor in most cases. The one to be careful with is filter on a parent element, because it creates a containing block for descendants with position: fixed, which is a fun afternoon when you don’t know it.
Transitions and animations
Transitions
A transition interpolates a property when its value changes, typically on hover or focus.
.button {
background-color: #3498db;
transition: background-color 0.3s ease;
}
.button:hover {
background-color: #2c3e50;
}Name the properties you’re transitioning. transition: all is tempting and will one day animate something you did not intend, usually a layout property, usually on a page with a hundred of these elements.
Animations
Animations run from keyframes, on their own schedule, without needing a state change to trigger them.
@keyframes slideIn {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
.element {
animation: slideIn 0.5s forwards;
}The element slides in from the left over half a second. forwards is an animation-fill-mode value and keeps the final keyframe applied after the animation ends; without it the element snaps back to its original position, which looks like a bug and is reported as one.
Wrap decorative animation in @media (prefers-reduced-motion: reduce) and give people a still version. It takes four lines.
Beyond the layout systems
Custom properties
I covered CSS variables properly in an earlier post, so here is the short version.
:root {
--primary: #3498db;
--padding: 20px;
}
.container {
color: var(--primary);
padding: var(--padding);
}The thing that makes them more than a preprocessor variable is that they live in the cascade. You can redefine --primary inside a .dark class or a media query and everything downstream updates, at runtime, with no rebuild.
Shapes
shape-outside makes text wrap around a non-rectangular path.
.shape {
float: left;
shape-outside: circle(50%);
width: 200px;
height: 200px;
background: #f0f0f0;
clip-path: circle(50%);
}shape-outside controls how surrounding content flows, clip-path controls what you can see of the element itself. You usually want both, and you always need the float, because shape-outside only applies to floated elements. That restriction is why this feature never quite caught on.
Combining them
Grid for the page, Flexbox for the components inside it. That is the division of labour I use almost every time.
.grid-container {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 20px;
}
.flex-item {
display: flex;
flex-direction: column;
justify-content: center;
align-items: start;
}Grid decides where the boxes go, Flexbox decides how the contents of each box arrange themselves. Trying to do the whole page in Flexbox is possible and produces stylesheets full of percentage widths that nobody wants to inherit.
What I still find annoying
Sizing an element based on its container rather than the viewport is still awkward, and every workaround I’ve used has involved either JavaScript or a resize observer pretending to be CSS. That is the gap container queries are supposed to close, and I want to spend some proper time with them before I write anything about them.
‘Till next time!