New: Activity Terrain +12 more

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.

9 examplesDepends on@base-ui/react@tabler/icons-reactclass-variance-authoritycnFrom HextaUI (MIT)

Installation

Run this in your project. It adds the files, installs the npm packages and anything this builds on, and sets up the theme:

npx shadcn@latest add https://kryptonite-ui.vercel.app/r/switch.json
Install manually
  1. Install the dependencies:

    npm install @base-ui/react @tabler/icons-react class-variance-authority cn
  2. It builds on these, so copy them first:

  3. Copy this into components/ui/switch.tsx:

    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 [optimistic, setOptimistic] = React.useState<boolean | null>(null)
      const [slow, setSlow] = React.useState(false)
      const controlled = checkedProp !== undefined
      const checked =
        pending && optimistic !== null
          ? optimistic
          : controlled
            ? checkedProp
            : internal
      const latest = React.useRef(0)
    
      React.useEffect(() => {
        if (!pending) {
          setSlow(false)
          return
        }
        const timer = setTimeout(() => setSlow(true), 400)
        return () => clearTimeout(timer)
      }, [pending])
    
      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
        setOptimistic(next)
        setPending(true)
        Promise.resolve(result).then(
          () => {
            if (latest.current === id) {
              setPending(false)
              setOptimistic(null)
            }
          },
          () => {
            if (latest.current !== id) {
              return
            }
            setPending(false)
            setOptimistic(null)
            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 && slow ? (
              <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 }

Examples

Saving

Controlled

In a field

On and off marks

iOS

Right to left

Sizes

Disabled and read-only

Feedback

Questions, bugs or ideas for Switch? Post them here, or reply to someone else. Sign in with GitHub to comment.