stream/ui
Browse the library

Comic Switch

A halftone pill with a starburst thumb that rolls across and shouts ON!

betaComicSwitch.tsx

Examples

Basic

comic-switch/basic.tsx
import { ComicSwitch } from "@streamuilib/react";

export default function ComicSwitchBasic() {
  return (
    <div className="flex flex-col gap-4">
      <label className="flex items-center gap-3 font-bold">
        <ComicSwitch defaultChecked /> Sound effects
      </label>
      <label className="flex items-center gap-3 font-bold">
        <ComicSwitch /> Dark lair mode
      </label>
      <label className="flex items-center gap-3 font-bold">
        <ComicSwitch defaultChecked disabled /> Always on
      </label>
    </div>
  );
}

Controlled, with your own words

Hero mode is off.

comic-switch/controlled.tsx
"use client";

import { useState } from "react";
import { ComicSwitch } from "@streamuilib/react";

export default function ComicSwitchControlled() {
  const [on, setOn] = useState(false);
  return (
    <div className="flex flex-col items-start gap-3">
      <label className="flex items-center gap-3 font-bold">
        <ComicSwitch checked={on} onCheckedChange={setOn} labels={["Yes!", "Nah"]} /> Hero mode
      </label>
      <p className="font-mono text-sm">Hero mode is {on ? "ON. Up, up, and away!" : "off."}</p>
    </div>
  );
}

Installation

bun add @streamuilib/react
Open in v0 (opens in a new tab)
usage.tsx
import { ComicSwitch } from "@streamuilib/react";

<label>
  <ComicSwitch defaultChecked /> Sound effects
</label>

API

PropTypeDefaultDescription
checkedboolean—Controlled state.
defaultCheckedbooleanfalseInitial state when uncontrolled.
onCheckedChange(checked: boolean) => void—Called with the new state.
labels[ReactNode, ReactNode] | false["On!", "Off"]Words in the track, on then off; false for none.
...propsComponentProps<"button">—Native button attributes, like disabled and aria-label.

Accessibility

  • A button with role="switch" and aria-checked; Space and Enter toggle it.
  • The words in the track are decorative; name the switch with a <label> or aria-label.
  • The roll stops when the user prefers reduced motion.

Source

packages/react/src/ComicSwitch.tsx
"use client";

import { useState, type ReactNode } from "react";
import { cn } from "./cn";
import { halftone } from "./comic";
import { SHAPES } from "./ComicBadge";
import type { SwitchProps } from "./Switch";

export type ComicSwitchProps = SwitchProps & {
  /** Words shown in the track, on then off. Defaults to ON! / OFF; false for none. */
  labels?: [ReactNode, ReactNode] | false;
};

/**
 * A comic-book switch: a halftone pill with a starburst thumb that rolls
 * across, and the state shouted in the track (ON! / OFF). A button with
 * role="switch", the same as Switch. Motion stops for reduced motion.
 */
export function ComicSwitch({
  checked,
  defaultChecked = false,
  onCheckedChange,
  labels = ["On!", "Off"],
  className,
  ...props
}: ComicSwitchProps) {
  const [internal, setInternal] = useState(defaultChecked);
  const isOn = checked ?? internal;

  return (
    <button
      type="button"
      role="switch"
      aria-checked={isOn}
      data-on={isOn || undefined}
      onClick={() => {
        setInternal(!isOn);
        onCheckedChange?.(!isOn);
      }}
      className={cn(
        "sui-comic-switch relative inline-flex h-9 w-[4.75rem] shrink-0 cursor-pointer items-center rounded-full border-[3px] border-ink shadow-[3px_3px_0_var(--ink)]",
        "disabled:pointer-events-none disabled:opacity-50",
        isOn ? "bg-lime" : "bg-paper-sunken",
        className,
      )}
      style={halftone(14)}
      {...props}
    >
      {labels && (
        <span
          aria-hidden
          className={cn(
            "absolute font-mono text-[10px] font-bold tracking-wide uppercase",
            isOn ? "left-2.5 text-on-bright" : "right-2.5 text-ink-muted",
          )}
        >
          {isOn ? labels[0] : labels[1]}
        </span>
      )}
      <span aria-hidden className={cn("sui-comic-switch-thumb absolute top-1/2 size-9 -translate-y-1/2", isOn ? "left-[calc(100%-1.9rem)]" : "-left-1")}>
        <svg viewBox="0 0 100 100" overflow="visible" className="size-full">
          <path
            d={SHAPES.burst.d}
            className={cn("stroke-ink", isOn ? "fill-pink" : "fill-paper-raised")}
            strokeWidth={7}
            strokeLinejoin="round"
          />
        </svg>
      </span>
    </button>
  );
}