Units

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

const BLADE = 176;
const CM = 14; // px per centimetre on the drawing
const IN = CM * 2.54;
const at = (v: string) => `.radio-units:has(input[value='${v}']:checked)`;

const css = `
  @property --radio-units-x { syntax: '<length>'; inherits: true; initial-value: ${BLADE + 30}px; }
  .radio-units { --blade: light-dark(#f3f4f7, #e5e6e9); --tick: light-dark(#373a40, #121417); --shade: light-dark(#373a40, #121417); --case: light-dark(#42454c, #232529); --steel: light-dark(#6d7179, #a7abb2); display: grid; justify-items: center; gap: 18px; }
  .radio-units__tool { position: relative; width: 262px; height: 112px; }
  /* The case: a rubber-cornered body, hub screw, lock and the size printed on it. */
  .radio-units__case { position: absolute; left: 2px; top: 10px; width: 88px; height: 88px; border-radius: 22px 22px 22px 8px; background: radial-gradient(110% 100% at 30% 15%, color-mix(in oklab, var(--case) 75%, light-dark(#373a40, #f1f2f3)), var(--case) 60%); box-shadow: inset 0 0 0 5px color-mix(in oklab, var(--case) 70%, light-dark(#fbfcfd, #121417)), inset 0 1px 0 6px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 10%, transparent), 0 12px 18px -8px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.6)); z-index: 2; }
  .radio-units__hub { position: absolute; left: 50%; top: 42%; width: 32px; height: 32px; translate: -50% -50%; border-radius: 50%; background: radial-gradient(circle at 40% 35%, color-mix(in oklab, var(--steel) 60%, light-dark(#373a40, #f1f2f3)), var(--steel) 70%); box-shadow: 0 0 0 3px color-mix(in oklab, var(--case) 80%, light-dark(#fbfcfd, #121417)); }
  .radio-units__hub::after { content: ''; position: absolute; left: 50%; top: 50%; width: 10px; height: 2px; translate: -50% -50%; rotate: 35deg; background: color-mix(in oklab, var(--steel) 50%, light-dark(#fbfcfd, #121417)); }
  .radio-units__lock { position: absolute; right: 12px; top: -4px; width: 22px; height: 8px; border-radius: 3px; background: var(--steel); }
  .radio-units__size { position: absolute; left: 0; right: 0; bottom: 11px; display: grid; color: light-dark(#dbdde1, #c6c8cd); font: 700 9px/1 ui-monospace, 'SF Mono', Menlo, Consolas, monospace; letter-spacing: 0.08em; text-align: center; }
  .radio-units__size > span { grid-area: 1 / 1; transition: opacity 0.3s 0.3s; }
  ${at('cm')} .radio-units__size > :last-child, ${at('in')} .radio-units__size > :first-child { opacity: 0; transition-delay: 0s; }
  /* The blade: two printed sides. A pinch rides along it where it turns over. */
  .radio-units__blade { position: absolute; left: 84px; top: 70px; width: ${BLADE}px; height: 20px; filter: drop-shadow(0 4px 3px light-dark(rgba(0, 0, 0, 0.11), rgba(0, 0, 0, 0.35))); }
  .radio-units__strip { position: absolute; inset: 0; mask: radial-gradient(22px 9.6px at var(--radio-units-x) 0, transparent 98%, #000) , radial-gradient(22px 9.6px at var(--radio-units-x) 100%, transparent 98%, #000); mask-composite: intersect; }
  .radio-units__face { position: absolute; inset: 0; background-color: var(--blade); box-shadow: inset 0 2px 0 color-mix(in oklab, light-dark(#373a40, #f1f2f3) 10%, transparent), inset 0 -1.5px 0 color-mix(in oklab, var(--shade) 25%, transparent); }
  .radio-units__face--cm { background-image: repeating-linear-gradient(90deg, var(--tick) 0 1.2px, transparent 1.2px ${CM}px), repeating-linear-gradient(90deg, var(--tick) 0 1px, transparent 1px ${CM / 2}px), repeating-linear-gradient(90deg, color-mix(in oklab, var(--tick) 60%, transparent) 0 0.7px, transparent 0.7px ${CM / 10}px); background-size: 100% 9px, 100% 5px, 100% 3px; background-repeat: no-repeat; }
  .radio-units__face--in { background-image: repeating-linear-gradient(90deg, var(--tick) 0 1.2px, transparent 1.2px ${IN}px), repeating-linear-gradient(90deg, var(--tick) 0 1px, transparent 1px ${IN / 2}px), repeating-linear-gradient(90deg, var(--tick) 0 0.8px, transparent 0.8px ${IN / 4}px), repeating-linear-gradient(90deg, color-mix(in oklab, var(--tick) 60%, transparent) 0 0.7px, transparent 0.7px ${IN / 8}px); background-size: 100% 11px, 100% 7px, 100% 5px, 100% 3px; background-repeat: no-repeat; clip-path: inset(0 100% 0 0); }
  .radio-units__face span { position: absolute; top: 9px; color: var(--tick); font: 800 7.5px/1 system-ui, sans-serif; }
  .radio-units__face--in span { top: 10px; font-size: 8.5px; }
  .radio-units__face b { position: absolute; left: 4px; bottom: 2px; color: color-mix(in oklab, var(--tick) 55%, transparent); font: 700 5.5px/1 system-ui, sans-serif; letter-spacing: 0.1em; }
  .radio-units__turn { position: absolute; inset: 0; background: linear-gradient(90deg, transparent calc(var(--radio-units-x) - 26px), color-mix(in oklab, var(--shade) 45%, transparent) var(--radio-units-x), transparent calc(var(--radio-units-x) + 26px)); pointer-events: none; }
  ${at('in')} { animation: radio-units-over 0.95s cubic-bezier(0.45, 0, 0.35, 1) forwards; }
  ${at('cm')} { animation: radio-units-back 0.95s cubic-bezier(0.45, 0, 0.35, 1) forwards; }
  ${at('in')} .radio-units__face--in { clip-path: inset(0 calc(100% - var(--radio-units-x)) 0 0); }
  ${at('cm')} .radio-units__face--in { clip-path: inset(0 0 0 var(--radio-units-x)); }
  @keyframes radio-units-over { from { --radio-units-x: -26px; } to { --radio-units-x: ${BLADE + 30}px; } }
  @keyframes radio-units-back { from { --radio-units-x: -26px; } to { --radio-units-x: ${BLADE + 30}px; } }
  .radio-units__hook { position: absolute; right: -5px; top: -4px; width: 6px; height: 28px; border-radius: 1px 2px 2px 1px; background: linear-gradient(90deg, var(--steel), color-mix(in oklab, var(--steel) 70%, light-dark(#373a40, #f1f2f3))); box-shadow: 0 2px 2px light-dark(rgba(0, 0, 0, 0.11), rgba(0, 0, 0, 0.35)); }
  ${at('in')} .radio-units__hook { animation: radio-units-flick-a 0.4s ease-out 0.8s; }
  ${at('cm')} .radio-units__hook { animation: radio-units-flick-b 0.4s ease-out 0.8s; }
  @keyframes radio-units-flick-a { 40% { transform: scaleY(0.2); } }
  @keyframes radio-units-flick-b { 40% { transform: scaleY(0.2); } }
  .radio-units__opts { display: flex; gap: 22px; }
  .radio-units__opt { position: relative; display: flex; align-items: center; gap: 7px; color: light-dark(#6d7179, #7d8087); font: 600 12.5px system-ui, sans-serif; cursor: pointer; transition: color 0.25s; }
  .radio-units__opt b { font: 700 11px ui-monospace, 'SF Mono', Menlo, Consolas, monospace; padding: 2px 5px; border-radius: 4px; box-shadow: inset 0 0 0 1px currentColor; }
  .radio-units__opt:hover { color: light-dark(#42454c, #e5e6e9); }
  .radio-units__opt:has(input:checked) { color: light-dark(#373a40, #f1f2f3); }
  .radio-units__opt:has(input:focus-visible) { outline: 1.5px solid light-dark(#50535b, #c6c8cd); outline-offset: 4px; border-radius: 4px; }
  .radio-units__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; }
  @media (prefers-reduced-motion: reduce) {
    .radio-units, .radio-units * { animation: none !important; transition: none !important; }
    ${at('in')} .radio-units__face--in { clip-path: none; }
  }
`;

export function Preview() {
  const name = useId();
  return (
    <>
      <style href="kit-radios-units" precedence="kit">
        {css}
      </style>
      <div className="radio-units">
        <div className="radio-units__tool" aria-hidden="true">
          <span className="radio-units__blade">
            <span className="radio-units__strip">
              <span className="radio-units__face radio-units__face--cm">
                {Array.from({ length: 12 }, (_, k) => (
                  <span key={k} style={{ left: (k + 1) * CM + 1.5 }}>
                    {k + 1}
                  </span>
                ))}
                <b>CM</b>
              </span>
              <span className="radio-units__face radio-units__face--in">
                {Array.from({ length: 4 }, (_, k) => (
                  <span key={k} style={{ left: (k + 1) * IN + 1.5 }}>
                    {k + 1}
                  </span>
                ))}
                <b>INCH</b>
              </span>
              <span className="radio-units__turn" />
            </span>
            <span className="radio-units__hook" />
          </span>
          <span className="radio-units__case">
            <span className="radio-units__lock" />
            <span className="radio-units__hub" />
            <span className="radio-units__size">
              <span>5 m</span>
              <span>16 ft</span>
            </span>
          </span>
        </div>
        <div className="radio-units__opts" role="radiogroup" aria-label="Units">
          <label className="radio-units__opt">
            <input className="radio-units__input" type="radio" name={name} value="cm" defaultChecked />
            <b aria-hidden="true">cm</b>
            Metric
          </label>
          <label className="radio-units__opt">
            <input className="radio-units__input" type="radio" name={name} value="in" />
            <b aria-hidden="true">in</b>
            Imperial
          </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-units
Source on GitHub

About this component

Metric or imperial on a tape measure: the blade twists over from the case to the hook to show its other side, with a pinch running along it where it turns.

Dependencies
None
Difficulty
Advanced
Triggers
click, state change, keyframe, morph
Techniques
css property, clip path, keyframes, radial gradient
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A role="radiogroup" labelled "Units" around native radio inputs sharing one name, so the arrow keys move the choice and Tab enters the group once. The options read Metric and Imperial and focus outlines them; the tape measure is aria-hidden. Under prefers-reduced-motion the sides swap at once.

More radios

See all
Egg
Elevation
Firmness
12 frames a second. Steppy, like stop motion.
FPS