# Switch

> An on/off toggle you can tap, press or drag, with a thumb that stretches under your finger and an optional pending state for saves.

Docs: https://hextaui.com/docs/switch
Markdown: https://hextaui.com/docs/switch.md

```tsx title="components/examples/switch/demo.tsx"
import { Switch } from "@/components/ui/switch"

const settings = [
  {
    id: "wifi",
    title: "Wi-Fi",
    description: "Join known networks automatically.",
    on: true,
  },
  {
    id: "bluetooth",
    title: "Bluetooth",
    description: "Connect to headphones and keyboards.",
    on: false,
  },
  {
    id: "airdrop",
    title: "Nearby sharing",
    description: "Let people around you send files.",
    on: true,
  },
]

export function SwitchDemo() {
  return (
    <div className="flex w-full max-w-sm flex-col divide-y rounded-xl border">
      {settings.map((setting) => (
        <label
          key={setting.id}
          className="flex items-center justify-between gap-4 p-4 text-sm"
        >
          <span className="flex flex-col gap-0.5">
            <span className="font-medium">{setting.title}</span>
            <span className="text-muted-foreground">{setting.description}</span>
          </span>
          <Switch defaultChecked={setting.on} />
        </label>
      ))}
    </div>
  )
}
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/switch.json
```

This adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

### Manual

Add the HextaUI theme tokens (https://hextaui.com/docs/installation#theme) to your global CSS if you haven't yet, then install the dependencies.

```bash
pnpm add @base-ui/react @tabler/icons-react class-variance-authority cn
```

Copy and paste the following code into your project.

```tsx title="components/ui/switch.tsx"
"use client"

import * as React from "react"
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { IconCheck } from "@tabler/icons-react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"

import { Spinner } from "@/components/ui/spinner"
import { prefersReducedMotion } from "@/lib/motion"

const switchVariants = cva(
  "group/switch peer relative inline-flex h-(--switch-h) w-(--switch-w) shrink-0 cursor-pointer items-center rounded-full bg-foreground/20 p-0.5 transition-[background-color,box-shadow] duration-200 ease-out-quint outline-none select-none [--switch-dir:1] [--switch-stretch:0px] [--switch-thumb-w:var(--switch-thumb)] [--switch-x:0px] after:absolute after:-inset-1.5 focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:outline-hidden aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-dragging:[--switch-x:var(--switch-drag)] data-invalid:ring-3 data-invalid:ring-destructive/20 data-pending:cursor-progress data-readonly:cursor-default data-[drag=off]:bg-foreground/20 data-[drag=on]:bg-primary data-shake:motion-safe:animate-button-shake rtl:[--switch-dir:-1] dark:bg-foreground/25 dark:data-[drag=off]:bg-foreground/25 dark:data-[drag=on]:bg-primary forced-colors:border pointer-coarse:after:-inset-3 data-checked:bg-primary data-checked:[--switch-x:calc(var(--switch-w)-var(--switch-thumb-w)-4px-var(--switch-stretch))] dark:data-checked:bg-primary forced-colors:data-checked:bg-highlight data-disabled:cursor-not-allowed data-disabled:opacity-50 [&:active:not([data-disabled]):not([data-readonly]):not([data-pending])]:[--switch-stretch:4px]",
  {
    variants: {
      variant: {
        default: "",
        ios: "bg-foreground/10 inset-ring-(length:--hairline) inset-ring-foreground/5 [--switch-thumb-w:calc(var(--switch-thumb)*1.5)] data-[drag=off]:bg-foreground/10 data-[drag=on]:bg-success dark:bg-foreground/20 dark:data-[drag=off]:bg-foreground/20 dark:data-[drag=on]:bg-success forced-colors:border data-checked:bg-success data-checked:inset-ring-transparent dark:data-checked:bg-success",
      },
      size: {
        sm: "[--switch-h:1rem] [--switch-thumb:0.75rem] [--switch-w:1.75rem]",
        default:
          "[--switch-h:1.25rem] [--switch-thumb:1rem] [--switch-w:2.25rem]",
        lg: "[--switch-h:1.5rem] [--switch-thumb:1.25rem] [--switch-w:2.75rem]",
      },
    },
    compoundVariants: [
      {
        variant: "ios",
        size: "sm",
        className:
          "[--switch-h:1.5rem] [--switch-thumb:1.25rem] [--switch-w:3.5rem]",
      },
      {
        variant: "ios",
        size: "default",
        className:
          "[--switch-h:1.75rem] [--switch-thumb:1.5rem] [--switch-w:4rem]",
      },
      {
        variant: "ios",
        size: "lg",
        className:
          "[--switch-h:2rem] [--switch-thumb:1.75rem] [--switch-w:4.5rem]",
      },
    ],
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

type SwitchProps = Omit<SwitchPrimitive.Root.Props, "onCheckedChange"> &
  VariantProps<typeof switchVariants> & {
    icons?: boolean
    onCheckedChange?: (
      checked: boolean,
      eventDetails: SwitchPrimitive.Root.ChangeEventDetails
    ) => unknown
  }

function isThenable(value: unknown): value is PromiseLike<unknown> {
  return (
    typeof value === "object" &&
    value !== null &&
    typeof (value as PromiseLike<unknown>).then === "function"
  )
}

function useThumbDrag(
  rootRef: React.RefObject<HTMLElement | null>,
  canDrag: () => boolean
) {
  const drag = React.useRef<{
    id: number
    startX: number
    from: boolean
    max: number
    moved: boolean
    position: number
  } | null>(null)
  const suppressClick = React.useRef(false)
  const pendingToggle = React.useRef(false)

  const onPointerDown = (event: React.PointerEvent<HTMLElement>) => {
    const root = rootRef.current
    if (!root || event.button !== 0 || !canDrag()) {
      return
    }
    drag.current = {
      id: event.pointerId,
      startX: event.clientX,
      from: root.hasAttribute("data-checked"),
      max: 0,
      moved: false,
      position: 0,
    }
  }

  const onPointerMove = (event: React.PointerEvent<HTMLElement>) => {
    const state = drag.current
    const root = rootRef.current
    if (!state || !root || event.pointerId !== state.id) {
      return
    }
    const rtl = getComputedStyle(root).direction === "rtl"
    const delta = (event.clientX - state.startX) * (rtl ? -1 : 1)
    if (!state.moved && Math.abs(delta) < 3) {
      return
    }
    if (!state.moved) {
      const thumb = root.querySelector<HTMLElement>("[data-slot=switch-thumb]")
      state.max = Math.max(0, root.clientWidth - (thumb?.offsetWidth ?? 0) - 4)
      state.moved = true
      root.setPointerCapture?.(event.pointerId)
      root.setAttribute("data-dragging", "")
    }
    const base = state.from ? state.max : 0
    state.position = Math.min(state.max, Math.max(0, base + delta))
    root.setAttribute(
      "data-drag",
      state.position > state.max / 2 ? "on" : "off"
    )
    root.style.setProperty("--switch-drag", `${state.position}px`)
  }

  const finish = (event: React.PointerEvent<HTMLElement>) => {
    const state = drag.current
    const root = rootRef.current
    drag.current = null
    if (!state || !root || !state.moved) {
      return
    }
    root.removeAttribute("data-dragging")
    root.removeAttribute("data-drag")
    root.style.removeProperty("--switch-drag")
    if (root.hasPointerCapture?.(event.pointerId)) {
      root.releasePointerCapture(event.pointerId)
    }
    const to = state.position > state.max / 2
    suppressClick.current = true
    pendingToggle.current = to !== state.from && event.type === "pointerup"
    setTimeout(() => {
      suppressClick.current = false
      if (pendingToggle.current) {
        pendingToggle.current = false
        root.click()
      }
    }, 0)
  }

  const onClickCapture = (event: React.MouseEvent<HTMLElement>) => {
    if (suppressClick.current) {
      event.preventDefault()
      event.stopPropagation()
    }
  }

  return {
    onPointerDown,
    onPointerMove,
    onPointerUp: finish,
    onPointerCancel: finish,
    onClickCapture,
  }
}

function Switch({
  className,
  size = "default",
  variant = "default",
  icons = false,
  checked: checkedProp,
  defaultChecked = false,
  onCheckedChange,
  disabled,
  readOnly,
  onPointerDown,
  onPointerMove,
  onPointerUp,
  onPointerCancel,
  onClickCapture,
  onAnimationEnd,
  ref,
  ...props
}: SwitchProps) {
  const rootRef = React.useRef<HTMLElement | null>(null)
  const [internal, setInternal] = React.useState(defaultChecked)
  const [pending, setPending] = React.useState(false)
  const controlled = checkedProp !== undefined
  const checked = controlled ? checkedProp : internal
  const latest = React.useRef(0)

  const blocked = Boolean(disabled || readOnly || pending)
  const dragHandlers = useThumbDrag(rootRef, () => !blocked)

  const setRef = React.useCallback(
    (node: HTMLElement | null) => {
      rootRef.current = node
      if (typeof ref === "function") {
        return ref(node)
      }
      if (ref) {
        ref.current = node
      }
      return undefined
    },
    [ref]
  )

  const handleChange = (
    next: boolean,
    details: SwitchPrimitive.Root.ChangeEventDetails
  ) => {
    if (pending) {
      details.cancel()
      return
    }
    if (!controlled) {
      setInternal(next)
    }
    const result = onCheckedChange?.(next, details)
    if (!isThenable(result)) {
      return
    }
    const id = ++latest.current
    setPending(true)
    Promise.resolve(result).then(
      () => {
        if (latest.current === id) {
          setPending(false)
        }
      },
      () => {
        if (latest.current !== id) {
          return
        }
        setPending(false)
        if (!controlled) {
          setInternal(!next)
        }
        const root = rootRef.current
        if (root && !prefersReducedMotion()) {
          root.removeAttribute("data-shake")
          void root.offsetWidth
          root.setAttribute("data-shake", "")
        }
      }
    )
  }

  return (
    <SwitchPrimitive.Root
      ref={setRef}
      checked={checked}
      onCheckedChange={handleChange}
      disabled={disabled}
      readOnly={readOnly}
      aria-busy={pending || undefined}
      data-pending={pending ? "" : undefined}
      data-size={size}
      data-variant={variant}
      className={(state) =>
        cn(
          switchVariants({ variant, size }),
          typeof className === "function" ? className(state) : className
        )
      }
      onPointerDown={(event) => {
        onPointerDown?.(event)
        if (!event.defaultPrevented) {
          dragHandlers.onPointerDown(event)
        }
      }}
      onPointerMove={(event) => {
        onPointerMove?.(event)
        dragHandlers.onPointerMove(event)
      }}
      onPointerUp={(event) => {
        onPointerUp?.(event)
        dragHandlers.onPointerUp(event)
      }}
      onPointerCancel={(event) => {
        onPointerCancel?.(event)
        dragHandlers.onPointerCancel(event)
      }}
      onClickCapture={(event) => {
        onClickCapture?.(event)
        dragHandlers.onClickCapture(event)
      }}
      onAnimationEnd={(event) => {
        onAnimationEnd?.(event)
        if (event.target === event.currentTarget) {
          event.currentTarget.removeAttribute("data-shake")
        }
      }}
      {...props}
      data-slot="switch"
    >
      {icons ? (
        <span
          aria-hidden="true"
          data-slot="switch-icons"
          className="pointer-events-none absolute inset-0 flex items-center justify-between px-[calc((var(--switch-h)-0.625rem)/2)] text-primary-foreground group-data-[variant=ios]/switch:text-success-foreground [&_svg]:size-2.5"
        >
          <IconCheck
            stroke={3}
            className="opacity-0 transition-opacity duration-200 group-data-[drag=off]/switch:opacity-0 group-data-[drag=on]/switch:opacity-100 group-data-checked/switch:opacity-100"
          />
          <span className="size-2 rounded-full opacity-100 inset-ring-[1.5px] inset-ring-muted-foreground transition-opacity duration-200 group-data-[drag=off]/switch:opacity-100 group-data-[drag=on]/switch:opacity-0 group-data-checked/switch:opacity-0" />
        </span>
      ) : null}
      <SwitchPrimitive.Thumb
        data-slot="switch-thumb"
        className="pointer-events-none relative z-1 flex h-(--switch-thumb) w-[calc(var(--switch-thumb-w)+var(--switch-stretch))] translate-x-[calc(var(--switch-x)*var(--switch-dir))] items-center justify-center rounded-full bg-background ring-(length:--hairline) ring-foreground/5 transition-[translate,width] duration-300 ease-spring group-data-dragging/switch:transition-none motion-reduce:transition-none dark:bg-foreground dark:group-data-[drag=off]/switch:bg-foreground dark:group-data-[drag=on]/switch:bg-primary-foreground dark:group-data-[variant=ios]/switch:group-data-[drag=on]/switch:bg-foreground dark:group-data-checked/switch:bg-primary-foreground dark:group-data-[variant=ios]/switch:group-data-checked/switch:bg-foreground forced-colors:border forced-colors:bg-canvas-text"
      >
        {pending ? (
          <Spinner
            aria-hidden
            size={null}
            className="size-[calc(var(--switch-thumb)-0.375rem)] text-muted-foreground"
          />
        ) : null}
      </SwitchPrimitive.Thumb>
    </SwitchPrimitive.Root>
  )
}

export { Switch, switchVariants }
export type { SwitchProps }
```

```tsx title="components/ui/spinner.tsx"
"use client"

import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"

import {
  useDelayedLoading,
  type DelayedLoadingOptions,
} from "@/hooks/use-delayed-loading"

const spinnerVariants = cva("shrink-0", {
  variants: {
    size: {
      sm: "size-3.5",
      default: "size-4",
      lg: "size-5",
      xl: "size-8",
    },
  },
  defaultVariants: {
    size: "default",
  },
})

type SpinnerVariant = "ticks" | "ring"

type SpinnerProps = Omit<React.ComponentProps<"svg">, "children"> &
  VariantProps<typeof spinnerVariants> & {
    variant?: SpinnerVariant
    label?: string
    animated?: boolean
    loading?: boolean
    delay?: number
    minDuration?: number
  }

const ticks = Array.from({ length: 8 }, (_, index) => index)

function Spinner({
  className,
  variant = "ticks",
  size = "default",
  label = "Loading",
  animated = true,
  loading,
  delay,
  minDuration,
  ...props
}: SpinnerProps) {
  const delayed = useDelayedLoading(loading ?? false, { delay, minDuration })
  const shown = loading === undefined || delayed

  if (!shown) {
    return null
  }

  const hidden =
    props["aria-hidden"] === true || props["aria-hidden"] === "true"

  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      role={hidden ? undefined : "status"}
      aria-label={hidden ? undefined : label}
      data-slot="spinner"
      data-variant={variant}
      className={cn(
        spinnerVariants({ size }),
        animated &&
          (variant === "ticks"
            ? "motion-safe:animate-spinner-ticks"
            : "motion-safe:animate-spinner-rotate"),
        animated && "motion-reduce:animate-spinner-pulse",
        loading !== undefined &&
          "transition-opacity duration-200 ease-out motion-reduce:transition-none starting:opacity-0",
        className
      )}
      {...props}
    >
      {variant === "ticks" ? (
        ticks.map((index) => (
          <line
            key={index}
            x1="12"
            y1="2.75"
            x2="12"
            y2="6.75"
            stroke="currentColor"
            strokeWidth="2.25"
            strokeLinecap="round"
            opacity={1 - index * 0.11}
            transform={`rotate(${-index * 45} 12 12)`}
          />
        ))
      ) : (
        <>
          <circle
            cx="12"
            cy="12"
            r="9"
            stroke="currentColor"
            strokeWidth="2.5"
            opacity="0.2"
          />
          <circle
            cx="12"
            cy="12"
            r="9"
            stroke="currentColor"
            strokeWidth="2.5"
            strokeLinecap="round"
            strokeDasharray="16 60"
            transform="rotate(-90 12 12)"
            className={cn(animated && "motion-safe:animate-spinner-dash")}
          />
        </>
      )}
    </svg>
  )
}

export { Spinner, spinnerVariants, useDelayedLoading }
export type { DelayedLoadingOptions, SpinnerProps, SpinnerVariant }
```

```ts title="lib/motion.ts"
import * as React from "react"

const easeOut = "cubic-bezier(0.23, 1, 0.32, 1)"
const easeInOut = "cubic-bezier(0.77, 0, 0.175, 1)"
const easeSpring =
  "linear(0, 0.015 2%, 0.0532 4%, 0.1065 6%, 0.1686 8%, 0.2351 10%, 0.3363 13%, 0.4332 16%, 0.5495 20%, 0.648 24%, 0.7287 28%, 0.807 33%, 0.8646 38%, 0.9128 44%, 0.9447 50%, 0.968 57%, 0.9834 65%, 0.9924 74%, 0.9975 85%, 1)"

const duration = {
  press: 100,
  release: 200,
  hover: 150,
  enter: 200,
  exit: 150,
  morph: 300,
} as const

function prefersReducedMotion() {
  return (
    typeof window === "undefined" ||
    typeof window.matchMedia !== "function" ||
    window.matchMedia("(prefers-reduced-motion: reduce)").matches
  )
}

type SizeAxis = "width" | "height"

type SizeMorphOptions = {
  axis: SizeAxis
  enabled?: boolean
  duration?: number
  easing?: string
}

function readSize(element: HTMLElement, axis: SizeAxis) {
  const value = parseFloat(getComputedStyle(element)[axis])
  if (Number.isFinite(value)) {
    return value
  }
  const rect = element.getBoundingClientRect()
  return axis === "width" ? rect.width : rect.height
}

function attachSizeMorph(
  element: HTMLElement,
  axis: SizeAxis,
  time: number,
  easing: string
) {
  if (
    typeof MutationObserver === "undefined" ||
    typeof element.animate !== "function"
  ) {
    return undefined
  }

  let settled = readSize(element, axis)
  let animation: Animation | null = null

  const morph = () => {
    const from = animation ? readSize(element, axis) : settled
    animation?.cancel()
    animation = null
    const to = readSize(element, axis)
    settled = to

    if (
      Math.abs(from - to) < 0.5 ||
      prefersReducedMotion() ||
      !element.isConnected ||
      element.getClientRects().length === 0
    ) {
      element.removeAttribute("data-morphing")
      return
    }

    element.setAttribute("data-morphing", "")
    const running = element.animate(
      [{ [axis]: `${from}px` }, { [axis]: `${to}px` }],
      { duration: time, easing }
    )
    animation = running
    running.onfinish = () => {
      if (animation === running) {
        animation = null
        element.removeAttribute("data-morphing")
        settled = readSize(element, axis)
      }
    }
  }

  const mutations = new MutationObserver(morph)
  mutations.observe(element, {
    childList: true,
    subtree: true,
    characterData: true,
  })

  const resize =
    typeof ResizeObserver === "undefined"
      ? null
      : new ResizeObserver(() => {
          if (!animation) {
            settled = readSize(element, axis)
          }
        })
  resize?.observe(element)

  return () => {
    mutations.disconnect()
    resize?.disconnect()
    animation?.cancel()
    element.removeAttribute("data-morphing")
  }
}

function useSizeMorph<T extends HTMLElement>({
  axis,
  enabled = true,
  duration: time = duration.morph,
  easing = easeOut,
}: SizeMorphOptions): React.RefCallback<T> {
  return React.useCallback(
    (element: T | null) => {
      if (!element || !enabled) {
        return undefined
      }
      return attachSizeMorph(element, axis, time, easing)
    },
    [axis, enabled, time, easing]
  )
}

function useSlidingHighlight(
  barRef: React.RefObject<HTMLElement | null>,
  highlightRef: React.RefObject<HTMLElement | null>,
  selector: string,
  attribute = "data-popup-open"
) {
  React.useLayoutEffect(() => {
    const bar = barRef.current
    const highlight = highlightRef.current
    if (!bar || !highlight) {
      return
    }

    let current: HTMLElement | null = null

    const place = (trigger: HTMLElement, instant: boolean) => {
      if (instant || prefersReducedMotion()) {
        highlight.setAttribute("data-instant", "")
      } else {
        highlight.removeAttribute("data-instant")
      }
      const frame = bar.getBoundingClientRect()
      const box = trigger.getBoundingClientRect()
      const scale = bar.offsetWidth > 0 ? frame.width / bar.offsetWidth : 1
      highlight.style.left = "0px"
      highlight.style.width = `${box.width / scale}px`
      highlight.style.height = `${box.height / scale}px`
      highlight.style.transform = `translate(${(box.left - frame.left) / scale - bar.clientLeft}px, ${(box.top - frame.top) / scale - bar.clientTop}px)`
    }

    const sync = () => {
      const trigger = bar.querySelector<HTMLElement>(selector)
      if (trigger === current) {
        if (trigger) {
          place(trigger, true)
        }
        return
      }
      const appearing = current === null
      current = trigger
      if (!trigger) {
        highlight.removeAttribute("data-visible")
        return
      }
      place(trigger, appearing)
      if (appearing) {
        void highlight.offsetWidth
      }
      highlight.setAttribute("data-visible", "")
    }

    sync()
    const mutations = new MutationObserver(sync)
    mutations.observe(bar, {
      subtree: true,
      childList: true,
      attributes: true,
      attributeFilter: [attribute],
    })
    const resize =
      typeof ResizeObserver === "undefined"
        ? null
        : new ResizeObserver(() => {
            if (current) {
              place(current, true)
            }
          })
    resize?.observe(bar)
    const onScroll = () => {
      if (current) {
        place(current, true)
      }
    }
    bar.addEventListener("scroll", onScroll, { capture: true, passive: true })

    return () => {
      mutations.disconnect()
      resize?.disconnect()
      bar.removeEventListener("scroll", onScroll, { capture: true })
    }
  }, [barRef, highlightRef, selector, attribute])
}

export {
  duration,
  easeInOut,
  easeOut,
  easeSpring,
  prefersReducedMotion,
  useSizeMorph,
  useSlidingHighlight,
}
export type { SizeMorphOptions }
```

Update the import paths to match your project setup.

## Usage

```tsx
import { Switch } from "@/components/ui/switch"
```

```tsx
<label className="flex items-center gap-3">
  <Switch defaultChecked />
  Wi-Fi
</label>
```

Press and the thumb stretches toward the other side before springing across, so you feel the press land. You can also grab the thumb and drag it: past the middle it flips, short of it it snaps back.

## Examples

### iOS

`variant="ios"` uses a taller track, a capsule thumb and green when on. Press, drag and saving work the same.

```tsx title="components/examples/switch/ios.tsx"
import { Switch } from "@/components/ui/switch"

export function SwitchIos() {
  return (
    <div className="flex flex-col gap-4 text-sm">
      <label className="flex items-center gap-3">
        <Switch variant="ios" />
        Airplane mode
      </label>
      <label className="flex items-center gap-3">
        <Switch variant="ios" defaultChecked />
        Wi-Fi
      </label>
      <div className="flex items-center gap-4">
        <Switch variant="ios" size="sm" aria-label="Small" defaultChecked />
        <Switch variant="ios" size="lg" aria-label="Large" defaultChecked />
      </div>
    </div>
  )
}
```

### Saving

Return a promise from `onCheckedChange` and the switch flips right away with a spinner in the thumb. If it rejects, the switch flips back and shakes.

```tsx title="components/examples/switch/async.tsx"
"use client"

import { Switch } from "@/components/ui/switch"

function save(succeed: boolean) {
  return new Promise<void>((resolve, reject) =>
    setTimeout(() => (succeed ? resolve() : reject(new Error("Offline"))), 1200)
  )
}

export function SwitchAsync() {
  return (
    <div className="flex flex-col gap-4 text-sm">
      <label className="flex items-center gap-3">
        <Switch onCheckedChange={() => save(true)} />
        Sync to cloud (saves)
      </label>
      <label className="flex items-center gap-3">
        <Switch defaultChecked onCheckedChange={() => save(false)} />
        Public profile (fails and flips back)
      </label>
    </div>
  )
}
```

### On and off marks

`icons` adds a check and a ring to the track, so the state doesn't rely on color.

```tsx title="components/examples/switch/icons.tsx"
import { Switch } from "@/components/ui/switch"

export function SwitchIcons() {
  return (
    <div className="flex flex-col gap-4 text-sm">
      <label className="flex items-center gap-3">
        <Switch icons defaultChecked />
        Captions
      </label>
      <label className="flex items-center gap-3">
        <Switch icons size="lg" />
        Reduce transparency
      </label>
    </div>
  )
}
```

### Sizes

`sm`, `default` and `lg`.

```tsx title="components/examples/switch/sizes.tsx"
import { Switch } from "@/components/ui/switch"

export function SwitchSizes() {
  return (
    <div className="flex items-center gap-6">
      <Switch size="sm" aria-label="Small" defaultChecked />
      <Switch aria-label="Default" defaultChecked />
      <Switch size="lg" aria-label="Large" defaultChecked />
    </div>
  )
}
```

### In a field

With `Field`, the label and description are linked to the switch.

```tsx title="components/examples/switch/field.tsx"
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldLabel,
} from "@/components/ui/field"
import { Switch } from "@/components/ui/switch"

export function SwitchField() {
  return (
    <Field orientation="horizontal" className="w-full max-w-sm">
      <FieldContent>
        <FieldLabel>Marketing emails</FieldLabel>
        <FieldDescription>
          Product news and offers, about once a month.
        </FieldDescription>
      </FieldContent>
      <Switch name="marketing" />
    </Field>
  )
}
```

### Disabled and read-only

`readOnly` keeps the value visible and focusable but won't change it.

```tsx title="components/examples/switch/states.tsx"
import { Switch } from "@/components/ui/switch"

export function SwitchStates() {
  return (
    <div className="flex flex-col gap-4 text-sm">
      <label className="flex items-center gap-3">
        <Switch disabled />
        Disabled
      </label>
      <label className="flex items-center gap-3">
        <Switch disabled defaultChecked />
        Disabled and on
      </label>
      <label className="flex items-center gap-3">
        <Switch readOnly defaultChecked />
        Read-only (managed by your admin)
      </label>
    </div>
  )
}
```

### Controlled

Pass `checked` and `onCheckedChange`.

```tsx title="components/examples/switch/controlled.tsx"
"use client"

import * as React from "react"

import { Switch } from "@/components/ui/switch"

export function SwitchControlled() {
  const [dark, setDark] = React.useState(false)

  return (
    <label className="flex items-center gap-3 text-sm">
      <Switch checked={dark} onCheckedChange={setDark} />
      Dark mode is {dark ? "on" : "off"}
    </label>
  )
}
```

### Right to left

The thumb starts on the right and slides left, and dragging follows.

```tsx title="components/examples/switch/rtl.tsx"
import { Switch } from "@/components/ui/switch"

export function SwitchRtl() {
  return (
    <div dir="rtl" className="flex flex-col gap-4 text-sm">
      <label className="flex items-center gap-3">
        <Switch defaultChecked icons />
        الإشعارات
      </label>
      <label className="flex items-center gap-3">
        <Switch />
        الوضع الداكن
      </label>
    </div>
  )
}
```

## Keyboard

| Key | Action |
| --- | --- |
| `Space` `Enter` | Turns the focused switch on or off. |

## Accessibility

- The switch is a `switch` role named by its label. Wrap it in a `<label>` or use `Field`.
- While saving, it's `aria-busy` and ignores new presses.
- The hit area is larger than the switch, and larger again on touch.
- With reduced motion, the thumb moves instantly and nothing shakes.

## API reference

### Switch

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `checked` | `boolean` | – |  |
| `defaultChecked` | `boolean` | `false` |  |
| `onCheckedChange` | `(checked, details) => void \| Promise` | – | Return a promise to show the saving state. On rejection an uncontrolled switch flips back. |
| `variant` | `"default" \| "ios"` | `"default"` |  |
| `size` | `"sm" \| "default" \| "lg"` | `"default"` |  |
| `icons` | `boolean` | `false` | Show on and off marks in the track. |
| `name` | `string` | – |  |
| `value` | `string` | – |  |
| `disabled` | `boolean` | `false` |  |
| `readOnly` | `boolean` | `false` |  |
| `required` | `boolean` | `false` |  |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<span>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="switch"` | The track, with data-size and data-variant. |
| `data-slot="switch-thumb"` | The thumb. |
| `data-checked / data-unchecked` | Whether the switch is on. |
| `data-dragging` | Present while the thumb is being dragged. |
| `data-pending` | Present while a returned promise is pending. |
| `--switch-w / --switch-h / --switch-thumb / --switch-thumb-w` | Track and thumb sizes, set by size. |

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt
