Skip to content

Showcase layout (stage and roster)

3 min read · Updated 8 Oct 2026

A showcase page puts one item on a stage (its artwork, key facts and one signature chart, such as a radar) and lists every item as a tile in a roster that picks what is on the stage. It scales one composition instead of reflowing in tiers; both strategies are in Layout and responsiveness.

Showcase layout (stage and roster): One item on stage; a roster supports browsing.

Illustrative example with synthetic data. One item on stage; a roster supports browsing.

Use it when each item has artwork and readers browse items one at a time, then set two against each other: a product catalogue, player selection, property listings.

The page is mainly Use instead
Items compared on many measures, with no artwork Compare page
One item picked from a list for data-heavy detail Master–detail
Many items, one metric each Card grid
  1. Do readers set a second item against the one on the stage? Use compare.
  2. Otherwise, use single.

Compare re-lays the same grid, so offer it as a mode of the single page.

Variant Layout Use cases
Single Artwork and key facts, the signature chart beside them, a band below, the roster on the right Phone catalogue: the render, price and battery life, a radar of camera, battery and display
Compare Item A, the signature chart with both items, item B, a band of differences, the roster along the bottom Two cars on power, range, price and safety rating
  • Draw the page at a reference size and scale it: lengths inside the board are in rem; see Scale one composition. Keep one breakpoint, where the root font size reaches the clamp’s floor.
  • Give every row of the stage a fixed height, truncate long names, and show the total in the band’s heading (DIFFERENCES 7), so the stage does not move between items.
  • Pin the roster’s tracks to the full item count; see Grid traps.
  • Size each artwork from its subject’s bounds; see Images: fit the subject, not the file. Put the swap animation on a wrapper.
  • Read names, scores and colours from the semantic layer. Where items have a colour field, set it as the figure’s --accent.
  • Render each tile as a button and mark the items on the stage with aria-current="true".
/* Drawn at 1440 × 900: 1.1111vw = 16 / 1440, 1.7778svh = 16 / 900.
The 12px floor is reached below 1080 wide or 675 tall; the 24px cap above 2160 × 1350. */
html {
font-size: clamp(12px, min(1.1111vw, 1.7778svh), 24px);
}
.board {
display: grid;
gap: var(--gap);
}
.showcase {
--roster-w: 22rem;
--band-h: 10rem;
--strip-h: 7rem;
--tile: 7rem;
/* At the 12px floor: 24rem = 288px, above --chart-min. */
--stage-min: 24rem;
gap: 1rem;
}
.showcase > * {
min-width: 0;
min-height: 0;
}
.fig--a {
grid-area: a;
}
.fig--b {
grid-area: b;
}
.signature {
grid-area: chart;
}
.band--diff {
grid-area: band;
}
.roster {
grid-area: roster;
}
.fig {
display: grid;
/* Name, artwork, key facts: only the artwork row flexes. */
grid-template-rows: 2.5rem minmax(0, 1fr) 5rem;
gap: 0.5rem;
border-top: 0.25rem solid var(--accent, currentColor);
}
.fig > h2 {
margin: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.fig .art {
position: relative;
}
.fig .art img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
}
.band--diff {
display: grid;
grid-template-rows: 1.5rem minmax(0, 1fr);
gap: 0.5rem;
min-height: 0;
}
.band--diff ul {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-auto-rows: 1.5rem;
gap: 0 1rem;
margin: 0;
padding: 0;
list-style: none;
overflow: hidden;
}
.roster {
display: grid;
gap: 0.5rem;
align-content: start;
}
.roster > button {
min-width: 0;
min-height: 0;
}

.board takes its fixed height from Bands above the board.

┌───────────┬───────────┬────────┐
│ Name │ Signature │ Roster │
│ Artwork │ chart │ ▢ ▢ ▢ │
│ Key facts │ │ ▢ ▢ ▢ │
├───────────┴───────────┤ ▢ ▢ ▢ │
│ Band │ … │
└───────────────────────┴────────┘
<div class="board showcase">
<section class="fig fig--a" aria-labelledby="a-name">
<h2 id="a-name">Item A</h2>
<div class="art"><img src="…" alt="…" /></div>
<dl class="facts"><!-- key facts --></dl>
</section>
<section class="signature" aria-label="Attributes"><!-- chart host --></section>
<section class="band band--diff" aria-labelledby="band-title">
<h3 id="band-title">Variants 12</h3>
<ul>
<!-- one item per line -->
</ul>
</section>
<nav class="roster" aria-label="Items" style="--roster-count: 24; --roster-rows: 8">
<button type="button" aria-current="true">…</button>
<!-- one button per item -->
</nav>
</div>
.showcase {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) var(--roster-w);
grid-template-rows: minmax(0, 1fr) var(--band-h);
grid-template-areas:
'a chart roster'
'band band roster';
}
.showcase:not(.showcase--compare) .roster {
/* --roster-rows: the full item count divided by 3, rounded up. */
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-rows: repeat(var(--roster-rows), var(--tile));
/* The roster does not size the rows; it scrolls inside them. */
contain: size;
overflow-y: auto;
}
/* At the 12px floor: 64 + 2 × 16 + 288 + 12 + 120 = 516 */
@media (height < 516px) {
.showcase {
height: auto;
grid-template-rows: var(--stage-min) var(--band-h);
}
}
Width Stage Roster Height
1080px and wider Artwork and chart side by side, band below Column on the right, scrolls inside itself Fits the window from 516px tall
Under 1080px Stacked Below the band Scrolls
┌──────────┬───────────┬──────────┐
│ Item A │ Signature │ Item B │
│ Artwork │ chart, │ Artwork │
│ Facts │ A and B │ Facts │
├──────────┴───────────┴──────────┤
│ Band: what differs │
├─────────────────────────────────┤
│ ▢ ▢ ▢ ▢ ▢ ▢ ▢ ▢ ▢ ▢ ▢ ▢ roster │
└─────────────────────────────────┘

Add showcase--compare to the same board and insert item B’s figure. Remove B’s figure in single mode; the single template has no b area, so a figure left in place lands in an implicit track.

  • List in the band only the attributes whose values differ, with their count in the heading.
  • A radar with a dimension takes exactly one measure, so showing both items needs one row per item and attribute; see radar definitions.
  • The radar cannot give a named item its own colour or line style; see App limitations.
.showcase--compare {
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-rows: minmax(0, 1fr) var(--band-h) var(--strip-h);
grid-template-areas:
'a chart b'
'band band band'
'roster roster roster';
}
.showcase--compare .roster {
grid-auto-flow: column;
grid-template-columns: repeat(var(--roster-count), var(--tile));
grid-template-rows: minmax(0, 1fr);
overflow-x: auto;
overflow-y: hidden;
}
/* At the 12px floor: 64 + 2 × 16 + 288 + 2 × 12 + 120 + 84 = 612 */
@media (height < 612px) {
.showcase--compare {
height: auto;
grid-template-rows: var(--stage-min) var(--band-h) var(--strip-h);
}
}
Width Stage Roster Height
1080px and wider A, chart, B in equal columns, band below Strip along the bottom Fits the window from 612px tall
Under 1080px Stacked Strip below the band Scrolls

In single mode a pick replaces the item on the stage; in compare mode it replaces item B, and a pick of an item already on the stage changes nothing.

type Stage = { mode: 'single'; a: string } | { mode: 'compare'; a: string; b: string | null };
function pick(stage: Stage, id: string): Stage {
if (stage.mode === 'single') return { mode: 'single', a: id };
if (id === stage.a || id === stage.b) return stage;
return { mode: 'compare', a: stage.a, b: id };
}

When the signature chart is a mounted object, write the stage items as a selection on the item field, as in Master–detail.

Decode the next image before the swap animation, and drop a decode that a later pick overtook:

const latest = new WeakMap<HTMLImageElement, string>();
async function swapArt(img: HTMLImageElement, url: string): Promise<void> {
latest.set(img, url);
const next = new Image();
next.src = url;
try {
await next.decode();
} catch {
// Not decodable: set the source anyway so the alt text shows.
}
if (latest.get(img) !== url) return;
img.src = url;
// Start the swap animation on the wrapper here.
}

Below 1080px the root stays at its 12px floor, so the columns stack and the page scrolls.

/* 12px / 1.1111vw = 1080px */
@media (width < 1080px) {
.showcase {
height: auto;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: 30rem var(--stage-min) var(--band-h) auto;
grid-template-areas: 'a' 'chart' 'band' 'roster';
}
.showcase:not(.showcase--compare) .roster {
grid-template-columns: repeat(auto-fill, minmax(var(--tile), 1fr));
grid-template-rows: none;
grid-auto-rows: var(--tile);
contain: none;
overflow: visible;
}
.showcase--compare {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: 30rem var(--stage-min) var(--band-h) var(--strip-h);
grid-template-areas: 'a b' 'chart chart' 'band band' 'roster roster';
}
}