Web InnoventixFreeCode

Switch Basic Toggle

Original · free

basic on/off switches in sizes

byWeb InnoventixReact + Tailwind
tglswitchbasictoggles
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/tgl-switch-basic.json
tgl-switch-basic.tsx
"use client";

import { useId, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

type SwitchSize = "sm" | "md" | "lg";
type Accent = "indigo" | "emerald" | "violet" | "rose" | "amber" | "sky";

type SwitchProps = {
  checked: boolean;
  onCheckedChange: (value: boolean) => void;
  size?: SwitchSize;
  accent?: Accent;
  disabled?: boolean;
  id?: string;
  label?: string;
  labelledBy?: string;
  describedBy?: string;
};

const SIZE_MAP: Record<
  SwitchSize,
  { track: string; thumb: string; icon: string; onX: number }
> = {
  sm: { track: "h-5 w-9", thumb: "h-4 w-4", icon: "h-0 w-0", onX: 16 },
  md: { track: "h-6 w-11", thumb: "h-5 w-5", icon: "h-2.5 w-2.5", onX: 20 },
  lg: { track: "h-8 w-14", thumb: "h-7 w-7", icon: "h-3.5 w-3.5", onX: 24 },
};

const TRACK_ON: Record<Accent, string> = {
  indigo: "bg-indigo-600 dark:bg-indigo-500",
  emerald: "bg-emerald-600 dark:bg-emerald-500",
  violet: "bg-violet-600 dark:bg-violet-500",
  rose: "bg-rose-600 dark:bg-rose-500",
  amber: "bg-amber-500 dark:bg-amber-400",
  sky: "bg-sky-600 dark:bg-sky-500",
};

const RING: Record<Accent, string> = {
  indigo: "focus-visible:ring-indigo-500",
  emerald: "focus-visible:ring-emerald-500",
  violet: "focus-visible:ring-violet-500",
  rose: "focus-visible:ring-rose-500",
  amber: "focus-visible:ring-amber-500",
  sky: "focus-visible:ring-sky-500",
};

const ICON_COLOR: Record<Accent, string> = {
  indigo: "text-indigo-600",
  emerald: "text-emerald-600",
  violet: "text-violet-600",
  rose: "text-rose-600",
  amber: "text-amber-500",
  sky: "text-sky-600",
};

function CheckIcon({ className }: { className: string }) {
  return (
    <svg
      viewBox="0 0 12 12"
      fill="none"
      className={className}
      aria-hidden="true"
    >
      <path
        d="M2.5 6.3 4.7 8.5 9.5 3.5"
        stroke="currentColor"
        strokeWidth={1.7}
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function Switch({
  checked,
  onCheckedChange,
  size = "md",
  accent = "indigo",
  disabled = false,
  id,
  label,
  labelledBy,
  describedBy,
}: SwitchProps) {
  const reduce = useReducedMotion();
  const dims = SIZE_MAP[size];

  return (
    <button
      type="button"
      role="switch"
      id={id}
      aria-checked={checked}
      aria-label={labelledBy ? undefined : label}
      aria-labelledby={labelledBy}
      aria-describedby={describedBy}
      disabled={disabled}
      onClick={() => onCheckedChange(!checked)}
      className={[
        "relative inline-flex shrink-0 items-center rounded-full p-0.5",
        "cursor-pointer outline-none transition-colors duration-200",
        "ring-offset-white focus-visible:ring-2 focus-visible:ring-offset-2 dark:ring-offset-slate-950",
        "disabled:cursor-not-allowed disabled:opacity-50",
        dims.track,
        RING[accent],
        checked ? TRACK_ON[accent] : "bg-slate-200 dark:bg-slate-700",
      ].join(" ")}
    >
      <motion.span
        className={[
          "flex items-center justify-center rounded-full bg-white shadow-sm ring-1 ring-black/5",
          dims.thumb,
        ].join(" ")}
        animate={{ x: checked ? dims.onX : 0 }}
        transition={
          reduce
            ? { duration: 0 }
            : { type: "spring", stiffness: 520, damping: 34, mass: 0.7 }
        }
      >
        {checked && size !== "sm" ? (
          <CheckIcon className={[dims.icon, ICON_COLOR[accent]].join(" ")} />
        ) : null}
      </motion.span>
    </button>
  );
}

const SIZES: { key: SwitchSize; name: string; hint: string }[] = [
  { key: "sm", name: "Small", hint: "36 × 20" },
  { key: "md", name: "Medium", hint: "44 × 24" },
  { key: "lg", name: "Large", hint: "56 × 32" },
];

const ACCENTS: { key: Accent; name: string }[] = [
  { key: "indigo", name: "Indigo" },
  { key: "emerald", name: "Emerald" },
  { key: "violet", name: "Violet" },
  { key: "rose", name: "Rose" },
  { key: "amber", name: "Amber" },
  { key: "sky", name: "Sky" },
];

const SETTINGS: {
  key: "notifications" | "twoFactor" | "autoUpdate" | "telemetry";
  accent: Accent;
  title: string;
  desc: string;
}[] = [
  {
    key: "notifications",
    accent: "indigo",
    title: "Push notifications",
    desc: "Get alerts on your devices when something needs your attention.",
  },
  {
    key: "twoFactor",
    accent: "emerald",
    title: "Two-factor authentication",
    desc: "Require a one-time verification code at each new sign-in.",
  },
  {
    key: "autoUpdate",
    accent: "sky",
    title: "Automatic updates",
    desc: "Install security patches as soon as they ship, no restart needed.",
  },
  {
    key: "telemetry",
    accent: "violet",
    title: "Usage analytics",
    desc: "Share anonymous product usage to help us improve the app.",
  },
];

export default function TglSwitchBasic() {
  const uid = useId();
  const reduce = useReducedMotion();

  const [sizes, setSizes] = useState<Record<SwitchSize, boolean>>({
    sm: true,
    md: true,
    lg: false,
  });
  const [accents, setAccents] = useState<Record<Accent, boolean>>({
    indigo: true,
    emerald: true,
    violet: false,
    rose: true,
    amber: false,
    sky: true,
  });
  const [settings, setSettings] = useState({
    notifications: true,
    twoFactor: false,
    autoUpdate: true,
    telemetry: false,
  });

  const enabledCount = Object.values(settings).filter(Boolean).length;

  const card =
    "tglsw-rise rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900";

  return (
    <section className="relative w-full bg-slate-50 px-6 py-20 text-slate-900 sm:px-8 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes tglsw-rise {
          from { opacity: 0; transform: translateY(10px); }
          to { opacity: 1; transform: none; }
        }
        .tglsw-rise { animation: tglsw-rise .55s cubic-bezier(.22,.61,.36,1) both; }
        @media (prefers-reduced-motion: reduce) {
          .tglsw-rise { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-5xl">
        <header className="tglsw-rise max-w-2xl">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium uppercase tracking-widest text-slate-500 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
            <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
            Toggles
          </span>
          <h2 className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl">
            On / off switches
          </h2>
          <p className="mt-3 text-base leading-relaxed text-slate-500 dark:text-slate-400">
            Accessible switch controls built on a real{" "}
            <code className="rounded bg-slate-100 px-1.5 py-0.5 text-[0.8em] text-slate-700 dark:bg-slate-800 dark:text-slate-300">
              role=&quot;switch&quot;
            </code>{" "}
            button. Fully keyboard driven, focus-visible rings, and three sizes.
          </p>
        </header>

        <div className="mt-10 grid grid-cols-1 gap-6 lg:grid-cols-2">
          {/* Sizes */}
          <div className={card}>
            <h3 className="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
              Sizes
            </h3>
            <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
              The same control at three scales. Tab to focus, then Space or
              Enter to toggle.
            </p>
            <div className="mt-6 space-y-4">
              {SIZES.map(({ key, name, hint }) => (
                <div
                  key={key}
                  className="flex items-center justify-between gap-4 rounded-xl border border-slate-100 bg-slate-50/60 px-4 py-3 dark:border-slate-800 dark:bg-slate-800/30"
                >
                  <div>
                    <label
                      id={`${uid}-size-${key}-label`}
                      htmlFor={`${uid}-size-${key}`}
                      className="cursor-pointer text-sm font-medium text-slate-800 dark:text-slate-200"
                    >
                      {name}
                    </label>
                    <p className="text-xs tabular-nums text-slate-400 dark:text-slate-500">
                      {hint} px
                    </p>
                  </div>
                  <Switch
                    id={`${uid}-size-${key}`}
                    labelledBy={`${uid}-size-${key}-label`}
                    size={key}
                    accent="indigo"
                    checked={sizes[key]}
                    onCheckedChange={(v) =>
                      setSizes((s) => ({ ...s, [key]: v }))
                    }
                  />
                </div>
              ))}
            </div>
          </div>

          {/* Accents */}
          <div className={card}>
            <h3 className="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
              Accent colors
            </h3>
            <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
              Six portable palettes. Each keeps its own focus ring color.
            </p>
            <div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-3">
              {ACCENTS.map(({ key, name }) => (
                <label
                  key={key}
                  htmlFor={`${uid}-accent-${key}`}
                  className="flex cursor-pointer items-center justify-between gap-3 rounded-xl border border-slate-100 bg-slate-50/60 px-3 py-3 dark:border-slate-800 dark:bg-slate-800/30"
                >
                  <span className="text-sm font-medium text-slate-700 dark:text-slate-300">
                    {name}
                  </span>
                  <Switch
                    id={`${uid}-accent-${key}`}
                    label={`${name} accent`}
                    size="sm"
                    accent={key}
                    checked={accents[key]}
                    onCheckedChange={(v) =>
                      setAccents((a) => ({ ...a, [key]: v }))
                    }
                  />
                </label>
              ))}
            </div>
          </div>

          {/* Settings list */}
          <div className={`${card} lg:col-span-2`}>
            <div className="flex flex-wrap items-center justify-between gap-3">
              <div>
                <h3 className="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
                  Account settings
                </h3>
                <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
                  Labels and descriptions wired with{" "}
                  <code className="rounded bg-slate-100 px-1 py-0.5 text-[0.8em] text-slate-700 dark:bg-slate-800 dark:text-slate-300">
                    aria-labelledby
                  </code>
                  .
                </p>
              </div>
              <span
                aria-live="polite"
                className="rounded-full bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700 tabular-nums dark:bg-emerald-500/10 dark:text-emerald-300"
              >
                {enabledCount} of {SETTINGS.length} enabled
              </span>
            </div>

            <div className="mt-5 divide-y divide-slate-100 dark:divide-slate-800">
              {SETTINGS.map(({ key, accent, title, desc }) => (
                <div
                  key={key}
                  className="flex items-center justify-between gap-6 py-4"
                >
                  <div className="min-w-0">
                    <label
                      id={`${uid}-set-${key}-label`}
                      htmlFor={`${uid}-set-${key}`}
                      className="cursor-pointer text-sm font-medium text-slate-800 dark:text-slate-200"
                    >
                      {title}
                    </label>
                    <p
                      id={`${uid}-set-${key}-desc`}
                      className="mt-0.5 text-sm text-slate-500 dark:text-slate-400"
                    >
                      {desc}
                    </p>
                  </div>
                  <Switch
                    id={`${uid}-set-${key}`}
                    labelledBy={`${uid}-set-${key}-label`}
                    describedBy={`${uid}-set-${key}-desc`}
                    size="md"
                    accent={accent}
                    checked={settings[key]}
                    onCheckedChange={(v) =>
                      setSettings((s) => ({ ...s, [key]: v }))
                    }
                  />
                </div>
              ))}
            </div>
          </div>

          {/* Disabled states */}
          <div className={`${card} lg:col-span-2`}>
            <h3 className="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
              Disabled
            </h3>
            <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
              Non-interactive states retain their on/off appearance and skip
              focus.
            </p>
            <div className="mt-6 flex flex-wrap items-center gap-8">
              <div className="flex items-center gap-3">
                <Switch
                  label="Disabled, off"
                  size="md"
                  accent="indigo"
                  disabled
                  checked={false}
                  onCheckedChange={() => {}}
                />
                <span className="text-sm text-slate-500 dark:text-slate-400">
                  Disabled &middot; off
                </span>
              </div>
              <div className="flex items-center gap-3">
                <Switch
                  label="Disabled, on"
                  size="md"
                  accent="emerald"
                  disabled
                  checked={true}
                  onCheckedChange={() => {}}
                />
                <span className="text-sm text-slate-500 dark:text-slate-400">
                  Disabled &middot; on
                </span>
              </div>
            </div>
          </div>
        </div>

        <p className="tglsw-rise mt-8 text-center text-xs text-slate-400 dark:text-slate-500">
          {reduce
            ? "Reduced-motion preference detected — thumb transitions are disabled."
            : "Thumb glides on a spring; respects prefers-reduced-motion."}
        </p>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →