Multitool

No dependencies
Code format
React + Tailwind
import { useId } from 'react';

// Every tool is drawn pointing right from its pivot at (0, 10). Right-hand
// tools fold back to 180deg; left-hand ones fold to 0 and open the other way.
const TOOLS = [
  { value: 'blade', label: 'Blade', side: 'r', open: 360, shift: -40, d: 'M0 4.5H64C78 4.5 90 7.5 99 12.5C84 15.5 70 15.5 54 15.5H0Z', extra: 'M20 7.4A2.4 2.4 0 0 1 24.6 7.4' },
  { value: 'saw', label: 'Saw', side: 'r', open: 360, shift: -40, d: 'M0 5H88L92 9V12H86L83 15L80 12L77 15L74 12L71 15L68 12L65 15L62 12L59 15L56 12L53 15L50 12L47 15L44 12L41 15L38 12L35 15L32 12L29 15L26 12L23 15L20 12H0Z', extra: '' },
  { value: 'opener', label: 'Opener', side: 'l', open: -180, shift: 40, d: 'M0 6H62L66 4H76V8.4H80L84 7.8V12.2L80 11.6H76V16H66L62 14H0Z', extra: 'M68 8.2H73V11.8H68' },
  { value: 'corkscrew', label: 'Corkscrew', side: 'l', open: -90, shift: 0, d: 'M0 8.5H14V11.5H0Z', extra: 'M14 10C18 3 22 3 24 10S30 17 34 10S40 3 44 10S50 17 54 10S60 3 64 10S70 17 74 10L82 10' },
];
const at = (n: number) => `.radio-multitool:has(.radio-multitool__label:nth-child(${n + 1}) input:checked)`;
const SNAP = 'linear(0, 0.156, 0.492, 0.838, 1.091, 1.215, 1.228, 1.169, 1.086, 1.012, 0.964, 0.946, 0.951, 0.968, 0.987, 1.002, 1.011, 1.013, 1.01, 1.006, 1.001, 0.998, 0.997, 0.997, 1)';

const css = `
  .radio-multitool { --scale: light-dark(#42454c, #232529); --scale-hi: light-dark(#5f626a, #393c41); --steel-hi: light-dark(#fbfcfd, #e5e6e9); --steel-lo: light-dark(#6d7179, #7d8087); display: grid; justify-items: center; gap: 16px; }
  .radio-multitool__bench { position: relative; width: 262px; height: 136px; }
  .radio-multitool__knife { position: absolute; left: 56px; top: 92px; width: 150px; height: 38px; transform: translateX(var(--shift, 0px)); transition: transform 0.7s cubic-bezier(0.5, 0, 0.2, 1); }
  .radio-multitool__body { position: absolute; inset: 0; z-index: 1; border-radius: 19px; background: radial-gradient(120% 90% at 50% 0%, var(--scale-hi), var(--scale) 60%); box-shadow: inset 0 1px 0 color-mix(in oklab, light-dark(#373a40, #f1f2f3) 18%, transparent), inset 0 -2px 0 light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.25)), 0 0 0 1.5px color-mix(in oklab, var(--steel-lo) 70%, transparent), 0 10px 14px -6px light-dark(rgba(0, 0, 0, 0.17), rgba(0, 0, 0, 0.55)); }
  .radio-multitool__rivet { position: absolute; top: 14px; width: 10px; height: 10px; border-radius: 50%; background: radial-gradient(circle at 35% 30%, var(--steel-hi), var(--steel-lo) 75%); box-shadow: 0 0 0 1px light-dark(rgba(0, 0, 0, 0.09), rgba(0, 0, 0, 0.3)); }
  .radio-multitool__mark { position: absolute; left: 50%; top: 50%; width: 30px; height: 2px; margin: -1px 0 0 -15px; border-radius: 1px; background: color-mix(in oklab, var(--scale-hi) 60%, transparent); box-shadow: 0 -5px 0 color-mix(in oklab, var(--scale-hi) 35%, transparent), 0 5px 0 color-mix(in oklab, var(--scale-hi) 35%, transparent); }
  /* A tool sits behind the scales with its pivot on a rivet, folded away until picked. */
  .radio-multitool__tool { position: absolute; top: 9px; width: 100px; height: 20px; overflow: visible; transform-origin: 0 50%; transition: transform 0.42s cubic-bezier(0.55, 0, 0.45, 1); }
  .radio-multitool__tool--r { left: 134px; transform: rotate(180deg); }
  .radio-multitool__tool--l { left: 16px; transform: rotate(0deg); }
  .radio-multitool__tool path { fill: url(#radio-multitool-steel); stroke: color-mix(in oklab, var(--steel-lo) 70%, light-dark(#fbfcfd, #121417)); stroke-width: 0.6; }
  .radio-multitool__tool path + path { fill: none; stroke: var(--steel-hi); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
  .radio-multitool__tool[data-tool='corkscrew'] path + path { stroke: url(#radio-multitool-steel); stroke-width: 2.6; }
  .radio-multitool__stop-hi { stop-color: var(--steel-hi); }
  .radio-multitool__stop-lo { stop-color: var(--steel-lo); }
  ${TOOLS.map((t, i) => `${at(i)} { --shift: ${t.shift}px; }\n  ${at(i)} .radio-multitool__tool[data-tool='${t.value}'] { transform: rotate(${t.open}deg); transition: transform 0.8s ${SNAP} 0.12s; }\n  ${at(i)} .radio-multitool__knife { animation: radio-multitool-click${i} 0.3s ease-out 0.5s; }\n  @keyframes radio-multitool-click${i} { 30% { rotate: ${t.side === 'r' ? -1.2 : 1.2}deg; } }`).join('\n  ')}
  .radio-multitool__labels { display: flex; gap: 18px; }
  .radio-multitool__label { position: relative; color: light-dark(#6d7179, #7d8087); font: 700 10px/1 system-ui, sans-serif; letter-spacing: 0.14em; text-transform: uppercase; cursor: pointer; transition: color 0.25s; }
  .radio-multitool__label::before { content: ''; position: absolute; left: 50%; top: -9px; width: 4px; height: 4px; margin-left: -2px; border-radius: 50%; background: light-dark(#373a40, #f1f2f3); transform: scale(0); transition: transform 0.3s; }
  .radio-multitool__label:hover { color: light-dark(#42454c, #e5e6e9); }
  .radio-multitool__label:has(input:checked) { color: light-dark(#373a40, #f1f2f3); }
  .radio-multitool__label:has(input:checked)::before { transform: scale(1); transition: transform 0.45s ${SNAP} 0.3s; }
  .radio-multitool__label:has(input:focus-visible) { outline: 1.5px solid light-dark(#50535b, #c6c8cd); outline-offset: 4px; border-radius: 2px; }
  .radio-multitool__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; }
  @media (prefers-reduced-motion: reduce) {
    .radio-multitool *, .radio-multitool *::before { animation: none !important; transition: none !important; }
  }
`;

export function Preview() {
  const name = useId();
  return (
    <>
      <style href="kit-radios-multitool" precedence="kit">
        {css}
      </style>
      <div className="radio-multitool">
        <div className="radio-multitool__bench" aria-hidden="true">
          <svg width="0" height="0" style={{ position: 'absolute' }}>
            <linearGradient id="radio-multitool-steel" x1="0" y1="0" x2="0" y2="1">
              <stop className="radio-multitool__stop-hi" offset="0" />
              <stop className="radio-multitool__stop-lo" offset="1" />
            </linearGradient>
          </svg>
          <div className="radio-multitool__knife">
            {TOOLS.map((t) => (
              <svg key={t.value} className={`radio-multitool__tool radio-multitool__tool--${t.side}`} data-tool={t.value} viewBox="0 0 100 20">
                <path d={t.d} />
                {t.extra && <path d={t.extra} />}
              </svg>
            ))}
            <span className="radio-multitool__body">
              <span className="radio-multitool__rivet" style={{ left: 11 }} />
              <span className="radio-multitool__rivet" style={{ left: 129 }} />
              <span className="radio-multitool__mark" />
            </span>
          </div>
        </div>
        <div className="radio-multitool__labels" role="radiogroup" aria-label="Tool">
          {TOOLS.map((t, i) => (
            <label key={t.value} className="radio-multitool__label">
              <input className="radio-multitool__input" type="radio" name={name} value={t.value} defaultChecked={i === 0} />
              {t.label}
            </label>
          ))}
        </div>
      </div>
    </>
  );
}

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

Install with shadcn

npx shadcn@latest add @movin/radio-multitool
Source on GitHub

About this component

A pocket knife: the tool you pick swings out over the top on its pivot and clicks open, the last one folds away, and the knife slides over to keep the open tool in the middle.

Dependencies
None
Difficulty
Intermediate
Triggers
click, state change, spring, keyframe
Techniques
transform only, css transition, css variables, spring, svg
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A role="radiogroup" labelled "Tool" around native radio inputs sharing one name, so the arrow keys move the choice and Tab enters the group once. The tool names are the visible labels and focus outlines the name. The knife drawing is aria-hidden. Under prefers-reduced-motion the tools open and fold at once.

More radios

See all
Paper Stock
Pigeonhole
Pillbox
Sewing