LIBRARY DOCS · v0.2.17

Tetris UI docs

Sectioned guides for Block, ShapeFrame, TetrisProvider, viewports, sizeAt / shapeAt / layoutAt media mutations, size="auto", accentPlacement, hover overlays, entrance animations, every tetromino — plus a live playground and API tables.

Getting started

Overview

Tetris UI is a spatial component library. Author cards with surface, radius, size, and priority. Pack them with TetrisProvider across desktop, tablet, and mobile tracks — mutate footprints with sizeAt and silhouettes with shapeAt, reflow ShapeFrame cells with layoutAt, fill leftover columns with size="auto", add entrance motion with animation, or compose literal tetrominos with ShapeFrame.

Getting started

Install

Package

terminal
npm install @stack_layout/tetris-ui

Quick start

App.tsx
import { Block, ShapeFrame, TetrisProvider } from "@stack_layout/tetris-ui";
import "@stack_layout/tetris-ui/styles.css";
<TetrisProvider viewport="auto" gap={20} stretch>
<Block
title="Revenue"
priority={95}
size="enlarge"
sizeAt={{ tablet: "md", mobile: "sm" }}
accent="#0F766E"
accentPlacement="left"
hover={{
background: "rgba(15, 118, 110, 0.2)",
content: <span>Open report</span>,
}}
minHeight={160}
maxHeight={280}
scroll
>
$128k
</Block>
<Block title="Feed" priority={70} size="auto" accent="none" />
</TetrisProvider>
// ShapeFrame — silhouette on desktop · stack cards on mobile
<ShapeFrame
shape="T"
layout="silhouette"
layoutAt={{ mobile: "stack", tablet: "carousel" }}
voids="invisible"
glass
backdrop={false}
accent="none"
>
<span>A</span>
<span>B</span>
<span>C</span>
<span>D</span>
</ShapeFrame>

Output

Frosted card

No shape glyph · no P chip · hover for actions

Extra lines to demonstrate maxHeight + scroll on the card.

Line three · line four · line five.

Open report

Component

Block

Content module with optional tetromino shape / shapeAt. Prefer Tile for freeform boards that do not need shape glyphs.

Freeform Tile · accent + hover (no shape)

Tile.tsx
<Tile
title="Revenue"
size="enlarge"
sizeAt={{ tablet: "md", mobile: "sm" }}
priority={95}
accent="#0F766E"
accentPlacement="left"
hover={{
background: "rgba(15, 118, 110, 0.2)",
content: <span>Open</span>,
}}
>
$128k
</Tile>

Output

Revenue

$128k

Hover · left hairline · title optional

Open

Component

ShapeFrame

Places exactly four children into a tetromino silhouette — or reflow them with layout / layoutAt (stack / carousel). Empty bounding cells stay void in silhouette mode — control them with voids.

L silhouette · stack on mobile

ShapeFrame.tsx
<ShapeFrame
shape="L"
layout="silhouette"
layoutAt={{ mobile: "stack", tablet: "carousel" }}
voids="invisible"
glass
border="none"
gap={8}
size="sm"
accent="none"
>
<span>A</span>
<span>B</span>
<span>C</span>
<span>Foot</span>
</ShapeFrame>

Output

Component

TetrisProvider

Priority packer for freeform dashboards. Prefer Tile children (no shape API). Reads size / priority and assigns CSS grid placement. Use Block when you need shape glyphs.

Provider · Tile + auto fill + stretch

Board.tsx
<TetrisProvider viewport="desktop" gap={16} stretch>
<Tile title="Hero" size="enlarge" priority={95} accent="#0F766E" accentPlacement="left" />
<Tile size="auto" priority={70} accent="none">Feed</Tile>
</TetrisProvider>

Output

Hero

Packs first

auto fills leftover · no title

Spatial engine

Rules, not coordinates

Tetris UI never asks “put this at column 3, row 2.” You declare who matters, how big it wants to be, and what shape it is. The packer finds free cells, stacks neighbors, and reflows when the viewport or the set of blocks changes.

priority · 0–100

Priority decides who packs first

Think of priority as boarding order for a plane. P95 boards before P25. The engine sorts blocks descending and places them one by one into the first free footprint that fits.

90–100

Mission-critical

Revenue, checkout, alerts

70–89

Primary UI

Feeds, banners, main nav

40–69

Supporting

Tasks, team, messages

0–39

Ambient

Weather, tips, décor

Higher priority packs first

PriorityBoard.tsx
import { Block, TetrisProvider } from "@stack_layout/tetris-ui";
export function PriorityBoard() {
return (
<TetrisProvider viewport="desktop" stretch gap={16}>
<Block title="Revenue" priority={95} size="enlarge" showPriority accent="#0F766E" accentPlacement="left" />
<Block title="Banner" priority={88} size="full" showPriority accent="#0369A1" />
<Block title="Team feed" priority={70} size="md" showPriority accent="#334155" />
<Block title="Weather" priority={25} size="sm" showPriority accent="none" />
</TetrisProvider>
);
}

Output

Revenue

P95

Must stay visible. Packs first — claims the best top-left real estate.

size enlarge

Banner

P88

Wide strip. High priority so it still lands early on a full row.

size full

Team feed

P70

Important, but yields to Revenue. Fills the next open gap.

size md

Weather

P25

Nice-to-have. On mobile it often drops lower or squeezes last.

size sm

Toggle viewport above — Revenue still claims space first; Weather yields first. That is priority reclaim in action. Use stretch so cards that share a row match the tallest content height, and gap for spacing between them. Without stretch, each card height fits its own content.

cols · size · height

Size sets width; height fits content

The packer sees a CSS grid of columns (12 desktop / 8 tablet / 4 mobile). Each block asks for a column span — size tokens are shorthand for that width. Card height grows with its content unless you set minHeight / maxHeight.

sizecolumn spanmin colsFeels like
sm22Chip / ambient widget
md32Default module
lg43Rich content panel
enlarge64Hero metric or chart
full124Full-width banner strip
auto01Fills leftover columns · even split with peer autos

Size maps to column span; height is content

size-tokens.ts
// size → preferred column span (height fits content)
size = "sm"; // 2 cols — chip, weather, badge cluster
size = "md"; // 3 cols — default card
size = "lg"; // 4 cols — rich panel
size = "enlarge"; // 6 cols — hero metric / chart
size = "full"; // 12 cols — banner across the track
size = "auto"; // fills leftover columns (even split when several are auto)
// Height bounds are optional
<Block minHeight={160} maxHeight={320} scroll></Block>
// Fill the row: fixed + flexible
<Block size="sm" />
<Block size="md" />
<Block size="auto" /> // takes remaining cols on the track
// Conceptual packer input
const preferred = { cols: 4, rows: 1 };
const min = { cols: 2, rows: 1 };
const max = { cols: 6, rows: 1 };

viewport="desktop"

12 columns

viewport="tablet"

8 columns

viewport="mobile"

4 columns

Full interactive demos for each track → Viewports.

min · max · resolve

Min and max let blocks breathe — then compress

Preferred is the dream column span. Min is the smallest usable width. Max caps greed. When the track is narrow, the engine clamps columns: cols = clamp(preferred, min…max) ∩ viewport. Height still fits content unless you set minHeight / maxHeight on the Block.

preferred

Ideal column span when space is plentiful. Mapped from size tokens in the library.

min

Never narrower than this. Protects readability — a chart that can’t go below 2 cols.

max

Never wider than this. Stops a low-priority widget from swallowing the board.

Clamp preferred into the viewport track

resolveSize.ts
function resolveSize(preferred, min, max, columns) {
const cols = Math.min(
columns,
Math.max(min.cols, Math.min(preferred.cols, max.cols)),
);
const rows = Math.max(min.rows, Math.min(preferred.rows, max.rows));
return { cols, rows };
}
// Example: enlarge on mobile (4 cols)
// preferred 6 cols → clamped to 4 cols (fits the track, respects min)

Live clamp across viewports

FootprintDemo.tsx
import { Block, TetrisProvider } from "@stack_layout/tetris-ui";
export function FootprintDemo() {
return (
<TetrisProvider viewport="desktop">
<Block title="Hero metric" priority={94} size="enlarge" accent="#0F766E" accentPlacement="left" />
<Block title="Side note" priority={40} size="sm" accent="#64748B" />
<Block title="Full banner" priority={80} size="full" accent="#1D4E4A" accentPlacement="bottom" />
</TetrisProvider>
);
}

Output

Hero metric

$128k

Wants 6 cols — shrinks on tablet/mobile without going below min.

Side note

Already near min — little room to shrink, so it reflows downward first.

Full banner

Prefers the full track width; on mobile it still spans all 4 columns.

shape · I O T L S J Z

Shape is grammar — not decoration

Shape tells humans (and your product language) what kind of module this is. Inside TetrisProvider it also tags the block for docs, glyphs, and ShapeFrame silhouettes. Pick the silhouette that matches the relationship between parts.

I

Banners, steppers, timelines

One long run — sequential story.

O

KPIs, avatars, stats

Balanced square — equal weight.

T

Nav + feature stage

Crown of three, stem of focus.

L

Feed + context foot

Stack with a detail footing.

S

Offset chart / media

Staggered pairs — never boring.

J

Rail + nested panel

Sidebar with a leftward base.

Z

Alerts, status strips

Mirror stagger — urgency grammar.

Shape as meaning

ShapeGrammar.tsx
import { Block, ShapeFrame } from "@stack_layout/tetris-ui";
// Shape as meaning on packed modules
<Block shape="O" size="enlarge" priority={95} title="Revenue" />
<Block shape="I" size="full" priority={88} title="Onboarding" />
<Block shape="L" size="md" priority={70} title="Activity" />
// Literal tetromino — stack cards on mobile, carousel on tablet
<ShapeFrame
shape="T"
layout="silhouette"
layoutAt={{ mobile: "stack", tablet: "carousel" }}
size="md"
voids="invisible"
>
<NavItem />
<NavItem />
<NavItem />
<FeaturePanel />
</ShapeFrame>

Output

Use ShapeFrame when the internal layout should read as a tetromino. Use shape on Block when the packer footprint is rectangular but the module still speaks that grammar. Swap silhouettes per track with shapeAt, or reflow cells into stacked / carousel cards with layoutAt.

freeform · auto fill

How to get freeform that fills the row

Declare rules — never gridColumn / gridRow. Prefer Tile inside TetrisProvider. The freeform lab is a full sample dashboard (KPIs, chart, avatars, activity, image cards, tasks). glass="regular" is opaque; frosted/transparent are glass. Scroll moves body content only. accentPlacement, hover, titles, and glass are toggleable.

  1. 01 · Wrap in TetrisProvider

    Pass gap (and optional stretch). The packer writes placement for you.

  2. 02 · Mix fixed + auto sizes

    sm / md / enlarge claim fixed spans. auto fills what is left — or even-splits when every card in the band is auto.

  3. 03 · Priority orders the pack

    Give metric autos a higher priority than the hero so the top row packs first, then enlarge + auto on the next band.

  4. 04 · Stretch vs custom height

    stretch = shared row height from content. Or set minHeight / maxHeight per Tile for explicit bounds.

Live on /system under Freeform stretch and Freeform auto + height — same board as below.

Viewport

accentPlacement

border

radius

glass

hover type

Hover background

Sample ops dashboard — metrics, chart, avatars, activity, image cards, and tasks on Tile. border="none" + accent = accent-only edges; glass="frosted" uses real backdrop blur.

Patients

vs last week

2,739

+4.2%

Occupancy

vs last week

87%

+1.1%

Revenue

vs last week

$1.28M

+8.4%

Admissions pulse

7-day rolling · live

MTWTFSS
AdmitsDischargesPeak · Fri 84

Activity

Network feed

  • 2m

    Census sync completed for Ward B

    sync
  • 18m

    Priya shared Q3 occupancy forecast

    share
  • 1h

    New transfer request · ICU → Step-down

    alert
  • 3h

    Staffing model recalibrated (+2 nights)

    ops
  • Yesterday

    East wing imaging suite went live

    launch

On duty

Clinical leadership

  • Amara Okafor

    Chief of Staff

  • Jonas Berg

    Ops Lead

  • Priya Shah

    Analytics

  • Leo Mensah

    Floor Charge

East Wing

Imaging · 12 bays

Imaging · 12 bays

Open tasks

Today

  • Approve weekend roster
  • Review pharmacy stockout
  • Sign imaging SLA
  • Publish bed board brief

River Clinic

Outpatient · live

Outpatient · live

Alerts

Needs attention

2 pharmacy SKUs below par

OR-3 delayed 25m

Channels

Broadcast

#floor#pharmacy#icu#leadership#imaging

Pin a channel to the bed board — leftover width from size="auto".

size=auto fills leftover · stretch equalizes row height

FreeformStretch.tsx
import { Tile, TetrisProvider } from "@stack_layout/tetris-ui";
import "@stack_layout/tetris-ui/styles.css";
<TetrisProvider viewport="desktop" gap={16} stretch>
<Tile title="Patients" size="auto" priority={96} glass="frosted" accent={"#0F766E"} accentPlacement="top">
{/* KPI + sparkline */}
</Tile>
<Tile title="Occupancy" size="auto" priority={95} glass="frosted" accent={"#0369A1"} accentPlacement="left" />
<Tile title="Revenue" size="auto" priority={94} glass="frosted" accent={"#7C3AED"} accentPlacement="all" />
<Tile title="Admissions pulse" size="enlarge" priority={88} glass="frosted" accent={"#0F766E"}>
{/* chart */}
</Tile>
<Tile title="Activity" size="auto" priority={87} accent={"#B45309"} accentPlacement="left">
{/* feed list */}
</Tile>
<Tile title="On duty" size="md" priority={80}>{/* avatars */}</Tile>
<Tile title="East Wing" size="auto" priority={78}>{/* image card */}</Tile>
<Tile title="Open tasks" size="auto" priority={76}>{/* checklist */}</Tile>
</TetrisProvider>
size="auto" expands into free columns. stretch equalizes heights in a row; omit it and set minHeight / maxHeight when you want explicit card heights.

TetrisProvider · viewport

Viewports

viewport sets the track width the packer optimizes for. Same blocks, different column counts — low-priority modules yield first as the board narrows. Use sizeAt on each Block to shrink (or grow) footprints per track, shapeAt to mutate silhouettes (media mutation), and viewport="auto" if you want the library to follow the window width.

Active track: 12 cols · desktop

Same blocks, three tracks

ViewportBoard.tsx
import { Block, TetrisProvider } from "@stack_layout/tetris-ui";
export function ViewportBoard() {
return (
<TetrisProvider viewport="desktop" gap={16} stretch>
<Block
title="Revenue"
priority={95}
size="enlarge"
sizeAt={{ tablet: "md", mobile: "sm" }}
shape="O"
/>
<Block title="Feed" priority={70} size="md" shape="L" />
<Block title="Alerts" priority={80} size="sm" shape="Z" />
<Block title="Weather" priority={25} size="sm" shape="S" />
</TetrisProvider>
);
}

Output

Revenue

$128k

enlarge → enlarge

Feed

Yields to Revenue on narrow tracks

Alerts

High urgency

Weather

Ambient · drops first on mobile

viewportcolumnsTypical use
desktop12Full dashboards, side-by-side modules
tablet8Condensed boards, fewer columns per row
mobile4Stack-first; low priority reclaim space
autofollow windowmatchMedia: ≤767 mobile · ≤1023 tablet · else desktop

sizeAt — responsive footprints

Keep a base size and override per track. Omitted keys fall back to size. The packer uses the resolved size (not CSS alone), so grid span actually changes. Affects Block inside TetrisProvider.

enlarge on desktop · md on tablet · sm on mobile

ResponsiveCard.tsx
<TetrisProvider viewport="desktop" gap={16} stretch>
<Block
title="Hero metric"
size="enlarge"
sizeAt={{ tablet: "md", mobile: "sm" }}
priority={95}
/>
<Block title="Sidebar" size="md" priority={60} />
</TetrisProvider>
// Or let the window pick the track:
<TetrisProvider viewport="auto" breakpoints={{ mobileMax: 767, tabletMax: 1023 }}>
</TetrisProvider>

Output

Hero metric

Active size: enlarge

Sidebar

No sizeAt · always md

Block · ShapeFrame · media

shapeAt — media mutation

Swap silhouettes per track the same way sizeAt swaps footprints. Keep a base shape; override with shapeAt. Omitted keys fall back to the base. Use it when desktop wants an S stagger and mobile should collapse to a solid O.

Active track: desktop · resolved shape: S

S on desktop · T on tablet · O on mobile

ResponsiveShape.tsx
import { Block, ShapeFrame, TetrisProvider } from "@stack_layout/tetris-ui";
// Block — glyph + data-shape follow the packer track
<TetrisProvider viewport="desktop" gap={16}>
<Block
title="Hero"
shape="S"
shapeAt={{ tablet: "T", mobile: "O" }}
size="enlarge"
sizeAt={{ mobile: "md" }}
priority={90}
/>
</TetrisProvider>
// ShapeFrame — silhouette mutates; combine with layoutAt for stack / carousel
<ShapeFrame
shape="S"
shapeAt={{ tablet: "T", mobile: "O" }}
layout="silhouette"
layoutAt={{ mobile: "stack" }}
viewport="desktop"
voids="invisible"
size="sm"
>
<span>A</span>
<span>B</span>
<span>C</span>
<span>D</span>
</ShapeFrame>
// Window-driven (no manual viewport chips):
<TetrisProvider viewport="auto">
<Block shape="S" shapeAt={{ mobile: "O" }} />
</TetrisProvider>

Output

Hero

Base S · active S

Fixed O

No shapeAt · always O

ShapeFrame · cells A–D

Block

Resolves inside TetrisProvider — updates the glyph, footer meta, and data-shape. Packing footprint still comes from size / sizeAt.

ShapeFrame

Rebuilds the tetromino grid. Nested under a provider, it inherits the active track; standalone, pass viewport (defaults to "auto" when shapeAt is set).

ShapeFrame · responsive flow

layoutAt — stack & carousel

Keep the tetromino silhouette on desktop, then choose how cells reflow on smaller screens. layout sets the base mode; layoutAt overrides per track (same pattern as sizeAt / shapeAt). Cells always follow child index order 0→3 — voids are skipped in stack and carousel.

Resolved layout: silhouette · track desktop

silhouette

Literal tetromino grid. Voids respected. Best for desktop dashboards that teach the shape.

stack

One full-width column of cards. Ideal for phones — each cell reads as a stacked row card.

carousel

Horizontal scroll-snap. Swipe between cells. Use carouselSnap="page" (full slide) or "cell" (peek).

T silhouette · stack on mobile · carousel on tablet

ResponsiveLayout.tsx
import { ShapeFrame } from "@stack_layout/tetris-ui";
<ShapeFrame
shape="T"
layout="silhouette"
layoutAt={{ mobile: "stack", tablet: "carousel" }}
viewport="desktop"
voids="invisible"
size="sm"
gap={12}
carouselSnap="page"
>
<span>Nav 1</span> {/* index 0 */}
<span>Nav 2</span> {/* index 1 */}
<span>Nav 3</span> {/* index 2 */}
<span>Feature</span>{/* index 3 T stem */}
</ShapeFrame>
// Window-driven (no chips):
<ShapeFrame
shape="T"
layout="silhouette"
layoutAt={{ mobile: "stack", tablet: "carousel" }}
voids="invisible"
>
</ShapeFrame>

Output

ShapeFrame · T · silhouette

Index order

Stack and carousel ignore grid coordinates — they flatten cells in child order 0→3. For a T, that is crown-left, crown-center, crown-right, then stem. Reorder children if you want a different reading order on mobile.

With shapeAt

You can combine both: keep layoutAt for flow and shapeAt if you also need a different silhouette before stacking. Most UIs only need layoutAt.

Block · ShapeFrame

Card chrome props

Style freeform cards with glass, color, accent + accentPlacement, and optional hover overlays. Shape glyphs and priority chips stay hidden unless you set showShape / showPriority.

Color · glass white by default

color tints the solid or glass fill. Default is rgba(255, 255, 255, 0.72). Pair with glass (default true) for the frosted look.

Default glass white · custom tint

ColoredCard.tsx
<Block title="Default" /> {/* glass + glass white */}
<Block title="Sky" color="rgba(14, 165, 233, 0.22)" />
<Block title="Solid" glass={false} color="#ffffff" />
<ShapeFrame shape="O" color="rgba(197, 230, 58, 0.28)">
<span>A</span><span>B</span><span>C</span><span>D</span>
</ShapeFrame>

Output

Default

glass white

Sky

tinted glass

Solid

opaque white

Animation · entrance motion

Pass animation on Block or ShapeFrame. puzzle-entrance and lego-drop-in stagger per ShapeFrame cell. Respects prefers-reduced-motion.

Try every animation on cards & shapes

Glass + backdrop

glass makes the fill translucent. backdrop (default true) controls blur. Affects Block and ShapeFrame.

Glass with optional blur

GlassCard.tsx
<Block glass backdrop={true} border="all" title="Frost">
Translucent + blur
</Block>
<ShapeFrame shape="O" glass backdrop={true} size="sm">
<span>A</span><span>B</span><span>C</span><span>D</span>
</ShapeFrame>

Output

Frost

Glass + backdrop blur

Accent · placement

accent sets the color (or "none"). accentPlacement chooses which sides get the classic hairline — drawn as a radius-aware ring so rounded corners stay continuous: top, right, bottom, left, all (full round), or none. Title / subtitle stay optional.

Accent color + side

AccentCard.tsx
<Block
title="With accent"
accent="#0F766E"
accentPlacement="top"
/>
<Block title="Clean card" accent="none" />

Output

Card A

Edge · top

Card B

Always accent="none"

Hover overlay

Opt-in with hover. Pass true for a subtle wash, or an object with background and custom content.

Custom hover content

HoverCard.tsx
<Block
title="Patients"
size="auto"
accent="#0F766E"
accentPlacement="left"
hover={{
background: "rgba(15, 118, 110, 0.22)",
content: <button type="button">Open chart</button>,
}}
>
2,739
</Block>

Output

Patients

2,739

Hover this card

Open chart

minHeight · maxHeight · scroll

Optional height bounds (number → px, or any CSS length) and overflow scrolling. Omit both to let the card height fit its content. Affects Block.

Bounded height + scroll

ScrollCard.tsx
<Block
title="Notes"
minHeight={160}
maxHeight={200}
scroll
accent="none"
>
{/* long body */}
</Block>

Output

Notes

  • Line 1 — scroll the card when content exceeds maxHeight.
  • Line 2 — scroll the card when content exceeds maxHeight.
  • Line 3 — scroll the card when content exceeds maxHeight.
  • Line 4 — scroll the card when content exceeds maxHeight.
  • Line 5 — scroll the card when content exceeds maxHeight.
  • Line 6 — scroll the card when content exceeds maxHeight.
  • Line 7 — scroll the card when content exceeds maxHeight.
  • Line 8 — scroll the card when content exceeds maxHeight.
  • Line 9 — scroll the card when content exceeds maxHeight.
  • Line 10 — scroll the card when content exceeds maxHeight.
  • Line 11 — scroll the card when content exceeds maxHeight.
  • Line 12 — scroll the card when content exceeds maxHeight.

ShapeFrame · gap

Space between silhouette cells (px). Affects ShapeFrame only — board spacing uses TetrisProvider gap.

Cell gap inside the silhouette

ShapeGap.tsx
<ShapeFrame
shape="T"
layout="silhouette"
layoutAt={{ mobile: "stack" }}
gap={6}
voids="invisible"
size="sm"
>
<span>A</span><span>B</span><span>C</span><span>D</span>
</ShapeFrame>

Output

className · style

Merge your own classes and inline styles on Block, ShapeFrame, and TetrisProvider.

Bring your own styles

CustomCard.tsx
<Block
className="ring-1 ring-black/5"
style={{ boxShadow: "0 8px 24px rgba(0,0,0,0.06)" }}
title="Custom"
/>
<TetrisProvider className="my-board" gap={20}>
</TetrisProvider>

Output

Custom

className + style on the card shell

Try it · Block · ShapeFrame

Animations

Entrance motion for cards and tetromino silhouettes. Pick a type, watch the live Block and ShapeFrame, then copy the prop. puzzle-entrance and lego-drop-in stagger per shape cell.

Animation type

fade-upRise into place

Block · card

Studio card

animation="fade-up"

Cards animate as one shell. Use this for packed modules and stand-alone content blocks.

ShapeFrame · silhouette

Puzzle and lego stagger cells A→D. Other types animate the whole frame.

Copy the prop

AnimatedUi.tsx
import { Block, ShapeFrame } from "@stack_layout/tetris-ui";
<Block
title="Studio card"
animation="fade-up"
accent="#0F766E"
>
Content
</Block>
<ShapeFrame
shape="T"
layout="silhouette"
layoutAt={{ mobile: "stack" }}
animation="fade-up"
voids="invisible"
>
<span>A</span>
<span>B</span>
<span>C</span>
<span>D</span>
</ShapeFrame>

Interactive

Variant playground

Toggle glass, accent placement, hover overlay, layout, and chrome flags. Freeform defaults hide shape glyphs and priority chips — turn them on here if you need them.

Studio block

3 cols · accent left

Freeform chrome: no glyph / P chip unless toggled. Hover is opt-in.

Custom hover

Fill

accentPlacement

Color

Animation

Border

Sides

Radius

Size

Shape

ShapeFrame layout

Empty cells (voids)

Priority · 72

minHeight · off

maxHeight · off

Scroll

ShapeFrame · L · layout: silhouette · voids: visible

Shape primitives

Every shape, in detail

Shapes are a visual grammar. Use Block shape to tag meaning on a packed module, or ShapeFrame when four children must occupy the literal tetromino silhouette — voids and all. Each example shows IDE-style source plus a live output.

One straight run — sequence over hierarchy

The I is the simplest sentence in the grammar: four units in a line. Use it when the user should move left→right through equal steps, metrics, or filters. Nothing branches. Nothing nests. Continuity is the product.

Anatomy

Bounding box 4×1. All four cells are solid — no voids. In a ShapeFrame, children map 1→2→3→4 along the bar. As a packed Block with size="full", it stretches across the track like a banner.

Reading order

Left → right (or top → bottom if you rotate the mental model vertically).

Packing tip

Pair with size="full" and priority 80–95 for headers and onboarding rails. On mobile the footprint still wants the full 4-column track.

Voids

No empty cells — every slot in the box is solid.

Cell roles (ShapeFrame children 1–4)

  1. 01Step / KPI 1
  2. 02Step / KPI 2
  3. 03Step / KPI 3
  4. 04Step / KPI 4

Best for

  • +Timelines and process steppers
  • +KPI / metric strips
  • +Full-width banners and page headers
  • +Table toolbars and filter bars

Avoid

  • Deep hierarchical content
  • Anything that needs a foot, rail, or sidebar
  • Equal-weight 2×2 clusters (use O)

Code → output

Packed banner Block

OnboardingBanner.tsx
import { Block, TetrisProvider } from "@stack_layout/tetris-ui";
export function OnboardingBanner() {
return (
<TetrisProvider viewport="desktop">
<Block
shape="I"
size="full"
priority={90}
title="Onboarding"
accent="#0F766E"
>
Discover Compose Assemble Ship
</Block>
</TetrisProvider>
);
}

Output

Onboarding

Discover → Compose → Assemble → Ship

Literal four-step ShapeFrame

ProcessSteps.tsx
import { ShapeFrame } from "@stack_layout/tetris-ui";
export function ProcessSteps() {
return (
<ShapeFrame shape="I" size="lg" surface="bordered" accent="#0F766E">
<Step n={1} label="Discover" />
<Step n={2} label="Compose" />
<Step n={3} label="Assemble" />
<Step n={4} label="Ship" />
</ShapeFrame>
);
}

Output

Glass toolbar strip

TableToolbar.tsx
import { ShapeFrame } from "@stack_layout/tetris-ui";
export function TableToolbar() {
return (
<ShapeFrame
shape="I"
size="md"
glass
border="bottom"
voids="invisible"
>
<Filter label="Status" />
<Filter label="Owner" />
<Filter label="Range" />
<Filter label="Export" />
</ShapeFrame>
);
}

Output

Perfect balance — four equal weights

The O is a square of trust. No cell outranks another. Use it for metric clusters, avatar grids, and scoreboards where hierarchy would lie. If one number must dominate, bump its Block priority — don’t break the O.

Anatomy

Bounding box 2×2. Four solid cells, no voids. ShapeFrame children fill TL → TR → BL → BR. As a single Block (size enlarge/md), the O reads as one solid module — still “square energy.”

Reading order

Clockwise from top-left, or scan as a 2×2 dashboard tile.

Packing tip

Default hero for Revenue-style modules: shape="O" size="enlarge" priority={95}. On narrow viewports it clamps toward min 2×2 and stays readable.

Voids

No empty cells — every slot in the box is solid.

Cell roles (ShapeFrame children 1–4)

  1. 01Top-left metric
  2. 02Top-right metric
  3. 03Bottom-left metric
  4. 04Bottom-right metric

Best for

  • +Metric clusters (MRR, NRR, MAU, NPS)
  • +Profile / avatar cards
  • +Stat tiles and scoreboards
  • +Compact dashboard islands

Avoid

  • Long-form reading
  • Asymmetric media layouts (use S / Z)
  • Nav-above-content patterns (use T)

Code → output

Hero metric Block

RevenueHero.tsx
import { Block } from "@stack_layout/tetris-ui";
export function RevenueHero() {
return (
<Block
shape="O"
size="enlarge"
priority={95}
surface="glass"
border="all"
title="Revenue"
accent="#B45309"
>
<p className="text-4xl">$128.4k</p>
<p>+12.4% vs last month</p>
</Block>
);
}

Output

Revenue

$128.4k

+12.4% vs last month

2×2 scoreboard Frame

Scoreboard.tsx
import { ShapeFrame } from "@stack_layout/tetris-ui";
export function Scoreboard() {
return (
<ShapeFrame shape="O" size="md" surface="bordered" accent="#B45309">
<Stat label="MRR" value="$42k" />
<Stat label="NRR" value="118%" />
<Stat label="MAU" value="12.4k" />
<Stat label="NPS" value="72" />
</ShapeFrame>
);
}

Output

Avatar cluster

TeamAvatars.tsx
import { ShapeFrame } from "@stack_layout/tetris-ui";
export function TeamAvatars() {
return (
<ShapeFrame shape="O" size="sm" glass border="none">
<Avatar name="Maya" />
<Avatar name="Jon" />
<Avatar name="Ava" />
<Avatar name="Rae" />
</ShapeFrame>
);
}

Output

Crown of three, stem of focus

The T is how you say “these options crown one stage.” Three peers across the top; the stem below is always the thing that matters — feature panel, canvas, or primary content. If every child is equal, you wanted an O.

Anatomy

Bounding box 3×2 with one void under the left and right crown cells. Occupied: (0,0) (1,0) (2,0) (1,1). Pass voids="invisible" so empty cells disappear, or "ghost" to hint the silhouette.

Reading order

Scan the crown left→right, then drop into the stem — the primary panel.

Packing tip

Great as ShapeFrame for composed UI. As a packed Block, shape="T" tags the grammar while size="md"/"lg" sets the rectangular footprint the packer places.

Voids

T / L / S / J / Z have empty cells in the bounding box. Control them with voids="visible" | "ghost" | "invisible".

Cell roles (ShapeFrame children 1–4)

  1. 01Crown left (nav)
  2. 02Crown center (nav)
  3. 03Crown right (nav)
  4. 04Stem (feature stage)

Best for

  • +Navigation crowning a feature stage
  • +Tab row + primary panel
  • +Hero feature with supporting lanes

Avoid

  • Flat lists with no primary focus
  • Equal-weight grids (use O)
  • Sidebar-first layouts (use J or L)

Code → output

Nav crown + feature stem

WorkspaceStage.tsx
import { ShapeFrame } from "@stack_layout/tetris-ui";
export function WorkspaceStage() {
return (
<ShapeFrame
shape="T"
layout="silhouette"
layoutAt={{ mobile: "stack", tablet: "carousel" }}
size="lg"
voids="invisible"
glass
border="all"
accent="#0E7490"
>
<NavItem label="Overview" />
<NavItem label="Analytics" active />
<NavItem label="Team" />
<FeatureStage title="Live conversion" />
</ShapeFrame>
);
}

Output

Packed module tagged as T

WorkspaceBlock.tsx
import { Block, TetrisProvider } from "@stack_layout/tetris-ui";
export function WorkspaceBlock() {
return (
<TetrisProvider>
<Block
shape="T"
size="md"
priority={75}
title="Workspace"
subtitle="Nav + stage grammar"
accent="#0E7490"
>
Primary panel sits under the crown.
</Block>
</TetrisProvider>
);
}

Output

Workspace

Nav + stage grammar

Primary panel sits under the crown.

Ghost voids — teach the silhouette

TSilhouette.tsx
import { ShapeFrame } from "@stack_layout/tetris-ui";
export function TSilhouette() {
return (
<ShapeFrame shape="T" voids="ghost" size="md" surface="flat">
<span>A</span>
<span>B</span>
<span>C</span>
<span>Stage</span>
</ShapeFrame>
);
}

Output

Stack down, then open sideways

The L is a vertical story with a door at the bottom. Feed, history, or tools stack in the column; the foot opens into related detail without leaving the stack. Perfect when context should feel attached, not modal.

Anatomy

Bounding box 2×3. Cells: (0,0) (0,1) (0,2) (1,2). Void cells sit to the right of the upper column. Child order follows the column top→bottom, then the foot.

Reading order

Top → down the spine, then right into the foot.

Packing tip

Use ShapeFrame when the foot must read as literal L. Pair with priority 65–80 for activity modules that matter but yield to Revenue.

Voids

Hide empty cells with voids="invisible" for production UI; use "visible" while designing.

Cell roles (ShapeFrame children 1–4)

  1. 01Column top
  2. 02Column mid
  3. 03Column base
  4. 04Foot / context panel

Best for

  • +Activity feeds with a context foot
  • +Chat / history + detail drawer
  • +Vertical tooling with a related panel

Avoid

  • Symmetric marketing grids
  • Content that must stay centered
  • Right-rail inbox patterns (use J)

Code → output

Feed + related foot

ActivityL.tsx
import { ShapeFrame } from "@stack_layout/tetris-ui";
export function ActivityL() {
return (
<ShapeFrame
shape="L"
layout="silhouette"
layoutAt={{ mobile: "stack" }}
size="lg"
voids="invisible"
surface="bordered"
accent="#A16207"
>
<FeedItem who="Maya" action="merged" />
<FeedItem who="Jon" action="shipped" />
<FeedItem who="Ava" action="cleared" />
<RelatedPanel title="Invoice #4821" />
</ShapeFrame>
);
}

Output

Completable task Block (L grammar)

VerifyTask.tsx
import { Block, TetrisProvider } from "@stack_layout/tetris-ui";
export function VerifyTask() {
return (
<TetrisProvider>
<Block
shape="L"
size="md"
priority={92}
title="Verify email"
subtitle="Complete to clear this block"
accent="#B45309"
>
Completing clears the block neighbors RE-FLOW.
</Block>
</TetrisProvider>
);
}

Output

Verify email

Complete to clear this block

Completing clears the block — neighbors RE-FLOW.

Borderless glass stack

ToolStack.tsx
import { ShapeFrame } from "@stack_layout/tetris-ui";
export function ToolStack() {
return (
<ShapeFrame
shape="L"
glass
border="none"
voids="invisible"
size="md"
>
<Tool label="Select" />
<Tool label="Draw" />
<Tool label="Erase" />
<Detail label="Inspector" />
</ShapeFrame>
);
}

Output

Intentional offset — still on-grid

The S refuses the boring rectangle without leaving the grid. Two staggered pairs — chart beside media, before beside after. Use it when comparison or energy matters more than symmetry.

Anatomy

Bounding box 3×2. Cells: (1,0) (2,0) (0,1) (1,1). Voids at (0,0) and (2,1). Children fill top-right pair, then bottom-left pair.

Reading order

Top staggered pair, then the offset pair below — a gentle zigzag.

Packing tip

Ideal ShapeFrame for galleries and analytics pairings. As a Block, size="sm"/"md" keeps ambient S modules from dominating priority.

Voids

Empty corners are part of the silhouette — set voids="invisible" so layout doesn’t show blank cards.

Cell roles (ShapeFrame children 1–4)

  1. 01Top pair A
  2. 02Top pair B
  3. 03Bottom pair A
  4. 04Bottom pair B

Best for

  • +Chart + media pairings
  • +Asymmetric galleries
  • +Before / after compare layouts

Avoid

  • Strict alignment systems
  • Dense data tables
  • Calm equal-weight dashboards (use O)

Code → output

Chart + media stagger

AnalyticsS.tsx
import { ShapeFrame } from "@stack_layout/tetris-ui";
export function AnalyticsS() {
return (
<ShapeFrame
shape="S"
size="lg"
voids="invisible"
radius="soft"
accent="#3F6212"
>
<Chart title="Funnel" value="68%" />
<Chart title="Cohort" value="41%" />
<Media title="Cover" />
<Media title="Clip" />
</ShapeFrame>
);
}

Output

Packed ambient widget

WeatherWidget.tsx
import { Block, TetrisProvider } from "@stack_layout/tetris-ui";
export function WeatherWidget() {
return (
<TetrisProvider>
<Block
shape="S"
size="sm"
priority={30}
title="Weather"
accent="#64748B"
>
72° · Partly cloudy
</Block>
</TetrisProvider>
);
}

Output

Weather

72°

Partly cloudy

Before / after

CompareS.tsx
import { ShapeFrame } from "@stack_layout/tetris-ui";
export function CompareS() {
return (
<ShapeFrame shape="S" voids="ghost" surface="flat" size="md">
<Before label="Draft" />
<BeforeMeta label="v1" />
<After label="Shipped" />
<AfterMeta label="v2" />
</ShapeFrame>
);
}

Output

Rail first — panel opens left

The J mirrors the L: a vertical rail with a base that opens leftward. Inbox, boards, archives — anything where the spine lives on the right and nested detail unfolds from the foot. If your primary reading flow needs a left spine, pick L instead.

Anatomy

Bounding box 2×3. Cells: (1,0) (1,1) (1,2) (0,2). Void on the upper left. Children run down the right rail, then into the leftward base panel.

Reading order

Down the right rail, then left into the nested panel.

Packing tip

ShapeFrame owns the silhouette. Priority 60–75 suits supporting rails that should not steal space from P90+ heroes.

Voids

Same void controls as L/T — prefer invisible voids in product UI.

Cell roles (ShapeFrame children 1–4)

  1. 01Rail top
  2. 02Rail mid
  3. 03Rail base
  4. 04Left panel / thread

Best for

  • +Sidebars with nested detail
  • +Inbox / boards / archive rails
  • +Tool drawers opening from the base

Avoid

  • LTR flows that need a left spine (use L)
  • Centered marketing sections

Code → output

Inbox rail + thread

InboxJ.tsx
import { ShapeFrame } from "@stack_layout/tetris-ui";
export function InboxJ() {
return (
<ShapeFrame
shape="J"
size="enlarge"
voids="invisible"
surface="bordered"
accent="#475569"
>
<RailItem label="Inbox" count={12} />
<RailItem label="Drafts" count={3} />
<RailItem label="Archive" count={90} />
<ThreadPanel subject="Layout rules for Q3" />
</ShapeFrame>
);
}

Output

Packed notifications module

Notifications.tsx
import { Block, TetrisProvider } from "@stack_layout/tetris-ui";
export function Notifications() {
return (
<TetrisProvider>
<Block
shape="J"
size="md"
priority={65}
title="Notifications"
subtitle="3 unread"
accent="#475569"
>
Priority reflow ready · New shape: J-block
</Block>
</TetrisProvider>
);
}

Output

Notifications

3 unread

Priority reflow ready
New shape: J-block

Glass drawer

ToolDrawer.tsx
import { ShapeFrame } from "@stack_layout/tetris-ui";
export function ToolDrawer() {
return (
<ShapeFrame
shape="J"
glass
border="left"
voids="invisible"
size="md"
>
<Tool label="Layers" />
<Tool label="Assets" />
<Tool label="Export" />
<DrawerBody title="Inspector" />
</ShapeFrame>
);
}

Output

Diagonal tension — signal, not square

The Z is the alarm grammar. Staggered like S but mirrored — use it when the UI should feel like a signal: critical → warning, degraded → recovering. Calm dashboards should stay on O; urgency belongs on Z.

Anatomy

Bounding box 3×2. Cells: (0,0) (1,0) (1,1) (2,1). Voids at (2,0) and (0,1). Children fill the top-left pair, then the bottom-right pair — a sharp zigzag.

Reading order

Top-left pair → jump diagonally to bottom-right pair.

Packing tip

Give alerts high priority (80+) even with size="sm" so they pack early. Shape tags the urgency; priority keeps them on-screen when the viewport shrinks.

Voids

Invisible voids keep the zigzag tight; ghost voids help during design critiques.

Cell roles (ShapeFrame children 1–4)

  1. 01Alert A
  2. 02Alert B
  3. 03Status A
  4. 04Status B

Best for

  • +Alert / status clusters
  • +Warning → recovery narratives
  • +Press or credit zigzags

Avoid

  • Calm, equal-weight dashboards (use O)
  • Long sequential steps (use I)

Code → output

Status zigzag

IncidentZ.tsx
import { ShapeFrame } from "@stack_layout/tetris-ui";
export function IncidentZ() {
return (
<ShapeFrame
shape="Z"
size="md"
voids="invisible"
surface="flat"
accent="#9F1239"
>
<Alert level="critical" count={2} />
<Alert level="warning" count={5} />
<Status label="Degraded" />
<Status label="Recovering" />
</ShapeFrame>
);
}

Output

High-priority packed signal

IncidentsBlock.tsx
import { Block, TetrisProvider } from "@stack_layout/tetris-ui";
export function IncidentsBlock() {
return (
<TetrisProvider>
<Block
shape="Z"
size="sm"
priority={88}
title="Incidents"
accent="#9F1239"
>
2 critical · page on-call
</Block>
</TetrisProvider>
);
}

Output

Incidents

2 critical · page on-call

Glass urgency strip

SignalStrip.tsx
import { ShapeFrame } from "@stack_layout/tetris-ui";
export function SignalStrip() {
return (
<ShapeFrame
shape="Z"
glass
border={["top", "bottom"]}
voids="invisible"
>
<Signal label="CPU" />
<Signal label="Mem" />
<Signal label="Err" />
<Signal label="Lat" />
</ShapeFrame>
);
}

Output

Reference

API reference

Props by element. New chrome and layout controls are listed under the components they affect.

Block

Single content module — alone or inside TetrisProvider.

PropValuesNotes
glassfrosted | transparent | regular | booleanfrosted/transparent = glass; regular = opaque solid (true→frosted, false→regular)
backdropbooleanLegacy blur toggle when glass is frosted-capable — prefer glass="transparent"
colorCSS colorFill tint — default glass white rgba(255,255,255,0.72)
animationfade-in | fade-up | fade-bottom | slide-in | slide-up | slide | puzzle-entrance | lego-drop-in | falseEntrance motion on the card shell
bordernone | false | all | true | side | side[]Outline. none/false = no outline (accent-only when accent is set). Omitted + accent on → none
borderless / borderedboolean (legacy)Maps to border="none" / border="all"
surfacepreset | features objectglass | bordered | borderless | flat
radiusflat | sharp | rounded | softCorner language — flat/sharp = square; rounded/soft = curved
sizesm | md | lg | enlarge | full | autoColumn span — auto fills leftover cols (even split with peer autos)
sizeAt{ desktop?, tablet?, mobile? }Per-viewport size overrides for the packer
priority0–100Packer order — hidden unless showPriority
shapeI O T L S J ZOptional glyph via showShape (off by default)
shapeAt{ desktop?, tablet?, mobile? }Per-viewport shape overrides (glyph + data-shape)
accentCSS color | "none" | falseAccent color; none disables
accentPlacementtop | right | bottom | left | all | full | noneHairline ring follows border-radius (default top); all/full = all sides
hovertrue | { type?: regular|frosted, background?, content?, className? }Overlay — does not block scroll; does not center content
showShape / showPriority / showMetabooleanChrome toggles — all default false for freeform
minHeight / maxHeightnumber | CSS lengthOptional bounds — omit to fit content
scrolltrue | "y" | "x" | "both"Scrolls body content only — header/footer stay fixed
statefall | stack | lock | clearMotion model hook
className / stylestring / CSSPropertiesMerge onto the card shell
title / subtitlestring?Optional header copy

Tile

Freeform TetrisProvider card — Block without shape / shapeAt / showShape. Prefer Tile on packed dashboards.

PropValuesNotes
(inherits Block)omit shape*Same chrome, size, accentPlacement, hover, title?, etc.
title / subtitlestring?Optional — body-only tiles are valid

ShapeFrame

Four children in a tetromino silhouette — or stack / carousel via layoutAt. Fluid width.

PropValuesNotes
shapeI O T L S J ZRequired base silhouette
shapeAt{ desktop?, tablet?, mobile? }Per-viewport silhouette overrides
layoutsilhouette | stack | carouselBase cell flow (default silhouette)
layoutAt{ desktop?, tablet?, mobile? }Per-viewport layout overrides (stack / carousel)
carouselSnap"page" | "cell"Carousel slide width — full page or peek cell
viewport / breakpointsauto | mode · { mobileMax?, tabletMax? }Resolve shapeAt / layoutAt outside TetrisProvider (inherits when nested)
glass / flat / backdrop / bordersame as BlockPer-cell chrome (glass on by default)
colorCSS colorCell fill tint — default glass white
animationsame tokens as BlockFrame or staggered cell entrance (puzzle / lego)
accentCSS color | "none" | falseHairline + wash; none disables
voidsvisible | ghost | invisibleEmpty cells on T L S J Z
radius / sizesame tokens as BlockCell scale language
gapnumber (px)Space between silhouette cells (default 10)
classNamestringMerge onto the frame root

TetrisProvider

Priority packer — assigns CSS grid placement from child Block rules. Card heights fit content by default.

PropValuesNotes
viewportdesktop | tablet | mobile | "auto"Track width 12/8/4, or matchMedia from the window
breakpoints{ mobileMax?, tabletMax? }Cutoffs for viewport="auto" (default 767 / 1023)
stretchbooleanEqualize heights of cards that share a row
gapnumber | string | [row, col] | { row, column }Freeform spacing between packed cards (default 12)
className / stylestring / CSSPropertiesMerge onto the board grid

VIEWPORT_COLUMNS

Constant map: desktop → 12, tablet → 8, mobile → 4. Used by the packer and docs demos.

resolveSizeForViewport

Pure helper: size + sizeAt + active viewport → effective SizeVariant.

resolveShapeForViewport

Pure helper: shape + shapeAt + active viewport → effective BlockShape.

resolveLayoutForViewport

Pure helper: layout + layoutAt + active viewport → effective ShapeLayoutMode (silhouette / stack / carousel).

SHAPE_GUIDES

Data for docs and tooling — principle, best-for, avoid, and mental model per shape.

packLayout

Headless packer (no React). Pass block defs + viewport; get placed rects.

resolveGap

Normalizes gap into CSS + px helpers for custom boards.