UI Components Easy
Masonry Bento Layout
A responsive masonry column layout that avoids JavaScript measurement work.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
:root {
--bg: #0b1020;
--panel: #121a2d;
--panel-2: #18233b;
--line: #2b3d62;
--text: #eef2ff;
--muted: #a8b5d1;
--accent: #78a9ff;
--accent-2: #4de3c1;
color-scheme: dark;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
padding: clamp(1rem, 4vw, 3rem);
background:
radial-gradient(900px 480px at 12% -12%, #1b2a55 0%, transparent 60%),
var(--bg);
color: var(--text);
font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
button, input { font: inherit; }
.demo {
width: min(980px, 100%);
margin: auto;
background: var(--panel);
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 .35rem; font-size: 1.45rem; letter-spacing: -.02em; }
.muted { color: var(--muted); margin: 0 0 1.1rem; }
.controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .75rem;
padding: .7rem .85rem;
margin-bottom: 1.25rem;
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: 14px;
}
.controls label { color: var(--muted); font-size: .85rem; }
.controls output { font-variant-numeric: tabular-nums; min-width: 1ch; }
.controls input[type="range"] { accent-color: var(--accent); width: 120px; }
.switch { display: inline-flex; align-items: center; gap: .4rem; cursor: pointer; }
.switch input { accent-color: var(--accent-2); }
#shuffle {
margin-left: auto;
padding: .45rem .9rem;
border-radius: 10px;
border: 1px solid var(--line);
background: #101a30;
color: var(--text);
cursor: pointer;
}
#shuffle:hover { border-color: var(--accent); color: var(--accent); }
:is(button, input):focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* masonry board: JS distributes tiles across these columns */
.board { display: grid; gap: 14px; align-items: start; }
.board__col { display: grid; gap: 14px; align-content: start; }
.tile {
position: relative;
padding: 1rem;
border-radius: 14px;
border: 1px solid var(--line);
background: linear-gradient(160deg, var(--panel-2), #131c30);
cursor: grab;
transition: transform .18s ease, border-color .18s ease, box-shadow .18s ease;
}
.tile:hover { transform: translateY(-2px); border-color: #405b8f; }
.tile:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.tile.is-dragging { opacity: .4; cursor: grabbing; }
.tile.is-over { border-color: var(--accent-2); box-shadow: 0 0 0 2px rgba(77, 227, 193, .25); }
.tile h2 { margin: 0 0 .35rem; font-size: .98rem; letter-spacing: -.01em; }
.tile p { margin: 0; color: var(--muted); font-size: .86rem; }
.tile__tag {
display: inline-block;
margin-bottom: .6rem;
padding: .1rem .5rem;
border-radius: 999px;
font-size: .68rem;
letter-spacing: .06em;
text-transform: uppercase;
color: #0b1020;
background: var(--accent-2);
}
.tile__bar {
margin-top: .8rem;
height: 6px;
border-radius: 999px;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
opacity: .7;
}
.sr-only {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
@media (max-width: 600px) { .demo { border-radius: 16px; } }
@media (prefers-reduced-motion: reduce) {
.tile { transition: none; }
.tile:hover { transform: none; }
}/* Masonry Bento Layout — column-balanced masonry with drag + keyboard reordering. */
(() => {
const board = document.querySelector("#board");
const colsInput = document.querySelector("#cols");
const colsOut = document.querySelector("#colsOut");
const balanceInput = document.querySelector("#balance");
const shuffleBtn = document.querySelector("#shuffle");
const live = document.querySelector("#live");
if (!board) return;
const DATA = [
{ tag: "Research", title: "Discovery notes", body: "Twelve interviews synthesised into four recurring jobs-to-be-done.", lines: 3 },
{ tag: "Signals", title: "Weekly activation", body: "Activation is up 6.2% week over week, driven mostly by the new empty state.", lines: 5 },
{ tag: "Metrics", title: "p95 latency", body: "142 ms", lines: 1 },
{ tag: "Notes", title: "Design debt", body: "Two legacy modals still bypass the focus trap and need migrating.", lines: 4 },
{ tag: "Team", title: "On call", body: "Ana → Wed, Marco → Thu.", lines: 2 },
{ tag: "Revenue", title: "MRR", body: "Expansion revenue now covers churn for the third consecutive month.", lines: 6 },
{ tag: "Support", title: "Backlog", body: "31 open tickets, median first reply 47 min.", lines: 2 },
{ tag: "Release", title: "v2.9", body: "Ships Friday behind a flag; rollback plan documented.", lines: 3 }
];
let order = DATA.map((_, i) => i);
let dragFrom = null;
function makeTile(item, index) {
const el = document.createElement("article");
el.className = "tile";
el.draggable = true;
el.tabIndex = 0;
el.dataset.index = String(index);
el.setAttribute("role", "listitem");
el.setAttribute("aria-roledescription", "reorderable tile");
const tag = document.createElement("span");
tag.className = "tile__tag";
tag.textContent = item.tag;
const h = document.createElement("h2");
h.textContent = item.title;
const p = document.createElement("p");
// `lines` fakes varying intrinsic height — that is what makes masonry visible.
p.textContent = Array.from({ length: item.lines }, () => item.body).join(" ");
const bar = document.createElement("div");
bar.className = "tile__bar";
bar.style.width = 30 + item.lines * 10 + "%";
el.append(tag, h, p, bar);
return el;
}
/* Layout ------------------------------------------------------------- */
function columnCount() {
const requested = Number(colsInput.value);
const w = board.clientWidth || window.innerWidth;
// Responsive clamp: never squeeze columns below ~220px.
return Math.max(1, Math.min(requested, Math.floor(w / 220) || 1));
}
function layout() {
const n = columnCount();
const balanced = balanceInput.checked;
board.style.gridTemplateColumns = `repeat(${n}, minmax(0, 1fr))`;
board.replaceChildren();
const cols = [];
const heights = new Array(n).fill(0);
for (let i = 0; i < n; i++) {
const c = document.createElement("div");
c.className = "board__col";
c.setAttribute("role", "list");
cols.push(c);
board.append(c);
}
order.forEach((dataIndex, pos) => {
const tile = makeTile(DATA[dataIndex], pos);
// Shortest-column-first placement needs a measured height, so tiles are
// appended provisionally then measured; without balancing we round-robin.
const target = balanced ? heights.indexOf(Math.min(...heights)) : pos % n;
cols[target].append(tile);
heights[target] += tile.offsetHeight + 14;
});
}
/* Reordering --------------------------------------------------------- */
function move(from, to) {
if (to < 0 || to >= order.length || from === to) return;
const [item] = order.splice(from, 1);
order.splice(to, 0, item);
layout();
const moved = board.querySelector(`.tile[data-index="${to}"]`);
if (moved) moved.focus();
live.textContent = `${DATA[item].title} moved to position ${to + 1} of ${order.length}.`;
}
board.addEventListener("dragstart", (e) => {
const tile = e.target.closest(".tile");
if (!tile) return;
dragFrom = Number(tile.dataset.index);
tile.classList.add("is-dragging");
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", String(dragFrom));
});
board.addEventListener("dragover", (e) => {
const tile = e.target.closest(".tile");
if (!tile || dragFrom === null) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
board.querySelectorAll(".is-over").forEach((t) => t.classList.remove("is-over"));
tile.classList.add("is-over");
});
board.addEventListener("drop", (e) => {
const tile = e.target.closest(".tile");
if (!tile || dragFrom === null) return;
e.preventDefault();
move(dragFrom, Number(tile.dataset.index));
dragFrom = null;
});
board.addEventListener("dragend", () => {
dragFrom = null;
board.querySelectorAll(".is-dragging, .is-over")
.forEach((t) => t.classList.remove("is-dragging", "is-over"));
});
board.addEventListener("keydown", (e) => {
const tile = e.target.closest(".tile");
if (!tile) return;
const i = Number(tile.dataset.index);
const n = columnCount();
const map = {
ArrowLeft: i - 1,
ArrowRight: i + 1,
ArrowUp: balanceInput.checked ? i - 1 : i - n,
ArrowDown: balanceInput.checked ? i + 1 : i + n
};
if (!(e.key in map)) return;
e.preventDefault();
move(i, Math.max(0, Math.min(order.length - 1, map[e.key])));
});
/* Controls ----------------------------------------------------------- */
colsInput.addEventListener("input", () => {
colsOut.textContent = colsInput.value;
layout();
});
balanceInput.addEventListener("change", layout);
shuffleBtn.addEventListener("click", () => {
for (let i = order.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[order[i], order[j]] = [order[j], order[i]];
}
layout();
live.textContent = "Tiles shuffled.";
});
let raf = 0;
const onResize = () => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(layout);
};
if ("ResizeObserver" in window) new ResizeObserver(onResize).observe(board);
else window.addEventListener("resize", onResize);
layout();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Masonry Bento Layout</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="demo" data-demo="Masonry Bento Layout">
<header class="demo__head">
<h1>Masonry Bento Layout</h1>
<p class="muted">
Tiles flow into balanced columns. Drag a tile to reorder it, or focus one and
press the arrow keys.
</p>
<div class="controls">
<label for="cols">Columns</label>
<input id="cols" type="range" min="1" max="4" step="1" value="3" />
<output id="colsOut" for="cols">3</output>
<label class="switch">
<input id="balance" type="checkbox" checked />
<span>Balance by height</span>
</label>
<button id="shuffle" type="button">Shuffle</button>
</div>
</header>
<section id="board" class="board" aria-label="Bento tiles"></section>
<p id="live" class="sr-only" role="status" aria-live="polite"></p>
</main>
<script src="script.js"></script>
</body>
</html>import { useState } from "react";
export function BentoMasonryLayout() {
const [items, setItems] = useState(["Alpha", "Beta", "Gamma"]);
return (
<section className="demo">
<h2>Masonry Bento Layout</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>
);
}Masonry Bento Layout
A responsive masonry column layout that avoids JavaScript measurement work.
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.