Drawbridge

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

const css = `
  .toggle-drawbridge { display: inline-flex; align-items: center; gap: 14px; color: light-dark(#42454c, #e5e6e9); font-size: 15px; cursor: pointer; user-select: none; }
  .toggle-drawbridge__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
  .toggle-drawbridge__art { display: block; width: 140px; height: 70px; overflow: visible; border-radius: 8px; }
  .toggle-drawbridge__input:focus-visible + .toggle-drawbridge__art { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 4px; }
  .toggle-drawbridge__water { fill: light-dark(#ebedf0, #232529); }
  .toggle-drawbridge__wave { fill: none; stroke: light-dark(#dbdde1, #2e3035); stroke-width: 1.2; stroke-linecap: round; }
  .toggle-drawbridge__bank { fill: light-dark(#dbdde1, #2e3035); }
  .toggle-drawbridge__tower { fill: light-dark(#cbced3, #393c41); }
  .toggle-drawbridge__slit { fill: light-dark(#ebedf0, #232529); }
  .toggle-drawbridge__deck { fill: light-dark(#50535b, #c6c8cd); }
  .toggle-drawbridge__plank { stroke: light-dark(#6d7179, #7d8087); stroke-width: 0.8; }
  .toggle-drawbridge__chain { stroke: light-dark(#5f626a, #a7abb2); stroke-width: 1.4; stroke-dasharray: 2 1.4; }
  .toggle-drawbridge__hinge { fill: light-dark(#6d7179, #7d8087); }
`;

const UP = 72; // how far each leaf stands when raised, in degrees
const LEAF = 37;
const L = { x: 31, y: 42, top: { x: 20, y: 14 } };
const R = { x: 105, y: 42, top: { x: 116, y: 14 } };

// Where a leaf's free end is when it stands `a` degrees up.
const tipL = (a: number) => ({ x: L.x + LEAF * Math.cos((a * Math.PI) / 180), y: L.y - LEAF * Math.sin((a * Math.PI) / 180) });
const tipR = (a: number) => ({ x: R.x - LEAF * Math.cos((a * Math.PI) / 180), y: R.y - LEAF * Math.sin((a * Math.PI) / 180) });

function Leaf({ side }: { side: 'l' | 'r' }) {
  const x0 = side === 'l' ? L.x : R.x - LEAF;
  return (
    <>
      <rect className="toggle-drawbridge__deck" x={x0} y="40" width={LEAF} height="4.5" rx="1" />
      {[8, 16, 24, 32].map((d) => (
        <line key={d} className="toggle-drawbridge__plank" x1={x0 + d} y1="40" x2={x0 + d} y2="44.5" />
      ))}
    </>
  );
}

export function DrawbridgeToggle() {
  const left = useRef<SVGGElement>(null);
  const right = useRef<SVGGElement>(null);
  const chains = useRef<(SVGLineElement | null)[]>([]);
  const st = useRef({ down: false, a: UP, v: 0, raf: 0 });

  useEffect(() => () => cancelAnimationFrame(st.current.raf), []);

  const draw = (a: number) => {
    left.current!.setAttribute('transform', `rotate(${-a} ${L.x} ${L.y})`);
    right.current!.setAttribute('transform', `rotate(${a} ${R.x} ${R.y})`);
    const [l, r] = [tipL(a), tipR(a)];
    chains.current[0]!.setAttribute('x2', l.x.toFixed(2));
    chains.current[0]!.setAttribute('y2', l.y.toFixed(2));
    chains.current[1]!.setAttribute('x2', r.x.toFixed(2));
    chains.current[1]!.setAttribute('y2', r.y.toFixed(2));
  };

  // Lowering, the leaves fall under their own weight, meet and bounce a little on each other. Raising,
  // the winch hauls them up smoothly.
  const move = (down: boolean) => {
    const s = st.current;
    s.down = down;
    cancelAnimationFrame(s.raf);
    if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
      Object.assign(s, { a: down ? 0 : UP, v: 0 });
      return draw(s.a);
    }
    let last = performance.now();
    const step = (now: number) => {
      const dt = Math.min(0.032, (now - last) / 1000);
      last = now;
      if (s.down) {
        s.v -= 520 * Math.cos((s.a * Math.PI) / 180) * dt + 40 * dt;
        s.a += s.v * dt;
        if (s.a <= 0) {
          s.a = 0;
          s.v = Math.abs(s.v) < 25 ? 0 : -s.v * 0.3;
        }
      } else {
        s.v += (90 * (UP - s.a) - 19 * s.v) * dt;
        s.a += s.v * dt;
      }
      draw(s.a);
      const goal = s.down ? 0 : UP;
      if (Math.abs(s.a - goal) < 0.05 && Math.abs(s.v) < 0.5) return draw(goal);
      s.raf = requestAnimationFrame(step);
    };
    s.raf = requestAnimationFrame(step);
  };

  const l = tipL(UP);
  const r = tipR(UP);
  return (
    <>
      <style>
        {css}
      </style>
      <label className="toggle-drawbridge">
        <input className="toggle-drawbridge__input" type="checkbox" role="switch" onChange={(e) => move(e.target.checked)} />
        <svg className="toggle-drawbridge__art" viewBox="0 0 136 68" aria-hidden="true">
          <rect className="toggle-drawbridge__water" x="28" y="48" width="80" height="20" />
          <path className="toggle-drawbridge__wave" d="M38 55q4-2 8 0t8 0M66 60q4-2 8 0t8 0M86 53q4-2 8 0" />
          <rect className="toggle-drawbridge__bank" x="0" y="42" width="31" height="26" />
          <rect className="toggle-drawbridge__bank" x="105" y="42" width="31" height="26" />
          <path className="toggle-drawbridge__tower" d="M6 42V10H9V6H13V10H16V6H20V42Z" />
          <path className="toggle-drawbridge__tower" d="M116 42V6H120V10H123V6H127V10H130V42Z" />
          <rect className="toggle-drawbridge__slit" x="12" y="20" width="2.5" height="7" rx="1" />
          <rect className="toggle-drawbridge__slit" x="121.5" y="20" width="2.5" height="7" rx="1" />
          <g ref={left} transform={`rotate(${-UP} ${L.x} ${L.y})`}>
            <Leaf side="l" />
          </g>
          <g ref={right} transform={`rotate(${UP} ${R.x} ${R.y})`}>
            <Leaf side="r" />
          </g>
          <line
            className="toggle-drawbridge__chain"
            ref={(el) => {
              chains.current[0] = el;
            }}
            x1={L.top.x}
            y1={L.top.y}
            x2={l.x.toFixed(2)}
            y2={l.y.toFixed(2)}
          />
          <line
            className="toggle-drawbridge__chain"
            ref={(el) => {
              chains.current[1] = el;
            }}
            x1={R.top.x}
            y1={R.top.y}
            x2={r.x.toFixed(2)}
            y2={r.y.toFixed(2)}
          />
          <circle className="toggle-drawbridge__hinge" cx={L.x} cy={L.y} r="2" />
          <circle className="toggle-drawbridge__hinge" cx={R.x} cy={R.y} r="2" />
        </svg>
        <span>Connected</span>
      </label>
    </>
  );
}

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

Install with shadcn

npx shadcn@latest add @movin/toggle-drawbridge
Source on GitHub

About this component

A connection switch drawn as a drawbridge: the two leaves fall under their own weight on their chains, meet over the river and bounce; off winches them smoothly back up.

Dependencies
None
Difficulty
Advanced
Triggers
click, state change, spring
Techniques
raf, spring, svg
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A native checkbox with role="switch" inside a <label>, so Space toggles it and the visible text names it; :focus-visible on the input outlines the drawing, which is aria-hidden. Under prefers-reduced-motion the leaves move at once.

More toggles

See all
Flytrap
Gate
Handbrake
Jack