A grid of six illustrated destination cards where one is always open. The open card's column and row both grow, so it expands in two directions while its neighbours shrink to make room, and its description, price and link fade in. Hover, tap or tab to a card to open it. Left alone, the grid tours the cards by itself. It is three columns by two rows on desktop and two by three on phones.
import { useEffect, useRef, useState } from "react";
const places = [
{
name: "Lisbon",
country: "Portugal",
blurb: "Tiled hills, late dinners and a tram that does the climbing for you.",
nights: "3 nights",
price: "from $420",
sky: "from-rose-400 via-orange-300 to-amber-200",
sun: "#fff7ed",
far: "#be123c",
near: "#4c0519",
},
{
name: "Kyoto",
country: "Japan",
blurb: "Moss gardens before breakfast and alleys that glow after dark.",
nights: "4 nights",
price: "from $690",
sky: "from-emerald-700 via-emerald-500 to-lime-200",
sun: "#fecdd3",
far: "#065f46",
near: "#022c22",
},
{
name: "Reykjavik",
country: "Iceland",
blurb: "Hot pools, black sand and daylight that forgets to end.",
nights: "3 nights",
price: "from $780",
sky: "from-sky-900 via-sky-500 to-cyan-200",
sun: "#f0f9ff",
far: "#0c4a6e",
near: "#082f49",
},
{
name: "Marrakech",
country: "Morocco",
blurb: "Rooftop mint tea above a market that never quite closes.",
nights: "3 nights",
price: "from $380",
sky: "from-orange-600 via-amber-400 to-yellow-200",
sun: "#fffbeb",
far: "#9a3412",
near: "#431407",
},
{
name: "Hobart",
country: "Tasmania",
blurb: "Harbour oysters, a mountain at the end of the street and no rush.",
nights: "4 nights",
price: "from $540",
sky: "from-indigo-900 via-violet-600 to-fuchsia-300",
sun: "#fae8ff",
far: "#4c1d95",
near: "#1e1b4b",
},
{
name: "Oaxaca",
country: "Mexico",
blurb: "Seven moles, painted streets and mezcal poured by the maker.",
nights: "5 nights",
price: "from $460",
sky: "from-fuchsia-700 via-pink-500 to-orange-300",
sun: "#fef3c7",
far: "#9d174d",
near: "#500724",
},
];
/** How much bigger the open card's column and row are than the rest. */
const GROW_COL = 2.7; // three columns (desktop)
const GROW_COL_NARROW = 1.9; // two columns (phones)
const GROW_ROW = 2.1;
/** The order the demo visits cards in while nobody is interacting. */
const TOUR = [0, 4, 2, 3, 1, 5];
export default function ExpandingCardGridOnHoverAndFocus() {
const [active, setActive] = useState(0);
const [cols, setCols] = useState(3);
const gridRef = useRef<HTMLUListElement>(null);
const held = useRef(false);
useEffect(() => {
const mq = window.matchMedia("(min-width: 768px)");
const fit = () => setCols(mq.matches ? 3 : 2);
fit();
mq.addEventListener("change", fit);
return () => mq.removeEventListener("change", fit);
}, []);
// Idle tour: opens a different card every two seconds until someone takes over.
useEffect(() => {
const grid = gridRef.current;
if (!grid || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let visible = true;
let step = 0;
const io = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
});
io.observe(grid);
const tick = () => {
if (held.current || !visible || document.hidden) return;
step = (step + 1) % TOUR.length;
setActive(TOUR[step]);
};
const first = window.setTimeout(tick, 800);
const loop = window.setInterval(tick, 2000);
return () => {
window.clearTimeout(first);
window.clearInterval(loop);
io.disconnect();
};
}, []);
const rows = places.length / cols;
const template = (count: number, open: number, grow: number) => Array.from({ length: count }, (_, k) => `${k === open ? grow : 1}fr`).join(" ");
const open = (i: number) => {
held.current = true;
setActive(i);
};
return (
<section className="bg-stone-950 px-4 py-16 text-white sm:px-6 sm:py-20">
<style>{`
@keyframes expanding-card-grid-on-hover-and-focus-rise {
from { opacity: 0; transform: scale(0.94) translateY(18px); }
to { opacity: 1; transform: none; }
}
.expanding-card-grid-on-hover-and-focus-grid {
transition: grid-template-columns 0.75s cubic-bezier(0.22, 1, 0.36, 1), grid-template-rows 0.75s cubic-bezier(0.22, 1, 0.36, 1);
}
.expanding-card-grid-on-hover-and-focus-rise {
animation: expanding-card-grid-on-hover-and-focus-rise 0.7s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.expanding-card-grid-on-hover-and-focus-more {
display: grid;
grid-template-rows: 0fr;
opacity: 0;
transition: grid-template-rows 0.6s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.3s ease;
}
[data-open="true"] .expanding-card-grid-on-hover-and-focus-more {
grid-template-rows: 1fr;
opacity: 1;
transition: grid-template-rows 0.6s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.5s ease 0.2s;
}
@media (prefers-reduced-motion: reduce) {
.expanding-card-grid-on-hover-and-focus-grid,
.expanding-card-grid-on-hover-and-focus-more,
[data-open="true"] .expanding-card-grid-on-hover-and-focus-more { transition: none; }
.expanding-card-grid-on-hover-and-focus-rise { animation: none; }
}
`}</style>
<div className="mx-auto max-w-5xl">
<div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-end">
<div>
<p className="text-sm font-semibold uppercase tracking-widest text-amber-300">Slow Weekends, autumn list</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight sm:text-5xl">Six cities worth taking slowly</h2>
</div>
<p className="max-w-xs text-sm leading-6 text-stone-400">Short trips planned by people who live there. Hover, tap or tab to a city to open it.</p>
</div>
<ul
ref={gridRef}
onMouseLeave={() => (held.current = false)}
className="expanding-card-grid-on-hover-and-focus-grid mt-10 grid h-[600px] gap-2 sm:gap-3 md:h-[520px]"
style={{ gridTemplateColumns: template(cols, active % cols, cols === 3 ? GROW_COL : GROW_COL_NARROW), gridTemplateRows: template(rows, Math.floor(active / cols), GROW_ROW) }}
>
{places.map((p, i) => {
const isOpen = i === active;
return (
<li
key={p.name}
data-open={isOpen}
onMouseEnter={() => open(i)}
className={`expanding-card-grid-on-hover-and-focus-rise relative min-h-0 min-w-0 overflow-hidden rounded-2xl bg-gradient-to-b sm:rounded-3xl ${p.sky}`}
style={{ animationDelay: `${i * 0.06}s` }}
>
{/* The scene is sized in percentages of the card, so it grows as the card opens and never crops badly. */}
<span aria-hidden="true" className="absolute bottom-[36%] right-[10%] aspect-square h-[40%] max-h-24 rounded-full sm:right-[13%] sm:max-h-32" style={{ backgroundColor: p.sun }} />
<svg viewBox="0 0 400 200" preserveAspectRatio="none" aria-hidden="true" className="absolute inset-x-0 bottom-0 h-[58%] w-full">
<path d="M0 96 L44 60 L82 88 L128 30 L170 80 L214 52 L262 92 L318 40 L360 78 L400 56 V200 H0Z" fill={p.far} />
</svg>
<svg viewBox="0 0 400 200" preserveAspectRatio="none" aria-hidden="true" className="absolute inset-x-0 bottom-0 h-[34%] w-full">
<path d="M0 60 C60 14 110 44 170 54 C240 66 300 12 400 46 V200 H0Z" fill={p.near} />
</svg>
<span aria-hidden="true" className="absolute inset-x-0 bottom-0 h-3/4 bg-gradient-to-t from-black/70 via-black/25 to-transparent" />
{/* A second shade from the text side, only while the card is open, keeps the details readable over the scene. */}
<span aria-hidden="true" className={`absolute inset-0 bg-gradient-to-r from-black/60 via-black/20 to-transparent transition-opacity duration-500 motion-reduce:transition-none ${isOpen ? "opacity-100" : "opacity-0"}`} />
<span aria-hidden="true" className="absolute left-3 top-3 rounded-full bg-black/25 px-2.5 py-1 text-xs font-semibold tabular-nums text-white sm:left-5 sm:top-5">
{String(i + 1).padStart(2, "0")}
</span>
<button
type="button"
aria-expanded={isOpen}
aria-label={`${p.name}, ${p.country}`}
onFocus={() => open(i)}
onClick={() => open(i)}
className="absolute inset-0 rounded-2xl focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white sm:rounded-3xl"
/>
<div className="pointer-events-none absolute bottom-0 left-0 w-[200px] p-3 sm:w-[340px] sm:p-5">
<h3 className="text-base font-semibold tracking-tight sm:text-2xl">{p.name}</h3>
<p className="text-xs font-medium text-white/80">{p.country}</p>
<div className="expanding-card-grid-on-hover-and-focus-more" aria-hidden={!isOpen}>
<div className="overflow-hidden">
<p className="pt-3 text-sm leading-5 text-white/90 sm:leading-6">{p.blurb}</p>
<div className="flex flex-wrap items-center gap-2 pt-3">
<span className="rounded-full bg-white/20 px-2.5 py-1 text-xs font-semibold">{p.nights}</span>
<span className="rounded-full bg-white/20 px-2.5 py-1 text-xs font-semibold">{p.price}</span>
</div>
<div className="pt-3">
<a
href="#"
tabIndex={isOpen ? 0 : -1}
onFocus={() => open(i)}
className={`inline-flex h-10 items-center gap-1.5 rounded-full bg-white px-4 text-sm font-semibold text-stone-950 transition hover:bg-amber-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-stone-900 ${isOpen ? "pointer-events-auto" : ""}`}
>
Open the guide
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-4 w-4">
<path d="M4 10h12M11 5l5 5-5 5" />
</svg>
</a>
</div>
</div>
</div>
</div>
</li>
);
})}
</ul>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/expanding-card-grid-on-hover-and-focus.json
The grid's `grid-template-columns` and `grid-template-rows` are written as `fr` values, for example `2.7fr 1fr 1fr`. Opening a card moves the large value to that card's column and row. Browsers can animate between two track lists of the same length, so a CSS transition on those two properties does all the movement.
The text block has a fixed width and is pinned to the bottom left corner, so the card resizing around it never re-wraps a line. The details open with the `grid-template-rows: 0fr` to `1fr` technique, which animates height without measuring anything.
It is a layout animation, so it costs more than a transform, but with six cards and no text reflow it stays smooth. Avoid putting heavy content such as video or large blurred layers inside the cards.
A tap opens a card, because each card has a button covering it. The link only becomes tappable once its card is open, so the first tap never follows a link by accident.
Yes. Set the column count in `fit` to the number of items so there is one row, and the grid behaves like a horizontal expanding strip. On phones keep two columns or let it become one column with rows that grow.
No. It uses React state and CSS transitions only.
Related: Bento Grid with Six Feature Tiles, Blog Post Card Grid with Category Tags and Reading Time, Spotlight Cards with Pointer Light.
More animated cards or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.