Hangers
import { useId, useRef, useState, type CSSProperties } from 'react';
const ITEMS = [
{
label: 'Coat',
tone: 'coat',
shape: 'M26 15L14 20Q10 22 9 30L5 74L13 76L17 42L17 97H63L63 42L67 76L75 74L71 30Q70 22 66 20L54 15Q47 21 40 21Q33 21 26 15Z',
lines: 'M26 15L33 40L40 30M54 15L47 40L40 30M43 31V97M22 72H31M49 72H58',
dots: [[36, 52], [47, 52], [36, 64], [47, 64]],
body: 'Wool, double-breasted and fully lined. Sizes S to XL.',
},
{
label: 'Shirt',
tone: 'shirt',
shape: 'M27 15L13 21Q10 23 9 30L6 58L14 60L18 36L18 72Q40 78 62 72L62 36L66 60L74 58L71 30Q70 23 67 21L53 15Q46 19 40 19Q34 19 27 15Z',
lines: 'M30 15L36 26L40 19L44 26L50 15M40 22V75M46 32H54V40H46Z',
dots: [[40, 32], [40, 42], [40, 52], [40, 62]],
body: 'Oxford cotton with a button-down collar. Pressed and folded.',
},
{
label: 'Dress',
tone: 'dress',
shape: 'M30 15L26 16Q24 30 27 40Q28 46 24 52L10 96Q40 102 70 96L56 52Q52 46 53 40Q56 30 54 16L50 15Q45 24 40 24Q35 24 30 15Z',
lines: 'M27 44Q40 48 53 44M33 50L26 94M40 50V98M47 50L54 94',
dots: [[34, 30], [46, 34], [38, 38], [22, 80], [30, 66], [50, 62], [58, 82], [42, 76], [36, 88], [46, 90]],
body: 'Linen, cut on the bias, with pockets in the side seams.',
},
{
label: 'Knit',
tone: 'knit',
shape: 'M26 15L12 22Q9 24 8 32L5 66L14 68L18 38L19 74H61L62 38L66 68L75 66L72 32Q71 24 68 22L54 15Q47 22 40 22Q33 22 26 15Z',
lines: 'M19 69H61M5.5 62L14.5 64M74.5 62L65.5 64M36 28L40 33L36 38L40 43L36 48L40 53L36 58L40 63M44 28L40 33L44 38L40 43L44 48L40 53L44 58L40 63',
dots: [],
body: 'A heavy cable knit in undyed wool, for the coldest days.',
},
];
const CENTER = [40, 90, 140, 190];
const css = `
.tabs-hangers { --wood: light-dark(#6d7179, #7d8087); width: 230px; }
.tabs-hangers__rail { position: relative; height: 148px; perspective: 520px; }
.tabs-hangers__rod { position: absolute; left: 2px; right: 2px; top: 6px; height: 4px; border-radius: 2px; background: linear-gradient(light-dark(#50535b, #c6c8cd), light-dark(#6d7179, #7d8087)); box-shadow: 0 2px 3px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.5)); }
.tabs-hangers__rod::before, .tabs-hangers__rod::after { content: ''; position: absolute; top: -4px; width: 4px; height: 12px; border-radius: 2px; background: light-dark(#6d7179, #7d8087); }
.tabs-hangers__rod::before { left: -2px; }
.tabs-hangers__rod::after { right: -2px; }
/* Each column is a tab: the garment on its hanger and its name below. */
.tabs-hangers__slot { position: absolute; top: 0; width: 50px; height: 148px; margin-left: -25px; padding: 0; border: 0; background: none; cursor: pointer; transform-style: preserve-3d; }
.tabs-hangers__slot:focus-visible { outline: none; }
.tabs-hangers__slot:focus-visible .tabs-hangers__name { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; border-radius: 3px; }
.tabs-hangers__hook { position: absolute; left: 50%; top: 2px; width: 12px; height: 16px; margin-left: -4px; overflow: visible; fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.6; stroke-linecap: round; }
/* The garment turns on the hook: face-on when picked; otherwise side-on, all the same way
as on a real rail, and shoved along away from the one you are looking at. */
.tabs-hangers__garment { position: absolute; left: 50%; top: 13px; width: 80px; height: 104px; margin-left: -40px; transform-origin: 50% 0; transform: translateX(var(--push)) rotateY(var(--turn)); filter: brightness(var(--lit)); transition: transform 0.65s cubic-bezier(0.3, 1.25, 0.45, 1), filter 0.4s; pointer-events: none; }
.tabs-hangers__sway { display: block; transform-origin: 50% 0; }
.tabs-hangers__slot[aria-selected='true'] .tabs-hangers__sway { animation: tabs-hangers-sway 1.4s ease-out 0.15s; }
.tabs-hangers__garment svg { display: block; overflow: visible; }
.tabs-hangers__bar { fill: var(--wood); }
.tabs-hangers__cloth { stroke-width: 0.8; stroke-linejoin: round; }
.tabs-hangers__cloth[data-tone='coat'] { fill: light-dark(#cbced3, #393c41); stroke: light-dark(#dbdde1, #2e3035); }
.tabs-hangers__cloth[data-tone='shirt'] { fill: light-dark(#fbfcfd, #e5e6e9); stroke: light-dark(#dbdde1, #a7abb2); }
.tabs-hangers__cloth[data-tone='dress'] { fill: light-dark(#dbdde1, #2e3035); stroke: light-dark(#ebedf0, #232529); }
.tabs-hangers__cloth[data-tone='knit'] { fill: light-dark(#6d7179, #7d8087); stroke: light-dark(#cbced3, #393c41); }
.tabs-hangers__seam { fill: none; stroke-width: 1; stroke-linecap: round; stroke-linejoin: round; }
.tabs-hangers__seam[data-tone='coat'] { stroke: light-dark(#ebedf0, #232529); }
.tabs-hangers__seam[data-tone='shirt'] { stroke: light-dark(#cbced3, #7d8087); }
.tabs-hangers__seam[data-tone='dress'] { stroke: light-dark(#f3f4f7, #1a1c1f); }
.tabs-hangers__seam[data-tone='knit'] { stroke: light-dark(#dbdde1, #2e3035); stroke-width: 1.4; }
.tabs-hangers__dot { fill: light-dark(#50535b, #c6c8cd); }
.tabs-hangers__dot[data-tone='shirt'] { fill: light-dark(#cbced3, #7d8087); }
.tabs-hangers__dot[data-tone='dress'] { fill: light-dark(#6d7179, #7d8087); }
.tabs-hangers__name { position: absolute; left: 0; right: 0; bottom: 0; color: light-dark(#5f626a, #a7abb2); font: 13px/1.2 Didot, 'Bodoni 72', 'Times New Roman', serif; letter-spacing: 0.04em; transition: color 0.3s; }
.tabs-hangers__slot:hover .tabs-hangers__name { color: light-dark(#42454c, #e5e6e9); }
.tabs-hangers__slot[aria-selected='true'] .tabs-hangers__name { color: light-dark(#373a40, #f1f2f3); font-style: italic; }
.tabs-hangers__panel { min-height: 3em; margin-top: 10px; color: light-dark(#50535b, #c6c8cd); font-size: 12.5px; line-height: 1.5; }
@keyframes tabs-hangers-sway { 0% { transform: rotate(0); } 20% { transform: rotate(4deg); } 45% { transform: rotate(-2.4deg); } 70% { transform: rotate(1deg); } 100% { transform: rotate(0); } }
@media (prefers-reduced-motion: reduce) {
.tabs-hangers__garment { transition: none; }
.tabs-hangers__slot[aria-selected='true'] .tabs-hangers__sway { animation: none; }
}
`;
export function Preview() {
const id = useId();
const [sel, setSel] = useState(0);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const n = ITEMS.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 href="kit-tabs-hangers" precedence="kit">
{css}
</style>
<div className="tabs-hangers">
<div className="tabs-hangers__rail" role="tablist" aria-label="Rail" onKeyDown={onKeyDown}>
<span className="tabs-hangers__rod" aria-hidden="true" />
{ITEMS.map((g, i) => {
const side = Math.sign(i - sel);
return (
<button
key={g.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-hangers__slot"
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-selected={sel === i}
aria-controls={`${id}-panel-${i}`}
tabIndex={sel === i ? 0 : -1}
style={{ left: CENTER[i] }}
onClick={() => setSel(i)}
>
<svg className="tabs-hangers__hook" viewBox="0 0 12 16" aria-hidden="true">
<path d="M4 15V9Q4 6 7 5.5Q9.5 5 9.5 2.8Q9.5 0.5 7 0.5Q4.6 0.5 4.4 2.6" />
</svg>
<span className="tabs-hangers__garment" style={{ '--turn': side ? '62deg' : '0deg', '--push': `${side * 8}px`, '--lit': side ? 0.72 : 1 } as CSSProperties} aria-hidden="true">
<span className="tabs-hangers__sway">
<svg width="80" height="104" viewBox="0 0 80 104">
<path className="tabs-hangers__cloth" data-tone={g.tone} d={g.shape} />
<path className="tabs-hangers__seam" data-tone={g.tone} d={g.lines} />
{g.dots.map(([x, y]) => (
<circle key={`${x}-${y}`} className="tabs-hangers__dot" data-tone={g.tone} cx={x} cy={y} r={g.tone === 'dress' ? 1.6 : 1.3} />
))}
<path className="tabs-hangers__bar" d="M40 5L73 15.5Q75.5 17.5 72 17.5H8Q4.5 17.5 7 15.5Z" />
</svg>
</span>
</span>
<span className="tabs-hangers__name">{g.label}</span>
</button>
);
})}
</div>
{ITEMS.map((g, i) => (
<div key={g.label} className="tabs-hangers__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
{g.body}
</div>
))}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsHangers(el) on the mounted element instead.
<div class="tabs-hangers">
<div class="tabs-hangers__rail" role="tablist" aria-label="Rail">
<span class="tabs-hangers__rod" aria-hidden="true"></span>
<button class="tabs-hangers__slot" type="button" role="tab" id="tabs-hangers-tab-0" aria-selected="true" aria-controls="tabs-hangers-panel-0" tabindex="0" style="left:40px">
<svg class="tabs-hangers__hook" viewBox="0 0 12 16" aria-hidden="true">
<path d="M4 15V9Q4 6 7 5.5Q9.5 5 9.5 2.8Q9.5 0.5 7 0.5Q4.6 0.5 4.4 2.6"></path>
</svg>
<span class="tabs-hangers__garment" style="--turn:0deg;--push:0px;--lit:1" aria-hidden="true">
<span class="tabs-hangers__sway">
<svg width="80" height="104" viewBox="0 0 80 104">
<path class="tabs-hangers__cloth" data-tone="coat" d="M26 15L14 20Q10 22 9 30L5 74L13 76L17 42L17 97H63L63 42L67 76L75 74L71 30Q70 22 66 20L54 15Q47 21 40 21Q33 21 26 15Z"></path>
<path class="tabs-hangers__seam" data-tone="coat" d="M26 15L33 40L40 30M54 15L47 40L40 30M43 31V97M22 72H31M49 72H58"></path>
<circle class="tabs-hangers__dot" data-tone="coat" cx="36" cy="52" r="1.3"></circle>
<circle class="tabs-hangers__dot" data-tone="coat" cx="47" cy="52" r="1.3"></circle>
<circle class="tabs-hangers__dot" data-tone="coat" cx="36" cy="64" r="1.3"></circle>
<circle class="tabs-hangers__dot" data-tone="coat" cx="47" cy="64" r="1.3"></circle>
<path class="tabs-hangers__bar" d="M40 5L73 15.5Q75.5 17.5 72 17.5H8Q4.5 17.5 7 15.5Z"></path>
</svg>
</span>
</span>
<span class="tabs-hangers__name">Coat</span>
</button>
<button class="tabs-hangers__slot" type="button" role="tab" id="tabs-hangers-tab-1" aria-selected="false" aria-controls="tabs-hangers-panel-1" tabindex="-1" style="left:90px">
<svg class="tabs-hangers__hook" viewBox="0 0 12 16" aria-hidden="true">
<path d="M4 15V9Q4 6 7 5.5Q9.5 5 9.5 2.8Q9.5 0.5 7 0.5Q4.6 0.5 4.4 2.6"></path>
</svg>
<span class="tabs-hangers__garment" style="--turn:62deg;--push:8px;--lit:0.72" aria-hidden="true">
<span class="tabs-hangers__sway">
<svg width="80" height="104" viewBox="0 0 80 104">
<path class="tabs-hangers__cloth" data-tone="shirt" d="M27 15L13 21Q10 23 9 30L6 58L14 60L18 36L18 72Q40 78 62 72L62 36L66 60L74 58L71 30Q70 23 67 21L53 15Q46 19 40 19Q34 19 27 15Z"></path>
<path class="tabs-hangers__seam" data-tone="shirt" d="M30 15L36 26L40 19L44 26L50 15M40 22V75M46 32H54V40H46Z"></path>
<circle class="tabs-hangers__dot" data-tone="shirt" cx="40" cy="32" r="1.3"></circle>
<circle class="tabs-hangers__dot" data-tone="shirt" cx="40" cy="42" r="1.3"></circle>
<circle class="tabs-hangers__dot" data-tone="shirt" cx="40" cy="52" r="1.3"></circle>
<circle class="tabs-hangers__dot" data-tone="shirt" cx="40" cy="62" r="1.3"></circle>
<path class="tabs-hangers__bar" d="M40 5L73 15.5Q75.5 17.5 72 17.5H8Q4.5 17.5 7 15.5Z"></path>
</svg>
</span>
</span>
<span class="tabs-hangers__name">Shirt</span>
</button>
<button class="tabs-hangers__slot" type="button" role="tab" id="tabs-hangers-tab-2" aria-selected="false" aria-controls="tabs-hangers-panel-2" tabindex="-1" style="left:140px">
<svg class="tabs-hangers__hook" viewBox="0 0 12 16" aria-hidden="true">
<path d="M4 15V9Q4 6 7 5.5Q9.5 5 9.5 2.8Q9.5 0.5 7 0.5Q4.6 0.5 4.4 2.6"></path>
</svg>
<span class="tabs-hangers__garment" style="--turn:62deg;--push:8px;--lit:0.72" aria-hidden="true">
<span class="tabs-hangers__sway">
<svg width="80" height="104" viewBox="0 0 80 104">
<path class="tabs-hangers__cloth" data-tone="dress" d="M30 15L26 16Q24 30 27 40Q28 46 24 52L10 96Q40 102 70 96L56 52Q52 46 53 40Q56 30 54 16L50 15Q45 24 40 24Q35 24 30 15Z"></path>
<path class="tabs-hangers__seam" data-tone="dress" d="M27 44Q40 48 53 44M33 50L26 94M40 50V98M47 50L54 94"></path>
<circle class="tabs-hangers__dot" data-tone="dress" cx="34" cy="30" r="1.6"></circle>
<circle class="tabs-hangers__dot" data-tone="dress" cx="46" cy="34" r="1.6"></circle>
<circle class="tabs-hangers__dot" data-tone="dress" cx="38" cy="38" r="1.6"></circle>
<circle class="tabs-hangers__dot" data-tone="dress" cx="22" cy="80" r="1.6"></circle>
<circle class="tabs-hangers__dot" data-tone="dress" cx="30" cy="66" r="1.6"></circle>
<circle class="tabs-hangers__dot" data-tone="dress" cx="50" cy="62" r="1.6"></circle>
<circle class="tabs-hangers__dot" data-tone="dress" cx="58" cy="82" r="1.6"></circle>
<circle class="tabs-hangers__dot" data-tone="dress" cx="42" cy="76" r="1.6"></circle>
<circle class="tabs-hangers__dot" data-tone="dress" cx="36" cy="88" r="1.6"></circle>
<circle class="tabs-hangers__dot" data-tone="dress" cx="46" cy="90" r="1.6"></circle>
<path class="tabs-hangers__bar" d="M40 5L73 15.5Q75.5 17.5 72 17.5H8Q4.5 17.5 7 15.5Z"></path>
</svg>
</span>
</span>
<span class="tabs-hangers__name">Dress</span>
</button>
<button class="tabs-hangers__slot" type="button" role="tab" id="tabs-hangers-tab-3" aria-selected="false" aria-controls="tabs-hangers-panel-3" tabindex="-1" style="left:190px">
<svg class="tabs-hangers__hook" viewBox="0 0 12 16" aria-hidden="true">
<path d="M4 15V9Q4 6 7 5.5Q9.5 5 9.5 2.8Q9.5 0.5 7 0.5Q4.6 0.5 4.4 2.6"></path>
</svg>
<span class="tabs-hangers__garment" style="--turn:62deg;--push:8px;--lit:0.72" aria-hidden="true">
<span class="tabs-hangers__sway">
<svg width="80" height="104" viewBox="0 0 80 104">
<path class="tabs-hangers__cloth" data-tone="knit" d="M26 15L12 22Q9 24 8 32L5 66L14 68L18 38L19 74H61L62 38L66 68L75 66L72 32Q71 24 68 22L54 15Q47 22 40 22Q33 22 26 15Z"></path>
<path class="tabs-hangers__seam" data-tone="knit" d="M19 69H61M5.5 62L14.5 64M74.5 62L65.5 64M36 28L40 33L36 38L40 43L36 48L40 53L36 58L40 63M44 28L40 33L44 38L40 43L44 48L40 53L44 58L40 63"></path>
<path class="tabs-hangers__bar" d="M40 5L73 15.5Q75.5 17.5 72 17.5H8Q4.5 17.5 7 15.5Z"></path>
</svg>
</span>
</span>
<span class="tabs-hangers__name">Knit</span>
</button>
</div>
<div class="tabs-hangers__panel" role="tabpanel" id="tabs-hangers-panel-0" aria-labelledby="tabs-hangers-tab-0">Wool, double-breasted and fully lined. Sizes S to XL.</div>
<div class="tabs-hangers__panel" role="tabpanel" id="tabs-hangers-panel-1" aria-labelledby="tabs-hangers-tab-1" hidden="">Oxford cotton with a button-down collar. Pressed and folded.</div>
<div class="tabs-hangers__panel" role="tabpanel" id="tabs-hangers-panel-2" aria-labelledby="tabs-hangers-tab-2" hidden="">Linen, cut on the bias, with pockets in the side seams.</div>
<div class="tabs-hangers__panel" role="tabpanel" id="tabs-hangers-panel-3" aria-labelledby="tabs-hangers-tab-3" hidden="">A heavy cable knit in undyed wool, for the coldest days.</div>
</div>.tabs-hangers { --wood: light-dark(#6d7179, #7d8087); width: 230px; }
.tabs-hangers__rail { position: relative; height: 148px; perspective: 520px; }
.tabs-hangers__rod { position: absolute; left: 2px; right: 2px; top: 6px; height: 4px; border-radius: 2px; background: linear-gradient(light-dark(#50535b, #c6c8cd), light-dark(#6d7179, #7d8087)); box-shadow: 0 2px 3px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.5)); }
.tabs-hangers__rod::before, .tabs-hangers__rod::after { content: ''; position: absolute; top: -4px; width: 4px; height: 12px; border-radius: 2px; background: light-dark(#6d7179, #7d8087); }
.tabs-hangers__rod::before { left: -2px; }
.tabs-hangers__rod::after { right: -2px; }
/* Each column is a tab: the garment on its hanger and its name below. */
.tabs-hangers__slot { position: absolute; top: 0; width: 50px; height: 148px; margin-left: -25px; padding: 0; border: 0; background: none; cursor: pointer; transform-style: preserve-3d; }
.tabs-hangers__slot:focus-visible { outline: none; }
.tabs-hangers__slot:focus-visible .tabs-hangers__name { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; border-radius: 3px; }
.tabs-hangers__hook { position: absolute; left: 50%; top: 2px; width: 12px; height: 16px; margin-left: -4px; overflow: visible; fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.6; stroke-linecap: round; }
/* The garment turns on the hook: face-on when picked; otherwise side-on, all the same way
as on a real rail, and shoved along away from the one you are looking at. */
.tabs-hangers__garment { position: absolute; left: 50%; top: 13px; width: 80px; height: 104px; margin-left: -40px; transform-origin: 50% 0; transform: translateX(var(--push)) rotateY(var(--turn)); filter: brightness(var(--lit)); transition: transform 0.65s cubic-bezier(0.3, 1.25, 0.45, 1), filter 0.4s; pointer-events: none; }
.tabs-hangers__sway { display: block; transform-origin: 50% 0; }
.tabs-hangers__slot[aria-selected='true'] .tabs-hangers__sway { animation: tabs-hangers-sway 1.4s ease-out 0.15s; }
.tabs-hangers__garment svg { display: block; overflow: visible; }
.tabs-hangers__bar { fill: var(--wood); }
.tabs-hangers__cloth { stroke-width: 0.8; stroke-linejoin: round; }
.tabs-hangers__cloth[data-tone='coat'] { fill: light-dark(#cbced3, #393c41); stroke: light-dark(#dbdde1, #2e3035); }
.tabs-hangers__cloth[data-tone='shirt'] { fill: light-dark(#fbfcfd, #e5e6e9); stroke: light-dark(#dbdde1, #a7abb2); }
.tabs-hangers__cloth[data-tone='dress'] { fill: light-dark(#dbdde1, #2e3035); stroke: light-dark(#ebedf0, #232529); }
.tabs-hangers__cloth[data-tone='knit'] { fill: light-dark(#6d7179, #7d8087); stroke: light-dark(#cbced3, #393c41); }
.tabs-hangers__seam { fill: none; stroke-width: 1; stroke-linecap: round; stroke-linejoin: round; }
.tabs-hangers__seam[data-tone='coat'] { stroke: light-dark(#ebedf0, #232529); }
.tabs-hangers__seam[data-tone='shirt'] { stroke: light-dark(#cbced3, #7d8087); }
.tabs-hangers__seam[data-tone='dress'] { stroke: light-dark(#f3f4f7, #1a1c1f); }
.tabs-hangers__seam[data-tone='knit'] { stroke: light-dark(#dbdde1, #2e3035); stroke-width: 1.4; }
.tabs-hangers__dot { fill: light-dark(#50535b, #c6c8cd); }
.tabs-hangers__dot[data-tone='shirt'] { fill: light-dark(#cbced3, #7d8087); }
.tabs-hangers__dot[data-tone='dress'] { fill: light-dark(#6d7179, #7d8087); }
.tabs-hangers__name { position: absolute; left: 0; right: 0; bottom: 0; color: light-dark(#5f626a, #a7abb2); font: 13px/1.2 Didot, 'Bodoni 72', 'Times New Roman', serif; letter-spacing: 0.04em; transition: color 0.3s; }
.tabs-hangers__slot:hover .tabs-hangers__name { color: light-dark(#42454c, #e5e6e9); }
.tabs-hangers__slot[aria-selected='true'] .tabs-hangers__name { color: light-dark(#373a40, #f1f2f3); font-style: italic; }
.tabs-hangers__panel { min-height: 3em; margin-top: 10px; color: light-dark(#50535b, #c6c8cd); font-size: 12.5px; line-height: 1.5; }
@keyframes tabs-hangers-sway { 0% { transform: rotate(0); } 20% { transform: rotate(4deg); } 45% { transform: rotate(-2.4deg); } 70% { transform: rotate(1deg); } 100% { transform: rotate(0); } }
@media (prefers-reduced-motion: reduce) {
.tabs-hangers__garment { transition: none; }
.tabs-hangers__slot[aria-selected='true'] .tabs-hangers__sway { animation: none; }
}function initTabsHangers(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 garments = root.querySelectorAll('.tabs-hangers__garment');
// The picked garment faces you; the rest hang side-on, all the same way, shoved along the rail.
function update() {
garments.forEach(function (garment, k) {
var side = Math.sign(k - sel);
garment.style.setProperty('--turn', side ? '62deg' : '0deg');
garment.style.setProperty('--push', side * 8 + 'px');
garment.style.setProperty('--lit', side ? '0.72' : '1');
});
}
function pick(i) {
if (i === sel) return;
var from = sel;
sel = i;
tabs.forEach(function (tab, k) {
tab.setAttribute('aria-selected', String(k === sel));
tab.tabIndex = k === sel ? 0 : -1;
});
panels.forEach(function (panel, k) { panel.hidden = k !== sel; });
update(from);
}
function onClick(e) {
var i = tabs.indexOf(e.currentTarget);
if (i >= 0) pick(i);
}
function onKey(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();
pick(next);
tabs[next].focus();
}
tabs.forEach(function (tab) { tab.addEventListener('click', onClick); });
list.addEventListener('keydown', onKey);
return function () {
tabs.forEach(function (tab) { tab.removeEventListener('click', onClick); });
list.removeEventListener('keydown', onKey);
};
}
document.querySelectorAll('.tabs-hangers').forEach(initTabsHangers);Goal: Create a "Hangers" tab.
Visual behavior: A clothes rail: the garment you pick turns on its hanger to face you and sways, the others turn side-on and are pushed along the rail.
Technique:
- A real tablist of buttons with roving tabindex and a labelled panel for each tab; everything drawn follows the selection.
- Each garment turns about the vertical line through its hook with rotateY. Those not picked all turn the same way, as on a real rail, so one never swings through face-on when the selection crosses it.
- They are also pushed along the rail away from the picked one and dimmed with a brightness filter, so the one you look at stands out.
- The picked garment sways from the hook once, on a keyframe that dies away. Each column, hanger, garment and name is one tab.
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. The drawing is aria-hidden. Under prefers-reduced-motion the garments turn at once and do not sway.
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 "Hangers" tab.
Visual behavior: A clothes rail: the garment you pick turns on its hanger to face you and sways, the others turn side-on and are pushed along the rail.
Technique:
- A real tablist of buttons with roving tabindex and a labelled panel for each tab; everything drawn follows the selection.
- Each garment turns about the vertical line through its hook with rotateY. Those not picked all turn the same way, as on a real rail, so one never swings through face-on when the selection crosses it.
- They are also pushed along the rail away from the picked one and dimmed with a brightness filter, so the one you look at stands out.
- The picked garment sways from the hook once, on a keyframe that dies away. Each column, hanger, garment and name is one tab.
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. The drawing is aria-hidden. Under prefers-reduced-motion the garments turn at once and do not sway.
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 clothes rail: the garment you pick turns on its hanger to face you and sways, the others turn side-on and are pushed along the rail.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, 3d, spring
- Techniques
- 3d transform, perspective, css variables, css transition, keyframes
- 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. The drawing is aria-hidden. Under prefers-reduced-motion the garments turn at once and do not sway.