Search code examples
csslayoutflexboxcss-grid

How can I make a responsive CSS layout with cards, keeping the top and bottom sections of the cards the same height as the tallest in the row


I have a layout where I want to display multiple "cards." Each card will consist of 3 sections laid out vertically. The top section will contain text that will be different lengths for different cards, and could end up being long enough to wrap to 3 lines. The middle section will contain a div that can contain various other elements that will be different heights for each card. And the bottom section will either be empty or have a single line of text. If none of the cards on a row have text in the bottom section, I don't want any space to be taken up by them. I want the cards to be responsive, so that if they don't all fit side-by-side, it will wrap down and put them in a grid pattern, and if the screen is small enough, the cards will all just be displayed in a single column, top to bottom. The part I'm struggling with is that I want all of the cards that are on the same row to have the same height as the tallest card on that row, and I want the top and bottom of each section of each card on the same row to be aligned.

This is essentially what I want it to look like on a larger screen: A layout with 3 cards fitting in the given width

And on a smaller screen:

A layout with 2 cards fitting in the given width

My first thought was that a CSS grid would work. I could put the top part of each card on the first row and the middle part on the second row, and the bottom part on the third row. That will make it easy to get the heights the same and keep everything aligned. But I can't figure out how to make that responsive the way I want. Using auto-fit is going to wrap a single cell at a time, which is only part of a card.

.container {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(133px, 1fr));
  gap: 30px 5px;
  align-items: end;
  border: 1px solid grey;
  margin-bottom: 20px;
}

.middle {
  align-self: start;
  background-color: lightblue;
}

.a2 {
  height: 50px;
}

.b2 {
  height: 30px;
}

.c2 {
  height: 15px;
}

.optional {
  font-size: .6rem;
}

.pseudo-screen-large {
  width: 500px;
}

.pseudo-screen-medium {
  width: 300px;
}

.pseudo-screen-small {
  width: 150px;
}
On a large screen everything looks good:
<div class="pseudo-screen-large">
  <div class="container">
    <span>A1 - Short Text</span>
    <span>B1 - Longer Text that is going to wrap to 3 lines because it is really long</span>
    <span>C1 - Medium Text that is going to wrap to 2 lines</span>
    <div class="middle a2">A2</div>
    <div class="middle b2">B2</div>
    <div class="middle c2">C2</div>
    <div class="optional">*Explanatory Text</div>
    <div class="optional"></div>
    <div class="optional">*Explanatory Text</div>
  </div>
</div>

Even with no optional text, we get some extra whitespace because of the row gap. I haven't tried to fix that yet, because of the other problems.
<div class="pseudo-screen-large">
  <div class="container">
    <span>A1 - Short Text</span>
    <span>B1 - Longer Text that is going to wrap to 3 lines because it is really long</span>
    <span>C1 - Medium Text that is going to wrap to 2 lines</span>
    <div class="middle a2">A2</div>
    <div class="middle b2">B2</div>
    <div class="middle c2">C2</div>
    <div class="optional"></div>
    <div class="optional"></div>
    <div class="optional"></div>
  </div>
</div>

But once we start wrapping on smaller screens, there's immediately a problem:
<div class="pseudo-screen-medium">
  <div class="container">
    <span>A1 - Short Text</span>
    <span>B1 - Longer Text that is going to wrap to 3 lines because it is really long</span>
    <span>C1 - Medium Text that is going to wrap to 2 lines</span>
    <div class="middle a2">A2</div>
    <div class="middle b2">B2</div>
    <div class="middle c2">C2</div>
    <div class="optional">*Explanatory Text</div>
    <div class="optional hide">*Explanatory Text</div>
    <div class="optional">*Explanatory Text</div>
  </div>
</div>

<div class="pseudo-screen-small">
  <div class="container">
    <span>A1 - Short Text</span>
    <span>B1 - Longer Text that is going to wrap to 3 lines because it is really long</span>
    <span>C1 - Medium Text that is going to wrap to 2 lines</span>
    <div class="middle a2">A2</div>
    <div class="middle b2">B2</div>
    <div class="middle c2">C2</div>
    <div class="optional">*Explanatory Text</div>
    <div class="optional hide">*Explanatory Text</div>
    <div class="optional">*Explanatory Text</div>
  </div>
</div>

I could make each card its own cell in the grid and then use a flexbox or another grid within each card cell for the parts of the card, but then there's no relationship between the tops of the cards on the same row, to keep them aligned.

.container {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(133px, 1fr));
  gap: 30px 5px;
  border: 1px solid grey;
  margin-bottom: 30px;
}

.card {
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
}

.card-grid {
  display: grid;
  align-items: end;
}

.middle {
  background-color: lightblue;
}

.card-grid .middle {
  align-self: start;
}

.a2 {
  height: 50px;
}

.b2 {
  height: 30px;
}

.c2 {
  height: 15px;
}

.optional {
  font-size: .6rem;
}

.pseudo-screen-large {
  width: 500px;
}

.pseudo-screen-medium {
  width: 300px;
}

.pseudo-screen-small {
  width: 150px;
}
Here we get the wrapping how I want, but the vertical alignment of the cards is all messed up:
<div class="pseudo-screen-medium">
  <div class="container">
    <div class="card">
      <span>A1 - Short Text</span>
      <div class="middle a2">A2</div>
      <div class="optional">*Explanatory Text</div>
    </div>
    <div class="card">
      <span>B1 - Longer Text that is going to wrap to 3 lines because it is really long</span>
      <div class="middle b2">B2</div>
      <div class="optional"></div>
    </div>
    <div class="card">
      <span>C1 - Medium Text that is going to wrap to 2 lines</span>
      <div class="middle c2">C2</div>
      <div class="optional">*Explanatory Text</div>
    </div>
  </div>
</div>

Using a subgrid instead of a flexbox for the cards results in the same:
<div class="pseudo-screen-medium">
  <div class="container">
    <div class="card-grid">
      <span>A1 - Short Text</span>
      <div class="middle a2">A2</div>
      <div class="optional">*Explanatory Text</div>
    </div>
    <div class="card-grid">
      <span>B1 - Longer Text that is going to wrap to 3 lines because it is really long</span>
      <div class="middle b2">B2</div>
      <div class="optional"></div>
    </div>
    <div class="card-grid">
      <span>C1 - Medium Text that is going to wrap to 2 lines</span>
      <div class="middle c2">C2</div>
      <div class="optional">*Explanatory Text</div>
    </div>
  </div>
</div>

Since I want the wrapping at the card level, a flexbox also makes sense. However that results in the same problem as grid with each card being in its own cell.

.container {
  display: flex;
  flex-wrap: wrap;
  gap: 20px 5px;
  margin-bottom: 20px;
}

.card {
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  width: 161px;
  border: 1px solid grey;
}

.middle {
  background-color: lightblue;
}

.a2 {
  height: 50px;
}

.b2 {
  height: 30px;
}

.c2 {
  height: 15px;
}

.optional {
  font-size: .6rem;
}

.hide {
  visibility: hidden;
}

.pseudo-screen-large {
  width: 500px;
}

.pseudo-screen-medium {
  width: 350px;
}

.pseudo-screen-small {
  width: 150px;
}
Again, everything wraps well, but the vertical alignment is all messed up:
<div class="pseudo-screen-medium">
  <div class="container">
    <div class="card">
      <span>A1 - Short Text</span>
      <div class="middle a2">A2</div>
      <div class="optional">*Explanatory Text</div>
    </div>
    <div class="card">
      <span>B1 - Longer Text that is going to wrap to 3 lines because it is really long</span>
      <div class="middle b2">B2</div>
      <div class="optional"></div>
    </div>
    <div class="card">
      <span>C1 - Medium Text that is going to wrap to 2 lines</span>
      <div class="middle c2">C2</div>
      <div class="optional">*Explanatory Text</div>
    </div>
  </div>
</div>

I could set the height on all of the middle and bottom sections to be the height of the largest that exists for each section. But then when a card is on a row where the largest isn't, there's a lot of extra unwanted whitespace.

.container {
  display: flex;
  flex-wrap: wrap;
  gap: 30px 5px;
  border: 1px solid grey;
  margin-bottom: 20px;
}

.card {
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  width: 161px;
}

.middle-container {
  height: 50px;
}

.middle {
  background-color: lightblue;
}

.a2 {
  height: 50px;
}

.b2 {
  height: 30px;
}

.c2 {
  height: 15px;
}

.optional {
  font-size: .6rem;
  height: 11px;
}

.hide {
  visibility: hidden;
}

.pseudo-screen-large {
  width: 500px;
}

.pseudo-screen-medium {
  width: 350px;
}

.pseudo-screen-small {
  width: 150px;
}
Here everything wraps correctly, and the vertical alignment is pretty good. But we have a huge gap below C2 & D2, and a smaller gap below E2, neither of which I want.
<div class="pseudo-screen-medium">
  <div class="container">
    <div class="card">
      <span>A1 - Short Text</span>
      <div class="middle-container">
        <div class="middle a2">A2</div>
      </div>
      <div class="optional">*Explanatory Text</div>
    </div>
    <div class="card">
      <span>B1 - Longer Text that is going to wrap to 3 lines because it is really long</span>
      <div class="middle-container">
        <div class="middle b2">B2</div>
      </div>
      <div class="optional"></div>
    </div>
    <div class="card">
      <span>C1 - Medium Text that is going to wrap to 2 lines</span>
      <div class="middle-container">
        <div class="middle c2">C2</div>
      </div>
      <div class="optional"></div>
    </div>
    <div class="card">
      <span>D1 - Short Text</span>
      <div class="middle-container">
        <div class="middle c2">D2</div>
      </div>
      <div class="optional">*Explanatory Text</div>
    </div>
    <div class="card">
      <span>E1 - Short Text</span>
      <div class="middle-container">
        <div class="middle a2">E2</div>
      </div>
      <div class="optional"></div>
    </div>
  </div>
</div>

The only thing I've found that works is to use a grid like in my first snippet above, and instead of relying on auto-fit to make it responsive, I would use media queries with grid-template-areas to explicitly layout where each cell should be placed. But this would require using a media query for a screen the width of a single card, another media query for a screen the width of 2 cards, etc. And it would require assigning each card its own grid-area name. This is possible, and could probably be simplified with a Sass mixin, but it still seems pretty heavy-handed, so I'm wondering if there's a simpler way.


Solution

  • Using actual subgrid seems to solve your issue as covered in Align Child Elements Different Blocks

    * {
      margin: 0
    }
    
    .container {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(133px, 1fr));
      gap: 5px;
      border: 1px solid grey;
      margin-bottom: 30px;
    }
    
    .card {
      display: grid;
      grid-template-rows: subgrid;
      grid-row: span 3;
      border: 1px solid red;
    }
    
    .middle {
      background-color: lightblue;
    }
    
    .optional {
      font-size: .6rem;
    }
    
    .pseudo-screen-large {
      width: 600px;
    }
    
    .pseudo-screen-medium {
      width: 300px;
    }
    
    .pseudo-screen-small {
      width: 150px;
    }
    Small:
    <div class="pseudo-screen-small">
      <div class="container">
        <div class="card">
          <span>A1 - Short Text</span>
          <div class="middle a2">A2</div>
          <div class="optional">*Explanatory Text</div>
        </div>
        <div class="card">
          <span>B1 - Longer Text that is going to wrap to 3 lines because it is really long</span>
          <div class="middle b2">B2</div>
          <div class="optional"></div>
        </div>
        <div class="card">
          <span>C1 - Medium Text that is going to wrap to 2 lines</span>
          <div class="middle c2">C2</div>
          <div class="optional">*Explanatory Text</div>
        </div>
      </div>
    </div>
    
    Medium:
    <div class="pseudo-screen-medium">
      <div class="container">
        <div class="card">
          <span>A1 - Short Text</span>
          <div class="middle a2">A2</div>
          <div class="optional">*Explanatory Text</div>
        </div>
        <div class="card">
          <span>B1 - Longer Text that is going to wrap to 3 lines because it is really long</span>
          <div class="middle b2">B2</div>
          <div class="optional"></div>
        </div>
        <div class="card">
          <span>C1 - Medium Text that is going to wrap to 2 lines</span>
          <div class="middle c2">C2</div>
          <div class="optional">*Explanatory Text</div>
        </div>
      </div>
    </div>
    
    Large:
    <div class="pseudo-screen-large">
      <div class="container">
        <div class="card">
          <span>A1 - Short Text</span>
          <div class="middle a2">A2</div>
          <div class="optional">*Explanatory Text</div>
        </div>
        <div class="card">
          <span>B1 - Longer Text that is going to wrap to 3 lines because it is really long</span>
          <div class="middle b2">B2</div>
          <div class="optional"></div>
        </div>
        <div class="card">
          <span>C1 - Medium Text that is going to wrap to 2 lines</span>
          <div class="middle c2">C2</div>
          <div class="optional">*Explanatory Text</div>
        </div>
      </div>
    </div>