Hangers

Wool, double-breasted and fully lined. Sizes S to XL.
No dependencies
Code format
React + Tailwind
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>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/tab-hangers
Source on GitHub

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.

More tabs

See all
Ladder
A 7 cm blade that locks open, for parcels, rope and apples.
Multitool
Pens
WednesdayVitamin D, iron, and a fish oil with dinner.
Pill Box