Nib

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

const OPTIONS = [{"value": "fine", "label": "Fine"}, {"value": "medium", "label": "Medium"}, {"value": "broad", "label": "Broad"}];
const SIGN = 'M8 46C14 34 20 20 26 18C30 17 26 40 30 44C34 32 40 22 44 24C48 26 42 42 48 44C54 46 58 30 64 30C70 30 64 44 72 44C80 44 84 34 92 32C100 30 98 40 106 40C118 40 140 28 172 30';

const css = `
  .radio-nib { --i: 1; display: grid; justify-items: center; gap: 16px; width: 260px; }
  .radio-nib:has(.radio-nib__opt:nth-child(1) input:checked) { --i: 0; }
  .radio-nib:has(.radio-nib__opt:nth-child(2) input:checked) { --i: 1; }
  .radio-nib:has(.radio-nib__opt:nth-child(3) input:checked) { --i: 2; }
  @keyframes radio-nib-draw-a { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } }
  @keyframes radio-nib-draw-b { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } }
  @keyframes radio-nib-ride-a { from { offset-distance: 0%; } to { offset-distance: 100%; } }
  @keyframes radio-nib-ride-b { from { offset-distance: 0%; } to { offset-distance: 100%; } }
  .radio-nib__paper { position: relative; width: 200px; height: 70px; }
  .radio-nib__paper svg { width: 100%; height: 100%; overflow: visible; }
  .radio-nib__base { stroke: light-dark(#dbdde1, #2e3035); stroke-width: 1; }
  .radio-nib__ink { fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 1; stroke-width: calc(1.2px + var(--i) * 1.6px); transition: stroke-width 0.4s; }
  .radio-nib[data-flip='a'] .radio-nib__ink { animation: radio-nib-draw-a 0.9s cubic-bezier(0.5, 0, 0.4, 1); }
  .radio-nib[data-flip='b'] .radio-nib__ink { animation: radio-nib-draw-b 0.9s cubic-bezier(0.5, 0, 0.4, 1); }
  .radio-nib__nib { position: absolute; left: 0; top: 0; width: 16px; height: 26px; margin: -24px 0 0 -2px; offset-path: path('M8 46C14 34 20 20 26 18C30 17 26 40 30 44C34 32 40 22 44 24C48 26 42 42 48 44C54 46 58 30 64 30C70 30 64 44 72 44C80 44 84 34 92 32C100 30 98 40 106 40C118 40 140 28 172 30'); offset-distance: 100%; offset-rotate: 0deg; offset-anchor: 2px 24px; }
  .radio-nib[data-flip='a'] .radio-nib__nib { animation: radio-nib-ride-a 0.9s cubic-bezier(0.5, 0, 0.4, 1); }
  .radio-nib[data-flip='b'] .radio-nib__nib { animation: radio-nib-ride-b 0.9s cubic-bezier(0.5, 0, 0.4, 1); }
  .radio-nib__nib svg { width: 100%; height: 100%; overflow: visible; transform: rotate(28deg); transform-origin: 2px 24px; }
  .radio-nib__nib path { fill: light-dark(#50535b, #c6c8cd); }
  .radio-nib__nib .radio-nib__slit { fill: none; stroke: light-dark(#f3f4f7, #1a1c1f); stroke-width: 1; }
  .radio-nib__read { display: grid; color: light-dark(#50535b, #c6c8cd); font-size: 12px; font-variant-numeric: tabular-nums; }
  .radio-nib__read > * { grid-area: 1 / 1; text-align: center; opacity: 0; transform: translateY(4px); transition: opacity 0.25s, transform 0.3s; }
  .radio-nib:has(.radio-nib__opt:nth-child(1) input:checked) .radio-nib__read > :nth-child(1) { opacity: 1; transform: none; }
  .radio-nib:has(.radio-nib__opt:nth-child(2) input:checked) .radio-nib__read > :nth-child(2) { opacity: 1; transform: none; }
  .radio-nib:has(.radio-nib__opt:nth-child(3) input:checked) .radio-nib__read > :nth-child(3) { opacity: 1; transform: none; }
  @media (prefers-reduced-motion: reduce) {
    .radio-nib * { animation: none !important; transition: none !important; }
  }
  .radio-nib__opts { display: flex; gap: 3px; padding: 4px; border-radius: 12px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
  .radio-nib__opt { position: relative; flex: 1; padding: 7px 10px; border-radius: 8px; color: light-dark(#5f626a, #a7abb2); font-size: 12.5px; font-weight: 500; text-align: center; white-space: nowrap; cursor: pointer; transition: background-color 0.25s, color 0.25s; }
  .radio-nib__opt:hover { color: light-dark(#42454c, #e5e6e9); }
  .radio-nib__opt:has(input:checked) { background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); }
  .radio-nib__opt:has(input:focus-visible) { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
  .radio-nib__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; }
`;

export function NibRadio() {
  const name = useId();
  const [flip, setFlip] = useState(0);
  return (
    <>
      <style>
        {css}
      </style>
      <div className="radio-nib" data-flip={flip ? (flip % 2 ? 'a' : 'b') : undefined}>
        <div className="radio-nib__paper" aria-hidden="true">
          <svg viewBox="0 0 200 70">
            <line className="radio-nib__base" x1="0" y1="56" x2="200" y2="56" />
            <path className="radio-nib__ink" d={SIGN} pathLength={1} />
          </svg>
          <span className="radio-nib__nib">
            <svg viewBox="0 0 16 26">
              <path d="M2 24L0 12C0 6 4 2 8 0C12 2 16 6 16 12L14 24C12 25 4 25 2 24Z" />
              <path className="radio-nib__slit" d="M8 10V24" />
            </svg>
          </span>
        </div>
        <span className="radio-nib__read" aria-hidden="true">
          <span>0.5 mm, for small notes</span>
          <span>1.0 mm, everyday writing</span>
          <span>1.8 mm, signatures</span>
        </span>
        <div className="radio-nib__opts" role="radiogroup" aria-label="Nib size" onChange={() => setFlip((f) => f + 1)}>
          {OPTIONS.map((o, i) => (
            <label key={o.value} className="radio-nib__opt">
              <input className="radio-nib__input" type="radio" name={name} value={o.value} defaultChecked={i === 1} />
              {o.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-nib
Source on GitHub

About this component

A nib size picker: choose fine, medium or broad and the pen signs the line again in that width, the nib riding the stroke from the first letter to the flourish.

Dependencies
None
Difficulty
Intermediate
Triggers
click, state change, draw
Techniques
svg, stroke dashoffset, keyframes, css variables
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A role="radiogroup" with an aria-label around native radio inputs sharing one name, so the arrow keys move the choice and Tab enters the group once; :focus-visible on the input outlines its option. Under prefers-reduced-motion the signature changes width in place without redrawing.

More radios

See all
Pace
Paper
Roast
Specimen