9 CSS Bento Grid Hover Animations to Copy-Paste
A bento grid is a layout built from big and small boxes fitted together like a photo collage, the shape you see on portfolio and product pages.
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Breathing scale + shadow
- 02 Border beam tile
- 03 Image tile zoom
- 04 Icon float tile
- 05 Gradient reveal tile
- 06 Stat count tile
- 07 Grid span expand on hover
- 08 Tiles reveal via scroll stagger
- 09 Neighboring tile glow
The nine below are ordered by how an eye actually scans a grid, not by popularity. 01 and 02 show that a whole tile can react before anything inside it does. 03 through 05 move something inside the tile instead — a photo layer, an icon, a caption. 06 changes what a number says, 07 fakes a bigger tile the boldest way in the set, 08 handles the grid's first entrance on scroll, and 09 closes with a glow the whole grid shares at once.
01Breathing scale + shadow
Hovering a tile scales the whole card up slightly, from 1 to 1.04, while its box-shadow deepens from a flatter, pressed-in shadow to a taller, lifted one on the same transition. Nothing about the tile's position or size in the grid changes — only how tall and lit it looks.
.bs-focal {
transition: transform $duration $easing, box-shadow $duration $easing;
box-shadow: $shadow-press;
}
.bs-focal:hover {
transform: scale(1.04);
box-shadow: $shadow-raised;
}
02Border beam tile
On hover, a thin ring of light spins once around a tile's border while the border's own thickness never moves. The ring is a conic-gradient cut down to a thin strip with mask-composite: exclude, and only that ring layer's transform: rotate() spins — the gradient's colors themselves stay fixed.
.bb-focal::before {
content: "";
position: absolute;
inset: 0;
padding: $sp-1;
background: conic-gradient(from 0deg, transparent 0 70%, $color 85%, transparent 100%);
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
mask-composite: exclude;
opacity: 0;
}
.bb-focal:hover::before {
opacity: 1;
animation: bb-spin 1.6s linear infinite;
}
@keyframes bb-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
03Image tile zoom
The tile's outer frame stays exactly where it is — overflow: hidden clips anything that spills past its edge — while only the photo layer inside scales up on hover, so the zoom reads as happening inside a fixed picture frame rather than the frame itself growing. It's the same trick a lightbox thumbnail uses to zoom without shifting any layout around it.
.iz-tile--a { overflow: hidden; }
.iz-tile__media {
position: absolute;
inset: 0;
transition: transform $duration $easing;
}
.iz-focal:hover .iz-tile__media {
transform: scale(1.15);
}
04Icon float tile
The icon inside a feature tile — a rounded square drawn purely in CSS with a dot inset, no emoji or image file involved — sits completely still until a hover starts a translateY keyframe loop that gently bobs it up and down. It stops the moment the cursor leaves, so the tile never drifts on its own.
.if-icon {
position: relative;
width: $sp-8;
height: $sp-8;
border-radius: $r-control;
background: $color;
}
.if-icon::after {
content: "";
position: absolute;
inset: $sp-2;
border-radius: $r-pill;
background: $subject-cream;
}
.if-focal:hover .if-icon {
animation: if-bob 1.4s $ease-out infinite;
}
@keyframes if-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
05Gradient reveal tile
A photo-only tile hides a dark gradient scrim underneath at rest; hovering fades that scrim in with opacity while the title text slides up with translateY, so the caption reads clearly only once the photo behind it has dimmed enough. Neither the photo nor the tile's own size is touched — just the two layers stacked on top of it.
.gr-tile__scrim {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0, 0, 0, .75), transparent 65%);
opacity: 0;
transition: opacity 350ms $ease-out;
}
.gr-tile__label {
transform: translateY($sp-2);
transition: transform 350ms $ease-out;
}
.gr-focal:hover .gr-tile__scrim { opacity: 1; }
.gr-focal:hover .gr-tile__label { transform: translateY(0); }
06Stat count tile
A number tile counts up from 0 to its target the moment a cursor lands on it, with no JavaScript running the count — @property registers a custom property as an integer, and the digits shown actually come from a ::after pseudo-element reading counter(num), not from any text sitting in the tile itself. That registration has to say inherits: true; writing it as inherits: false, the way most examples online do, measures out as a counter that never updates at all in this render pipeline.
@property --num {
syntax: '<integer>';
inherits: true;
initial-value: 0;
}
.sc-stat__value {
--num: 0;
}
.sc-stat__value::after {
counter-reset: num var(--num);
content: counter(num);
}
@keyframes sc-countup {
to { --num: 87; }
}
.sc-focal:hover .sc-stat__value {
animation: sc-countup 1.2s $ease-out forwards;
}
07Grid span expand on hover
A tile looks like it grows to cover the cell next to it on hover, but the grid itself never actually changes — grid-column and grid-row span values are discrete, so a browser has no in-between frame to draw and can't tween them. The illusion comes entirely from transform: scale(1.35) paired with a raised z-index, which lets the enlarged tile visually overlap its neighbor without moving a single grid line.
.he-focal {
transition: transform $duration $easing, box-shadow $duration $easing;
box-shadow: $shadow-press;
}
.he-focal:hover {
transform: scale(1.35);
box-shadow: $shadow-raised;
z-index: 5;
}
08Tiles reveal via stagger
As the grid scrolls into view, a real IntersectionObserver adds an .in class to each tile the moment it crosses the viewport, and every tile shares the exact same opacity/transform transition — only transition-delay differs per tile, so they rise into place one after another instead of all at once. This runs on IntersectionObserver rather than the newer animation-timeline: view(), since scroll-driven CSS animations still render inconsistently enough across browsers to skip for now.
var tiles = document.querySelectorAll('.ss-tile');
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (e.isIntersecting) {
e.target.classList.add('in');
io.unobserve(e.target);
}
});
}, { threshold: 0.3 });
tiles.forEach(function (t) { io.observe(t); });
.ss-tile {
transition: opacity 400ms $ease-out, transform 400ms $ease-out;
}
.ss-tile.in { opacity: 1; transform: translateY(0); }
.ss-tile:nth-child(2).in { transition-delay: 160ms; }
.ss-tile:nth-child(3).in { transition-delay: 320ms; }
.ss-tile:nth-child(4).in { transition-delay: 480ms; }
09Neighboring tile glow
Moving the cursor anywhere over the grid writes its position into --mx and --my, two custom properties shared by every tile through CSS inheritance, so a radial-gradient glow can read the same coordinate on all of them. A :has() selector checks whether any tile in the grid is currently hovered and, if so, switches every tile's glow on together — which is why the light spreads into tiles the cursor was never actually over.
@property --mx { syntax: '<percentage>'; inherits: true; initial-value: 50%; }
@property --my { syntax: '<percentage>'; inherits: true; initial-value: 50%; }
.sl-tile::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(140px circle at var(--mx) var(--my), rgba($color, .6), transparent 70%);
opacity: 0;
transition: opacity 200ms $easing;
}
.sl-grid:has(.sl-tile:hover) .sl-tile::before { opacity: 1; }
grid.addEventListener('pointermove', function (e) {
var r = grid.getBoundingClientRect();
var x = ((e.clientX - r.left) / r.width * 100).toFixed(1) + '%';
var y = ((e.clientY - r.top) / r.height * 100).toFixed(1) + '%';
grid.style.setProperty('--mx', x);
grid.style.setProperty('--my', y);
});
Where does grid-column span break on hover?
The one place in this set most likely to trip someone up is item 07. grid-column: span 2 looks like any other CSS value, so it's tempting to add a :hover rule that changes the span and expect a smooth expansion — but span values are discrete integers, not a range a browser can interpolate between, so the instant a hover rule flips a 1 into a 2, the layout snaps rather than animating. That's exactly why item 07 never touches grid-column or grid-row on hover at all: the two-column, two-row layout stays fixed the whole time, and transform: scale(1.35) with a raised z-index is what makes the tile look like it's claiming the cell beside it. Item 08 runs into a related decision — CSS now has animation-timeline: view(), a way to drive an animation purely off scroll position with no JavaScript at all, but it still renders differently enough across browsers that this whole scroll-timeline family gets skipped here on principle, which is why item 08's staggered entrance runs through a plain IntersectionObserver and transition-delay instead. The full SCSS and React source for all nine tiles, the exact IntersectionObserver and pointermove scripts included, sits behind one zip that opens with 6s9wc2fb, typed exactly as it appears in this sentence.
Variants worth trying
| Variant | Changed value | Feel |
|---|---|---|
| Bigger lift | Item 01's scale(1.04) raised to scale(1.08) |
Reads like a firmer press instead of a slow breath |
| Gentler overlap | Item 07's scale(1.35) lowered to scale(1.15) |
The tile nudges over its neighbor instead of fully covering it |
| Wider glow | Item 09's 140px circle widened to 220px circle |
The light reaches two neighbors at once instead of staying close to the cursor |
Accessibility
All nine turn their animation off under prefers-reduced-motion: reduce, but what's left on screen differs depending on how much the motion was carrying the message. 01, 02, 03, 05, 07, and 09 just hold their resting look — a flat tile, a hidden ring, an unzoomed photo — since nothing about their content depended on movement to be understood. 06 and 08 are different: 06 jumps --num straight to its target value instead of freezing at 0, and 08 sets every tile's opacity and transform to their finished state right away, because a stat that never counts up or a tile that never appears would hide real content, not just skip an effect. Two features here also depend on the browser: @property, which items 06 and 09 both need to animate a custom property smoothly, reaches Chrome/Edge 85+ and Safari 16.4+ but only arrived in Firefox 128; :has(), which item 09 uses to detect a hover anywhere in the grid, needs Chrome 105+, Safari 15.4+, or Firefox 121+. Older browsers just show the static end state instead of erroring out. More layout tricks for the same kind of grid live in Trend-Driven, Ready-to-Copy Bento Grid Design, and the rest of what this site has published sits in the CSS category hub.
FAQ
Do I need JavaScript to build a bento grid layout?
No — display: grid with grid-template-columns and grid-template-rows is all a bento layout needs. Every hover effect in this set sits on top of that grid and is mostly pure CSS, with real JavaScript running behind only items 08 and 09.
Why doesn't my grid-column transition smoothly on hover?
Span values like grid-column: span 2 are discrete, not a range a browser can tween between, so any transition on them jumps instead of animating. Item 07 works around this with transform: scale and z-index instead of touching the grid at all.
Does hover even work on a touchscreen?
Not reliably — many touch browsers either skip :hover entirely or toggle it on with the first tap and never release it. Purely decorative motion like 01, 03, and 05 loses nothing important without it, but a number that matters, like item 06's counter, is worth triggering off scrolling into view instead of off hover on a touch layout.