Pens

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

const f = (n: number) => n.toFixed(1);
// Pencil: a row of cursive loops (a prolate cycloid).
const LOOPS = (() => {
  let d = '';
  for (let t = 0; t <= 9 * Math.PI; t += 0.12) d += `${d ? 'L' : 'M'}${f(92 + 4.05 * t - 8 * Math.sin(t))} ${f(40 + 9 * Math.cos(t))}`;
  return d;
})();
// Brush: a wave whose width swells under pressure and thins to a hairline at each end.
const WAVE = (() => {
  const pts = Array.from({ length: 61 }, (_, i) => {
    const t = i / 60;
    return { x: 92 + 114 * t, y: 40 + 13 * Math.sin(2 * Math.PI * 1.25 * t + 0.3), w: 0.6 + 7 * Math.pow(Math.sin(Math.PI * t), 0.7) * (0.65 + 0.35 * Math.sin(9 * t)) };
  });
  const side = (s: number) =>
    pts.map((p, i) => {
      const a = pts[Math.max(0, i - 1)];
      const b = pts[Math.min(pts.length - 1, i + 1)];
      const len = Math.hypot(b.x - a.x, b.y - a.y);
      return `${f(p.x + (s * (a.y - b.y) * p.w) / 2 / len)} ${f(p.y + (s * (b.x - a.x) * p.w) / 2 / len)}`;
    });
  const left = side(1);
  const right = side(-1).reverse();
  return {
    line: pts.map((p, i) => `${i ? 'L' : 'M'}${f(p.x)} ${f(p.y)}`).join(''),
    shape: `M${left.join('L')}L${right.join('L')}Z`,
  };
})();
const ZIGZAG = 'M94 26L206 32L98 42L204 48L102 58L202 62';
const SIGN = 'M92 52C98 30 106 28 106 42C106 58 97 60 101 46C105 34 118 30 117 47C116 60 126 57 131 44C135 34 141 37 139 47C137 58 151 56 157 42S171 30 169 47C167 61 181 50 190 41Q200 32 208 44';
const TOOLS = [
  { label: 'Pencil', line: LOOPS, body: '2B graphite, soft enough to shade.' },
  { label: 'Brush', line: WAVE.line, body: 'Round sable, size 6: press to swell.' },
  { label: 'Marker', line: ZIGZAG, body: 'A chisel tip for the line that matters.' },
  { label: 'Ink', line: SIGN, body: 'A fine steel nib, and ink that dries fast.' },
];
const LEN = 76;
const BASE = [{ x: 19, r: -11 }, { x: 29, r: -3 }, { x: 39, r: 5 }, { x: 49, r: 12 }];
const tipAt = (i: number, raised: boolean) => {
  const r = (BASE[i].r * Math.PI) / 180;
  return { x: BASE[i].x + LEN * Math.sin(r), y: 142 - LEN * Math.cos(r) - (raised ? 16 : 0) };
};
const pose = (x: number, y: number, r: number) => `translate(${f(x)}px, ${f(y)}px) rotate(${f(r)}deg)`;
const WRITE = -155; // tip down, the barrel leaning back to the right like a writing hand
const T = { lift: 260, fly: 380, write: 1150, back: 380, drop: 260 };

const css = `
  .tabs-pens { --paper: light-dark(#fbfcfd, #e5e6e9); --ink: light-dark(#373a40, #121417); width: 230px; }
  .tabs-pens__desk { position: relative; height: 150px; }
  .tabs-pens__card { position: absolute; left: 74px; top: 4px; width: 154px; height: 112px; border-radius: 3px; background: var(--paper); box-shadow: 0 1px 2px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4)), 0 8px 16px -8px light-dark(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.6)); }
  .tabs-pens__ink { position: absolute; left: 0; top: 0; overflow: visible; pointer-events: none; }
  .tabs-pens__stroke { opacity: 0; transition: opacity 0.35s; }
  .tabs-pens__stroke[data-on] { opacity: 1; transition: none; }
  .tabs-pens__mask { mask-type: alpha; }
  .tabs-pens__reveal { fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-linecap: round; stroke-linejoin: round; }
  .tabs-pens__pencil { fill: none; stroke: color-mix(in oklab, var(--ink) 75%, var(--paper)); stroke-width: 1.4; stroke-linecap: round; stroke-dasharray: 3 0.7 1.4 0.6; }
  .tabs-pens__brush { fill: var(--ink); }
  .tabs-pens__marker { fill: none; stroke: color-mix(in oklab, var(--ink) 38%, transparent); stroke-width: 8; stroke-linejoin: round; }
  .tabs-pens__nib { fill: none; stroke: var(--ink); stroke-width: 1.2; stroke-linecap: round; }
  .tabs-pens__blot { fill: var(--ink); }
  .tabs-pens__panel { position: absolute; left: 86px; right: 14px; top: 76px; color: light-dark(#50535b, #232529); font-size: 11.5px; line-height: 1.45; }
  /* The pot: its back rim behind the pens, its body in front of them. */
  .tabs-pens__pot { position: absolute; left: 0; top: 0; overflow: visible; pointer-events: none; }
  .tabs-pens__inside { fill: light-dark(#f3f4f7, #1a1c1f); }
  .tabs-pens__s1 { stop-color: light-dark(#dbdde1, #2e3035); }
  .tabs-pens__s2 { stop-color: light-dark(#6d7179, #7d8087); }
  .tabs-pens__s3 { stop-color: light-dark(#ebedf0, #232529); }
  .tabs-pens__slit { fill: none; stroke: light-dark(#dbdde1, #2e3035); stroke-width: 0.6; }
  .tabs-pens__rim { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 1.5; }
  .tabs-pens__tool { position: absolute; left: -5px; top: 0; width: 10px; height: ${LEN}px; transform-origin: 5px 0; transition: transform 0.35s cubic-bezier(0.3, 1.3, 0.5, 1); pointer-events: none; }
  .tabs-pens__tool svg { display: block; overflow: visible; }
  .tabs-pens__wood { fill: light-dark(#dbdde1, #a7abb2); }
  .tabs-pens__lead { fill: light-dark(#373a40, #f1f2f3); }
  .tabs-pens__a { fill: light-dark(#6d7179, #7d8087); }
  .tabs-pens__b { fill: light-dark(#cbced3, #393c41); }
  .tabs-pens__c { fill: light-dark(#50535b, #c6c8cd); }
  .tabs-pens__d { fill: light-dark(#42454c, #e5e6e9); }
  .tabs-pens__e { fill: light-dark(#dbdde1, #2e3035); }
  .tabs-pens__tabs { position: absolute; left: 74px; right: 0; top: 124px; display: flex; justify-content: space-between; }
  .tabs-pens__tab { padding: 4px 3px; border: 0; background: none; color: light-dark(#5f626a, #a7abb2); font: 12px/1 'Bradley Hand', 'Segoe Print', 'Chalkboard SE', 'Comic Sans MS', cursive; cursor: pointer; transition: color 0.2s; }
  .tabs-pens__tab:hover { color: light-dark(#42454c, #e5e6e9); }
  .tabs-pens__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); text-decoration: underline; text-decoration-thickness: 1.5px; text-underline-offset: 4px; }
  .tabs-pens__tab:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 1px; border-radius: 3px; }
`;

// Each pen drawn upright with its writing tip at 5,0.
const DRAWINGS = [
  <>
    <path className="tabs-pens__wood" d="M5 0L8 11H2Z" />
    <path className="tabs-pens__lead" d="M5 0L6.2 4H3.8Z" />
    <rect className="tabs-pens__a" x="2" y="11" width="6" height="53" />
    <rect className="tabs-pens__b" x="4.2" y="11" width="1.6" height="53" />
    <rect className="tabs-pens__c" x="1.8" y="64" width="6.4" height="5" />
    <rect className="tabs-pens__e" x="2" y="69" width="6" height="7" rx="2" />
  </>,
  <>
    <path className="tabs-pens__d" d="M5 0C6.5 4 8.5 8 8.5 12C8.5 14 6.8 15 5 15C3.2 15 1.5 14 1.5 12C1.5 8 3.5 4 5 0Z" />
    <path className="tabs-pens__c" d="M2 14H8L7.5 27H2.5Z" />
    <path className="tabs-pens__a" d="M2.5 27H7.5L6.5 74Q5 76.5 3.5 74Z" />
  </>,
  <>
    <path className="tabs-pens__d" d="M3.5 0L7 2V7H3Z" />
    <rect className="tabs-pens__c" x="2" y="7" width="6" height="5" />
    <rect className="tabs-pens__b" x="0.5" y="12" width="9" height="64" rx="3" />
    <rect className="tabs-pens__c" x="0.5" y="30" width="9" height="16" />
  </>,
  <>
    <path className="tabs-pens__c" d="M5 0L7.6 10H2.4Z" />
    <path className="tabs-pens__slit" d="M5 2.5V8" />
    <rect className="tabs-pens__a" x="2.4" y="10" width="5.2" height="10" rx="1" />
    <rect className="tabs-pens__e" x="1.5" y="20" width="7" height="56" rx="3.5" />
    <rect className="tabs-pens__c" x="7.2" y="26" width="1.6" height="22" rx="0.8" />
  </>,
];

export function Preview() {
  const id = useId();
  const [{ sel, from }, setState] = useState({ sel: 3, from: -1 });
  const tabs = useRef<(HTMLButtonElement | null)[]>([]);
  const tools = useRef<(HTMLSpanElement | null)[]>([]);
  const reveals = useRef<(SVGPathElement | null)[]>([]);
  const running = useRef<Animation[]>([]);
  const pick = (i: number) => setState((s) => (s.sel === i ? s : { sel: i, from: s.sel }));

  // The pen lifts out of the pot, flies to the card, writes its line with the tip on the
  // stroke the whole way, then flies back and drops in, a little proud of the others.
  useEffect(() => {
    if (from < 0 || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    running.current.forEach((a) => a.cancel());
    const tool = tools.current[sel];
    const line = reveals.current[sel];
    if (!tool || !line) return;
    const total = T.lift + T.fly + T.write + T.back + T.drop;
    const L = line.getTotalLength();
    const rest = tipAt(sel, false);
    const home = tipAt(sel, true);
    const r = BASE[sel].r;
    const at = (ms: number) => ms / total;
    const frames: Keyframe[] = [
      { offset: 0, transform: pose(rest.x, rest.y, r), easing: 'cubic-bezier(0.3, 0, 0.3, 1)' },
      { offset: at(T.lift), transform: pose(rest.x, rest.y - 48, r * 0.3), easing: 'cubic-bezier(0.45, 0, 0.25, 1)' },
    ];
    const N = 60;
    for (let k = 0; k <= N; k++) {
      const p = line.getPointAtLength((L * k) / N);
      frames.push({ offset: at(T.lift + T.fly + (T.write * k) / N), transform: pose(p.x, p.y, WRITE + 3 * Math.sin(k / 4)) });
    }
    frames[frames.length - 1].easing = 'cubic-bezier(0.45, 0, 0.25, 1)';
    frames.push({ offset: at(total - T.drop), transform: pose(home.x, home.y - 40, 0), easing: 'cubic-bezier(0.4, 0, 0.6, 1.4)' });
    frames.push({ offset: 1, transform: pose(home.x, home.y, r) });
    running.current = [
      tool.animate(frames, { duration: total }),
      line.animate([{ strokeDasharray: `${L} ${L}`, strokeDashoffset: L }, { strokeDasharray: `${L} ${L}`, strokeDashoffset: 0 }], { delay: T.lift + T.fly, duration: T.write, fill: 'backwards' }),
    ];
    return () => running.current.forEach((a) => a.cancel());
  }, [sel, from]);

  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();
  };
  return (
    <>
      <style href="kit-tabs-pens" precedence="kit">
        {css}
      </style>
      <div className="tabs-pens">
        <div className="tabs-pens__desk">
          <div className="tabs-pens__card" aria-hidden="true" />
          {TOOLS.map((t, i) => (
            <div key={t.label} className="tabs-pens__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
              {t.body}
            </div>
          ))}
          <svg className="tabs-pens__ink" width="230" height="150" viewBox="0 0 230 150" aria-hidden="true">
            <defs>
              {TOOLS.map((t, i) => (
                <mask key={t.label} className="tabs-pens__mask" id={`${id}-mask-${i}`} maskUnits="userSpaceOnUse" x="0" y="0" width="230" height="150">
                  <path
                    ref={(el) => {
                      reveals.current[i] = el;
                    }}
                    className="tabs-pens__reveal"
                    d={t.line}
                    strokeWidth={i === 2 ? 14 : 11}
                  />
                </mask>
              ))}
            </defs>
            <g className="tabs-pens__stroke" data-on={sel === 0 ? '' : undefined} mask={`url(#${id}-mask-0)`}>
              <path className="tabs-pens__pencil" d={LOOPS} />
              <path className="tabs-pens__pencil" d={LOOPS} transform="translate(0.5 0.4)" opacity="0.5" />
            </g>
            <g className="tabs-pens__stroke" data-on={sel === 1 ? '' : undefined} mask={`url(#${id}-mask-1)`}>
              <path className="tabs-pens__brush" d={WAVE.shape} />
            </g>
            <g className="tabs-pens__stroke" data-on={sel === 2 ? '' : undefined} mask={`url(#${id}-mask-2)`}>
              <path className="tabs-pens__marker" d={ZIGZAG} />
            </g>
            <g className="tabs-pens__stroke" data-on={sel === 3 ? '' : undefined} mask={`url(#${id}-mask-3)`}>
              <circle className="tabs-pens__blot" cx="92.4" cy="51" r="1.9" />
              <path className="tabs-pens__nib" d={SIGN} />
            </g>
          </svg>
          <svg className="tabs-pens__pot" width="230" height="150" viewBox="0 0 230 150" aria-hidden="true">
            <ellipse className="tabs-pens__inside" cx="34" cy="100" rx="25" ry="6" />
          </svg>
          {TOOLS.map((t, i) => {
            const p = tipAt(i, sel === i);
            return (
              <span
                key={t.label}
                ref={(el) => {
                  tools.current[i] = el;
                }}
                className="tabs-pens__tool"
                style={{ transform: pose(p.x, p.y, BASE[i].r) }}
                aria-hidden="true"
              >
                <svg width="10" height={LEN} viewBox={`0 0 10 ${LEN}`}>
                  {DRAWINGS[i]}
                </svg>
              </span>
            );
          })}
          <svg className="tabs-pens__pot" width="230" height="150" viewBox="0 0 230 150" aria-hidden="true">
            <defs>
              <linearGradient id={`${id}-pot`} x1="0" x2="1">
                <stop className="tabs-pens__s1" offset="0" />
                <stop className="tabs-pens__s2" offset="0.4" />
                <stop className="tabs-pens__s3" offset="1" />
              </linearGradient>
            </defs>
            <path className="tabs-pens__body" fill={`url(#${id}-pot)`} d="M9 100A25 6 0 0 0 59 100L56 141Q55 146 50 146H18Q13 146 12 141Z" />
            <path className="tabs-pens__rim" d="M9 100A25 6 0 0 0 59 100" />
          </svg>
          <div className="tabs-pens__tabs" role="tablist" aria-label="Pens" onKeyDown={onKeyDown}>
            {TOOLS.map((t, i) => (
              <button
                key={t.label}
                ref={(el) => {
                  tabs.current[i] = el;
                }}
                className="tabs-pens__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>
        </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-pens
Source on GitHub

About this component

A pot of pens: the one you pick lifts out, flies to the card and writes a line in its own stroke (grainy pencil, swelling brush, flat marker, fine ink), then drops back in.

Dependencies
None
Difficulty
Advanced
Triggers
click, state change, draw
Techniques
svg, stroke dashoffset, state machine, 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 line is written already and the pen does not leave the pot.

More tabs

See all
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
Full on the fifteenth, rising over the east wall at dusk.
Shoji