GitHub

Toggle

A two-state button that persists its pressed/active state. Visually reflects on/off state and supports controlled and uncontrolled modes.

Install

$npx react-principles add toggle
01

Features

  • Visual State: Distinct pressed/unpressed styling
  • Controlled & Uncontrolled: Use pressed + onPressedChange or defaultPressed
  • Accessible: Correct aria-pressed attribute
  • Keyboard Accessible: Space/Enter to toggle
  • Variants & Sizes: default / outline in sm/md/lg
02

Live Demo

Toolbar (controlled)

Active: none

Default variant

Sizes

03

Code Snippet

Toggle.tsx
import { Toggle } from "@/ui/Toggle";

function Toolbar() {
  const [bold, setBold] = useState(false);
  const [italic, setItalic] = useState(false);

  return (
    <div className="flex gap-1">
      <Toggle pressed={bold} onPressedChange={setBold}>
        B
      </Toggle>
      <Toggle pressed={italic} onPressedChange={setItalic}>
        I
      </Toggle>
    </div>
  );
}
04

Copy-Paste (Single File)

Toggle.tsx
import { useState, type ButtonHTMLAttributes, type ReactNode } from "react";
import { cn } from "@/shared/utils/cn";

export type ToggleVariant = "default" | "outline";
export type ToggleSize = "sm" | "md" | "lg";

export interface ToggleProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  pressed?: boolean;
  defaultPressed?: boolean;
  onPressedChange?: (pressed: boolean) => void;
  variant?: ToggleVariant;
  size?: ToggleSize;
  disabled?: boolean;
  children: ReactNode;
}

const VARIANT_CLASSES: Record<ToggleVariant, Record<"on" | "off", string>> = {
  default: {
    on: "bg-primary text-white hover:bg-primary/90 focus-visible:ring-primary/40",
    off: "bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700 focus-visible:ring-slate-400/40",
  },
  outline: {
    on: "bg-slate-100 text-slate-900 border border-slate-300 dark:bg-slate-800 dark:text-white dark:border-slate-600 focus-visible:ring-slate-400/40",
    off: "border border-slate-300 text-slate-700 hover:bg-slate-50 dark:border-slate-600 dark:text-slate-300 dark:hover:bg-slate-800/50 focus-visible:ring-slate-400/40",
  },
};

const SIZE_CLASSES: Record<ToggleSize, string> = {
  sm: "text-xs px-3 py-1.5 h-7 gap-1.5",
  md: "text-sm px-4 py-2 h-9 gap-2",
  lg: "text-base px-6 py-2.5 h-11 gap-2",
};

export function Toggle({
  pressed, defaultPressed = false, onPressedChange,
  variant = "default", size = "md", disabled = false,
  children, className, onClick, ...props
}: ToggleProps) {
  const [internal, setInternal] = useState(defaultPressed);
  const isControlled = pressed !== undefined;
  const isOn = isControlled ? pressed : internal;

  const toggle = () => {
    if (disabled) return;
    const next = !isOn;
    if (!isControlled) setInternal(next);
    onPressedChange?.(next);
  };

  return (
    <button
      type="button"
      aria-pressed={isOn}
      disabled={disabled}
      onClick={(e) => { onClick?.(e); toggle(); }}
      className={cn(
        "inline-flex items-center justify-center font-semibold rounded-lg transition-all",
        "focus-visible:outline-hidden focus-visible:ring-2",
        "disabled:opacity-50 disabled:cursor-not-allowed",
        isOn ? VARIANT_CLASSES[variant].on : VARIANT_CLASSES[variant].off,
        SIZE_CLASSES[size],
        className,
      )}
      {...props}
    >
      {children}
    </button>
  );
}
05

Usage Examples

Uncontrolled

<Toggle defaultPressed>Toggle</Toggle>

Controlled

const [isOn, setIsOn] = useState(false);

<Toggle pressed={isOn} onPressedChange={setIsOn}>
  Toggle
</Toggle>

Outline variant with sizes

<div className="flex gap-3">
  <Toggle variant="outline" size="sm">Small</Toggle>
  <Toggle variant="outline" size="md">Medium</Toggle>
  <Toggle variant="outline" size="lg">Large</Toggle>
</div>
06

Props

PropTypeDefaultDescription
pressedbooleanControlled pressed state
defaultPressedbooleanfalseUncontrolled initial pressed state
onPressedChange(pressed: boolean) => voidCalled when pressed state changes
variant"default" | "outline""default"Visual variant
size"sm" | "md" | "lg""md"Button size
disabledbooleanfalseDisable the toggle
React Principles