Switch ASCII Knife

Switch

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.

Install
npx shadcn add https://design.helpmarq.com/r/switch-ascii-knife.json
Source
registry/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>
  );
}
Use when

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 spec

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.

Tags
switchtoggleasciimonoaccessibility