Article

Faking Masonry with CSS Grid and Subgrid

5 min read
Faking Masonry with CSS Grid and Subgrid

When building MutaMarket, I needed to display EVE Online’s Abyssal modules in a visually pleasing grid. The challenge? Each module card has different content heights: some have long names, others have more stats to display. A traditional grid would either clip content or leave awkward gaps.

True masonry layouts (like Pinterest) typically require JavaScript to calculate positions. But with CSS Grid and the newer subgrid feature, you can get surprisingly close without any JS at all.

The Problem with Regular Grids

With a standard CSS grid, all items in a row share the same height:

.grid {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 1rem;
}

This works fine when all cards have identical content. But when card heights vary, you get one of two problems:

  1. Cards stretch to match the tallest item in their row
  2. You set a fixed height and content overflows or gets clipped

Neither is ideal for a marketplace where users need to scan items quickly.

Enter Subgrid

Subgrid lets a grid item’s children participate in the parent grid’s track sizing. This means child elements across different cards can align to the same grid lines.

Here’s the basic structure:

.grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
    grid-auto-rows: auto;
    gap: 1rem;
}

.card {
    display: grid;
    grid-template-rows: subgrid;
    grid-row: span 4; /* image, title, stats, price */
}

Each card spans 4 rows in the parent grid. The subgrid value tells the card to use those 4 parent rows for its own internal layout. Now the title section of every card aligns, the stats section aligns, and the price section aligns, regardless of how much content each contains.

Applying It to MutaMarket

On MutaMarket, each module card has:

  1. An image showing the module
  2. A title (module name, which varies in length)
  3. Attribute stats (can be 3 to 8 lines depending on module type)
  4. Price and action buttons
.module-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
    gap: 1.5rem;
}

.module-card {
    display: grid;
    grid-template-rows: subgrid;
    grid-row: span 4;
    background: var(--card-bg);
    border: 1px solid var(--border);
}

.module-card > .image { grid-row: 1; }
.module-card > .title { grid-row: 2; align-self: start; }
.module-card > .stats { grid-row: 3; }
.module-card > .actions { grid-row: 4; align-self: end; }

The key insight is align-self. The title aligns to the start of its row, while the actions align to the end. This creates consistent spacing even when content heights differ.

Handling Variable Content

What if some cards need more rows than others? You can use grid-row: span X dynamically based on content.

The trick is to calculate the row span based on how many attributes each module has. A module with three attributes would span five rows: one for the header, three for the attributes, and one for the footer. The formula is simple: attributes.length + 2.

.module-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
    gap: 1rem;
}

.module-card {
    display: grid;
    grid-template-rows: subgrid;
    /* grid-row: span X; set dynamically based on attribute count */
}

/* For a module with 3 attributes: 1 (header) + 3 (attributes) + 1 (footer) = 5 */
.module-card[data-attributes="3"] {
    grid-row: span 5;
}

/* For a module with 5 attributes */
.module-card[data-attributes="5"] {
    grid-row: span 7;
}

In practice, you’d set this dynamically. Here’s how it looks in Vue.js:

<template>
    <div class="module-grid">
        <div
            v-for="module in modules"
            :key="module.id"
            class="module-card"
            :style="{ gridRow: `span ${module.attributes.length + 2}` }"
        >
            <header class="module-header">
                {{ module.name }}
            </header>

            <div
                v-for="attribute in module.attributes"
                :key="attribute.id"
                class="module-attribute"
            >
                <span class="attribute-name">{{ attribute.name }}</span>
                <span class="attribute-value">{{ attribute.value }}</span>
            </div>

            <footer class="module-footer">
                <span class="price">{{ module.price }} ISK</span>
                <button>Buy</button>
            </footer>
        </div>
    </div>
</template>

<script setup>
const modules = ref([
    {
        id: 1,
        name: 'Abyssal Damage Control',
        price: 450000000,
        attributes: [
            { id: 1, name: 'Resistances', value: '+24.5%' },
            { id: 2, name: 'Duration', value: '13.2s' },
            { id: 3, name: 'Cooldown', value: '142s' },
        ],
    },
    // ... more modules
]);
</script>

<style scoped>
.module-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
    gap: 1rem;
}

.module-card {
    display: grid;
    grid-template-rows: subgrid;
    background: var(--card-bg);
    border: 1px solid var(--border);
    border-radius: 0.5rem;
    padding: 1rem;
}

.module-header {
    font-weight: 600;
}

.module-attribute {
    display: flex;
    justify-content: space-between;
}

.module-footer {
    display: flex;
    justify-content: space-between;
    align-items: center;
    align-self: end;
}
</style>

The key line is :style="{ gridRow: \span ${module.attributes.length + 2}` }"`. This computes the row span dynamically for each card based on its attribute count.

The repeat(auto-fill, minmax(270px, 1fr)) on the grid container handles the responsive columns automatically, creating as many columns as will fit with a minimum width of 270 pixels and a maximum of one fraction of available space.

For MutaMarket, this approach works well because each module type has a consistent number of attributes. The grid adapts to different screen sizes while maintaining perfect alignment across cards with the same attribute count.

Browser Support

Subgrid has solid support in modern browsers. Firefox shipped it first, and Chrome/Safari followed. Check caniuse.com/css-subgrid for current numbers, but as of 2026, you can use it confidently with a simple fallback:

.card {
    display: grid;
    grid-template-rows: auto auto auto auto; /* fallback */
}

@supports (grid-template-rows: subgrid) {
    .card {
        grid-template-rows: subgrid;
        grid-row: span 4;
    }
}

Not Quite Masonry, But Close

This technique doesn’t give you true masonry where the layout automatically creates columns and places items dynamically based on available space. Items still align to explicit rows.

The main drawback: you need to know how many rows each item should span upfront. If you can’t calculate the relative row span for an item compared to others, this approach won’t work. For arbitrary content heights where you don’t have structured data to derive the span from, you’d still need JavaScript to measure elements and calculate positions.

But for card-based layouts with predictable content structure, like MutaMarket’s module cards where each attribute maps to a row, it’s a clean CSS-only solution. Module images line up, titles line up, and prices line up, making it easy for players to compare items at a glance.

TK

Tim Kunze

Web Developer, Austria