Blocks
import { useId, useRef, useState } from 'react';
const TABS = [
{ label: 'World 1', body: 'Grasslands. Eight levels, a hidden pipe in the fourth.' },
{ label: 'World 2', body: 'Caverns. Low ceilings, falling rocks and one long chase.' },
{ label: 'World 3', body: 'Sky fortress. Moving platforms all the way to the boss.' },
];
const COLS = 12;
const ROWS = 2;
// A scatter that is the same on the server and in the browser.
const jitter = (c: number, r: number) => ((c * 7 + r * 5) % COLS) / COLS;
const css = `
.tabs-blocks { width: 240px; }
.tabs-blocks__list { display: grid; grid-template-columns: repeat(${TABS.length}, 1fr); }
.tabs-blocks__tab { display: grid; justify-items: center; gap: 6px; padding: 6px 0 0; border: 0; background: none; font: inherit; font-size: 12.5px; font-weight: 600; color: light-dark(#5f626a, #a7abb2); cursor: pointer; transition: color 0.3s; }
.tabs-blocks__tab:hover, .tabs-blocks__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-blocks__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; border-radius: 6px; }
.tabs-blocks__bed { display: grid; grid-template-columns: repeat(${COLS}, 5px); grid-auto-rows: 5px; gap: 1px; height: 11px; border-bottom: 1px solid light-dark(#ebedf0, #232529); padding-bottom: 2px; }
.tabs-blocks__block { background: light-dark(#373a40, #f1f2f3); }
.tabs-blocks__block--dim { background: light-dark(#50535b, #c6c8cd); }
/* New blocks fall in and stack, bottom row first; the old ones crumble and drop away. */
.tabs-blocks__bed--land .tabs-blocks__block { animation: tabs-blocks-land 0.42s cubic-bezier(0.5, 0, 0.75, 0) backwards; }
@keyframes tabs-blocks-land {
from { opacity: 0; transform: translateY(-28px); }
70% { opacity: 1; transform: translateY(0); }
85% { transform: translateY(-1.5px); }
}
.tabs-blocks__bed--crumble .tabs-blocks__block { animation: tabs-blocks-crumble 0.5s cubic-bezier(0.5, 0, 0.9, 0.5) forwards; }
@keyframes tabs-blocks-crumble {
to { opacity: 0; transform: translateY(18px) rotate(var(--spin)); }
}
.tabs-blocks__panel { padding: 12px 0 0; min-height: calc(3.1em + 12px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }
@media (prefers-reduced-motion: reduce) {
.tabs-blocks__bed--land .tabs-blocks__block { animation: none; }
.tabs-blocks__bed--crumble { display: none; }
}
`;
function Bed({ mode }: { mode: 'set' | 'land' | 'crumble' }) {
return (
<span className={`tabs-blocks__bed tabs-blocks__bed--${mode}`} aria-hidden="true">
{Array.from({ length: ROWS * COLS }, (_, k) => {
const r = Math.floor(k / COLS);
const c = k % COLS;
const j = jitter(c, r);
const delay = mode === 'land' ? (ROWS - 1 - r) * 140 + j * 260 : j * 160;
return (
<span
key={k}
className={`tabs-blocks__block${(c + r) % 3 === 0 ? ' tabs-blocks__block--dim' : ''}`}
style={{ animationDelay: `${Math.round(delay)}ms`, '--spin': `${(j - 0.5) * 90}deg` } as React.CSSProperties}
/>
);
})}
</span>
);
}
export function BlocksTabs() {
const id = useId();
const [s, setS] = useState({ sel: 0, prev: -1, gen: 0 });
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const pick = (i: number) => setS((o) => (i === o.sel ? o : { sel: i, prev: o.sel, gen: o.gen + 1 }));
const n = TABS.length;
const onKeyDown = (e: React.KeyboardEvent) => {
const next = { ArrowRight: (s.sel + 1) % n, ArrowLeft: (s.sel + n - 1) % n, Home: 0, End: n - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
pick(next);
tabs.current[next]?.focus();
};
return (
<>
<style>
{css}
</style>
<div className="tabs-blocks">
<div className="tabs-blocks__list" role="tablist" aria-label="Worlds" onKeyDown={onKeyDown}>
{TABS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-blocks__tab"
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-selected={s.sel === i}
aria-controls={`${id}-panel-${i}`}
tabIndex={s.sel === i ? 0 : -1}
onClick={() => pick(i)}
>
{t.label}
{i === s.sel ? (
<Bed key={`${i}-${s.gen}`} mode={s.gen ? 'land' : 'set'} />
) : i === s.prev ? (
<Bed key={`${i}-${s.gen}`} mode="crumble" />
) : (
<span className="tabs-blocks__bed" aria-hidden="true" />
)}
</button>
))}
</div>
{TABS.map((t, i) => (
<div key={t.label} className="tabs-blocks__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={s.sel !== i}>
{t.body}
</div>
))}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsBlocks(el) on the mounted element instead.
<div class="tabs-blocks">
<div class="tabs-blocks__list" role="tablist" aria-label="Worlds">
<button class="tabs-blocks__tab" type="button" role="tab" id="tabs-blocks-tab-0" aria-selected="true" aria-controls="tabs-blocks-panel-0" tabindex="0">
World 1
<span class="tabs-blocks__bed tabs-blocks__bed--set" aria-hidden="true">
<span class="tabs-blocks__block tabs-blocks__block--dim" style="animation-delay:0ms;--spin:-45deg"></span>
<span class="tabs-blocks__block" style="animation-delay:93ms;--spin:7.5deg"></span>
<span class="tabs-blocks__block" style="animation-delay:27ms;--spin:-30deg"></span>
<span class="tabs-blocks__block tabs-blocks__block--dim" style="animation-delay:120ms;--spin:22.5deg"></span>
<span class="tabs-blocks__block" style="animation-delay:53ms;--spin:-15deg"></span>
<span class="tabs-blocks__block" style="animation-delay:147ms;--spin:37.5deg"></span>
<span class="tabs-blocks__block tabs-blocks__block--dim" style="animation-delay:80ms;--spin:0deg"></span>
<span class="tabs-blocks__block" style="animation-delay:13ms;--spin:-37.5deg"></span>
<span class="tabs-blocks__block" style="animation-delay:107ms;--spin:15deg"></span>
<span class="tabs-blocks__block tabs-blocks__block--dim" style="animation-delay:40ms;--spin:-22.5deg"></span>
<span class="tabs-blocks__block" style="animation-delay:133ms;--spin:30deg"></span>
<span class="tabs-blocks__block" style="animation-delay:67ms;--spin:-7.5deg"></span>
<span class="tabs-blocks__block" style="animation-delay:67ms;--spin:-7.5deg"></span>
<span class="tabs-blocks__block" style="animation-delay:0ms;--spin:-45deg"></span>
<span class="tabs-blocks__block tabs-blocks__block--dim" style="animation-delay:93ms;--spin:7.5deg"></span>
<span class="tabs-blocks__block" style="animation-delay:27ms;--spin:-30deg"></span>
<span class="tabs-blocks__block" style="animation-delay:120ms;--spin:22.5deg"></span>
<span class="tabs-blocks__block tabs-blocks__block--dim" style="animation-delay:53ms;--spin:-15deg"></span>
<span class="tabs-blocks__block" style="animation-delay:147ms;--spin:37.5deg"></span>
<span class="tabs-blocks__block" style="animation-delay:80ms;--spin:0deg"></span>
<span class="tabs-blocks__block tabs-blocks__block--dim" style="animation-delay:13ms;--spin:-37.5deg"></span>
<span class="tabs-blocks__block" style="animation-delay:107ms;--spin:15deg"></span>
<span class="tabs-blocks__block" style="animation-delay:40ms;--spin:-22.5deg"></span>
<span class="tabs-blocks__block tabs-blocks__block--dim" style="animation-delay:133ms;--spin:30deg"></span>
</span>
</button>
<button class="tabs-blocks__tab" type="button" role="tab" id="tabs-blocks-tab-1" aria-selected="false" aria-controls="tabs-blocks-panel-1" tabindex="-1">
World 2
<span class="tabs-blocks__bed" aria-hidden="true"></span>
</button>
<button class="tabs-blocks__tab" type="button" role="tab" id="tabs-blocks-tab-2" aria-selected="false" aria-controls="tabs-blocks-panel-2" tabindex="-1">
World 3
<span class="tabs-blocks__bed" aria-hidden="true"></span>
</button>
</div>
<div class="tabs-blocks__panel" role="tabpanel" id="tabs-blocks-panel-0" aria-labelledby="tabs-blocks-tab-0">Grasslands. Eight levels, a hidden pipe in the fourth.</div>
<div class="tabs-blocks__panel" role="tabpanel" id="tabs-blocks-panel-1" aria-labelledby="tabs-blocks-tab-1" hidden="">Caverns. Low ceilings, falling rocks and one long chase.</div>
<div class="tabs-blocks__panel" role="tabpanel" id="tabs-blocks-panel-2" aria-labelledby="tabs-blocks-tab-2" hidden="">Sky fortress. Moving platforms all the way to the boss.</div>
</div>.tabs-blocks { width: 240px; }
.tabs-blocks__list { display: grid; grid-template-columns: repeat(3, 1fr); }
.tabs-blocks__tab { display: grid; justify-items: center; gap: 6px; padding: 6px 0 0; border: 0; background: none; font: inherit; font-size: 12.5px; font-weight: 600; color: light-dark(#5f626a, #a7abb2); cursor: pointer; transition: color 0.3s; }
.tabs-blocks__tab:hover, .tabs-blocks__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-blocks__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; border-radius: 6px; }
.tabs-blocks__bed { display: grid; grid-template-columns: repeat(12, 5px); grid-auto-rows: 5px; gap: 1px; height: 11px; border-bottom: 1px solid light-dark(#ebedf0, #232529); padding-bottom: 2px; }
.tabs-blocks__block { background: light-dark(#373a40, #f1f2f3); }
.tabs-blocks__block--dim { background: light-dark(#50535b, #c6c8cd); }
/* New blocks fall in and stack, bottom row first; the old ones crumble and drop away. */
.tabs-blocks__bed--land .tabs-blocks__block { animation: tabs-blocks-land 0.42s cubic-bezier(0.5, 0, 0.75, 0) backwards; }
@keyframes tabs-blocks-land {
from { opacity: 0; transform: translateY(-28px); }
70% { opacity: 1; transform: translateY(0); }
85% { transform: translateY(-1.5px); }
}
.tabs-blocks__bed--crumble .tabs-blocks__block { animation: tabs-blocks-crumble 0.5s cubic-bezier(0.5, 0, 0.9, 0.5) forwards; }
@keyframes tabs-blocks-crumble {
to { opacity: 0; transform: translateY(18px) rotate(var(--spin)); }
}
.tabs-blocks__panel { padding: 12px 0 0; min-height: calc(3.1em + 12px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }
@media (prefers-reduced-motion: reduce) {
.tabs-blocks__bed--land .tabs-blocks__block { animation: none; }
.tabs-blocks__bed--crumble { display: none; }
}function initTabsBlocks(root) {
var list = root.querySelector('[role="tablist"]');
var tabs = Array.prototype.slice.call(list.querySelectorAll('[role="tab"]'));
var panels = Array.prototype.slice.call(root.querySelectorAll('[role="tabpanel"]'));
var n = tabs.length;
var sel = tabs.findIndex(function (tab) { return tab.getAttribute('aria-selected') === 'true'; });
var COLS = 12;
var ROWS = 2;
// A scatter that is the same every time.
function jitter(c, r) { return ((c * 7 + r * 5) % COLS) / COLS; }
// A fresh bed under a tab, so its blocks' animations start over: they land, crumble, or there are none.
function bed(tab, mode) {
var el = document.createElement('span');
el.className = 'tabs-blocks__bed' + (mode ? ' tabs-blocks__bed--' + mode : '');
el.setAttribute('aria-hidden', 'true');
for (var k = 0; mode && k < ROWS * COLS; k++) {
var r = Math.floor(k / COLS);
var c = k % COLS;
var j = jitter(c, r);
var block = document.createElement('span');
block.className = 'tabs-blocks__block' + ((c + r) % 3 === 0 ? ' tabs-blocks__block--dim' : '');
block.style.animationDelay = Math.round(mode === 'land' ? (ROWS - 1 - r) * 140 + j * 260 : j * 160) + 'ms';
block.style.setProperty('--spin', (j - 0.5) * 90 + 'deg');
el.appendChild(block);
}
tab.replaceChild(el, tab.querySelector('.tabs-blocks__bed'));
}
function select(i) {
if (i === sel) return;
var prev = sel;
sel = i;
tabs.forEach(function (tab, k) {
tab.setAttribute('aria-selected', String(k === i));
tab.tabIndex = k === i ? 0 : -1;
});
panels.forEach(function (panel, k) { panel.hidden = k !== i; });
tabs.forEach(function (tab, k) { bed(tab, k === i ? 'land' : k === prev ? 'crumble' : ''); });
}
function onClick(e) {
var tab = e.target.closest('[role="tab"]');
if (tab) select(tabs.indexOf(tab));
}
function onKeyDown(e) {
var next = { ArrowRight: (sel + 1) % n, ArrowLeft: (sel + n - 1) % n, Home: 0, End: n - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
select(next);
tabs[next].focus();
}
list.addEventListener('click', onClick);
list.addEventListener('keydown', onKeyDown);
return function () {
list.removeEventListener('click', onClick);
list.removeEventListener('keydown', onKeyDown);
};
}
document.querySelectorAll('.tabs-blocks').forEach(initTabsBlocks);Goal: Create a "Blocks" tab set.
Visual behavior: Game world tabs with an underline built of blocks: new blocks fall in and stack, bottom row first, while the old ones crumble and drop away.
Technique:
- The ARIA tabs pattern: a role="tablist" with an aria-label, buttons with role="tab", aria-selected and aria-controls, and role="tabpanel" panels labelled by their tab. Only the selected tab is in the Tab order (roving tabindex); the arrow keys, Home and End move between tabs and select as they go.
- The underline is a grid of 24 small blocks. Under the chosen tab they replay a falling keyframe, delayed by row so the bottom row lands first and by a fixed scatter within a row.
- Under the old tab the blocks replay a crumble keyframe that drops and spins each one by its own --spin angle.
- A fresh set of blocks is made on each switch, so the animations restart.
Accessibility: Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. Decoration is aria-hidden. Under prefers-reduced-motion the blocks appear without falling and the old ones vanish.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Blocks" tab set.
Visual behavior: Game world tabs with an underline built of blocks: new blocks fall in and stack, bottom row first, while the old ones crumble and drop away.
Technique:
- The ARIA tabs pattern: a role="tablist" with an aria-label, buttons with role="tab", aria-selected and aria-controls, and role="tabpanel" panels labelled by their tab. Only the selected tab is in the Tab order (roving tabindex); the arrow keys, Home and End move between tabs and select as they go.
- The underline is a grid of 24 small blocks. Under the chosen tab they replay a falling keyframe, delayed by row so the bottom row lands first and by a fixed scatter within a row.
- Under the old tab the blocks replay a crumble keyframe that drops and spins each one by its own --spin angle.
- A fresh set of blocks is made on each switch, so the animations restart.
Accessibility: Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. Decoration is aria-hidden. Under prefers-reduced-motion the blocks appear without falling and the old ones vanish.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
Game world tabs with an underline built of blocks: new blocks fall in and stack, bottom row first, while the old ones crumble and drop away.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, keyframe, delay stagger
- Techniques
- keyframes, stagger, css variables
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. Decoration is aria-hidden. Under prefers-reduced-motion the blocks appear without falling and the old ones vanish.
More tabs
See allEvening news
Floods in the valley, and the bridge reopens Monday.
Match of the day
Late winner at the harbour ground, 2 to 1.
Late film
A silent-era heist, starting in ten minutes.