Skip to main content

ns-ui

Grid Bento ASCII

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.

Usage

registry/core/grid-bento-ascii/demo.tsx
"use client";

import { GridBentoAscii, type BentoCell } from "./component";

const CELLS: [BentoCell, BentoCell, BentoCell, BentoCell] = [
  { id: "a", title: "Realtime", description: "Sub-100ms sync across every client." },
  { id: "b", title: "Typed", description: "End-to-end types, zero codegen." },
  { id: "c", title: "Offline", description: "Local-first, reconciles on reconnect." },
  { id: "d", title: "Open", description: "MIT, self-hostable." },
];

export default function GridBentoAsciiDemo() {
  return (
    <div className="flex min-h-screen flex-col items-center justify-center gap-8 px-6 py-16">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / grid-bento-ascii
      </p>
      <GridBentoAscii cells={CELLS} />
      <p className="max-w-md text-center text-xs text-ns-muted">
        Click a tile: it re-spans across every track, seam included, and the
        ┼ junction has nowhere left to be drawn. Click again to collapse.
      </p>
    </div>
  );
}

Component

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 {
  /** the grid's four tiles */
  cells: [BentoCell, BentoCell, BentoCell, BentoCell];
  /** extra classes merged onto the rendered root element */
  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,border-color] duration-150 motion-reduce:transition-none hover:border-foreground/25 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-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-ns-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-ns-muted group-hover:text-foreground/80">
                {cell.description}
              </span>
            )}
          </button>
        );
      })}
    </div>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/grid-bento-ascii.tsx, so point the import there.

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.

Install

npx shadcn add https://design.helpmarq.com/r/grid-bento-ascii.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

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-ns-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, --ns-muted, --ns-accent, --surface), no hex.

Props

PropTypeDefaultDescription
cells[BentoCell, BentoCell, BentoCell, BentoCell]—the grid's four tiles
className?string—extra classes merged onto the rendered root element