A 2x2 bento layout primitive built on its own content/seam track grid — a real vertical rule, horizontal rule and ┼ junction between the four tiles — where activating a tile re-spans it across every track, seam included, so the junction has nowhere left to be drawn.
npx shadcn add https://design.helpmarq.com /r/grid-bento-ascii.jsonregistry/core/grid-bento-ascii/component.tsx"use client";
import { useState, type ReactNode } from "react";
// ---------------------------------------------------------------------------
// GridBentoAscii — a 2x2 bento layout primitive where the seams between
// cells are real box-drawing structure, not a picture of one: the grid is
// built on its own track grid (content / seam / content, on both axes), so
// there is exactly one vertical rule, one horizontal rule and one ┼
// junction glyph, all as real elements at real grid positions. Activating a
// cell doesn't recolor anything — it re-spans that cell across every track,
// content and seam alike, so the seam literally has nowhere left to be
// drawn and disappears as a structural consequence, not an animation
// playing on its own clock. Collapsing brings the topology, and the seam,
// back. This mechanic does not exist on a single box: it needs the seam's
// topology to have something to gain or lose.
// ---------------------------------------------------------------------------
export interface BentoCell {
id: string;
title: string;
description?: string;
content?: ReactNode;
}
export interface GridBentoAsciiProps {
cells: [BentoCell, BentoCell, BentoCell, BentoCell];
className?: string;
}
export function GridBentoAscii({ cells, className = "" }: GridBentoAsciiProps) {
const [heroId, setHeroId] = useState<string | null>(null);
const positions: Record<number, { col: string; row: string }> = {
0: { col: "1 / 2", row: "1 / 2" },
1: { col: "3 / 4", row: "1 / 2" },
2: { col: "1 / 2", row: "3 / 4" },
3: { col: "3 / 4", row: "3 / 4" },
};
const heroIndex = heroId ? cells.findIndex((c) => c.id === heroId) : -1;
const expanded = heroIndex !== -1;
return (
<div
data-grid-bento-ascii
className={`relative grid aspect-square w-full max-w-md gap-0 font-mono ${className}`}
style={{
gridTemplateColumns: "1fr 1.4em 1fr",
gridTemplateRows: "1fr 1.4em 1fr",
}}
>
{/* seams: real elements at real grid tracks, only rendered while every
cell is at its own resting position */}
{!expanded && (
<>
<div
aria-hidden
className="pointer-events-none bg-border transition-opacity duration-150 motion-reduce:transition-none"
style={{ gridColumn: "2 / 3", gridRow: "1 / 4", width: 1, justifySelf: "center" }}
/>
<div
aria-hidden
className="pointer-events-none bg-border transition-opacity duration-150 motion-reduce:transition-none"
style={{ gridColumn: "1 / 4", gridRow: "2 / 3", height: 1, alignSelf: "center" }}
/>
<span
aria-hidden
className="pointer-events-none grid select-none place-items-center text-border transition-opacity duration-150 motion-reduce:transition-none"
style={{ gridColumn: "2 / 3", gridRow: "2 / 3" }}
>
+
</span>
</>
)}
{cells.map((cell, i) => {
const isHero = heroId === cell.id;
const hiddenByHero = expanded && !isHero;
const style = isHero && expanded ? { col: "1 / 4", row: "1 / 4" } : positions[i]!;
return (
<button
key={cell.id}
type="button"
data-cell={cell.id}
aria-pressed={isHero}
aria-label={isHero ? `Collapse ${cell.title}` : `Expand ${cell.title}`}
aria-hidden={hiddenByHero || undefined}
tabIndex={hiddenByHero ? -1 : 0}
onClick={() => setHeroId(isHero ? null : cell.id)}
className={`group relative flex flex-col items-start justify-end overflow-hidden rounded-sm border border-border bg-surface p-3 text-left transition-[opacity] duration-150 motion-reduce:transition-none hover:border-foreground/25 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent ${
hiddenByHero ? "pointer-events-none opacity-0" : "opacity-100"
}`}
style={{ gridColumn: style.col, gridRow: style.row }}
>
{isHero && expanded && (
<span
aria-hidden
data-hero-badge
className="absolute right-2 top-2 select-none rounded-sm border border-border bg-background px-2 py-1 text-[10px] uppercase tracking-wider text-muted"
>
click to collapse
</span>
)}
{cell.content}
<span className="text-sm font-semibold text-foreground">{cell.title}</span>
{cell.description && (
<span className="mt-1 text-xs text-muted group-hover:text-foreground/80">
{cell.description}
</span>
)}
</button>
);
})}
</div>
);
}
a 2x2 bento layout primitive whose seam structure is real (a track grid with its own rule/junction elements) rather than painted borders, and where activating a tile is a genuine re-span of the grid topology rather than an overlay. Pick container-box-drawing instead for a single panel whose own perimeter upgrades to a double-line border on hover/focus — that component has no second cell and nothing to re-span.
Build <GridBentoAscii cells className?> where cells is exactly a 4-tuple of BentoCell ({id, title, description?, content?}). STRUCTURE: a CSS grid with an explicit 3x3 track template — gridTemplateColumns/Rows of `1fr 1.4em 1fr` — so tracks 1 and 3 hold content and track 2 on each axis is a dedicated seam track, not a gap. At rest (no tile activated) the four tiles occupy the four content x content corners (col/row `1/2` and `3/4` in every combination) and three real seam elements are rendered at the seam tracks: a 1px-wide vertical bar (bg-border) spanning the full grid height at column `2/3`, a 1px-tall horizontal bar spanning the full grid width at row `2/3`, and a `┼` box-drawing glyph (text-border) at their crossing, column `2/3` row `2/3` — three genuine elements at real grid positions, not a painted picture of a cross. THE MECHANIC — the one that needs more than one cell and a real track topology to exist: each tile is a real <button data-cell={id} aria-pressed aria-label> covering its own content; clicking a tile that isn't the current hero sets it as hero, re-spanning ONLY that button's own gridColumn/gridRow to `1/4` on both axes — across every track, seam tracks included — while the seam elements and the other three tiles unmount their visible presence (opacity-0, pointer-events-none, aria-hidden, tabIndex=-1, kept mounted rather than removed so focus/state isn't lost). Because the hero's own grid area now covers the seam tracks, the seam elements are conditionally not rendered at all while any tile is expanded — the junction doesn't fade out, it structurally has nowhere left to be drawn, which is the whole point: this could not exist on a single box, since there would be no second cell's boundary to lose. Clicking the hero tile again (aria-label flips to 'Collapse {title}', a small aria-hidden 'click to collapse' badge appears in its corner) sets hero back to null, restoring all four positions and all three seam elements in the same render. ACCESSIBILITY: every tile is a real <button> (not a div with an onClick) so Tab/Enter/Space work natively with no keydown handler required; aria-pressed tracks whether that specific tile is the current hero; aria-label always names the action ('Expand {title}' / 'Collapse {title}'), never just the state; hover (border-foreground/25) and focus-visible (outline-2 outline-offset-2 outline-accent, no outline-none on the same element) are visually distinct from rest; the three hidden-while-expanded tiles are excluded from the tab order via tabIndex=-1 so Tab doesn't land on invisible controls, and aria-hidden keeps them out of the accessibility tree while a tile is expanded. Reduced motion drops the small opacity transition on tile visibility to an instant change. Colors are token-only (--border, --foreground, --muted, --accent, --surface), no hex.