GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

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

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. Adding light to each tile so the response reads louder is what the CSS glow effects supply, and reshaping how fast a tile moves at all is the list of curves in CSS easing and springs.

01Breathing scale + shadow

Where this fits in a bento grid design ui

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.

Breathing scale + shadow
Open in a new tab

01 · Breathing scale + shadow — Measured area 2.481% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

scalebox-shadow300ms

The core of it

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

What you see

On hover, a thin ring of light spins once around a tile's border while the border's own thickness never moves.

Border beam tile
Open in a new tab

02 · Border beam tile — Measured area 5.527% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

conic-gradientmask1.6s

The idea behind it

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

Where it fits

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.

Image tile zoom
Open in a new tab

03 · Image tile zoom — Measured area 2.692% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

scaleoverflow: hidden400ms

Why it is done this way

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

What moves

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.

Icon float tile
Open in a new tab

04 · Icon float tile — Measured area 0.461% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateY@keyframes1.4s

Worth noting

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

The moment it fires

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.

Gradient reveal tile
Open in a new tab

05 · Gradient reveal tile — Measured area 13.982% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

opacitytranslateY350ms

The key property

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

What the user notices

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.

Stat count tile
Open in a new tab

06 · Stat count tile — Measured area 0.54% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

@propertycounter()1.2s

What to read in the code

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

Where this shows up

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.

Grid span expand on hover
Open in a new tab

07 · Grid span expand on hover — Measured area 13.703% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

scalez-indextransform-origin: top left

How it is built

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

What happens on screen

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.

Tiles reveal via scroll stagger
Open in a new tab

08 · Tiles reveal via scroll stagger — Measured area 36.644% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

IntersectionObservertransition-delayJS 8줄

How it works

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

What it looks like

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.

Neighboring tile glow
Open in a new tab

09 · Neighboring tile glow — Measured area 36.649% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

:has()radial-gradientJS 8줄

How to wire it up

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?

What goes wrong

The one place in a bento grid layout css build 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.

How to avoid it

One tile growing while its neighbors get pushed aside is settled at the grid level in the bento grid landing sections, where the layout rules come first.

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

What reduced motion leaves

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.

What else to check

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 don't css grid hover effects transition grid-column smoothly?

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. Filling those tiles with a body of work, complete with sorting and zoom, continues in the portfolio gallery UI patterns.

Enter the archive password

The password is inside this article. You will find it as you read.