Grid Bento ASCII

Grid

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.

Install
npx shadcn add https://design.helpmarq.com/r/grid-bento-ascii.json
Source
registry/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>
  );
}
Use when

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 spec

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.

Tags
gridbentolayoutasciimonobox-drawing