I've been chasing the idea of using (abusing?) CSS grid to build a interconnected hexagonal grid, where each hexagon fits together seamlessly. An example of this would be a lot of tabletop war games, some board games (Settlers of Catan, for instance), and some computer games (I used to play The Battle for Wesnoth, it uses a system like this).
Here's the list of requirements I had going into this:
- Must interconnect. This means a certain amount of overlap of the individual items, because we're fitting a hexagon into a square hole.
- Must respect
grid-gap
, or allow for gutters of some kind. Preferablygrid-gap
. - Must be flexible. I need to be able to just throw a new item into it and have it just work.
- Must be responsive. A grid that starts out at 5 across on larger screens should go down to one or two across on smaller screens.
The end result
I'm going to break down how this works step by step, but here's the end result I was able to come up with. The article list for this website actually uses a version of this as well.
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
HTML
Let's break down what the html for this looks like, and then we'll get into how the grid is set up. One of the requirements was flexibility, and part of that was making sure that there wasn't a lot of complicated markup involved. Also, CSS grid requires us to have pretty flat markup, as all of our grid items have to be siblings right now. Sub-grid will make this not a requirement, but it's not quite there yet and this proved to not be an issue anyway.
<ul class="hex-grid__list">
<li class="hex-grid__item">
<div class="hex-grid__content">1</div>
</li>
<li class="hex-grid__item">
<div class="hex-grid__content">2</div>
</li>
...
</ul>
As you can see, we have a pretty standard list going here. We do need an extra content <div>
in order to pull off the look we're going for; this is due to needing to force an aspect ratio for these hexagons, as you'll see when we get into the CSS. If you're curious about forcing ratios in CSS, this is a good article to check out.
CSS
Alright, let's get into how this crazy grid system works. My first stab at this explored trying to figure out an auto placement scheme, but that quickly became a dead end as I realized that one of my requirements, interconnecting hexagons, was going to prevent any sort of automatic placement, at least from my understanding of grid. CSS grids are super interesting in that they really don't care if there's an element already occupying the space that you tell them to go to, so if you tell multiple elements to occupy the same grid cell they will happily do that, but you have to explicitly tell the grid what elements you need to overlap, so auto placement was a no-go. Once I figured that out, I moved into figuring out what my grid needed to actually look like. This proved to be far more challenging than I expected. Or maybe I'm just bad at math. I think that might be it.
Setting up the grid
Well first, we need to turn our list into a grid. :) Also, since my markup is a list, I'm going to remove the default list styles as well.
.hex-grid__list {
display: grid;
list-style-type: none;
margin: 0;
padding: 0;
}
Rows
Figuring out what my rows needed to look like was actually pretty easy. If you look at the hexagon shape, you'll see that the top and the bottom are flat:
This, coupled with the fact that the hexagon is mirrored across the y-axis, means that we don't have to do anything special to set up our rows; all of that will automatically be taken care of when we start placing individual items into the correct spaces.
Columns
Columns were substantially harder to figure out. Looking at the the hexagon again, my initial thought was that I should be able to get away with a grid where each individual item spans 3 columns. The center is one column, and each side will occupy a column as well:
Conveniently, the sides of the hexagon are exactly half the width of the center, meaning our grid columns should look something like 1 2 1 when we go to set up our columns for this grid. We're going to use the fr
unit as well, so we can scale the container and have the grid flex to match that. A very simple way of representing this is to just write out the columns I need, in this case I have 5 hexagons per row:
.hex-grid__list {
display: grid;
list-style-type: none;
margin: 0;
padding: 0;
grid-template-columns: 1fr 2fr 1fr 2fr 1fr 2fr 1fr 2fr 1fr 2fr 1fr;
}
I want to rewrite this to use a repeat()
function, but an interesting wrinkle to this is that due to our individual grid items needing to occupy an odd amount of columns (3), our grid as a whole needs to also be an odd number. To accomplish this, I'm going to set up a repeat that just repeats the 1fr 2fr
part, and then attach a single 1fr
on the the end. I'm also going to set up a CSS variable that will control the amount of hexagons that this grid per row. This is nice just to test things out, and also will come in handy down the road when we start getting into responsiveness.
.hex-grid__list {
--amount: 5;
display: grid;
list-style-type: none;
margin: 0;
padding: 0;
grid-template-columns: repeat(var(--amount), 1fr, 2fr) 1fr;
}
Here's what we've got so far, I added some colors so we have a visualization of where we are so far.
Alright, now we need to start to place items on to this grid. Our individual items need to span 3 columns and 2 rows, so we're going to start by adding grid-column: 1 / span 3
. This positions every item so it starts in the first column and spans 3 columns over. Then we add grid-row: 1 / span 2
, which makes the item span 2 rows. We now have our individual items spanning the amount of rows and columns we need, and I went ahead and started adding some of the additional visual styles we're going to need as well, pulling in the ratio hack for the list item and then positioning the content within that.
.hex-grid__item {
position: relative;
grid-column: 1 / span 3;
grid-row: 1 / span 2;
height: 0;
padding-bottom: 100%;
}
.hex-grid__content {
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 100%;
background-color: white;
}
This is cool, but decidedly not a grid. Yet. :) We're going to get into some crazy :nth-of-type
selectors to build out our row and column rules. The root of our problem is that we need to update the starting position of both our grid-column
and our grid-row
rules. We're going to start with the rows, since that's a little easier to explain, plus the columns layer nicely on top. We're going to add a counter variable to the list, and then increment it every time we've moved on to a new row. We're then going to update our grid-row
rule to set the row position to double our counter, since each "row" that our grid items sit in is actually two grid rows.
This is where I ran into my first issue. There is no good automatic way to keep track of what row we're on that I could figure out. My initial thought was that I could set up a :nth-of-type
rule like :nth-of-type(5n + 1)
that increments my counter variable, but from what I can tell, reassigning a variable using itself just doesn't work, or it may have been an issue with the :nth-of-type
not updating the variable correctly. Either way, that was out. I also exploring using CSS counters, but we need these numbers as actual numbers, and counters output as strings. I eventually gave up and fell back to using a series of :nth-of-type(n + <amount>)
. This unfortunately means that it's not as flexible as I would like, as you have to set up a fixed amount of rows and after that the grid will stop functioning correctly.
That being said, with a little SASS for loop I was good to go, I've set mine to generate 20 rows as I will probably never use this for anything more that that, and if so, it's a quick adjustment.
Here's what we're up to now. I've also included a few raw CSS snippets so you can see what the SASS loop compiles down to.
$amount: 5;
.hex-grid__list {
--amount: 5;
--counter: 1;
display: grid;
list-style-type: none;
margin: 0;
padding: 0;
grid-template-columns: repeat(var(--amount), 1fr, 2fr) 1fr;
}
.hex-grid__item {
position: relative;
grid-column: 1 / span 3;
grid-row: calc(var(--counter) + var(--counter)) / span 2;
height: 0;
padding-bottom: 100%;
@for $i from 1 through 20 {
&:nth-of-type(n + #{$i * $amount + 1}) {
--counter: #{$i + 1};
}
}
}
.hex-grid__content {
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 100%;
background-color: white;
}
Non-SCSS:
.hex-grid__item:nth-of-type(n + 6) {
--counter: 2;
}
.hex-grid__item:nth-of-type(n + 11) {
--counter: 3;
}
.hex-grid__item:nth-of-type(n + 16) {
--counter: 4;
}
/* ...etc */
Almost there, then we can start making this pretty! The final piece for positioning this is to set up the rules for the columns. We're going to use :nth-of-type
again to accomplish this, but this time we need to set up a rule per item column (each "column" is 3 grid columns, but they're interlocking. Yeah, this is complicated). What we're going for is something like :nth-of-type(<amount>n + <column>)
, so for this we'll need :nth-of-type(5n + 1)
through :nth-of-type(5n + 5)
. I chose to put this into a SASS loop again so we're not writing so much CSS, giving us this:
$amount: 5;
.hex-grid__list {
--amount: 5;
--counter: 1;
display: grid;
list-style-type: none;
margin: 0;
padding: 0;
grid-template-columns: repeat(var(--amount), 1fr, 2fr) 1fr;
}
.hex-grid__item {
position: relative;
grid-column: 1 / span 3;
grid-row: calc(var(--counter) + var(--counter)) / span 2;
height: 0;
padding-bottom: 100%;
// Columns
@for $i from 1 through $amount {
&:nth-of-type(#{$amount}n + #{$i}) {
grid-column: #{$i + $i - 1} / span 3;
@if $i % 2 == 0 {
grid-row: calc(var(--counter) + var(--counter) - 1) / span 2;
}
}
}
// Rows
@for $i from 1 through 20 {
&:nth-of-type(n + #{$i * $amount + 1}) {
--counter: #{$i + 1};
}
}
}
.hex-grid__content {
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 100%;
background-color: white;
}
One final thing you'll notice is that in my column loop, I'm modifying the even columns to offset their row number by -1. This gives us the interlocking pattern that we wanted, by moving every other column up to the middle of the previous row.
This is where we're at now:
Now, to make this this actually look like a hexagon, lets go ahead and add a clip-path to our content. I figured out the clip-path by starting with polygon and playing around with the values until I got what I was going for. I also added some padding to keep our content flowing nicely. I played around with CSS shapes hoping to get the content to stay inside the mask, but couldn't get anything working with shape-outside
. I'm pretty sure if/when shape-inside
exists, that will be perfect for this. Padding works fairly well so I'm not too worried about it. I also bumped the ratio down slightly, as this hexagon is actually not a perfect square, it's actually a 9:10 ratio.
.hex-grid__item {
/* snipped */
height: 0;
padding-bottom: 90%;
}
.hex-grid__content {
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 100%;
background-color: white;
clip-path: polygon(75% 0, 100% 50%, 75% 100%, 25% 100%, 0 50%, 25% 0);
padding: 2rem 25%;
}
Adding a gutter is as easy as adding a grid-gap
to our grid. You'll actually want to set your row gap to be what you want your gutter to be, and then your column gap to double that. I'm not entirely sure why this is, but I think it's because of how the math works out for the angled gaps? Who cares, it works, right? :)
.hex-grid__list {
--amount: 5;
--counter: 1;
display: grid;
list-style-type: none;
margin: 0;
padding: 0;
grid-template-columns: repeat(var(--amount), 1fr, 2fr) 1fr;
grid-gap: 1rem 2rem;
}
That's pretty much it! Making this responsive is as easy as setting up media queries so that smaller screen widths get less columns per row. I turned the parts needed for this to work into a SASS mixin and used that to build out my media queries. The full code is included below!
$block: '.hex-grid';
@mixin grid-item($amount) {
@for $i from 1 through $amount {
&:nth-of-type(#{$amount}n + #{$i}) {
grid-column: #{$i + $i - 1} / span 3;
@if $i % 2 == 0 {
grid-row: calc(var(--counter) + var(--counter) - 1) / span 2;
}
}
}
@for $i from 1 through 20 {
&:nth-of-type(n + #{$i * $amount + 1}) {
--counter: #{$i + 1};
}
}
}
#{$block} {
display: flex;
justify-content: center;
&__list {
--amount: 5;
position: relative;
padding: 0;
margin: 0;
list-style-type: none;
display: grid;
grid-template-columns: repeat(var(--amount), 1fr 2fr) 1fr;
grid-gap: 2.5rem 5rem;
}
&__item {
position: relative;
grid-column: 1 / span 3;
grid-row: calc(var(--counter) + var(--counter)) / span 2;
filter: drop-shadow(0 0 10px rgba(#444, 0.08));
height: 0;
padding-bottom: 90%;
}
&__content {
position: absolute;
height: 100%;
width: 100%;
font-size: 1.125rem;
color: #111111;
background-color: white;
clip-path: polygon(75% 0, 100% 50%, 75% 100%, 25% 100%, 0 50%, 25% 0);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 2rem 25%;
text-decoration: none;
text-align: center;
transition: transform 0.24s ease-out;
}
}
@media screen and (min-width: 1440px) {
#{$block} {
&__list {
--amount: 5;
--counter: 1;
}
&__item {
@include grid-item(5);
}
}
}
@media screen and (min-width: 1120px) and (max-width: 1439px) {
#{$block} {
&__list {
--amount: 4;
--counter: 1;
}
&__item {
@include grid-item(4);
}
}
}
@media screen and (min-width: 840px) and (max-width: 1119px) {
#{$block} {
&__list {
--amount: 3;
--counter: 1;
grid-gap: 1.5rem 3rem;
}
&__item {
@include grid-item(3);
}
}
}
@media screen and (min-width: 480px) and (max-width: 839px) {
#{$block} {
&__list {
--amount: 2;
--counter: 1;
grid-gap: 1.5rem 3rem;
}
&__item {
@include grid-item(2);
}
}
}
@media screen and (max-width: 479px) {
#{$block} {
&__list {
--amount: 1;
grid-gap: 1.5rem 3rem;
}
}
}