Skip to main content

ns-ui

Switch ASCII Knife

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.

Usage

registry/core/switch-ascii-knife/demo.tsx
"use client";

import { useState } from "react";
import { ThrowSwitch } from "./component";

export default function ThrowSwitchDemo() {
  const [power, setPower] = useState(false);

  return (
    <div className="flex min-h-screen flex-col items-center justify-center gap-8 px-6 py-16">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / switch-ascii-knife
      </p>
      <div className="flex flex-col items-center gap-4 rounded-md border border-border bg-surface px-10 py-8">
        <span className="font-mono text-xs uppercase tracking-[0.3em] text-ns-muted">
          main power
        </span>
        <ThrowSwitch
          checked={power}
          onCheckedChange={setPower}
          aria-label="Main power"
          className="text-2xl sm:text-3xl"
        />
      </div>
      <p className="max-w-md text-center text-xs text-ns-muted">
        Space or Enter throws the switch; the blade fills one cell at a time
        and the handle spins mid-transit.
      </p>
    </div>
  );
}

Component

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;
  /** uncontrolled initial state. Default false. */
  defaultChecked?: boolean;
  /** called with the new state after a toggle */
  onCheckedChange?: (checked: boolean) => void;
  /** blocks the switch */
  disabled?: boolean;
  /** extra classes merged onto the rendered root element */
  className?: string;
  /** accessible name for the switch */
  "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-ns-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-ns-accent ${
        disabled ? "cursor-not-allowed opacity-40" : "cursor-pointer hover:bg-surface"
      } ${className}`}
    >
      <span aria-hidden="true" className={isChecked ? "text-ns-muted" : "text-foreground font-semibold"}>
        OFF
      </span>
      <span
        aria-hidden="true"
        className="text-foreground transition-colors group-hover:text-ns-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-ns-muted"}>
        ON
      </span>
    </button>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/switch-ascii-knife.tsx, so point the import there.

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.

Install

npx shadcn add https://design.helpmarq.com/r/switch-ascii-knife.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

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-ns-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-ns-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-ns-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.

Props

PropTypeDefaultDescription
checked?boolean—controlled state; omit for uncontrolled
defaultChecked?booleanfalseuncontrolled initial state. Default false.
onCheckedChange?(checked: boolean) => void—called with the new state after a toggle
disabled?booleanfalseblocks the switch
className?string—extra classes merged onto the rendered root element
aria-label?string"Toggle"accessible name for the switch