Icons
import { useId, useRef, useState } from 'react';
const TABS = [
{ label: 'Home', title: 'Good morning', body: 'Three things on today, and the first is at ten.' },
{ label: 'Search', title: 'Search', body: 'Recent: poster sizes, risograph inks, Ana.' },
{ label: 'Alerts', title: 'Alerts', body: 'Jun commented on the proofs. Two new followers.' },
{ label: 'Profile', title: 'Mira Holt', body: 'Motion designer. 48 projects, 1,284 followers.' },
];
const css = `
.tabs-icons { width: 240px; }
.tabs-icons__screen { box-sizing: border-box; height: 92px; margin-bottom: 8px; padding: 14px; border-radius: 14px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.tabs-icons__title { margin: 0 0 4px; font-size: 14px; font-weight: 600; color: light-dark(#373a40, #f1f2f3); }
.tabs-icons__body { margin: 0; font-size: 12.5px; line-height: 1.5; color: light-dark(#50535b, #c6c8cd); }
.tabs-icons__panel { animation: tabs-icons-in 0.35s ease-out; }
@keyframes tabs-icons-in {
from { opacity: 0; transform: translateY(4px); }
}
.tabs-icons__bar { display: grid; grid-template-columns: repeat(${TABS.length}, 1fr); padding: 6px; border-radius: 16px; background: light-dark(#ebedf0, #232529); }
.tabs-icons__tab { display: grid; justify-items: center; gap: 3px; padding: 5px 0 4px; border: 0; border-radius: 11px; background: none; font: inherit; font-size: 10.5px; font-weight: 500; color: light-dark(#5f626a, #a7abb2); cursor: pointer; transition: color 0.25s; }
.tabs-icons__tab:hover, .tabs-icons__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-icons__tab:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: -2px; }
.tabs-icons__icon { width: 22px; height: 22px; overflow: visible; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.tabs-icons__icon * { transform-box: fill-box; }
.tabs-icons__shape { fill: currentColor; fill-opacity: 0; transition: fill-opacity 0.3s; }
.tabs-icons__tab[aria-selected='true'] .tabs-icons__shape { fill-opacity: 0.22; }
/* Each icon acts out its meaning when picked. */
.tabs-icons__door { transform-origin: 0 50%; }
.tabs-icons__tab[aria-selected='true'] .tabs-icons__door { animation: tabs-icons-door 0.9s ease-in-out; }
@keyframes tabs-icons-door {
30%, 65% { transform: scaleX(0.15); }
}
.tabs-icons__puff { opacity: 0; }
.tabs-icons__tab[aria-selected='true'] .tabs-icons__puff { animation: tabs-icons-puff 0.9s ease-out 0.15s; }
@keyframes tabs-icons-puff {
from { opacity: 0.9; transform: translateY(0) scale(0.6); }
to { opacity: 0; transform: translateY(-6px) scale(1.4); }
}
.tabs-icons__glass { transform-origin: 40% 40%; }
.tabs-icons__tab[aria-selected='true'] .tabs-icons__glass { animation: tabs-icons-scan 0.8s ease-in-out; }
@keyframes tabs-icons-scan {
25% { transform: translate(-2px, -1px) rotate(-14deg); }
55% { transform: translate(2px, -2px) rotate(10deg); }
80% { transform: translate(0.5px, 0.5px) rotate(-3deg); }
}
.tabs-icons__bell { transform-origin: 50% 0; }
.tabs-icons__tab[aria-selected='true'] .tabs-icons__bell { animation: tabs-icons-ring 0.9s ease-out; }
@keyframes tabs-icons-ring {
12% { transform: rotate(18deg); }
28% { transform: rotate(-15deg); }
44% { transform: rotate(10deg); }
60% { transform: rotate(-6deg); }
76% { transform: rotate(3deg); }
}
.tabs-icons__clapper { transform-origin: 50% -500%; }
.tabs-icons__tab[aria-selected='true'] .tabs-icons__clapper { animation: tabs-icons-clap 0.9s ease-out 0.05s; }
@keyframes tabs-icons-clap {
15% { transform: rotate(-26deg); }
32% { transform: rotate(22deg); }
50% { transform: rotate(-12deg); }
68% { transform: rotate(5deg); }
}
.tabs-icons__head { transform-origin: 50% 100%; }
.tabs-icons__tab[aria-selected='true'] .tabs-icons__head { animation: tabs-icons-nod 0.8s ease-in-out; }
@keyframes tabs-icons-nod {
30% { transform: translateY(-2.5px); }
60% { transform: translateY(1px); }
}
@media (prefers-reduced-motion: reduce) {
.tabs-icons__icon *, .tabs-icons__panel { animation: none !important; }
}
`;
function Icon({ i }: { i: number }) {
return (
<svg className="tabs-icons__icon" viewBox="0 0 24 24" aria-hidden="true">
{i === 0 && (
<>
<circle className="tabs-icons__puff" cx={17} cy={3} r={1.6} />
<path className="tabs-icons__shape" d="M3.5 11L12 4L20.5 11V20H3.5Z" />
<rect className="tabs-icons__door" x={10} y={14} width={4} height={6} />
</>
)}
{i === 1 && (
<g className="tabs-icons__glass">
<circle className="tabs-icons__shape" cx={10.5} cy={10.5} r={6} />
<path d="M15 15L20 20" />
</g>
)}
{i === 2 && (
<>
<path className="tabs-icons__bell tabs-icons__shape" d="M6 16.5V11A6 6 0 0 1 18 11V16.5L19.5 18.5H4.5Z" />
<circle className="tabs-icons__clapper" cx={12} cy={21} r={1.4} />
</>
)}
{i === 3 && (
<>
<circle className="tabs-icons__head tabs-icons__shape" cx={12} cy={8} r={3.6} />
<path className="tabs-icons__shape" d="M4.5 20.5C4.5 16.5 7.8 14.5 12 14.5S19.5 16.5 19.5 20.5" />
</>
)}
</svg>
);
}
export function IconsTabs() {
const id = useId();
const [sel, setSel] = useState(0);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const n = TABS.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-icons">
<div className="tabs-icons__screen">
{TABS.map((t, i) => (
<div key={t.label} className="tabs-icons__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
<p className="tabs-icons__title">{t.title}</p>
<p className="tabs-icons__body">{t.body}</p>
</div>
))}
</div>
<div className="tabs-icons__bar" role="tablist" aria-label="App" onKeyDown={onKeyDown}>
{TABS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-icons__tab"
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} />
{t.label}
</button>
))}
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsIcons(el) on the mounted element instead.
<div class="tabs-icons">
<div class="tabs-icons__screen">
<div class="tabs-icons__panel" role="tabpanel" id="tabs-icons-panel-0" aria-labelledby="tabs-icons-tab-0">
<p class="tabs-icons__title">Good morning</p>
<p class="tabs-icons__body">Three things on today, and the first is at ten.</p>
</div>
<div class="tabs-icons__panel" role="tabpanel" id="tabs-icons-panel-1" aria-labelledby="tabs-icons-tab-1" hidden="">
<p class="tabs-icons__title">Search</p>
<p class="tabs-icons__body">Recent: poster sizes, risograph inks, Ana.</p>
</div>
<div class="tabs-icons__panel" role="tabpanel" id="tabs-icons-panel-2" aria-labelledby="tabs-icons-tab-2" hidden="">
<p class="tabs-icons__title">Alerts</p>
<p class="tabs-icons__body">Jun commented on the proofs. Two new followers.</p>
</div>
<div class="tabs-icons__panel" role="tabpanel" id="tabs-icons-panel-3" aria-labelledby="tabs-icons-tab-3" hidden="">
<p class="tabs-icons__title">Mira Holt</p>
<p class="tabs-icons__body">Motion designer. 48 projects, 1,284 followers.</p>
</div>
</div>
<div class="tabs-icons__bar" role="tablist" aria-label="App">
<button class="tabs-icons__tab" type="button" role="tab" id="tabs-icons-tab-0" aria-selected="true" aria-controls="tabs-icons-panel-0" tabindex="0">
<svg class="tabs-icons__icon" viewBox="0 0 24 24" aria-hidden="true">
<circle class="tabs-icons__puff" cx="17" cy="3" r="1.6"></circle>
<path class="tabs-icons__shape" d="M3.5 11L12 4L20.5 11V20H3.5Z"></path>
<rect class="tabs-icons__door" x="10" y="14" width="4" height="6"></rect>
</svg>
Home
</button>
<button class="tabs-icons__tab" type="button" role="tab" id="tabs-icons-tab-1" aria-selected="false" aria-controls="tabs-icons-panel-1" tabindex="-1">
<svg class="tabs-icons__icon" viewBox="0 0 24 24" aria-hidden="true">
<g class="tabs-icons__glass">
<circle class="tabs-icons__shape" cx="10.5" cy="10.5" r="6"></circle>
<path d="M15 15L20 20"></path>
</g>
</svg>
Search
</button>
<button class="tabs-icons__tab" type="button" role="tab" id="tabs-icons-tab-2" aria-selected="false" aria-controls="tabs-icons-panel-2" tabindex="-1">
<svg class="tabs-icons__icon" viewBox="0 0 24 24" aria-hidden="true">
<path class="tabs-icons__bell tabs-icons__shape" d="M6 16.5V11A6 6 0 0 1 18 11V16.5L19.5 18.5H4.5Z"></path>
<circle class="tabs-icons__clapper" cx="12" cy="21" r="1.4"></circle>
</svg>
Alerts
</button>
<button class="tabs-icons__tab" type="button" role="tab" id="tabs-icons-tab-3" aria-selected="false" aria-controls="tabs-icons-panel-3" tabindex="-1">
<svg class="tabs-icons__icon" viewBox="0 0 24 24" aria-hidden="true">
<circle class="tabs-icons__head tabs-icons__shape" cx="12" cy="8" r="3.6"></circle>
<path class="tabs-icons__shape" d="M4.5 20.5C4.5 16.5 7.8 14.5 12 14.5S19.5 16.5 19.5 20.5"></path>
</svg>
Profile
</button>
</div>
</div>.tabs-icons { width: 240px; }
.tabs-icons__screen { box-sizing: border-box; height: 92px; margin-bottom: 8px; padding: 14px; border-radius: 14px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.tabs-icons__title { margin: 0 0 4px; font-size: 14px; font-weight: 600; color: light-dark(#373a40, #f1f2f3); }
.tabs-icons__body { margin: 0; font-size: 12.5px; line-height: 1.5; color: light-dark(#50535b, #c6c8cd); }
.tabs-icons__panel { animation: tabs-icons-in 0.35s ease-out; }
@keyframes tabs-icons-in {
from { opacity: 0; transform: translateY(4px); }
}
.tabs-icons__bar { display: grid; grid-template-columns: repeat(4, 1fr); padding: 6px; border-radius: 16px; background: light-dark(#ebedf0, #232529); }
.tabs-icons__tab { display: grid; justify-items: center; gap: 3px; padding: 5px 0 4px; border: 0; border-radius: 11px; background: none; font: inherit; font-size: 10.5px; font-weight: 500; color: light-dark(#5f626a, #a7abb2); cursor: pointer; transition: color 0.25s; }
.tabs-icons__tab:hover, .tabs-icons__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-icons__tab:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: -2px; }
.tabs-icons__icon { width: 22px; height: 22px; overflow: visible; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.tabs-icons__icon * { transform-box: fill-box; }
.tabs-icons__shape { fill: currentColor; fill-opacity: 0; transition: fill-opacity 0.3s; }
.tabs-icons__tab[aria-selected='true'] .tabs-icons__shape { fill-opacity: 0.22; }
/* Each icon acts out its meaning when picked. */
.tabs-icons__door { transform-origin: 0 50%; }
.tabs-icons__tab[aria-selected='true'] .tabs-icons__door { animation: tabs-icons-door 0.9s ease-in-out; }
@keyframes tabs-icons-door {
30%, 65% { transform: scaleX(0.15); }
}
.tabs-icons__puff { opacity: 0; }
.tabs-icons__tab[aria-selected='true'] .tabs-icons__puff { animation: tabs-icons-puff 0.9s ease-out 0.15s; }
@keyframes tabs-icons-puff {
from { opacity: 0.9; transform: translateY(0) scale(0.6); }
to { opacity: 0; transform: translateY(-6px) scale(1.4); }
}
.tabs-icons__glass { transform-origin: 40% 40%; }
.tabs-icons__tab[aria-selected='true'] .tabs-icons__glass { animation: tabs-icons-scan 0.8s ease-in-out; }
@keyframes tabs-icons-scan {
25% { transform: translate(-2px, -1px) rotate(-14deg); }
55% { transform: translate(2px, -2px) rotate(10deg); }
80% { transform: translate(0.5px, 0.5px) rotate(-3deg); }
}
.tabs-icons__bell { transform-origin: 50% 0; }
.tabs-icons__tab[aria-selected='true'] .tabs-icons__bell { animation: tabs-icons-ring 0.9s ease-out; }
@keyframes tabs-icons-ring {
12% { transform: rotate(18deg); }
28% { transform: rotate(-15deg); }
44% { transform: rotate(10deg); }
60% { transform: rotate(-6deg); }
76% { transform: rotate(3deg); }
}
.tabs-icons__clapper { transform-origin: 50% -500%; }
.tabs-icons__tab[aria-selected='true'] .tabs-icons__clapper { animation: tabs-icons-clap 0.9s ease-out 0.05s; }
@keyframes tabs-icons-clap {
15% { transform: rotate(-26deg); }
32% { transform: rotate(22deg); }
50% { transform: rotate(-12deg); }
68% { transform: rotate(5deg); }
}
.tabs-icons__head { transform-origin: 50% 100%; }
.tabs-icons__tab[aria-selected='true'] .tabs-icons__head { animation: tabs-icons-nod 0.8s ease-in-out; }
@keyframes tabs-icons-nod {
30% { transform: translateY(-2.5px); }
60% { transform: translateY(1px); }
}
@media (prefers-reduced-motion: reduce) {
.tabs-icons__icon *, .tabs-icons__panel { animation: none !important; }
}function initTabsIcons(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'; });
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; });
}
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-icons').forEach(initTabsIcons);Goal: Create a "Icons" tab set.
Visual behavior: A bottom bar whose icons act out their meaning when picked: the door opens with a puff from the chimney, the glass scans, the bell rings and the head nods.
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.
- Each icon is a small SVG split into the parts that move: the house door and a puff of smoke, the magnifying glass, the bell and its clapper, the head.
- Keyframes on those parts run when their tab becomes aria-selected, so each icon acts once as it is picked.
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. Every tab carries its text label. Under prefers-reduced-motion the icons stay still.
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 "Icons" tab set.
Visual behavior: A bottom bar whose icons act out their meaning when picked: the door opens with a puff from the chimney, the glass scans, the bell rings and the head nods.
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.
- Each icon is a small SVG split into the parts that move: the house door and a puff of smoke, the magnifying glass, the bell and its clapper, the head.
- Keyframes on those parts run when their tab becomes aria-selected, so each icon acts once as it is picked.
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. Every tab carries its text label. Under prefers-reduced-motion the icons stay still.
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
A bottom bar whose icons act out their meaning when picked: the door opens with a puff from the chimney, the glass scans, the bell rings and the head nods.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, icon, keyframe, state change
- Techniques
- keyframes, svg, transform only
- 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. Every tab carries its text label. Under prefers-reduced-motion the icons stay still.
More tabs
See all- Harbour, 6am
- Market stalls
- Fog on the pier