An accessible switch drawn entirely in box-drawing and block characters, in the register of a physical knife switch: the blade fills one cell at a time and the handle glyph spins through the throw.
npx shadcn add https://design.helpmarq.com /r/switch-ascii-knife.jsonregistry/core/switch-ascii-knife/component.tsx"use client";
import { useEffect, useRef, useState } from "react";
// ---------------------------------------------------------------------------
// ThrowSwitch — an accessible switch drawn entirely in box-drawing and block
// characters, in the register of a physical knife switch: `[━━━●──]` style,
// where the filled run is the blade already thrown from its left-hand pivot
// up to the handle knob, and the rest of the track is open (unthrown) rail.
// The throw animates one cell of that run at a time (never a slide), and the
// handle glyph itself cycles through a short rotation sequence while it is
// mid-transit, settling to a solid circle the instant it lands on a cell.
// A printed OFF / ON legend sits in the same font-mono row, flanking the
// track, brightened on whichever side is currently active.
// ---------------------------------------------------------------------------
const CELLS = 6; // interior track cells, excluding the brackets
const BEAT_MS = 70; // ms per cell the blade advances
const HEAVY = "━";
const LIGHT = "─";
const SETTLED = "●";
const ROT = ["◐", "◓", "◑", "◒"];
export interface ThrowSwitchProps {
/** controlled state; omit for uncontrolled */
checked?: boolean;
defaultChecked?: boolean;
onCheckedChange?: (checked: boolean) => void;
disabled?: boolean;
className?: string;
"aria-label"?: string;
}
export function ThrowSwitch({
checked,
defaultChecked = false,
onCheckedChange,
disabled = false,
className = "",
"aria-label": ariaLabel = "Toggle",
}: ThrowSwitchProps) {
const cellRefs = useRef<(HTMLSpanElement | null)[]>([]);
const posRef = useRef(0); // current handle cell, 0..CELLS-1
const mountedRef = useRef(false);
const isControlled = checked !== undefined;
const [internal, setInternal] = useState(defaultChecked);
const isChecked = isControlled ? checked : internal;
const render = (pos: number, settled: boolean, rotFrame: string | null) => {
for (let i = 0; i < CELLS; i++) {
const el = cellRefs.current[i];
if (!el) continue;
if (i < pos) {
el.textContent = HEAVY;
el.className = "text-foreground";
} else if (i === pos) {
el.textContent = settled ? SETTLED : rotFrame ?? SETTLED;
el.className = "text-foreground font-semibold";
} else {
el.textContent = LIGHT;
el.className = "text-muted";
}
}
};
useEffect(() => {
const target = isChecked ? CELLS - 1 : 0;
if (!mountedRef.current) {
mountedRef.current = true;
posRef.current = target;
render(target, true, null);
return;
}
if (posRef.current === target) {
render(target, true, null);
return;
}
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduced) {
posRef.current = target;
render(target, true, null);
return;
}
const start = posRef.current;
const dir = target > start ? 1 : -1;
const totalSteps = Math.abs(target - start);
const totalMs = totalSteps * BEAT_MS;
const startTime = performance.now();
let raf = 0;
const tick = (now: number) => {
const elapsed = now - startTime;
const stepFloat = Math.min(totalSteps, elapsed / BEAT_MS);
const stepIndex = Math.floor(stepFloat);
const currentPos = start + dir * stepIndex;
const arrived = stepIndex >= totalSteps;
const within = stepFloat - stepIndex;
const rotFrame = arrived ? null : ROT[Math.floor(within * ROT.length) % ROT.length]!;
render(currentPos, arrived, rotFrame);
if (elapsed < totalMs) {
raf = requestAnimationFrame(tick);
} else {
posRef.current = target;
render(target, true, null);
}
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isChecked]);
const toggle = () => {
if (disabled) return;
const next = !isChecked;
if (!isControlled) setInternal(next);
onCheckedChange?.(next);
};
return (
<button
type="button"
role="switch"
aria-checked={isChecked}
aria-label={ariaLabel}
disabled={disabled}
onClick={toggle}
className={`group inline-flex items-center gap-2 rounded-sm px-1.5 py-0.5 font-mono text-sm tabular-nums transition-colors focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-accent ${
disabled ? "cursor-not-allowed opacity-40" : "cursor-pointer hover:bg-surface"
} ${className}`}
>
<span aria-hidden="true" className={isChecked ? "text-muted" : "text-foreground font-semibold"}>
OFF
</span>
<span
aria-hidden="true"
className="text-foreground transition-colors group-hover:text-accent"
>
[
{Array.from({ length: CELLS }).map((_, i) => (
<span
key={i}
ref={(el) => {
cellRefs.current[i] = el;
}}
/>
))}
]
</span>
<span aria-hidden="true" className={isChecked ? "text-foreground font-semibold" : "text-muted"}>
ON
</span>
</button>
);
}
a binary control where the mechanical, character-by-character throw is a deliberate flourish and a printed OFF/ON legend belongs in the UI. Reach for switch-frost instead for an iOS-pill switch with a soft, decorative animation and no printed text legend.
Build <ThrowSwitch checked? defaultChecked? onCheckedChange? disabled? className? aria-label?> — same controlled/uncontrolled contract as the repo's other switches. STRUCTURE: a single <button role="switch" aria-checked aria-label> containing three font-mono spans in one row: a printed "OFF" legend, the bracketed track, a printed "ON" legend. Both legends are aria-hidden (the accessible name comes from aria-label) and brighten to text-foreground/font-semibold on whichever side is currently active, dimming to text-muted on the other — no other hue is used to indicate state. TRACK: 6 interior cells wrapped in literal `[` `]` characters. The blade is modeled as thrown from a fixed left-hand pivot: cells with index less than the handle's current position render the heavy box-drawing rule (━, text-foreground) representing blade already thrown across that cell; the cell at the handle's position renders the handle glyph; cells after it render the light rule (─, text-muted) representing untouched rail. OFF rests with the handle at position 0 (no heavy cells, all light); ON rests with the handle at position 5 (heavy cells 0-4, no light). THROW ANIMATION: on a state change, the handle steps one cell at a time toward the target position every 70ms (a plain timestamp-driven requestAnimationFrame loop, not CSS transition) — this is the character-quantised motion the whole ascii suite shares, not a slide. While a step is in flight the handle glyph itself is not the static solid circle; it cycles through a small rotation sequence (◐ ◓ ◑ ◒) driven by progress within the current 70ms beat, and the instant a step lands the glyph snaps back to the solid ● before starting (or finishing) the next step, so the settle reads as a distinct event separate from the spin. ACCESSIBILITY: role="switch" with aria-checked kept in sync, a real <button> so Space/Enter activate it via native semantics with no extra keydown handler, and a visible focus ring built ONLY from focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-accent with no base outline-none on the same element — pairing outline-none with focus-visible:outline-* sets --tw-outline-style to none permanently in Tailwind v4 and the ring never paints even though every class looks correct. prefers-reduced-motion snaps directly to the target position with no rotation frames, and the very first mount never animates regardless of the initial checked value — only a later toggle drives the throw.