UI Components Medium
Hover-expand Cell
A focused bento cell expands across the grid while neighboring cells yield gracefully.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
:root {
--bg: #0b1020;
--panel: #18233b;
--line: #2b3d62;
--text: #eef2ff;
--muted: #a8b5d1;
--ease: cubic-bezier(0.22, 1, 0.36, 1);
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(120% 90% at 50% -10%, #16203a 0%, var(--bg) 60%);
color: var(--text);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
padding: clamp(1rem, 4vw, 3rem);
}
.demo {
width: min(920px, 100%);
margin: auto;
background: #121a2d;
border: 1px solid var(--line);
border-radius: 22px;
padding: clamp(1rem, 3vw, 2rem);
box-shadow: 0 20px 70px #0004;
}
.demo__head h1 { margin: 0 0 4px; font-size: 1.35rem; letter-spacing: -0.02em; }
.muted { color: var(--muted); margin: 0 0 14px; }
.switch {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
color: var(--muted);
cursor: pointer;
margin-bottom: 20px;
}
.switch input { accent-color: #78a9ff; width: 15px; height: 15px; }
.bento {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-auto-rows: 128px;
gap: 12px;
}
.tile {
position: relative;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 16px;
background: linear-gradient(160deg, var(--panel), #101828);
padding: 16px 18px;
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: flex-end;
transition:
grid-column 0.45s var(--ease),
grid-row 0.45s var(--ease),
transform 0.4s var(--ease),
opacity 0.35s var(--ease),
filter 0.35s var(--ease),
border-color 0.35s var(--ease);
}
.tile[data-span="wide"] { grid-column: span 2; }
.tile[data-span="tall"] { grid-row: span 2; }
.tile::after {
content: "";
position: absolute;
inset: -40% -10% auto -10%;
height: 170px;
background: radial-gradient(closest-side, var(--glow, #78a9ff) 0%, transparent 100%);
opacity: 0.16;
transition: opacity 0.35s var(--ease);
pointer-events: none;
}
.tile[data-tone="a"] { --glow: #78a9ff; }
.tile[data-tone="b"] { --glow: #3fd0c9; }
.tile[data-tone="c"] { --glow: #ff8fa3; }
.tile[data-tone="d"] { --glow: #ffc46b; }
.tile[data-tone="e"] { --glow: #9be36b; }
.tile[data-tone="f"] { --glow: #c99bff; }
.tile__kicker {
font-size: 0.72rem;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--muted);
}
.tile__value {
font-size: 1.6rem;
font-weight: 650;
letter-spacing: -0.03em;
transition: font-size 0.4s var(--ease);
}
.tile__detail {
margin: 8px 0 0;
font-size: 0.87rem;
line-height: 1.5;
color: var(--muted);
max-width: 46ch;
opacity: 0;
transform: translateY(6px);
transition: opacity 0.3s var(--ease), transform 0.3s var(--ease);
}
.tile.is-focused {
grid-column: 1 / -1;
grid-row: span 2;
border-color: var(--glow);
z-index: 2;
}
.tile.is-focused::after { opacity: 0.38; }
.tile.is-focused .tile__detail { opacity: 1; transform: none; }
.tile.is-focused .tile__value { font-size: 2.4rem; }
.bento.has-focus .tile:not(.is-focused) {
opacity: 0.42;
filter: saturate(0.5);
transform: scale(0.97);
}
.tile.is-locked { box-shadow: 0 0 0 1px var(--glow) inset; }
.tile:focus-visible { outline: 2px solid #78a9ff; outline-offset: 2px; }
.hint { margin: 20px 0 0; font-size: 0.82rem; color: #9eb1d4; }
@media (max-width: 600px) {
.bento { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.demo { border-radius: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.tile, .tile__detail, .tile__value, .tile::after { transition-duration: 0.01ms !important; }
}(() => {
const grid = document.getElementById("expand-grid");
const lockToggle = document.getElementById("lock");
if (!grid) return;
const tiles = Array.from(grid.querySelectorAll(".tile"));
let locked = null;
let hovered = null;
let raf = 0;
const render = () => {
raf = 0;
const active = locked || hovered;
grid.classList.toggle("has-focus", Boolean(active));
tiles.forEach((tile) => {
const isActive = tile === active;
tile.classList.toggle("is-focused", isActive);
tile.classList.toggle("is-locked", tile === locked);
tile.setAttribute("aria-expanded", String(isActive));
});
};
const schedule = () => {
if (!raf) raf = requestAnimationFrame(render);
};
const setHover = (tile) => {
if (hovered === tile) return;
hovered = tile;
schedule();
};
const toggleLock = (tile) => {
locked = locked === tile ? null : tile;
schedule();
};
tiles.forEach((tile) => {
tile.setAttribute("role", "button");
tile.setAttribute("aria-expanded", "false");
tile.addEventListener("pointerenter", () => {
if (!locked) setHover(tile);
});
tile.addEventListener("focus", () => {
if (!locked) setHover(tile);
});
tile.addEventListener("click", () => {
if (lockToggle && lockToggle.checked) toggleLock(tile);
});
tile.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleLock(tile);
}
});
});
grid.addEventListener("pointerleave", () => {
if (!locked) setHover(null);
});
grid.addEventListener("focusout", (e) => {
if (!locked && !grid.contains(e.relatedTarget)) setHover(null);
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
locked = null;
hovered = null;
schedule();
}
});
render();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hover-expand Cell</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="demo" data-demo="Hover-expand Cell">
<header class="demo__head">
<h1>Hover-expand cell</h1>
<p class="muted">Focus a tile — it expands across the grid while its neighbours yield.</p>
<label class="switch">
<input type="checkbox" id="lock" checked />
<span>Click a tile to lock it open</span>
</label>
</header>
<ul class="bento" id="expand-grid" role="list" aria-describedby="hint">
<li class="tile" tabindex="0" data-tone="a" data-span="wide">
<span class="tile__kicker">Revenue</span>
<strong class="tile__value">$48.2k</strong>
<p class="tile__detail">Up 12.4% against last month, driven by annual upgrades.</p>
</li>
<li class="tile" tabindex="0" data-tone="b">
<span class="tile__kicker">Active users</span>
<strong class="tile__value">9,142</strong>
<p class="tile__detail">Rolling 7-day average across all workspaces.</p>
</li>
<li class="tile" tabindex="0" data-tone="c">
<span class="tile__kicker">Churn</span>
<strong class="tile__value">1.8%</strong>
<p class="tile__detail">Lowest since Q1 — retention plays are landing.</p>
</li>
<li class="tile" tabindex="0" data-tone="d" data-span="tall">
<span class="tile__kicker">Latency p95</span>
<strong class="tile__value">184 ms</strong>
<p class="tile__detail">Edge cache hit ratio at 91%, cold starts trimmed by 40 ms.</p>
</li>
<li class="tile" tabindex="0" data-tone="e">
<span class="tile__kicker">Signups</span>
<strong class="tile__value">312</strong>
<p class="tile__detail">Organic search remains the top acquisition channel.</p>
</li>
<li class="tile" tabindex="0" data-tone="f">
<span class="tile__kicker">NPS</span>
<strong class="tile__value">62</strong>
<p class="tile__detail">Promoters cite onboarding speed most often.</p>
</li>
</ul>
<p class="hint" id="hint">Hover or Tab through the tiles. Enter or Space locks a tile open; Escape releases it.</p>
</main>
<script src="script.js"></script>
</body>
</html>import { useState } from "react";
export function BentoHoverExpand() {
const [items, setItems] = useState(["Alpha", "Beta", "Gamma"]);
return (
<section className="demo">
<h2>Hover-expand Cell</h2>
<p>Vanilla behavior maps cleanly to Pointer Events and DOM state.</p>
<div className="stack">
{items.map((item, index) => (
<button
className="item"
key={item}
onClick={() => setItems([...items.slice(0, index), ...items.slice(index + 1), item])}
>
{item}
</button>
))}
</div>
</section>
);
}Hover-expand Cell
A focused bento cell expands across the grid while neighboring cells yield gracefully.
Support notes
CSS Grid and columns do most of the layout work. The drag demo adds native DnD only for rearrangement; keep tile order in application state when integrating it.
Included demo
- Vanilla HTML, CSS, and JavaScript with zero external dependencies.
- React equivalent using the same interaction model.
- A Lab route for trying the behavior in isolation.
Integration checklist
Keep state updates separate from presentation, preserve semantic labels, and add persistence or server callbacks at the boundary where your product needs them.