Multitool

A 7 cm blade that locks open, for parcels, rope and apples.
No dependencies
Code format
React + Tailwind
import { useId, useRef, useState, type CSSProperties } from 'react';

// Each tool is drawn pointing right from its pivot at 0,0. The blade and saw live at the
// left end of the body and open to the left; the opener and driver at the right end.
const TEETH = (() => {
  let d = '';
  for (let x = 76, up = true; x > 10; x -= 3, up = !up) d += `L${x} ${up ? 10.5 : 6.5}`;
  return d;
})();
const TOOLS = [
  {
    label: 'Blade',
    end: 'l',
    len: 88,
    shape: 'M-9 0A9 9 0 0 1 0-9H58C70-9 81-6 88-1C78 6 64 9 50 9H0A9 9 0 0 1-9 0Z',
    edge: 'M6 3.5H52C63 3.5 75 1.6 86-.6C78 6 64 9 50 9H6Z',
    line: 'M16-5.6Q22-7.6 28-5.6',
    body: 'A 7 cm blade that locks open, for parcels, rope and apples.',
  },
  {
    label: 'Saw',
    end: 'l',
    len: 80,
    shape: `M-9 0A9 9 0 0 1 0-9H74L80-3L78 6.5${TEETH}L10 6.5L0 9A9 9 0 0 1-9 0Z`,
    edge: 'M10 3H75L78 6.5H10Z',
    line: 'M6-6.4H72',
    body: 'Cuts green wood as thick as your thumb, on the pull stroke.',
  },
  {
    label: 'Opener',
    end: 'r',
    len: 56,
    shape: 'M-9 0A9 9 0 0 1 0-9L44-5Q54-5 56 0L52 8.5L48 3.5L40 5L0 9A9 9 0 0 1-9 0Z',
    edge: 'M8 4.5L40 2.6L48 1.6L52 8.5L48 3.5L40 5L8 7.6Z',
    line: 'M8-5.8L42-3.6',
    body: 'Opens a tin in a few turns, and the notch strips wire.',
  },
  {
    label: 'Driver',
    end: 'r',
    len: 60,
    shape: 'M-9 0A9 9 0 0 1 0-9H26L30-4H36L40-8H56L60-5V5L56 8L0 9A9 9 0 0 1-9 0Z',
    edge: 'M54-7.6L60-5V5L54 7.8Z',
    line: 'M8-6.4H24',
    body: 'A flat 3 mm tip for screws, and a lip that lifts bottle caps.',
  },
];
// Where the body sits so the open tool and the body are centred together.
const BODY_X = { l: 96, r: 36 };

const css = `
  .tabs-multitool { --shade: light-dark(#373a40, #121417); --steel: light-dark(#6d7179, #a7abb2); width: 230px; }
  .tabs-multitool__bench { position: relative; height: 84px; }
  .tabs-multitool__body { position: absolute; left: 0; top: 51px; width: 112px; height: 26px; transition: transform 0.42s cubic-bezier(0.65, 0, 0.35, 1) 0.26s; }
  /* A tool swings about its pivot. Closing is a quick snap shut; opening waits for the
     last tool and the body, then flicks out past straight and settles on the lock. */
  .tabs-multitool__tool { position: absolute; top: 1px; height: 24px; transform-origin: 10px 12px; transition: transform 0.3s cubic-bezier(0.55, 0, 0.8, 0.3); }
  .tabs-multitool__tool[data-end='l'] { left: 3px; transform: rotate(180deg) scaleX(-1); }
  .tabs-multitool__tool[data-end='r'] { left: 89px; transform: rotate(-180deg); }
  .tabs-multitool__tool[data-open] { transform: rotate(0deg) scaleX(var(--m)); transition: transform 0.55s cubic-bezier(0.25, 1.5, 0.45, 1) var(--d, 0s); }
  .tabs-multitool__tool svg { display: block; overflow: visible; }
  .tabs-multitool__steel { fill: var(--steel); }
  .tabs-multitool__edge { fill: color-mix(in oklab, var(--steel) 55%, light-dark(#373a40, #f1f2f3)); }
  .tabs-multitool__line { fill: none; stroke: color-mix(in oklab, var(--steel) 60%, var(--shade)); stroke-width: 1.4; stroke-linecap: round; }
  .tabs-multitool__glint { fill: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 70%, transparent); transform: translateX(-30px) skewX(-24deg); }
  .tabs-multitool__tool[data-open] .tabs-multitool__glint { animation: tabs-multitool-glint 0.7s ease-in-out calc(var(--d, 0s) + 0.45s) both; }
  /* The scale covers the folded tools, with a rivet at each pivot and a small inlay. */
  .tabs-multitool__scale { position: absolute; inset: 0; border-radius: 13px; background: radial-gradient(circle at 13px 13px, var(--steel) 0 3px, color-mix(in oklab, var(--steel) 50%, var(--shade)) 3.6px, transparent 4.2px), radial-gradient(circle at 99px 13px, var(--steel) 0 3px, color-mix(in oklab, var(--steel) 50%, var(--shade)) 3.6px, transparent 4.2px), linear-gradient(light-dark(#cbced3, #393c41), light-dark(#dbdde1, #2e3035) 55%, light-dark(#ebedf0, #232529)); box-shadow: inset 0 1px 0 color-mix(in oklab, light-dark(#373a40, #f1f2f3) 20%, transparent), inset 0 -1.5px 0 color-mix(in oklab, var(--shade) 40%, transparent), 0 8px 12px -6px light-dark(rgba(0, 0, 0, 0.25), rgba(0, 0, 0, 0.6)); }
  .tabs-multitool__inlay { position: absolute; left: 40px; top: 8px; width: 32px; height: 10px; border-radius: 3px; background: linear-gradient(var(--steel), color-mix(in oklab, var(--steel) 70%, var(--shade))); color: light-dark(#ebedf0, #232529); font: 700 6.5px/10px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0.12em; text-align: center; }
  .tabs-multitool__tabs { display: flex; justify-content: space-between; margin-top: 10px; }
  .tabs-multitool__tab { position: relative; padding: 6px 4px 7px; border: 0; background: none; color: light-dark(#5f626a, #a7abb2); font: 600 10px/1 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0.14em; text-transform: uppercase; cursor: pointer; transition: color 0.2s; }
  .tabs-multitool__tab::before { content: ''; position: absolute; left: 50%; top: 0; width: 1px; height: 3px; background: currentColor; transition: height 0.25s; }
  .tabs-multitool__tab:hover { color: light-dark(#42454c, #e5e6e9); }
  .tabs-multitool__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
  .tabs-multitool__tab[aria-selected='true']::before { height: 5px; width: 3px; translate: -1px 0; border-radius: 1px; }
  .tabs-multitool__tab:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; border-radius: 4px; }
  .tabs-multitool__panel { min-height: 3em; margin-top: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12.5px; line-height: 1.5; }
  @keyframes tabs-multitool-glint { from { transform: translateX(-30px) skewX(-24deg); } to { transform: translateX(110px) skewX(-24deg); } }
  @media (prefers-reduced-motion: reduce) {
    .tabs-multitool__tool, .tabs-multitool__tool[data-open], .tabs-multitool__body { transition: none; }
    .tabs-multitool__tool[data-open] .tabs-multitool__glint { animation: none; }
  }
`;

export function Preview() {
  const id = useId();
  const [{ sel, from }, setState] = useState({ sel: 0, from: 0 });
  const tabs = useRef<(HTMLButtonElement | null)[]>([]);
  const pick = (i: number) => setState((s) => (s.sel === i ? s : { sel: i, from: s.sel }));
  const n = TOOLS.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();
    pick(next);
    tabs.current[next]?.focus();
  };
  const end = TOOLS[sel].end as 'l' | 'r';
  // Open once the last tool is shut, and once the body has slid over if it had to.
  const delay = TOOLS[from].end === end ? '0.22s' : '0.62s';
  const x = `translateX(${BODY_X[end]}px)`;
  return (
    <>
      <style href="kit-tabs-multitool" precedence="kit">
        {css}
      </style>
      <div className="tabs-multitool">
        <div className="tabs-multitool__bench" aria-hidden="true">
          <span className="tabs-multitool__body" style={{ transform: x }}>
            {TOOLS.map((t, i) => (
              <span
                key={t.label}
                className="tabs-multitool__tool"
                data-end={t.end}
                data-open={sel === i ? '' : undefined}
                style={{ width: t.len + 12, '--m': t.end === 'l' ? -1 : 1, '--d': delay } as CSSProperties}
              >
                <svg width={t.len + 12} height="24" viewBox={`-10 -12 ${t.len + 12} 24`}>
                  <defs>
                    <clipPath id={`${id}-clip-${i}`}>
                      <path d={t.shape} />
                    </clipPath>
                  </defs>
                  <path className="tabs-multitool__steel" d={t.shape} />
                  <path className="tabs-multitool__edge" d={t.edge} />
                  <path className="tabs-multitool__line" d={t.line} />
                  <g clipPath={`url(#${id}-clip-${i})`}>
                    <rect className="tabs-multitool__glint" x="0" y="-12" width="10" height="24" />
                  </g>
                </svg>
              </span>
            ))}
            <span className="tabs-multitool__scale">
              <span className="tabs-multitool__inlay">No.4</span>
            </span>
          </span>
        </div>
        <div className="tabs-multitool__tabs" role="tablist" aria-label="Tools" onKeyDown={onKeyDown}>
          {TOOLS.map((t, i) => (
            <button
              key={t.label}
              ref={(el) => {
                tabs.current[i] = el;
              }}
              className="tabs-multitool__tab"
              type="button"
              role="tab"
              id={`${id}-tab-${i}`}
              aria-selected={sel === i}
              aria-controls={`${id}-panel-${i}`}
              tabIndex={sel === i ? 0 : -1}
              onClick={() => pick(i)}
            >
              {t.label}
            </button>
          ))}
        </div>
        {TOOLS.map((t, i) => (
          <div key={t.label} className="tabs-multitool__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
            {t.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-multitool
Source on GitHub

About this component

A pocket multitool: pick a tool and the last one folds shut, the body slides over if it has to, and the new one swings out on its pivot and locks open with a glint.

Dependencies
None
Difficulty
Intermediate
Triggers
click, state change, spring
Techniques
css transition, keyframes, svg, clip path
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 tools switch without swinging or a glint.

More tabs

See all
Pens
WednesdayVitamin D, iron, and a fish oil with dinner.
Pill Box
The May invoices are signed and on their way back.
Pneumatic
A bellied vase with a flared neck, 30 cm tall.
Pottery