How to Add Internal “Borders” Between Rows & Columns in a CSS Grid Layout

Adding a border between columns and rows in a CSS grid layout is a typical design pattern. While it’s easy to achieve in a design tool like Figma, it comes with a lot of challenges in a development environment.

Sure, there are a lot of manual, non-scalable ways to achieve this look. In fact, every time I’ve seen it done, the approach was very messy, inflexible, and hard to manage.

In this article, I’m going to explain my approach to solving the challenge of internal borders between rows and columns in a grid layout. While it might not be the only great approach, it’s both efficient and resilient.

Goals & Requirements

Here’s what we’re trying to achieve:

Example of CSS Grid with Internal Borders

If we’re going to do this right, a few things need to be accounted for:

  1. We need the freedom to change the number of grid columns without breaking the borders.
  2. A grid with a missing grid item should not break the borders.
  3. The borders should degrade gracefully from breakpoint to breakpoint – we must avoid changing our instructions at various breakpoints if possible.
  4. We want to avoid crazy :nth() selector tactics. If we’re forced to try and programmatically select items in the grid based on counting, we will lose our flexibility to change column counts.

Full Video Tutorial

There’s a complete write-up below, but if you’re the kind of person who prefers to watch a video, here you go:

Step #1: Basic HTML

We’re going to start with a basic 3-column grid:

<div class="grid">
   <div class="grid__item"></div>
   <div class="grid__item"></div>
   <div class="grid__item"></div>
   <div class="grid__item"></div>
   <div class="grid__item"></div>
   <div class="grid__item"></div>
</div>
Code language: HTML, XML (xml)

Step #1: Establish a Grid with CSS & Hide Any Overflow

Let’s make this a basic, 3-column grid:

.grid {
   display: grid;
   grid-template-columns: repeat(3, minmax(0, 1fr));
   overflow: hidden;
   gap: var(--gap);
}
Code language: CSS (css)

We need to hide all overflow in this grid because it’s going to singlehandedly solve a bunch of challenges that this design goal presents. In fact, hiding overflow allows us to avoid using :nth() selectors altogether.

Don’t mind the var(--gap) situation here. I’ll explain that in a moment.

Step #2: Relative Positioning for Grid Items

Contrary to most popular approaches, we’re not going to use borders. We’re going to use pseudo-elements for almost everything. Why? Because borders will have too many limitations for what we want to achieve and will force us to use :nth() selectors and media queries.

Since we need to position the pseudo-elements absolutely, we need to set the parents to position relative. We can do this on all grid items.

.grid__item {
   position: relative;
}
Code language: CSS (css)

Now, our parent elements are ready to have pseudo-elements attached. Before we do that, though, let’s create some locally scoped variables…

Step #3: Locally Scoped Variables

We want to make this situation as easy as possible to manipulate and maintain. Locally scoped variables will make our lives a lot easier.

.grid {
   --gap: 2em;
   --line-offset: calc(var(--gap) / 2);
   --line-thickness: 2px;
   --line-color: red;
}
Code language: CSS (css)

Our grid needs a gap, which we define with the --gap variable. When I do this IRL, I map this to the --grid-gap contextual variable from Automatic.css. For the sake of this example, though, I’m just using 2em.

When we position the “borders” in the grid, we need to position them dead center in the middle of the grid gaps. In order to do that, we need a token that represents half the value of the grid-gap. This is mapped to a token called --line-offset. I didn’t call it border-offset because we’re not actually using borders.

Next is the thickness of the lines, which is mapped to a token called --line-thickness. Pretty straightforward.

Last, we need to decide what color we want our lines to be. This is controlled via a --line-color token.

Now that our tokens are locked and loaded, we can move forward.

Step #4: Establish Base CSS for Pseudo Elements

We’re going to use both ::before and ::after elements for this technique. Both will share some styling, so in the interest of efficiency and not repeating ourself (DRY development), we’ll establish shared styles first:

.grid__item::before, 
.grid__item::after {
   content: '';
   position: absolute;
   background-color: var(--line-color);
   z-index: 1;
}
Code language: CSS (css)

This effectively creates a ::before and ::after pseudo-element attached to every single grid item that is absolutely positioned.

Notice that there are no inset coordinates or dimensions for these elements. That’s because those instructions are specific to either the ::before or ::after element and need to be declared separately.

Step #5: Row Lines

Grid Row Borders

The row lines are pretty straightforward. We’ll use the ::after elements that we created earlier for this.

.grid__item::after {
  inline-size: 100vw;
  block-size: var(--line-thickness);
  inset-inline-start: 0;
  inset-block-start: calc(var(--line-offset) * -1);
}
Code language: CSS (css)

Width (inline-size) of 100% would make the bottom line only the width of each grid item and would fail to account for the gap. It also causes issues when there are uneven items in the grid.

What we need is a width that is guaranteed to span the entire width of the grid container. 100vw will span the entire width of the grid parent in all situations since it is actually designed to span the entire width of the viewport.

The fact that 100vw causes these elements to overflow the container was solved earlier when we told the grid to hide all overflow.

Now that the width is taken care of, we need to set a height (block-size). This is done with the --line-thickness token, which has a value of 2px.

We’ll just set the left (inset-inline-start) position to 0 and the top (the inset-block-start) position to our --line-offset. The only issue is that we need our offset to be a negative number to move it away from the grid item and into the gap space. That’s easy to do with a calc() function. Just multiply any value by -1 and it makes the value negative.

Like magic, we have a “border” within every row of the grid.

Why isn’t there a border on top of the grid since these are positioned to the top of every item?

Good question! That’s solved by the overflow hidden we set earlier. There’s no need to exclude the first row of items in the grid. Since the pseudo-elements are offset, the elements across the top all appear outside of the grid, which is a hidden area.

Step #6: Column Lines

Column Grid Borders

Now that rows are taken care of, let’s do columns. We’re going to use a very similar technique. The only difference is that we need to use the ::before elements since the ::after elements have already been put to use.

.grid__item::before {
  inline-size: var(--line-thickness);
  block-size: 100vh;
  inset-block-start: 0;
  inset-inline-start: calc(var(--line-offset) * -1);
}
Code language: CSS (css)

Now, since these elements are tall and skinny, the inline-size (width) is equal to our line thickness and block-size is equal to 100vh (viewport height instead of viewport width).

Next, we have to position these lines. Remember, we want to position them on the left side of the grid items to ensure that every grid item has a left “border” regardless of how many columns there are.

We can’t just set the left value (inset-inline-start) to 0, though. We need to position it perfectly in the center of the gap. This is where our --line-offset token comes in handy again. Dropping into a calc and multiplying it by -1 moves it right into the center of the gap for us.

Now add an inset-block-start (top) value of 0 to make sure it always starts at the top of the grid item.

Why isn’t there a border on the left of the grid since these are positioned to the left of every item?

Hopefully, you’re catching on by now. The overflow is set to hidden, which ensures these lines aren’t seen because they exist outside the grid container’s edge. All this crazy stuff that people try and do with :nth() selectors and media queries is entirely unnecessary when you hide the overflow junk.

That’s it! Simple, Efficient, Flexible, Elegant, & Maintainable

We’ve accomplished our goal!

Here’s the full code:

.grid {
  
  // Locally scoped variables
  --gap: 2rem;
  --line-offset: calc(var(--gap) / 2);
  --line-thickness: 2px;
  --line-color: red;
  
  // Grid layout (Can be anything)
   display: grid;
   grid-template-columns: repeat(3, minmax(0, 1fr));
   overflow: hidden;
   gap: var(--gap);
}

// Make Grid Items Control Absolute Pseudo Positioning
.grid__item {
   position: relative;
}

// Pseudo Element Shared Styling
.grid__item::before, 
.grid__item::after {
   content: '';
   position: absolute;
   background-color: var(--line-color);
   z-index: 1;
}

// Row Borders
.grid__item::after {
  inline-size: 100vw;
  block-size: var(--line-thickness);
  inset-inline-start: 0;
  inset-block-start: calc(var(--line-offset) * -1);
}

// Column Borders
.grid__item::before {
  inline-size: var(--line-thickness);
  block-size: 100vh;
  inset-inline-start: calc(var(--line-offset) * -1);
}Code language: PHP (php)

And here’s a CodePen:

See the Pen Internal Grid "Borders" With CSS Grid by Kevin Geary (@geary-co) on CodePen.

I’ve seen many people attempt to achieve this design, and I’m pretty confident that the above approach is the most elegant and flexible. In fact, it’s almost so simple that it feels like cheating.

Go ahead, try to break it!

  • Use an uneven number of grid items (don’t fill the grid). Still works!
  • Change the number of grid columns. Still works!
  • Check your breakpoints. Still works! No adjustments are needed.

What if I don’t want grid borders on mobile?

No problem! Just wrap all the pseudo-element stuff in a min-width media query.

Any questions?

join the conversation

29 comments

  • For me, `position: absolute`on ::after seemed to move the row border above the elements. Using relative position instead fixed it.
    Also, if anyone is dealing with the border not being visible in the gaps, check if `overflow: hidden` is set for the `grid–item` elements. Had that issue cause Elementor set it.

    • Sujal Gurung

      Never mind. Absolute positioning is the way to go since relative may affect sizing.

  • For some reason, in my example, when using media queries at different widths to set new grid template columns ( from 3, to 2, to 1 ) the vertical lines were spilling over taller than my grid items.

    Setting the column height – the block size in the before – to height: 100% instead of 100vh fixed this…just for future reference for anyone that stumbles on this.

  • This solution can be simplified to a single custom property, `–gap` (or any arbitrary property name).

    “`
    // Grid items must fill their slot
    .grid-item {
    position: relative;
    background-color: var(–background-color, white);

    &::before {
    content: “”;
    background-color: var(–border-color, black);
    position: absolute;

    // –gap inherits from parent grid
    inset: calc(0px – var(–gap));

    // Or, for border colours that aren’t 100% opaque
    inset: calc(0px – var(–gap) / 2);
    }
    }
    “`

    • An even simpler solution involves setting the desired border colour as the background colour of the grid, and setting the desired background colour of the grid items, and ensuring they fill their slot. The grid background colour will be visible wherever there is a gap.

  • This is a pretty elegant solution! I’m very curious how you arrived here – what was your thought process for coming up with this? Where did you start, and what sparked the ‘a-ha!’ moment for solving this?

  • M. A. Rahal

    Wow, thanks ! I’ve been trying to implement this design I saw on dribble with border in the middle between cards, I wasted a few hours trying to do it myself until I realized how screwed I was.

    Your solution worked like charm, it’s just genius !

  • Very Nice

  • got it to work in tailwind, which is nice. Now just to figure out how to get it to be a dash instead of a solid line.

  • This fails if your grid items don’t entirely fill your grid cells. For instance, if you set “justify-items: center” on your grid (or, equivalently, “justify-self: center” on your grid items), then, because the lines are positioned relative to the individual grid items and the grid items don’t fill their grid cells, thus the lines don’t align with the grid.

  • Nice approach, however this solution doesn’t work if you need to span elements over multiple rows for example. Then, the solution I found and works is to set background color of Grid parent element to the border color you want, give it a gap of line-thickness and set background color of Grid item to white.
    Code snippet:

    // Make the grid items show up
    .grid__item {
    padding: 3em;
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    justify-content: center;
    background: white;
    }

    .grid {
    –line-thickness: 2px;
    –line-color: red;

    // Grid layout
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: var(–line-thickness);
    background: var(–line-color);
    }

  • Dom Eccleston

    Thanks for sharing this!

    I’ve found that this doesn’t work with borders that have an opacity of less than one: the borders will visibly be stacked on top of one another, creating an inconsistent effect. You can see what I mean here: https://ibb.co/87PPm23

  • Peter Wise

    Another interesting alternative… just use the grid gap for the line color and make the box padding bigger:
    https://codepen.io/squarecandy-1472176736/pen/MWxKPVa

    * Zero psudo-elements
    * no reliance on overflow:hidden
    * grid-tastic!

    • This is a great option but has 2 downsides:

      There is the lack of control for the empty spaces on the grid. So for example if you have a 3×3 grid with only 8 items, the last space will be filled with the color of the line color.

      It’s not possible to have the background color to be transparent since the line color will show through

    • A

      Your link doesn’t take me to an example, so I’m not sure what you mean.

  • I believe that vh and vw are “viewport height” and “viewport width” not “vertical height” and “vertical width”.

    Looks like using 100vw and 100vh is rendering very very long lines but they are just getting cropped properly due to the overflow:hidden. But if your box content is ever taller than the current viewport height it’s going to break.

    I would recommend using something like this instead:
    block-size: calc( 100% + var(–gap) );

    • A

      Percent doesn’t work here. It has to be viewport units. If the the box content is ever going to be taller than the current viewport height, you just extend the viewport units beyond 100. Something can be 1000vh if you want it to be — there’s no breakage here.

  • Stephen

    “Go ahead, try to break it!”

    It breaks if the last row isn’t full, and the grid takes up more than your screen height, due to 100vh no longer being sufficient for the last vertical line. You can see this in the code pen by deleting the last item and resizing to ensure it doesn’t all fit at once.

    • A

      The last row not being full doesn’t break it. You may not personally like the fact that the grid remains even when there’s only one item in the last row, but I personally think it looks dumb if the visual grid breaks simply because the last row only has one item. This is just personal preference.

      The 100vh issue is a non-issue. For one, nobody uses a grid design like this for tons of items. That’s not the use-case. But, even if you needed to, the value can be 1000vh. Or 9999vh.

      • Stephen

        You misunderstand. The grid looking even is completely expected and that part looks good. It’s simply that an uneven grid combined with a longer grid breaks when you scroll down – you get a partial vertical line. it’s a very common use case; the example in your screenshot above wouldn’t come close to fitting the vertical height of my screen.

        Changing 100vh to a larger number is likely a sufficient solution I agree, but definitely one that is required.

        • Came across this issue myself so I fixed it by setting this for ::before:

          inset-inline-start: calc(100% – var(–line-offset) * -1);

  • Nice approach! Would there be an easy way to implement the same amount of gap-spacing to the items in the first row as in the last row without nth-child’ing so that the vertical grid lines ‘come out’ of the grid in the same size as the gap? Or does this require a different approach?

  • I had trouble getting scrollable containers using this solution, I eventually ended up just using the background/gap as the border with a relatively simple solution
    “`
    .grid-with-borders {
    –border-thickness: 2px;
    gap: var(–border-thickness);
    background-color: var(–border);
    }

    .grid-with-borders > * {
    background-color: var(–background);
    }
    “`

  • James Burgos

    Very elegant. I’m wondering how I might implement this technique to simulate the appearance of ruled notebook paper where each line of copy carries the horizontal rules with vertical rules at the margins. Could this technique be adapted to that end?

    Something along the “lines” of this
    https://codepen.io/ceg9498/post/creating-lined-paper

    • Tyree Brown

      I love seeing how sexy this blog is!

    • A

      Probably not. I’ve seen that technique done and it’s typically done with a repeating gradient pattern.

  • A really clean and easy approach, Kevin! Easy to apply… Sure it is not so easy to come up with! 😉

Leave your comment

Kevin Geary

Kevin is the CEO of Digital Gravy, creator of Automatic.css, creator of Frames, and a passionate WordPress educator. If you're interested in learning directly from Kevin, you can join his 1500+ member Inner Circle.

More articles like this

related posts

My Cart
0
Add Coupon Code
Subtotal