Interaction
Roving-grid board
A dense grid of state cells that is one tab stop with arrow-key navigation, where each state differs in fill, border weight, and border style at once — and clicking selects but never writes.
Reach for it when building
- episode or chapter trackers
- habit and streak grids
- seat or slot pickers
- batch item status boards
- calendar day selectors
- keyboard
- roving-tabindex
- accessibility
- states
- dense-data
<div class="rgb-wrap">
<div class="rgb-grid" role="listbox" aria-label="Chapters"></div>
<div class="rgb-legend">
<span><i class="rgb-swatch rgb-sw-done"></i> read</span>
<span><i class="rgb-swatch rgb-sw-next"></i> next up</span>
<span><i class="rgb-swatch"></i> unread</span>
<span><i class="rgb-swatch rgb-sw-unavailable"></i> unreleased</span>
</div>
<div class="rgb-panel">
<span class="rgb-msg">Chapter 8 — next</span>
<button type="button" class="rgb-commit">Read up to here</button>
</div>
</div>Colors come from shared theme tokens — --surface, --ink, --border, --accent and friends — so this CSS carries no palette
of its own. Use Runnable file to copy the tokens along with it.
.rgb-wrap { max-width: 460px; }
.rgb-grid { display: flex; flex-wrap: wrap; gap: 6px; }
.rgb-cell {
width: 44px;
height: 44px;
display: grid;
place-items: center;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
font-family: var(--sans);
font-size: 1rem;
color: var(--ink);
cursor: pointer;
padding: 0;
font-variant-numeric: tabular-nums;
}
.rgb-cell:hover { border-color: var(--dim); }
/* Each state moves fill, border weight, and border style together, so no
state is distinguished by color alone. */
.rgb-cell[data-state='done'] {
background: var(--accent);
color: var(--accent-ink);
border-color: transparent;
font-weight: 700;
}
.rgb-cell[data-state='next'] {
border: 2px solid var(--accent);
font-weight: 700;
}
.rgb-cell[data-state='unavailable'] {
border-style: dashed;
color: var(--dim);
cursor: default;
}
.rgb-cell[data-selected='true'] {
box-shadow:
0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent),
0 0 0 1px var(--accent);
}
.rgb-legend {
display: flex;
flex-wrap: wrap;
gap: 1rem;
font-size: 0.9375rem;
color: var(--dim);
margin-top: 0.75rem;
}
.rgb-legend span { display: inline-flex; align-items: center; gap: 0.4rem; }
/* Legend swatches reuse the exact state treatments at small scale. */
.rgb-swatch {
width: 17px;
height: 17px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--surface);
display: inline-block;
}
.rgb-sw-done { background: var(--accent); border-color: transparent; }
.rgb-sw-next { border: 2px solid var(--accent); }
.rgb-sw-unavailable { border-style: dashed; }
.rgb-panel {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
padding: 0.7rem 1rem;
margin-top: 0.9rem;
font-size: 0.9375rem;
}
.rgb-commit {
font-family: var(--sans);
font-size: 0.9375rem;
font-weight: 600;
padding: 0.4rem 0.8rem;
border-radius: 6px;
border: 1px solid var(--dim);
background: var(--surface);
color: var(--ink);
cursor: pointer;
}
.rgb-commit:hover { background: var(--surface-2); }var TOTAL = 15;
var doneUpTo = 7;
var releasedUpTo = 12;
var selected = 8;
var grid = document.querySelector('.rgb-grid');
var message = document.querySelector('.rgb-msg');
var commit = document.querySelector('.rgb-commit');
function stateOf(index) {
if (index > releasedUpTo) return 'unavailable';
if (index <= doneUpTo) return 'done';
if (index === doneUpTo + 1) return 'next';
return 'unread';
}
function label(state) {
return { done: 'read', next: 'next', unread: 'unread', unavailable: 'unreleased' }[state];
}
function render() {
grid.innerHTML = '';
for (var index = 1; index <= TOTAL; index++) {
(function (cellIndex) {
var cell = document.createElement('button');
cell.type = 'button';
cell.className = 'rgb-cell';
cell.textContent = String(cellIndex);
var state = stateOf(cellIndex);
cell.dataset.state = state;
cell.dataset.selected = String(cellIndex === selected);
// Roving tabindex: the whole grid is one tab stop.
cell.tabIndex = cellIndex === selected ? 0 : -1;
cell.setAttribute('role', 'option');
cell.setAttribute('aria-selected', String(cellIndex === selected));
cell.setAttribute('aria-label', 'Chapter ' + cellIndex + ', ' + label(state));
cell.addEventListener('click', function () { select(cellIndex, false); });
cell.addEventListener('keydown', function (event) {
var target = null;
if (event.key === 'ArrowRight') target = Math.min(TOTAL, cellIndex + 1);
if (event.key === 'ArrowLeft') target = Math.max(1, cellIndex - 1);
if (event.key === 'Home') target = 1;
if (event.key === 'End') target = TOTAL;
if (target !== null) {
event.preventDefault();
select(target, true);
}
});
grid.appendChild(cell);
})(index);
}
message.textContent = 'Chapter ' + selected + ' \u2014 ' + label(stateOf(selected));
}
function select(index, focus) {
selected = index;
render();
if (focus) grid.children[index - 1].focus();
}
commit.addEventListener('click', function () {
if (stateOf(selected) !== 'unavailable') {
doneUpTo = selected;
render();
}
});
render();Paste this into an agent to rebuild the pattern from scratch.
Build a dense grid of small state cells — chapters read, days kept, slots taken — with a keyboard model and a state system that both survive real data.
States: each cell is a 44px button whose state moves three properties together — fill, border weight, and border style — so no state is distinguishable by color alone. Done: solid accent fill, contrast text, transparent border, bold. Next-up: 2px accent border on the neutral surface, bold. Unread: the plain 1px border. Unavailable (not yet released): dashed border and dimmed text. Ship a legend whose swatches reuse the identical state rules at 17px — a legend styled separately drifts the first time someone touches the CSS.
Keyboard: the whole grid is one tab stop, implemented with a roving tabindex. Only the selected cell has `tabIndex = 0`; every other cell is `-1`. ArrowLeft/ArrowRight move selection by one, Home and End jump to the ends, and each move both re-renders selection and calls `.focus()` on the new cell, with `preventDefault()` so the page doesn't scroll. Without this, a 40-cell grid costs 40 tab presses to cross.
Writes are decoupled from selection: clicking or arrowing onto a cell only selects it and updates a panel beneath ("Chapter 8 — next"); the actual mutation happens through an explicit button in that panel ("Read up to here"). A mis-click can never rewrite progress. The initial selection is the next-up cell — the one the person most likely came to act on — not cell 1 and not nothing.
Selection is shown with a box-shadow ring (an outer soft ring over a 1px hard ring) rather than a border change, so selecting never shifts layout. Give cells `aria-label` combining position and state, and the container a list role.
Every color comes from theme custom properties, and every state must stay distinguishable in both light and dark themes.