Drawbridge
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>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initToggleDrawbridge(el) on the mounted element instead.
<label class="toggle-drawbridge">
<input class="toggle-drawbridge__input" type="checkbox" role="switch"/>
<svg class="toggle-drawbridge__art" viewBox="0 0 136 68" aria-hidden="true">
<rect class="toggle-drawbridge__water" x="28" y="48" width="80" height="20"></rect>
<path class="toggle-drawbridge__wave" d="M38 55q4-2 8 0t8 0M66 60q4-2 8 0t8 0M86 53q4-2 8 0"></path>
<rect class="toggle-drawbridge__bank" x="0" y="42" width="31" height="26"></rect>
<rect class="toggle-drawbridge__bank" x="105" y="42" width="31" height="26"></rect>
<path class="toggle-drawbridge__tower" d="M6 42V10H9V6H13V10H16V6H20V42Z"></path>
<path class="toggle-drawbridge__tower" d="M116 42V6H120V10H123V6H127V10H130V42Z"></path>
<rect class="toggle-drawbridge__slit" x="12" y="20" width="2.5" height="7" rx="1"></rect>
<rect class="toggle-drawbridge__slit" x="121.5" y="20" width="2.5" height="7" rx="1"></rect>
<g transform="rotate(-72 31 42)">
<rect class="toggle-drawbridge__deck" x="31" y="40" width="37" height="4.5" rx="1"></rect>
<line class="toggle-drawbridge__plank" x1="39" y1="40" x2="39" y2="44.5"></line>
<line class="toggle-drawbridge__plank" x1="47" y1="40" x2="47" y2="44.5"></line>
<line class="toggle-drawbridge__plank" x1="55" y1="40" x2="55" y2="44.5"></line>
<line class="toggle-drawbridge__plank" x1="63" y1="40" x2="63" y2="44.5"></line>
</g>
<g transform="rotate(72 105 42)">
<rect class="toggle-drawbridge__deck" x="68" y="40" width="37" height="4.5" rx="1"></rect>
<line class="toggle-drawbridge__plank" x1="76" y1="40" x2="76" y2="44.5"></line>
<line class="toggle-drawbridge__plank" x1="84" y1="40" x2="84" y2="44.5"></line>
<line class="toggle-drawbridge__plank" x1="92" y1="40" x2="92" y2="44.5"></line>
<line class="toggle-drawbridge__plank" x1="100" y1="40" x2="100" y2="44.5"></line>
</g>
<line class="toggle-drawbridge__chain" x1="20" y1="14" x2="42.43" y2="6.81"></line>
<line class="toggle-drawbridge__chain" x1="116" y1="14" x2="93.57" y2="6.81"></line>
<circle class="toggle-drawbridge__hinge" cx="31" cy="42" r="2"></circle>
<circle class="toggle-drawbridge__hinge" cx="105" cy="42" r="2"></circle>
</svg>
<span>Connected</span>
</label>.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); }function initToggleDrawbridge(root) {
var UP = 72,
LEAF = 37,
L = { x: 31, y: 42 },
R = { x: 105, y: 42 };
var input = root.querySelector('.toggle-drawbridge__input');
var decks = root.querySelectorAll('.toggle-drawbridge__deck');
var left = decks[0].parentNode,
right = decks[1].parentNode;
var chains = root.querySelectorAll('.toggle-drawbridge__chain');
var down = false,
a = UP,
v = 0,
raf = 0;
function draw(deg) {
var rad = (deg * Math.PI) / 180,
dx = LEAF * Math.cos(rad),
dy = LEAF * Math.sin(rad);
left.setAttribute('transform', 'rotate(' + -deg + ' ' + L.x + ' ' + L.y + ')');
right.setAttribute('transform', 'rotate(' + deg + ' ' + R.x + ' ' + R.y + ')');
chains[0].setAttribute('x2', (L.x + dx).toFixed(2));
chains[0].setAttribute('y2', (L.y - dy).toFixed(2));
chains[1].setAttribute('x2', (R.x - dx).toFixed(2));
chains[1].setAttribute('y2', (R.y - dy).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.
function move() {
down = input.checked;
cancelAnimationFrame(raf);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
a = down ? 0 : UP;
v = 0;
return draw(a);
}
var last = performance.now();
function step(now) {
var dt = Math.min(0.032, (now - last) / 1000);
last = now;
if (down) {
v -= 520 * Math.cos((a * Math.PI) / 180) * dt + 40 * dt;
a += v * dt;
if (a <= 0) {
a = 0;
v = Math.abs(v) < 25 ? 0 : -v * 0.3;
}
} else {
v += (90 * (UP - a) - 19 * v) * dt;
a += v * dt;
}
draw(a);
var goal = down ? 0 : UP;
if (Math.abs(a - goal) < 0.05 && Math.abs(v) < 0.5) return draw(goal);
raf = requestAnimationFrame(step);
}
raf = requestAnimationFrame(step);
}
input.addEventListener('change', move);
return function () {
input.removeEventListener('change', move);
cancelAnimationFrame(raf);
};
}
document.querySelectorAll('.toggle-drawbridge').forEach(initToggleDrawbridge);Goal: Create a "Drawbridge" toggle switch.
Visual behavior: 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.
Technique:
- A native checkbox with role="switch", visually hidden but focusable, sits inside a <label> before an aria-hidden drawing and the visible text. Every on state is styled from the input's :checked state through the adjacent sibling combinator, so Space and clicks both work.
- Each frame updates one angle. Lowering, gravity pulls the leaves down and they bounce off each other, keeping 30% of their speed; raising, a damped spring hauls them back to 72 degrees.
- The leaves are SVG groups rotated about their hinges, and each chain is a line whose free end is worked out from the leaf angle, so it stays fixed to the tip.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Drawbridge" toggle switch.
Visual behavior: 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.
Technique:
- A native checkbox with role="switch", visually hidden but focusable, sits inside a <label> before an aria-hidden drawing and the visible text. Every on state is styled from the input's :checked state through the adjacent sibling combinator, so Space and clicks both work.
- Each frame updates one angle. Lowering, gravity pulls the leaves down and they bounce off each other, keeping 30% of their speed; raising, a damped spring hauls them back to 72 degrees.
- The leaves are SVG groups rotated about their hinges, and each chain is a line whose free end is worked out from the leaf angle, so it stays fixed to the tip.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
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.