Tiles
2,481 photos. The latest is from this morning, at the harbour.
Playing Tides. Three albums saved for the train.
Home in 24 minutes by bike. Light rain after four.
Poster sizes, the ink order and a list of printers to call.
import { useId, useRef, useState } from 'react';
const APPS = [
{ label: 'Photos', body: '2,481 photos. The latest is from this morning, at the harbour.' },
{ label: 'Music', body: 'Playing Tides. Three albums saved for the train.' },
{ label: 'Maps', body: 'Home in 24 minutes by bike. Light rain after four.' },
{ label: 'Notes', body: 'Poster sizes, the ink order and a list of printers to call.' },
];
const BIG = { x: 0, y: 0, w: 240, h: 104 };
const small = (k: number) => ({ x: k * 82, y: 112, w: 76, h: 48 });
// The chosen tile takes the big slot; the rest fill the small ones in their usual order.
function slots(sel: number) {
let k = 0;
return APPS.map((_, i) => (i === sel ? BIG : small(k++)));
}
const EASE = 'cubic-bezier(0.3, 1.15, 0.4, 1)';
const css = `
.tabs-tiles { position: relative; width: 240px; height: 160px; }
.tabs-tiles__tile {
position: absolute;
padding: 0;
border: 0;
border-radius: 14px;
background: light-dark(#ebedf0, #232529);
color: light-dark(#50535b, #c6c8cd);
font: inherit;
cursor: pointer;
transition: left 0.5s ${EASE}, top 0.5s ${EASE}, width 0.5s ${EASE}, height 0.5s ${EASE}, background-color 0.3s, color 0.3s;
}
.tabs-tiles__tile[aria-selected='true'] { z-index: 1; background: light-dark(#dbdde1, #2e3035); color: light-dark(#373a40, #f1f2f3); }
.tabs-tiles__tile:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.tabs-tiles__icon { position: absolute; left: calc(50% - 9px); top: 8px; width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; transition: left 0.5s ${EASE}, top 0.5s ${EASE}, width 0.5s ${EASE}, height 0.5s ${EASE}; }
.tabs-tiles__tile[aria-selected='true'] .tabs-tiles__icon { left: calc(100% - 42px); top: 14px; width: 28px; height: 28px; }
.tabs-tiles__label { position: absolute; left: 50%; top: 29px; font-size: 10.5px; font-weight: 600; white-space: nowrap; transform: translateX(-50%); transition: left 0.5s ${EASE}, top 0.5s ${EASE}, transform 0.5s ${EASE}, font-size 0.5s ${EASE}; }
.tabs-tiles__tile[aria-selected='true'] .tabs-tiles__label { left: 14px; top: 14px; font-size: 16px; transform: none; }
.tabs-tiles__panel { position: absolute; left: 14px; right: 14px; top: 46px; z-index: 2; margin: 0; font-size: 12px; line-height: 1.5; color: light-dark(#42454c, #e5e6e9); pointer-events: none; animation: tabs-tiles-in 0.35s ease-out 0.35s backwards; }
@keyframes tabs-tiles-in {
from { opacity: 0; transform: translateY(5px); }
}
@media (prefers-reduced-motion: reduce) {
.tabs-tiles__tile, .tabs-tiles__icon, .tabs-tiles__label { transition: none; }
.tabs-tiles__panel { animation: none; }
}
`;
function Icon({ i }: { i: number }) {
return (
<svg className="tabs-tiles__icon" viewBox="0 0 24 24" aria-hidden="true">
{i === 0 && <path d="M3 18L9 11L13 15L16 12L21 18ZM3 5H21V19H3Z" />}
{i === 1 && <path d="M9 18V6L19 4V16M9 18A2.5 2.5 0 1 1 4 18A2.5 2.5 0 0 1 9 18ZM19 16A2.5 2.5 0 1 1 14 16A2.5 2.5 0 0 1 19 16Z" />}
{i === 2 && <path d="M12 21C12 21 5 14.5 5 9.5A7 7 0 0 1 19 9.5C19 14.5 12 21 12 21ZM12 12A2.5 2.5 0 1 0 12 7A2.5 2.5 0 0 0 12 12Z" />}
{i === 3 && <path d="M6 3H18V21H6ZM9 8H15M9 12H15M9 16H13" />}
</svg>
);
}
export function TilesTabs() {
const id = useId();
const [sel, setSel] = useState(0);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const at = slots(sel);
const n = APPS.length;
const onKeyDown = (e: React.KeyboardEvent) => {
const next = { ArrowRight: (sel + 1) % n, ArrowLeft: (sel + n - 1) % n, Home: 0, End: n - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
setSel(next);
tabs.current[next]?.focus();
};
return (
<>
<style>
{css}
</style>
<div className="tabs-tiles">
<div role="tablist" aria-label="Apps" onKeyDown={onKeyDown}>
{APPS.map((a, i) => (
<button
key={a.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-tiles__tile"
style={{ left: at[i].x, top: at[i].y, width: at[i].w, height: at[i].h }}
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-selected={sel === i}
aria-controls={`${id}-panel-${i}`}
tabIndex={sel === i ? 0 : -1}
onClick={() => setSel(i)}
>
<Icon i={i} />
<span className="tabs-tiles__label">{a.label}</span>
</button>
))}
</div>
{APPS.map((a, i) => (
<p key={a.label} className="tabs-tiles__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
{a.body}
</p>
))}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsTiles(el) on the mounted element instead.
<div class="tabs-tiles">
<div role="tablist" aria-label="Apps">
<button class="tabs-tiles__tile" style="left:0;top:0;width:240px;height:104px" type="button" role="tab" id="tabs-tiles-tab-0" aria-selected="true" aria-controls="tabs-tiles-panel-0" tabindex="0">
<svg class="tabs-tiles__icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 18L9 11L13 15L16 12L21 18ZM3 5H21V19H3Z"></path>
</svg>
<span class="tabs-tiles__label">Photos</span>
</button>
<button class="tabs-tiles__tile" style="left:0;top:112px;width:76px;height:48px" type="button" role="tab" id="tabs-tiles-tab-1" aria-selected="false" aria-controls="tabs-tiles-panel-1" tabindex="-1">
<svg class="tabs-tiles__icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 18V6L19 4V16M9 18A2.5 2.5 0 1 1 4 18A2.5 2.5 0 0 1 9 18ZM19 16A2.5 2.5 0 1 1 14 16A2.5 2.5 0 0 1 19 16Z"></path>
</svg>
<span class="tabs-tiles__label">Music</span>
</button>
<button class="tabs-tiles__tile" style="left:82px;top:112px;width:76px;height:48px" type="button" role="tab" id="tabs-tiles-tab-2" aria-selected="false" aria-controls="tabs-tiles-panel-2" tabindex="-1">
<svg class="tabs-tiles__icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 21C12 21 5 14.5 5 9.5A7 7 0 0 1 19 9.5C19 14.5 12 21 12 21ZM12 12A2.5 2.5 0 1 0 12 7A2.5 2.5 0 0 0 12 12Z"></path>
</svg>
<span class="tabs-tiles__label">Maps</span>
</button>
<button class="tabs-tiles__tile" style="left:164px;top:112px;width:76px;height:48px" type="button" role="tab" id="tabs-tiles-tab-3" aria-selected="false" aria-controls="tabs-tiles-panel-3" tabindex="-1">
<svg class="tabs-tiles__icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 3H18V21H6ZM9 8H15M9 12H15M9 16H13"></path>
</svg>
<span class="tabs-tiles__label">Notes</span>
</button>
</div>
<p class="tabs-tiles__panel" role="tabpanel" id="tabs-tiles-panel-0" aria-labelledby="tabs-tiles-tab-0">2,481 photos. The latest is from this morning, at the harbour.</p>
<p class="tabs-tiles__panel" role="tabpanel" id="tabs-tiles-panel-1" aria-labelledby="tabs-tiles-tab-1" hidden="">Playing Tides. Three albums saved for the train.</p>
<p class="tabs-tiles__panel" role="tabpanel" id="tabs-tiles-panel-2" aria-labelledby="tabs-tiles-tab-2" hidden="">Home in 24 minutes by bike. Light rain after four.</p>
<p class="tabs-tiles__panel" role="tabpanel" id="tabs-tiles-panel-3" aria-labelledby="tabs-tiles-tab-3" hidden="">Poster sizes, the ink order and a list of printers to call.</p>
</div>.tabs-tiles { position: relative; width: 240px; height: 160px; }
.tabs-tiles__tile {
position: absolute;
padding: 0;
border: 0;
border-radius: 14px;
background: light-dark(#ebedf0, #232529);
color: light-dark(#50535b, #c6c8cd);
font: inherit;
cursor: pointer;
transition: left 0.5s cubic-bezier(0.3, 1.15, 0.4, 1), top 0.5s cubic-bezier(0.3, 1.15, 0.4, 1), width 0.5s cubic-bezier(0.3, 1.15, 0.4, 1), height 0.5s cubic-bezier(0.3, 1.15, 0.4, 1), background-color 0.3s, color 0.3s;
}
.tabs-tiles__tile[aria-selected='true'] { z-index: 1; background: light-dark(#dbdde1, #2e3035); color: light-dark(#373a40, #f1f2f3); }
.tabs-tiles__tile:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.tabs-tiles__icon { position: absolute; left: calc(50% - 9px); top: 8px; width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; transition: left 0.5s cubic-bezier(0.3, 1.15, 0.4, 1), top 0.5s cubic-bezier(0.3, 1.15, 0.4, 1), width 0.5s cubic-bezier(0.3, 1.15, 0.4, 1), height 0.5s cubic-bezier(0.3, 1.15, 0.4, 1); }
.tabs-tiles__tile[aria-selected='true'] .tabs-tiles__icon { left: calc(100% - 42px); top: 14px; width: 28px; height: 28px; }
.tabs-tiles__label { position: absolute; left: 50%; top: 29px; font-size: 10.5px; font-weight: 600; white-space: nowrap; transform: translateX(-50%); transition: left 0.5s cubic-bezier(0.3, 1.15, 0.4, 1), top 0.5s cubic-bezier(0.3, 1.15, 0.4, 1), transform 0.5s cubic-bezier(0.3, 1.15, 0.4, 1), font-size 0.5s cubic-bezier(0.3, 1.15, 0.4, 1); }
.tabs-tiles__tile[aria-selected='true'] .tabs-tiles__label { left: 14px; top: 14px; font-size: 16px; transform: none; }
.tabs-tiles__panel { position: absolute; left: 14px; right: 14px; top: 46px; z-index: 2; margin: 0; font-size: 12px; line-height: 1.5; color: light-dark(#42454c, #e5e6e9); pointer-events: none; animation: tabs-tiles-in 0.35s ease-out 0.35s backwards; }
@keyframes tabs-tiles-in {
from { opacity: 0; transform: translateY(5px); }
}
@media (prefers-reduced-motion: reduce) {
.tabs-tiles__tile, .tabs-tiles__icon, .tabs-tiles__label { transition: none; }
.tabs-tiles__panel { animation: none; }
}function initTabsTiles(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 BIG = { x: 0, y: 0, w: 240, h: 104 };
function small(k) { return { x: k * 82, y: 112, w: 76, h: 48 }; }
// The chosen app takes the big slot; the others fill the small row in order. The CSS transitions
// each tile's left, top, width and height, so the swap is one move.
function place() {
var k = 0;
tabs.forEach(function (tab, j) {
var at = j === sel ? BIG : small(k++);
tab.style.left = at.x + 'px';
tab.style.top = at.y + 'px';
tab.style.width = at.w + 'px';
tab.style.height = at.h + 'px';
});
}
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; });
place();
}
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-tiles').forEach(initTabsTiles);Goal: Create a "Tiles" tab set.
Visual behavior: App tiles: the chosen tile grows into the big slot with its icon and words, while the one it replaces shrinks down into the row of small tiles.
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 tiles are tab buttons absolutely placed in two kinds of slot, one big and a row of small. Picking one sets each tile's left, top, width and height, and an overshooting transition moves them all at once.
- In the big slot the icon moves up into the corner and grows, and the label sets larger.
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 tiles swap without moving.
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 "Tiles" tab set.
Visual behavior: App tiles: the chosen tile grows into the big slot with its icon and words, while the one it replaces shrinks down into the row of small tiles.
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 tiles are tab buttons absolutely placed in two kinds of slot, one big and a row of small. Picking one sets each tile's left, top, width and height, and an overshooting transition moves them all at once.
- In the big slot the icon moves up into the corner and grows, and the label sets larger.
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 tiles swap without moving.
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
App tiles: the chosen tile grows into the big slot with its icon and words, while the one it replaces shrinks down into the row of small tiles.
- Dependencies
- None
- Difficulty
- Beginner
- Triggers
- click, state change, spring
- Techniques
- css transition, svg
- 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 tiles swap without moving.
More tabs
See allHeavyweight cotton, garment dyed and cut slightly boxy.
Overview
A walnut desk lamp with a linen shade and a dimmer in the base.
Materials
Reviews
Chapter one
A winter studio, a borrowed press and a stack of offcuts.