Components

Toggle

A fun sketch theme based toggle switch component.

Code

tsx
326 lines
"use client"

import { useEffect, useId, useRef, useState } from "react"
import rough from "roughjs"
import { cn } from "@/lib/utils"

type ToggleShape = "round" | "rounded"
type ToggleSize = "sm" | "md" | "lg" | number

type ToggleProps = {
    checked?: boolean
    defaultChecked?: boolean
    onCheckedChange?: (checked: boolean) => void
    shape?: ToggleShape
    trackShape?: ToggleShape
    sliderShape?: ToggleShape
    size?: ToggleSize
    disabled?: boolean
    checkedColor?: string
    uncheckedColor?: string
    /*
     - Inner moving slider bg color
     */
    sliderColor?: string
    borderColor?: string
    /*
     - Optional content inside slider
     */
    checkedIcon?: React.ReactNode
    uncheckedIcon?: React.ReactNode
    /*
     - Optional accessible label
     */
    label?: string
    roughness?: number
    bowing?: number
    strokeWidth?: number
    seed?: number
    className?: string
    sliderClassName?: string
}

const sizeMap: Record<Exclude<ToggleSize, number>, number> = {
    sm: 34,
    md: 44,
    lg: 56,
}

function getHeight(size: ToggleSize) {
    return typeof size === "number" ? size : sizeMap[size]
}

function roundedRectPath(
    x: number,
    y: number,
    width: number,
    height: number,
    radius: number
) {
    const r = Math.min(radius, width / 2, height / 2)

    return `
        M ${x + r} ${y}
        L ${x + width - r} ${y}
        Q ${x + width} ${y} ${x + width} ${y + r}
        L ${x + width} ${y + height - r}
        Q ${x + width} ${y + height} ${x + width - r} ${y + height}
        L ${x + r} ${y + height}
        Q ${x} ${y + height} ${x} ${y + height - r}
        L ${x} ${y + r}
        Q ${x} ${y} ${x + r} ${y}
        Z
    `
}

export function Toggle({
    checked,
    defaultChecked = false,
    onCheckedChange,

    shape = "round",
    trackShape,
    sliderShape,

    size = "md",
    disabled = false,

    checkedColor = "#86efac",
    uncheckedColor = "#e5e7eb",
    sliderColor = "#fffbf2",
    borderColor = "#111",

    checkedIcon,
    uncheckedIcon,
    label = "Toggle",

    roughness = 1.35,
    bowing = 0.8,
    strokeWidth = 1.8,
    seed = 77,

    className,
    sliderClassName,
}: ToggleProps) {
    const id = useId()
    const trackSvgRef = useRef<SVGSVGElement | null>(null)
    const sliderSvgRef = useRef<SVGSVGElement | null>(null)

    const isControlled = checked !== undefined
    const [internalChecked, setInternalChecked] = useState(defaultChecked)

    const isChecked = isControlled ? checked : internalChecked

    const finalTrackShape = trackShape ?? shape
    const finalSliderShape = sliderShape ?? shape

    const height = getHeight(size)
    const width = Math.round(height * 1.9)

    const padding = Math.max(4, height * 0.1)
    const sliderSize = height - padding * 2

    const sliderX = isChecked ? width - padding - sliderSize : padding

    const trackRadius =
        finalTrackShape === "round" ? height / 2 : Math.max(10, height * 0.28)

    const sliderRadius =
        finalSliderShape === "round"
            ? sliderSize / 2
            : Math.max(8, sliderSize * 0.28)

    function handleToggle() {
        if (disabled) return

        const next = !isChecked

        if (!isControlled) {
            setInternalChecked(next)
        }

        onCheckedChange?.(next)
    }

    useEffect(() => {
        const svg = trackSvgRef.current
        if (!svg) return

        svg.replaceChildren()

        const rc = rough.svg(svg)

        const fill = isChecked ? checkedColor : uncheckedColor

        const node =
            finalTrackShape === "round"
                ? rc.path(
                    roundedRectPath(
                        2,
                        2,
                        width - 4,
                        height - 4,
                        height / 2
                    ),
                    {
                        seed,
                        stroke: borderColor,
                        strokeWidth,
                        fill,
                        fillStyle: "solid",
                        roughness,
                        bowing,
                    }
                )
                : rc.path(
                    roundedRectPath(
                        2,
                        2,
                        width - 4,
                        height - 4,
                        trackRadius
                    ),
                    {
                        seed,
                        stroke: borderColor,
                        strokeWidth,
                        fill,
                        fillStyle: "solid",
                        roughness,
                        bowing,
                    }
                )

        svg.appendChild(node)
    }, [
        isChecked,
        checkedColor,
        uncheckedColor,
        borderColor,
        width,
        height,
        finalTrackShape,
        trackRadius,
        roughness,
        bowing,
        strokeWidth,
        seed,
    ])

    useEffect(() => {
        const svg = sliderSvgRef.current
        if (!svg) return

        svg.replaceChildren()

        const rc = rough.svg(svg)

        const node =
            finalSliderShape === "round"
                ? rc.circle(sliderSize / 2, sliderSize / 2, sliderSize - 2, {
                    seed: seed + 10,
                    stroke: borderColor,
                    strokeWidth,
                    fill: sliderColor,
                    fillStyle: "solid",
                    roughness: roughness + 0.15,
                    bowing,
                })
                : rc.path(
                    roundedRectPath(
                        1,
                        1,
                        sliderSize - 2,
                        sliderSize - 2,
                        sliderRadius
                    ),
                    {
                        seed: seed + 10,
                        stroke: borderColor,
                        strokeWidth,
                        fill: sliderColor,
                        fillStyle: "solid",
                        roughness: roughness + 0.15,
                        bowing,
                    }
                )

        svg.appendChild(node)
    }, [
        sliderSize,
        finalSliderShape,
        sliderRadius,
        borderColor,
        sliderColor,
        roughness,
        bowing,
        strokeWidth,
        seed,
    ])

    return (
        <button
            type="button"
            role="switch"
            aria-checked={isChecked}
            aria-label={label}
            disabled={disabled}
            onClick={handleToggle}
            className={cn(
                "relative inline-flex shrink-0 items-center outline-none",
                "transition-transform duration-150 ease-out",
                "focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2",
                "disabled:cursor-not-allowed disabled:opacity-60",
                !disabled && "active:scale-[0.97]",
                className
            )}
            style={{
                width,
                height,
            }}
        >
            <svg
                ref={trackSvgRef}
                viewBox={`0 0 ${width} ${height}`}
                className="absolute inset-0 h-full w-full"
                aria-hidden="true"
            />

            <span
                className={cn(
                    "absolute top-1/2 flex items-center justify-center",
                    "transition-[left,transform] duration-300 ease-out",
                    sliderClassName
                )}
                style={{
                    width: sliderSize,
                    height: sliderSize,
                    left: sliderX,
                    transform: "translateY(-50%)",
                }}
            >
                <svg
                    ref={sliderSvgRef}
                    viewBox={`0 0 ${sliderSize} ${sliderSize}`}
                    className="absolute inset-0 h-full w-full overflow-visible"
                    aria-hidden="true"
                />

                <span className="relative z-10 flex items-center justify-center text-black">
                    {isChecked ? checkedIcon : uncheckedIcon}
                </span>
            </span>

            <input
                id={id}
                type="checkbox"
                checked={isChecked}
                onChange={handleToggle}
                disabled={disabled}
                className="sr-only"
                tabIndex={-1}
            />
        </button>
    )
}

Usage

tsx
    <Toggle />

Props

PropTypeDefaultDescription
checkedboolean-Controlled checked state of the toggle.
defaultCheckedbooleanfalseInitial checked state for uncontrolled mode.
onCheckedChange(checked: boolean) => void-Called when the toggle state changes.
shapeToggleShape"round"Controls the overall toggle appearance.
trackShapeToggleShape-Overrides the track shape.
sliderShapeToggleShape-Overrides the slider shape.
sizeToggleSize"md"Controls the size of the toggle.
disabledbooleanfalseDisables user interaction.
checkedColorstring"#86efac"Track color when checked.
uncheckedColorstring"#e5e7eb"Track color when unchecked.
sliderColorstring"#fffbf2"Color of the toggle slider.
borderColorstring"#111"Border color of the toggle.
checkedIconReactNode-Icon displayed when checked.
uncheckedIconReactNode-Icon displayed when unchecked.
labelstring"Toggle"Accessible label for the toggle.
roughnessnumber1.35Controls RoughJS sketch roughness.
bowingnumber0.8Controls the amount of line bowing.
strokeWidthnumber1.8Stroke width of the sketch outline.
seednumber77RoughJS seed for deterministic rendering.
classNamestring-Additional classes applied to the wrapper.
sliderClassNamestring-Additional classes applied to the slider.

Shapes

  • round
  • rounded

Sizes

  • sm
  • md
  • lg
  • number