Bloom Logo

Switch

A toggle control allowing users to switch between on and off states featuring animated thumb icons, dual labels, clickable card containers, and color themes.

importimport { Switch } from "@/components/ui/switch/switch";
$npx @bloomui-react/cli add switch
switch.tsx
"use client";

import * as SwitchPrimitives from "@radix-ui/react-switch";
import * as React from "react";
import { cn } from "@/lib/utils";

export interface SwitchProps
  extends React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> {
  color?:
    | "default"
    | "primary"
    | "secondary"
    | "accent"
    | "success"
    | "warning"
    | "danger";
  size?: "sm" | "md" | "lg";
  label?: React.ReactNode;
  description?: React.ReactNode;
  startIcon?: React.ReactNode;
  endIcon?: React.ReactNode;
  thumbIcon?: React.ReactNode;
  checkedThumbIcon?: React.ReactNode;
  uncheckedThumbIcon?: React.ReactNode;
  startLabel?: React.ReactNode;
  endLabel?: React.ReactNode;
  isCard?: boolean;
  isDisabled?: boolean;
  isRequired?: boolean;
}

const colorMap = {
  default:
    "data-[state=checked]:bg-zinc-400 dark:data-[state=checked]:bg-zinc-600",
  primary: "data-[state=checked]:bg-sky-500",
  secondary: "data-[state=checked]:bg-purple-500",
  accent: "data-[state=checked]:bg-pink-500",
  success: "data-[state=checked]:bg-emerald-500",
  warning: "data-[state=checked]:bg-amber-500",
  danger: "data-[state=checked]:bg-rose-500",
};

const sizeMap = {
  sm: {
    root: "h-5.5 w-10 p-0.5",
    thumb:
      "size-4.5 data-[state=checked]:translate-x-4.5 data-[state=unchecked]:translate-x-0",
  },
  md: {
    root: "h-7 w-13 p-0.5",
    thumb:
      "size-6 data-[state=checked]:translate-x-6 data-[state=unchecked]:translate-x-0",
  },
  lg: {
    root: "h-8.5 w-16 p-1",
    thumb:
      "size-6.5 data-[state=checked]:translate-x-7.5 data-[state=unchecked]:translate-x-0",
  },
};

const Switch = React.forwardRef<
  React.ComponentRef<typeof SwitchPrimitives.Root>,
  SwitchProps
>(
  (
    {
      className,
      color = "primary",
      size = "md",
      label,
      description,
      startIcon,
      endIcon,
      thumbIcon,
      checkedThumbIcon,
      uncheckedThumbIcon,
      startLabel,
      endLabel,
      isCard = false,
      id,
      disabled,
      isDisabled,
      checked,
      defaultChecked,
      onCheckedChange,
      isRequired = false,
      ...props
    },
    ref,
  ) => {
    const generatedId = React.useId();
    const switchId = id || generatedId;
    const isSwitchDisabled = disabled || isDisabled;

    const [internalChecked, setInternalChecked] = React.useState(
      !!defaultChecked,
    );
    const isChecked = checked !== undefined ? checked : internalChecked;

    const handleCheckedChange = (val: boolean) => {
      if (checked === undefined) {
        setInternalChecked(val);
      }
      onCheckedChange?.(val);
    };

    const renderThumbContent = () => {
      if (thumbIcon) return thumbIcon;
      if (isChecked && checkedThumbIcon) return checkedThumbIcon;
      if (!isChecked && uncheckedThumbIcon) return uncheckedThumbIcon;
      if (isChecked && startIcon) return startIcon;
      if (!isChecked && endIcon) return endIcon;
      return null;
    };

    const switchElement = (
      <div className="inline-flex items-center gap-2.5 shrink-0">
        {startLabel && (
          <span
            onClick={() => !isSwitchDisabled && handleCheckedChange(false)}
            className={cn(
              "text-xs font-semibold select-none cursor-pointer transition-colors",
              !isChecked
                ? "text-zinc-900 dark:text-zinc-100 font-bold"
                : "text-zinc-400 dark:text-zinc-500",
            )}
          >
            {startLabel}
          </span>
        )}
        <SwitchPrimitives.Root
          ref={ref}
          id={switchId}
          disabled={isSwitchDisabled}
          checked={checked}
          defaultChecked={defaultChecked}
          onCheckedChange={handleCheckedChange}
          className={cn(
            "peer relative inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-all duration-300 outline-none focus-visible:ring-2 focus-visible:ring-sky-500/30 disabled:cursor-not-allowed disabled:opacity-35 data-[state=unchecked]:bg-zinc-200 dark:data-[state=unchecked]:bg-zinc-800 shadow-inner",
            sizeMap[size].root,
            colorMap[color],
            className,
          )}
          {...props}
        >
          <SwitchPrimitives.Thumb
            className={cn(
              "pointer-events-none relative z-10 flex items-center justify-center rounded-full bg-white dark:bg-zinc-900 shadow-md ring-0 transition-transform duration-300 ease-spring text-zinc-700 dark:text-zinc-200",
              sizeMap[size].thumb,
            )}
          >
            <span className="transition-all duration-200 transform active:scale-95 flex items-center justify-center">
              {renderThumbContent()}
            </span>
          </SwitchPrimitives.Thumb>
        </SwitchPrimitives.Root>

        {endLabel && (
          <span
            onClick={() => !isSwitchDisabled && handleCheckedChange(true)}
            className={cn(
              "text-xs font-semibold select-none cursor-pointer transition-colors",
              isChecked
                ? "text-zinc-900 dark:text-zinc-100 font-bold"
                : "text-zinc-400 dark:text-zinc-500",
            )}
          >
            {endLabel}
          </span>
        )}
      </div>
    );

    const content = (
      <div className="flex items-center gap-3 w-full">
        {switchElement}
        {(label || description) && (
          <div className="flex flex-col gap-0.5 select-none flex-1">
            {label && (
              <label
                htmlFor={switchId}
                className="text-xs font-bold text-zinc-900 dark:text-zinc-100 leading-none cursor-pointer peer-disabled:cursor-not-allowed peer-disabled:opacity-40"
              >
                {label}
                {isRequired && <span className="text-rose-500 ml-0.5">*</span>}
              </label>
            )}
            {description && (
              <p className="text-xs text-zinc-500 dark:text-zinc-400 leading-snug">
                {description}
              </p>
            )}
          </div>
        )}
      </div>
    );

    if (isCard) {
      return (
        <label
          htmlFor={switchId}
          className={cn(
            "relative flex items-center justify-between p-4 rounded-2xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 transition-all duration-200 cursor-pointer hover:border-zinc-300 dark:hover:border-zinc-700 has-[:checked]:border-sky-500/50 has-[:checked]:bg-sky-500/5 shadow-xs w-full select-none",
            isSwitchDisabled &&
              "opacity-35 grayscale cursor-not-allowed pointer-events-none",
          )}
        >
          {content}
        </label>
      );
    }

    return content;
  },
);
Switch.displayName = SwitchPrimitives.Root.displayName;

export { Switch };

Default

Standard toggle switch with label.

Variants

Render as a card container for settings-style toggle rows.

isCard: booleanisDisabled: boolean

Colors

Apply theme alert colors to the active checked track.

color: default | primary | secondary | accent | success | warning | danger

Sizes

Choose from small, medium, or large switch dimensions.

size: sm | md | lg

Thumb Icons

Embed icons inside the sliding thumb to show context-aware state feedback.

checkedThumbIcon: ReactNodeuncheckedThumbIcon: ReactNodethumbIcon: ReactNode

Dual Labels

Display text labels on both ends of the switch track simultaneously.

startLabel: ReactNodeendLabel: ReactNode
MonthlyAnnual (Save 20%)

Required State

Displays an asterisk next to the label indicating that toggling the switch is mandatory.

isRequired: boolean

Props — Switch

Supported properties for Switch.

PropTypeDefaultDescription
checkedThumbIconReactNodeIcon rendered inside the thumb when checked.
uncheckedThumbIconReactNodeIcon rendered inside the thumb when unchecked.
startLabelReactNodeText label placed at the left extremity.
endLabelReactNodeText label placed at the right extremity.
isCardbooleanfalseWraps the switch in a clickable card container.