Interactive
ExpandableGrid
Open a tile and it takes the whole row while the other tiles slide around it. One opens at a time. Esc closes and puts the focus back on its tile.
Download
- React
- Tailwind CSS v4
- motion
components/ui/expandable-grid.tsx
"use client";
import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
export type ExpandableGridItem = {
id: string;
title: string;
summary?: string;
/** Image at the top of the tile. It sits inside a button, so no links here. */
media?: ReactNode;
/** Shown when the tile is open. Links and buttons are fine here. */
content: ReactNode;
};
type ExpandableGridProps = {
items: ExpandableGridItem[];
/** id of the tile open at the start. */
start?: string | null;
/** Smallest tile width in pixels. The grid fits as many columns as it can. */
minWidth?: number;
/** Look of each tile. */
cardClassName?: string;
/** On the grid. Set the gap and width here. */
className?: string;
};
const SPRING = { type: "spring", stiffness: 320, damping: 34 } as const;
/* ExpandableGrid: open a tile and it takes the full row while the others slide around it. Open one at a time, Esc closes. */
export function ExpandableGrid({
items,
start = null,
minWidth = 200,
cardClassName = "border border-white/10 bg-white/[0.04] text-white",
className = "gap-3",
}: ExpandableGridProps) {
const reduce = useReducedMotion();
const uid = useId();
const [openId, setOpenId] = useState(start);
const openPanel = useRef<HTMLDivElement>(null);
const transition = reduce ? { duration: 0 } : SPRING;
const closeOnEsc = (e: KeyboardEvent<HTMLLIElement>) => {
if (e.key !== "Escape") return;
setOpenId(null);
e.currentTarget.querySelector("button")?.focus();
};
// Waits for the tile to settle, because the box measured mid-animation is not its real size.
const showOpenCard = () => openPanel.current?.closest("li")?.scrollIntoView({ block: "nearest", behavior: reduce ? "auto" : "smooth" });
return (
<ul
className={`grid grid-flow-dense ${className}`}
style={{ gridTemplateColumns: `repeat(auto-fill, minmax(min(100%, ${minWidth}px), 1fr))` }}
>
{items.map((item) => {
const open = item.id === openId;
const panelId = `${uid}-${item.id}`;
return (
<motion.li
key={item.id}
layout
transition={transition}
onKeyDown={open ? closeOnEsc : undefined}
onLayoutAnimationComplete={open ? showOpenCard : undefined}
style={{ borderRadius: 16 }}
className={`relative scroll-my-3 overflow-hidden ${open ? "col-span-full" : ""} ${cardClassName}`}
>
<h3>
<button
type="button"
aria-expanded={open}
aria-controls={open ? panelId : undefined}
onClick={() => setOpenId(open ? null : item.id)}
className="block w-full rounded-2xl text-left outline-none focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-inset"
>
{item.media && (
<motion.span
layout
transition={transition}
className={`block overflow-hidden *:h-full *:w-full *:object-cover ${open ? "h-40" : "h-20"}`}
>
{item.media}
</motion.span>
)}
<motion.span layout="position" transition={transition} className="block p-4">
<span className="block pr-7 font-semibold">{item.title}</span>
{item.summary && <span className="mt-1 block text-sm font-normal opacity-65">{item.summary}</span>}
</motion.span>
<motion.span
layout="position"
transition={transition}
aria-hidden
className="absolute top-3 right-3 grid size-6 place-items-center rounded-full bg-black/45 text-white"
>
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
className={`transition-transform duration-300 motion-reduce:transition-none ${open ? "rotate-45" : ""}`}
>
<path d="M12 5v14M5 12h14" />
</svg>
</motion.span>
</button>
</h3>
{open && (
<motion.div
ref={openPanel}
id={panelId}
layout="position"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ opacity: { duration: 0.3, delay: reduce ? 0 : 0.12 }, layout: transition }}
className="px-4 pb-4 text-sm"
>
{item.content}
</motion.div>
)}
</motion.li>
);
})}
</ul>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/expandable-grid.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/expandable-grid-demo.tsx
import { ExpandableGrid } from "@/components/ui/expandable-grid"; // Gradients stand in for photos. Pass an <img> as media. const scene = (from: string, to: string) => <div style={{ background: `linear-gradient(135deg, ${from}, ${to})` }} />; const PROJECTS = [ { id: "orbit", title: "Orbit", summary: "Travel app", from: "#a78bfa", to: "#312e81", note: "Booking flow cut from nine screens to three." }, { id: "tide", title: "Tide", summary: "Weather site", from: "#22d3ee", to: "#155e75", note: "Live swell maps for every beach on the coast." }, { id: "ember", title: "Ember", summary: "Coffee brand", from: "#fb923c", to: "#7c2d12", note: "Packaging, shop and a loyalty card in one go." }, { id: "moss", title: "Moss", summary: "Plant shop", from: "#a3e635", to: "#14532d", note: "A care reminder sent for each plant you buy." }, ]; export default function ExpandableGridExample() { return ( <div className="h-full overflow-y-auto bg-[#0b0a14] p-4"> <ExpandableGrid minWidth={130} className="mx-auto max-w-[560px] gap-3" items={PROJECTS.map(({ id, title, summary, from, to, note }) => ({ id, title, summary, media: scene(from, to), content: ( <> <p className="text-white/70">{note}</p> <a href={`#${id}`} className="mt-2 inline-block text-[#a78bfa] underline underline-offset-4"> Read the case study </a> </> ), }))} /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| items | ExpandableGridItem[] | โ | Tiles: id, title, summary, media and content. Links go in content, not media. |
| start | string | null | null | id of the tile open at the start. |
| minWidth | number | 200 | Smallest tile width in pixels. The grid fits as many columns as it can. |
| cardClassName | string | dark card | Look of each tile. |
| className | string | "gap-3" | On the grid. Set the gap and width here. |