React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

switchtogglebooleanformcomponenttailwind

Switch

An on/off toggle control that visually represents a boolean state, commonly used for settings, preferences, and feature flags.

Use Cases

  • Enable or disable application settings (dark mode, notifications)
  • Toggle feature flags in admin panels
  • Opt-in/opt-out controls for marketing emails or cookies
  • Show/hide sections of a form dynamically
  • Mute/unmute audio or video in a media player
  • Activate/deactivate integrations in a dashboard
  • Toggle between two mutually exclusive modes (public/private)

Simplest Implementation

"use client";
 
import { useState } from "react";
 
interface SwitchProps {
  checked: boolean;
  onChange: (checked: boolean) => void;
}
 
export function Switch({ checked, onChange }: SwitchProps) {
  return (
    <button
      role="switch"
      aria-checked={checked}
      onClick={() => onChange(!checked)}
      className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
        checked ? "bg-blue-600" : "bg-gray-300"
      }`}
    >
      <span
        className={`inline-block h-4 w-4 rounded-full bg-white transition-transform ${
          checked ? "translate-x-6" : "translate-x-1"
        }`}
      />
    </button>
  );
}

A minimal toggle built on a <button> with role="switch" and aria-checked for accessibility. The thumb slides between positions using translate-x and the track color transitions between gray and blue. No hidden checkbox is needed because the button itself acts as the form control.

Variations

With Label

"use client";
 
import { useId } from "react";
 
interface SwitchProps {
  label: string;
  checked: boolean;
  onChange: (checked: boolean) => void;
}
 
export function Switch({ label, checked, onChange }: SwitchProps) {
  const id = useId();
 
  return (
    <div className="flex items-center gap-3">
      <button
        id={id}
        role="switch"
        aria-checked={checked}
        onClick={() => onChange(!checked)}
        className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
          checked ? "bg-blue-600" : "bg-gray-300"
        }`}
      >
        <span
          className={`inline-block h-4 w-4 rounded-full bg-white transition-transform ${
            checked ? "translate-x-6" : "translate-x-1"
          }`}
        />
      </button>
      <label htmlFor={id} className="text-sm font-medium text-gray-700 cursor-pointer">
        {label}
      </label>
    </div>
  );
}

The label is connected to the switch via htmlFor and the generated id, so clicking the label text also toggles the switch. The cursor-pointer class signals that the label is interactive.

With Description

"use client";
 
import { useId } from "react";
 
interface SwitchProps {
  label: string;
  description: string;
  checked: boolean;
  onChange: (checked: boolean) => void;
}
 
export function Switch({ label, description, checked, onChange }: SwitchProps) {
  const id = useId();
  const descId = `${id}-desc`;
 
  return (
    <div className="flex items-start justify-between gap-4">
      <div>
        <label htmlFor={id} className="text-sm font-medium text-gray-900 cursor-pointer">
          {label}
        </label>
        <p id={descId} className="text-sm text-gray-500">
          {description}
        </p>
      </div>
      <button
        id={id}
        role="switch"
        aria-checked={checked}
        aria-describedby={descId}
        onClick={() => onChange(!checked)}
        className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors ${
          checked ? "bg-blue-600" : "bg-gray-300"
        }`}
      >
        <span
          className={`inline-block h-4 w-4 rounded-full bg-white transition-transform ${
            checked ? "translate-x-6" : "translate-x-1"
          }`}
        />
      </button>
    </div>
  );
}

A settings-style layout with the label and description on the left, switch on the right. The aria-describedby links the description to the switch so screen readers announce it. The shrink-0 class prevents the switch from being squished by long text.

Sizes (sm / md / lg)

"use client";
 
type SwitchSize = "sm" | "md" | "lg";
 
interface SwitchProps {
  checked: boolean;
  onChange: (checked: boolean) => void;
  size?: SwitchSize;
}
 
const sizeClasses: Record<SwitchSize, { track: string; thumb: string; translate: string }> = {
  sm: { track: "h-5 w-9", thumb: "h-3 w-3", translate: "translate-x-5" },
  md: { track: "h-6 w-11", thumb: "h-4 w-4", translate: "translate-x-6" },
  lg: { track: "h-8 w-14", thumb: "h-6 w-6", translate: "translate-x-7" },
};
 
export function Switch({ checked, onChange, size = "md" }: SwitchProps) {
  const s = sizeClasses[size];
 
  return (
    <button
      role="switch"
      aria-checked={checked}
      onClick={() => onChange(!checked)}
      className={`relative inline-flex items-center rounded-full transition-colors ${s.track} ${
        checked ? "bg-blue-600" : "bg-gray-300"
      }`}
    >
      <span
        className={`inline-block rounded-full bg-white transition-transform ${s.thumb} ${
          checked ? s.translate : "translate-x-1"
        }`}
      />
    </button>
  );
}

A size map keeps the track and thumb proportional at each breakpoint. The translate distance adjusts per size so the thumb lands flush against the track edge in both states.

Colored Variants

"use client";
 
type SwitchColor = "blue" | "green" | "red";
 
interface SwitchProps {
  checked: boolean;
  onChange: (checked: boolean) => void;
  color?: SwitchColor;
}
 
const colorClasses: Record<SwitchColor, string> = {
  blue: "bg-blue-600",
  green: "bg-green-600",
  red: "bg-red-600",
};
 
export function Switch({ checked, onChange, color = "blue" }: SwitchProps) {
  return (
    <button
      role="switch"
      aria-checked={checked}
      onClick={() => onChange(!checked)}
      className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
        checked ? colorClasses[color] : "bg-gray-300"
      }`}
    >
      <span
        className={`inline-block h-4 w-4 rounded-full bg-white transition-transform ${
          checked ? "translate-x-6" : "translate-x-1"
        }`}
      />
    </button>
  );
}

Different colors communicate intent -- green for success/enable, red for destructive/danger, blue for neutral settings. The off state remains gray across all variants for consistency.

Disabled State

"use client";
 
interface SwitchProps {
  checked: boolean;
  onChange: (checked: boolean) => void;
  disabled?: boolean;
  label?: string;
}
 
export function Switch({ checked, onChange, disabled = false, label }: SwitchProps) {
  return (
    <div className="flex items-center gap-3">
      <button
        role="switch"
        aria-checked={checked}
        disabled={disabled}
        onClick={() => onChange(!checked)}
        className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
          disabled
            ? "cursor-not-allowed opacity-50"
            : ""
        } ${checked ? "bg-blue-600" : "bg-gray-300"}`}
      >
        <span
          className={`inline-block h-4 w-4 rounded-full bg-white transition-transform ${
            checked ? "translate-x-6" : "translate-x-1"
          }`}
        />
      </button>
      {label && (
        <span className={`text-sm font-medium ${disabled ? "text-gray-400" : "text-gray-700"}`}>
          {label}
        </span>
      )}
    </div>
  );
}

The disabled attribute on the button prevents clicks natively. The opacity-50 and cursor-not-allowed classes give a clear visual cue that the control is inactive. The label text also dims to reinforce the disabled state.

Complex Implementation

"use client";
 
import { forwardRef, useId, useCallback } from "react";
 
type SwitchSize = "sm" | "md" | "lg";
type SwitchColor = "blue" | "green" | "red";
 
interface SwitchProps {
  checked: boolean;
  onChange: (checked: boolean) => void;
  label?: string;
  description?: string;
  size?: SwitchSize;
  color?: SwitchColor;
  disabled?: boolean;
  name?: string;
  id?: string;
  className?: string;
}
 
const sizeClasses: Record<SwitchSize, { track: string; thumb: string; translate: string }> = {
  sm: { track: "h-5 w-9", thumb: "h-3 w-3", translate: "translate-x-5" },
  md: { track: "h-6 w-11", thumb: "h-4 w-4", translate: "translate-x-6" },
  lg: { track: "h-8 w-14", thumb: "h-6 w-6", translate: "translate-x-7" },
};
 
const colorClasses: Record<SwitchColor, string> = {
  blue: "bg-blue-600",
  green: "bg-green-600",
  red: "bg-red-600",
};
 
export const Switch = forwardRef<HTMLButtonElement, SwitchProps>(function Switch(
  {
    checked,
    onChange,
    label,
    description,
    size = "md",
    color = "blue",
    disabled = false,
    name,
    id: externalId,
    className,
  },
  ref
) {
  const generatedId = useId();
  const switchId = externalId ?? generatedId;
  const descId = `${switchId}-desc`;
  const s = sizeClasses[size];
 
  const handleClick = useCallback(() => {
    if (!disabled) onChange(!checked);
  }, [disabled, checked, onChange]);
 
  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent) => {
      if (disabled) return;
      if (e.key === "Enter" || e.key === " ") {
        e.preventDefault();
        onChange(!checked);
      }
    },
    [disabled, checked, onChange]
  );
 
  return (
    <div className={`flex items-start justify-between gap-4 ${className ?? ""}`}>
      {(label || description) && (
        <div className="min-w-0">
          {label && (
            <label
              htmlFor={switchId}
              className={`block text-sm font-medium cursor-pointer ${
                disabled ? "text-gray-400" : "text-gray-900"
              }`}
            >
              {label}
            </label>
          )}
          {description && (
            <p
              id={descId}
              className={`text-sm ${disabled ? "text-gray-300" : "text-gray-500"}`}
            >
              {description}
            </p>
          )}
        </div>
      )}
 
      {/* Hidden input for form serialization */}
      {name && (
        <input type="hidden" name={name} value={checked ? "on" : "off"} />
      )}
 
      <button
        ref={ref}
        id={switchId}
        role="switch"
        type="button"
        aria-checked={checked}
        aria-describedby={description ? descId : undefined}
        disabled={disabled}
        onClick={handleClick}
        onKeyDown={handleKeyDown}
        className={[
          "relative inline-flex shrink-0 items-center rounded-full transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2",
          s.track,
          disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
          checked ? colorClasses[color] : "bg-gray-300",
        ].join(" ")}
      >
        <span
          aria-hidden="true"
          className={[
            "inline-block rounded-full bg-white shadow-sm transition-transform duration-200",
            s.thumb,
            checked ? s.translate : "translate-x-1",
          ].join(" ")}
        />
      </button>
    </div>
  );
});

Key aspects:

  • forwardRef -- allows parent components to attach a ref for focus management or integration with form libraries that need imperative access.
  • Hidden input for form serialization -- when a name prop is provided, a hidden input is rendered so the switch value is included in native form submissions and FormData.
  • focus-visible ring -- the focus-visible ring appears only for keyboard navigation, not mouse clicks, giving a clean look while remaining accessible.
  • aria-checked and role="switch" -- the correct ARIA role ensures screen readers announce the control as a toggle with its current on/off state rather than a generic button.
  • aria-describedby -- links the description text to the switch so screen readers announce supplementary context when the control receives focus.
  • Keyboard handling -- explicit Enter and Space key handlers ensure consistent behavior across browsers, since some browsers do not fire click on Space for non-native controls.
  • Transition duration -- duration-200 on both the track and thumb creates a smooth, coordinated animation without feeling sluggish.
  • shrink-0 -- prevents the switch from collapsing when placed inside a flex container with long label text.

Gotchas

  • Missing role="switch" -- without this role, screen readers treat the element as a plain button. The aria-checked attribute is only valid when the role is switch or checkbox.

  • Using a checkbox instead of a button -- a hidden checkbox with a visual overlay works but requires careful keyboard handling and label association. A <button> with role="switch" is simpler and more predictable.

  • Transition not animating -- if you toggle classes that Tailwind purges (e.g., translate-x-6), the class will not exist in production. Ensure all translate values appear in your Tailwind config safelist or are always referenced in the source.

  • Thumb position off by a pixel -- the thumb translate distance must account for the track padding. If the thumb does not sit flush against the track edge, adjust the translate value or add padding to the track.

  • Not using type="button" -- inside a form, a <button> defaults to type="submit", which will submit the form when the switch is clicked. Always add type="button" to prevent this.

  • Click handler fires on disabled -- although the native disabled attribute prevents click events, some event delegation patterns or wrapper onClick handlers may still fire. Always guard with an early return in the handler.

  • No form value serialization -- unlike a native checkbox, a button-based switch does not automatically appear in FormData. Include a hidden input with the switch state to support native form submissions.

  • Input -- Text inputs used alongside switches in forms
  • Forms -- Form patterns and controlled component strategies
  • Toggle Group -- For selecting from multiple options instead of binary on/off