Toggle

A pressable Base UI toggle styled with shadcn conventions. Use it for a single on/off control that lives in a toolbar, not in a form.

Toggle
Every hand-drawn toggle variant, size and state.
Variants
Sizes
With icons
Pressed
Disabled

Installation

npx shadcn@latest add https://sketchcn.chuwii.com/r/toggle.json

Styles

Toggle leans on these rules from sketch.css. The shadcn CLI installs the file with sketch-provider; copying the component by hand means copying them too.

[data-sketch-outline] path {
  stroke-dasharray: var(--sketch-dash, none);
  animation: var(--sketch-dash-animation, none);
}

@keyframes sketch-dash-boil {
  to {
    stroke-dashoffset: -10;
  }
}

[data-sketch-outline] path[fill]:not([fill="none"]) {
  fill: var(--sketch-fill);
  fill-opacity: var(--sketch-fill-opacity, 1);
  transition:
    fill 150ms ease,
    fill-opacity 150ms ease;
}

Read the whole file for the drawing variables, paper textures and reduced-motion rules.

Usage

import { Bold } from "@matita/icons/react";
import { Toggle } from "@/components/ui/toggle";

export function BoldToggle() {
  return (
    <Toggle aria-label="Bold" defaultPressed>
      <Bold />
      Bold
    </Toggle>
  );
}

Props

PropTypeDefaultDescription
variant"default" | "outline""default"Outline draws a sketch border even when unpressed.
size"sm" | "default" | "lg""default"Height, padding and icon scale.

Everything else, including the pressed state props, is forwarded to the Base UI Toggle primitive. The component also exports toggleVariants, TOGGLE_VARIANTS and TOGGLE_SIZES.

Examples

Formatting toolbar

import { Toggle } from "@/components/ui/toggle";

const TEXT_MARKS = [
  { value: "bold", label: "Bold", Icon: Bold },
  { value: "italic", label: "Italic", Icon: Italic },
  { value: "underline", label: "Underline", Icon: Underline },
];

export function TextToolbar() {
  const [marks, setMarks] = useState<string[]>(["bold"]);

  function toggleMark(value: string, pressed: boolean) {
    setMarks((current) => {
      if (pressed) {
        return [...current, value];
      }

      return current.filter((mark) => mark !== value);
    });
  }

  return (
    <div className="flex gap-1">
      {TEXT_MARKS.map(({ value, label, Icon }) => (
        <Toggle
          key={value}
          variant="outline"
          size="sm"
          aria-label={label}
          pressed={marks.includes(value)}
          onPressedChange={(pressed) => toggleMark(value, pressed)}
        >
          <Icon />
        </Toggle>
      ))}
    </div>
  );
}

Controlled filter

import { Toggle } from "@/components/ui/toggle";

export function DraftsFilter() {
  const [showDrafts, setShowDrafts] = useState(false);

  return (
    <Toggle pressed={showDrafts} onPressedChange={setShowDrafts}>
      Show drafts
    </Toggle>
  );
}

Animation

A pressed toggle dashes its outline and swaps dashes and gaps with the sketch-dash-boil keyframe from sketch.css, so the border reads as redrawn by hand.

"data-pressed:[--sketch-dash:5_5] data-pressed:[--sketch-dash-animation:sketch-dash-boil_1s_steps(2)_infinite]"

Hovering raises the fill instead, through --sketch-fill-opacity.

"hover:[--sketch-fill-opacity:0.08] data-pressed:[--sketch-fill-opacity:0.14]"
<Toggle className="data-pressed:[--sketch-dash-animation:none]">Quiet</Toggle>