Overlapping Elements With Grid Stacking: Layers Without position: absolute
Captions over images, badges in corners, gradient overlays, text on hero photos, "play" icons centred on video thumbnails — interfaces are full of layered elements. The traditional technique is position: relative on a wrapper and position: absolute on the overlay, which works until the overlay's content is taller than the wrapper: absolutely positioned elements are out of flow, the wrapper does not grow, and a long caption spills out or is clipped. Grid stacking places every layer in the same grid cell. They overlap, but they stay in flow, so the cell grows to fit its tallest layer. This page covers the technique, per-layer alignment, painting order, and responsive patterns built on it. It belongs to CSS Grid & Subgrid Layouts in the Mastering Container Queries & Responsive Layouts guide.
Why stacking in flow is better
Absolute positioning answers the question "where does this element go?" by removing it from the layout and pinning it to coordinates. Its parent's size is then decided without it. That is fine for a small badge of known size, and fragile for anything with variable content: a caption translated into a longer language, text enlarged by the reader, or an overlay that should set the component's minimum height.
In a grid, several items can occupy the same cell. Each is laid out in flow within that cell, so the cell's height is the maximum of their heights. An image and a caption in one cell produce a component at least as tall as the caption needs, and at least as tall as the image, whichever is greater. No fixed heights, no overflow, no padding-bottom aspect-ratio hacks.
The complete implementation
The card below stacks four layers in one cell: an image, a gradient scrim, a caption aligned to the bottom, and a badge aligned to the top-right corner.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Grid stacking</title>
<style>
body { font: 15px/1.5 system-ui, sans-serif; margin: 1rem; }
.tile {
display: grid;
/* One cell. Every child goes into it. */
grid-template-areas: "stack";
max-width: 22rem;
border-radius: 12px;
overflow: hidden;
color: #f8fafc;
}
.tile > * { grid-area: stack; }
/* Layer 1: the image fills the cell and sets a minimum height. */
.tile__img {
width: 100%;
height: 100%;
min-height: 12rem;
object-fit: cover;
}
/* Layer 2: a scrim for text contrast, darkest at the bottom. */
.tile::after {
content: "";
grid-area: stack;
background: linear-gradient(to top, rgb(15 23 42 / 0.85), transparent 60%);
}
/* Layer 3: the caption, aligned to the bottom of the cell. It is in flow,
so a long caption makes the whole tile taller instead of overflowing. */
.tile__caption {
align-self: end;
z-index: 1; /* above the ::after scrim */
padding: 1rem;
}
.tile__caption h3 { margin: 0; font-size: 1.1rem; }
.tile__caption p { margin: 0.25rem 0 0; color: #cbd5e1; }
/* Layer 4: a badge pinned to the top-right corner. */
.tile__badge {
justify-self: end;
align-self: start;
z-index: 1;
margin: 0.75rem;
padding: 0.15rem 0.6rem;
border-radius: 999px;
background: #facc15;
color: #1c1917;
font-size: 0.75rem;
font-weight: 700;
}
</style>
</head>
<body>
<article class="tile">
<img class="tile__img" src="/img/harbour.jpg" alt="Fishing boats in a small harbour at dusk" width="800" height="600">
<span class="tile__badge">New</span>
<div class="tile__caption">
<h3>Harbour at dusk</h3>
<p>A short walk from the old town, best just after sunset when the lamps come on.</p>
</div>
</article>
</body>
</html>
The ::after pseudo-element joins the stack as another grid item because it is also given grid-area: stack. Generated content in a grid container behaves like any other child, which makes it a convenient, markup-free layer for scrims and tints. The caption and badge carry z-index: 1 to sit above it; grid items can use z-index without being positioned.
The key technique: one cell, independent alignment
Every layer is sized and aligned independently inside the shared cell. By default, grid items stretch to fill the cell in both axes, which is right for the image and the scrim. The caption uses align-self: end to sit at the bottom at its natural height; the badge uses justify-self: end and align-self: start to shrink to its content in the top-right corner.
Painting order follows source order unless z-index says otherwise: later items paint over earlier ones. That makes the DOM order a natural layering — background first, content last — and keeps the reading order sensible, since screen readers encounter the image, then the badge, then the caption. If the design needs a different paint order than reading order, change z-index, never the DOM.
When absolute positioning is still right
Grid stacking replaces most overlay uses of absolute positioning, but not all of them. Three cases still call for position: absolute or fixed.
The overlay must not affect size. A decorative flourish that sticks out beyond a card's edge, or a notification dot that should overlap a button's corner without enlarging it, must be out of flow. Grid stacking would grow the cell to fit it.
The overlay is anchored to something outside the component. Tooltips and dropdowns belong to their trigger but must escape the trigger's container. Those are jobs for anchor positioning and the top layer, not for a grid cell.
The overlay covers the viewport. Full-screen modals and toasts are positioned relative to the viewport, which a grid cell inside the page cannot provide; the native <dialog> and popover elements handle these better still.
A useful rule: if the layered content should influence the component's size — captions, text over images, cross-fading panels — use grid stacking. If it should float independently of the component's size, use positioning. Mixing both in one component is common and fine: a grid-stacked card can still contain an absolutely positioned notification dot on its badge.
Beyond cards: other stacking patterns
The same one-cell technique covers many layouts that used to need absolute positioning:
- Hero text over images, with the image setting the minimum height and long headlines growing the hero, as in Responsive Hero Sections With Container Queries.
- Play buttons on video thumbnails, centred with
place-self: center. - Before/after comparisons, two images in one cell with the top one clipped by
clip-path, as in the comparison slider in Clip-Path & Mask Animations. - Cross-fading content, where several panels share a cell and only one is visible; the cell is always as tall as the tallest panel, so switching never changes the component's height.
- Form fields with inline icons, the input and icon stacked with the icon aligned to one edge, and padding on the input reserving its space.
The cross-fading case deserves emphasis because it solves a common layout-shift problem. A carousel of testimonials of different lengths, built with absolute positioning, needs a fixed height to avoid collapse and still clips the longest quote. Built with stacking, the component is automatically as tall as the longest quote, and switching quotes never moves the content below.
Variation: responsive stacking
Stacking can be conditional. A card that places its caption below the image in narrow containers and over it in wide ones only needs the grid areas to change.
.tile-slot { container-type: inline-size; }
.tile {
display: grid;
grid-template-areas: "media" "caption";
}
.tile__img { grid-area: media; }
.tile__caption { grid-area: caption; }
.tile::after { display: none; }
@container (width > 30rem) {
.tile { grid-template-areas: "stack"; }
.tile > *, .tile::after { grid-area: stack; }
.tile::after { display: block; }
}
In narrow containers the caption sits in its own row with normal colours; in wide containers it overlays the image with the scrim. Because the caption is in flow in both cases, neither layout can overflow. Animating the switch is best done with a short fade on the caption rather than by trying to transition the grid itself.
Browser support
Placing multiple items in the same grid cell, grid-template-areas, justify-self, align-self and z-index on grid items are supported in every browser with CSS Grid: Chrome 57+, Edge 16+, Firefox 52+ and Safari 10.1+. object-fit for the image layer is supported in all current engines. Container queries, used in the responsive variation, are supported in Chrome and Edge 105+, Firefox 110+ and Safari 16+.
FAQ
How do I stack elements on top of each other with CSS Grid?
Place every child in the same cell, for example with grid-area: 1 / 1 or a single named area. They overlap, paint in source order, and each can be aligned independently with justify-self and align-self.
Why use grid stacking instead of position: absolute?
Absolutely positioned elements are removed from flow, so the container cannot grow to fit them and long captions overflow. Grid-stacked elements stay in flow: the cell grows to the tallest layer, so text is never clipped and no fixed height is needed.
How do I control which layer is on top?
Stacked grid items paint in source order, later items on top. To change it without reordering the DOM, give the item a z-index; grid items can use z-index without being positioned.
Can stacked layers be different sizes?
Yes. Each layer sizes itself within the shared cell and can be aligned to any edge or corner with justify-self and align-self, so a small badge can sit in the top-right corner while an image fills the whole cell.
Related
- CSS Grid & Subgrid Layouts — the parent guide.
- Responsive Layouts With grid-template-areas — naming the areas being stacked.
- Centering in CSS: Every Method — aligning a layer in the middle of its cell.
- Fade Edges With mask-image — soft transitions between stacked layers.
Related articles
More pages in the same section.