Web InnoventixFreeCode

Switch Loading Toggle

Original · free

switch with a loading state

byWeb InnoventixReact + Tailwind
tglswitchloadingtoggles
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-loading.json
tgl-switch-loading.tsx
"use client";

import {
  useCallback,
  useEffect,
  useId,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
  type ReactNode,
} from "react";
import { motion, useReducedMotion } from "motion/react";

type NetworkMode = "fast" | "slow" | "flaky";
type ToggleStatus = "idle" | "loading" | "error";

interface SettingDef {
  id: string;
  label: string;
  description: string;
  defaultOn: boolean;
  icon: ReactNode;
}

const MODES: { id: NetworkMode; label: string; hint: string }[] = [
  { id: "fast", label: "Fast", hint: "~1.0s" },
  { id: "slow", label: "Slow 3G", hint: "~1.7s" },
  { id: "flaky", label: "Offline", hint: "fails" },
];

const SETTINGS: SettingDef[] = [
  {
    id: "twofa",
    label: "Two-factor authentication",
    description:
      "Require a one-time code from your authenticator app at every sign-in.",
    defaultOn: true,
    icon: (
      <svg
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        strokeWidth={1.8}
        strokeLinecap="round"
        strokeLinejoin="round"
        className="h-5 w-5"
        aria-hidden="true"
      >
        <path d="M12 3l7 3v5c0 4.4-3 7.6-7 9-4-1.4-7-4.6-7-9V6l7-3Z" />
        <path d="M9 12l2 2 4-4" />
      </svg>
    ),
  },
  {
    id: "deploy",
    label: "Auto-deploy the main branch",
    description: "Ship to production automatically whenever main passes CI.",
    defaultOn: false,
    icon: (
      <svg
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        strokeWidth={1.8}
        strokeLinecap="round"
        strokeLinejoin="round"
        className="h-5 w-5"
        aria-hidden="true"
      >
        <path d="M12 20V5" />
        <path d="M6 11l6-6 6 6" />
        <path d="M7 20h10" />
      </svg>
    ),
  },
  {
    id: "alerts",
    label: "Deployment alerts",
    description:
      "Post to #eng-releases in Slack when a deploy starts or fails.",
    defaultOn: true,
    icon: (
      <svg
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        strokeWidth={1.8}
        strokeLinecap="round"
        strokeLinejoin="round"
        className="h-5 w-5"
        aria-hidden="true"
      >
        <path d="M18 8a6 6 0 1 0-12 0c0 7-3 8-3 8h18s-3-1-3-8" />
        <path d="M13.7 21a2 2 0 0 1-3.4 0" />
      </svg>
    ),
  },
  {
    id: "tokens",
    label: "Personal access tokens",
    description:
      "Let scripts and CI jobs authenticate to the REST API on your behalf.",
    defaultOn: false,
    icon: (
      <svg
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        strokeWidth={1.8}
        strokeLinecap="round"
        strokeLinejoin="round"
        className="h-5 w-5"
        aria-hidden="true"
      >
        <circle cx="8" cy="15" r="4" />
        <path d="M10.8 12.2 20 3" />
        <path d="M16 3h4v4" />
        <path d="M15 9l2 2" />
      </svg>
    ),
  },
];

const labelOf = (id: string): string =>
  SETTINGS.find((s) => s.id === id)?.label ?? id;

interface ToggleSwitchProps {
  checked: boolean;
  status: ToggleStatus;
  reduce: boolean;
  labelledBy: string;
  describedBy: string;
  onToggle: () => void;
}

function ToggleSwitch({
  checked,
  status,
  reduce,
  labelledBy,
  describedBy,
  onToggle,
}: ToggleSwitchProps) {
  const loading = status === "loading";
  const error = status === "error";
  const thumbX = loading ? 12 : checked ? 24 : 0;

  const trackClass = error
    ? "bg-rose-500"
    : loading
      ? "bg-indigo-500"
      : checked
        ? "bg-emerald-500"
        : "bg-zinc-300 dark:bg-zinc-600";

  return (
    <button
      type="button"
      role="switch"
      aria-checked={checked}
      aria-busy={loading}
      aria-labelledby={labelledBy}
      aria-describedby={describedBy}
      onClick={onToggle}
      style={
        error && !reduce ? { animation: "tglld_shake 0.4s ease" } : undefined
      }
      className={`tglld-anim relative inline-flex h-7 w-[3.25rem] shrink-0 cursor-pointer items-center rounded-full px-0.5 ring-1 ring-inset ring-black/5 transition-colors duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:ring-white/10 dark:focus-visible:ring-offset-zinc-900 ${
        loading ? "cursor-wait" : ""
      } ${trackClass}`}
    >
      {loading && (
        <span
          className="tglld-anim pointer-events-none absolute inset-0 rounded-full ring-2 ring-indigo-300"
          style={
            reduce
              ? undefined
              : { animation: "tglld_pulse 1.2s ease-in-out infinite" }
          }
          aria-hidden="true"
        />
      )}
      <motion.span
        animate={{ x: thumbX }}
        transition={
          reduce ? { duration: 0 } : { type: "spring", stiffness: 520, damping: 34 }
        }
        className="relative z-10 grid h-6 w-6 place-items-center rounded-full bg-white shadow ring-1 ring-black/5"
      >
        {loading ? (
          <svg
            viewBox="0 0 24 24"
            className="tglld-anim h-3.5 w-3.5 text-indigo-600"
            style={reduce ? undefined : { animation: "tglld_spin 0.7s linear infinite" }}
            aria-hidden="true"
          >
            <circle
              cx="12"
              cy="12"
              r="9"
              fill="none"
              stroke="currentColor"
              strokeWidth="3"
              className="opacity-20"
            />
            <path
              d="M12 3a9 9 0 0 1 9 9"
              fill="none"
              stroke="currentColor"
              strokeWidth="3"
              strokeLinecap="round"
            />
          </svg>
        ) : checked ? (
          <svg
            viewBox="0 0 24 24"
            className="h-3.5 w-3.5 text-emerald-600"
            aria-hidden="true"
          >
            <path
              d="M5 13l4 4L19 7"
              fill="none"
              stroke="currentColor"
              strokeWidth="3"
              strokeLinecap="round"
              strokeLinejoin="round"
            />
          </svg>
        ) : null}
      </motion.span>
    </button>
  );
}

export default function ToggleSwitchLoading() {
  const reduce = useReducedMotion() ?? false;
  const uid = useId();

  const [networkMode, setNetworkMode] = useState<NetworkMode>("fast");
  const [values, setValues] = useState<Record<string, boolean>>(() =>
    Object.fromEntries(SETTINGS.map((s) => [s.id, s.defaultOn])),
  );
  const [statuses, setStatuses] = useState<Record<string, ToggleStatus>>(() =>
    Object.fromEntries(SETTINGS.map((s) => [s.id, "idle"])),
  );
  const [errorTo, setErrorTo] = useState<Record<string, boolean>>({});
  const [liveMessage, setLiveMessage] = useState("");

  const timeouts = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
  const modeRefs = useRef<(HTMLButtonElement | null)[]>([]);

  useEffect(() => {
    const pending = timeouts.current;
    return () => {
      Object.values(pending).forEach((t) => clearTimeout(t));
    };
  }, []);

  const attempt = useCallback(
    (id: string, desired: boolean) => {
      clearTimeout(timeouts.current[id]);
      setStatuses((s) => ({ ...s, [id]: "loading" }));
      setErrorTo((e) => {
        const next = { ...e };
        delete next[id];
        return next;
      });
      setLiveMessage(`Saving ${labelOf(id)}…`);

      const delay = networkMode === "slow" ? 1700 : 1000;
      timeouts.current[id] = setTimeout(() => {
        if (networkMode === "flaky") {
          setStatuses((s) => ({ ...s, [id]: "error" }));
          setErrorTo((e) => ({ ...e, [id]: desired }));
          setLiveMessage(
            `Couldn't update ${labelOf(id)}. Network error — the switch was rolled back.`,
          );
        } else {
          setValues((v) => ({ ...v, [id]: desired }));
          setStatuses((s) => ({ ...s, [id]: "idle" }));
          setLiveMessage(`${labelOf(id)} turned ${desired ? "on" : "off"}.`);
        }
      }, delay);
    },
    [networkMode],
  );

  const handleToggle = (id: string) => {
    if (statuses[id] === "loading") return;
    attempt(id, !values[id]);
  };

  const handleRetry = (id: string) => {
    const desired = errorTo[id];
    if (desired === undefined) return;
    attempt(id, desired);
  };

  const handleModeKey = (
    e: ReactKeyboardEvent<HTMLButtonElement>,
    index: number,
  ) => {
    let next = -1;
    if (e.key === "ArrowRight" || e.key === "ArrowDown")
      next = (index + 1) % MODES.length;
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp")
      next = (index - 1 + MODES.length) % MODES.length;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = MODES.length - 1;
    if (next < 0) return;
    e.preventDefault();
    setNetworkMode(MODES[next].id);
    modeRefs.current[next]?.focus();
  };

  return (
    <section className="relative w-full bg-zinc-50 px-4 py-16 dark:bg-zinc-950 sm:py-24">
      <style>{`
        @keyframes tglld_spin { to { transform: rotate(360deg); } }
        @keyframes tglld_pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 0.9; } }
        @keyframes tglld_shake {
          0%, 100% { transform: translateX(0); }
          25% { transform: translateX(-3px); }
          75% { transform: translateX(3px); }
        }
        @media (prefers-reduced-motion: reduce) {
          .tglld-anim { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto w-full max-w-xl">
        <div className="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
          {/* Header */}
          <div className="border-b border-zinc-100 px-6 py-6 dark:border-zinc-800 sm:px-8">
            <span className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
              Workspace
            </span>
            <h2 className="mt-2 text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
              Security &amp; delivery settings
            </h2>
            <p className="mt-1.5 text-sm leading-relaxed text-zinc-500 dark:text-zinc-400">
              Every change is confirmed with the server before it takes effect.
              Pick a connection profile to see how each switch behaves under
              load.
            </p>

            {/* Network profile radio group */}
            <div
              role="radiogroup"
              aria-label="Connection profile"
              className="mt-5 inline-flex rounded-xl border border-zinc-200 bg-zinc-50 p-1 dark:border-zinc-700 dark:bg-zinc-800"
            >
              {MODES.map((mode, i) => {
                const selected = networkMode === mode.id;
                return (
                  <button
                    key={mode.id}
                    ref={(el) => {
                      modeRefs.current[i] = el;
                    }}
                    type="button"
                    role="radio"
                    aria-checked={selected}
                    tabIndex={selected ? 0 : -1}
                    onClick={() => setNetworkMode(mode.id)}
                    onKeyDown={(e) => handleModeKey(e, i)}
                    className={`rounded-lg px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-50 dark:focus-visible:ring-offset-zinc-800 ${
                      selected
                        ? "bg-white text-zinc-900 shadow-sm dark:bg-zinc-950 dark:text-zinc-50"
                        : "text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200"
                    }`}
                  >
                    {mode.label}
                    <span className="ml-1.5 text-xs font-normal text-zinc-400 dark:text-zinc-500">
                      {mode.hint}
                    </span>
                  </button>
                );
              })}
            </div>
          </div>

          {/* Settings list */}
          <ul className="divide-y divide-zinc-100 dark:divide-zinc-800">
            {SETTINGS.map((setting) => {
              const status = statuses[setting.id];
              const checked = values[setting.id];
              const labelId = `${uid}-${setting.id}-label`;
              const descId = `${uid}-${setting.id}-desc`;
              const errId = `${uid}-${setting.id}-err`;
              const isError = status === "error";
              const describedBy = isError ? `${descId} ${errId}` : descId;

              return (
                <li
                  key={setting.id}
                  className="flex items-start gap-4 px-6 py-5 sm:px-8"
                >
                  <span className="mt-0.5 grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
                    {setting.icon}
                  </span>

                  <div className="min-w-0 flex-1">
                    <p
                      id={labelId}
                      className="text-sm font-semibold text-zinc-900 dark:text-zinc-100"
                    >
                      {setting.label}
                    </p>
                    <p
                      id={descId}
                      className="mt-0.5 text-sm leading-relaxed text-zinc-500 dark:text-zinc-400"
                    >
                      {setting.description}
                    </p>

                    {isError && (
                      <div
                        className="mt-2 flex flex-wrap items-center gap-2"
                        id={errId}
                      >
                        <span className="inline-flex items-center gap-1.5 text-xs font-medium text-rose-600 dark:text-rose-400">
                          <svg
                            viewBox="0 0 24 24"
                            className="h-3.5 w-3.5"
                            fill="none"
                            stroke="currentColor"
                            strokeWidth={2}
                            strokeLinecap="round"
                            strokeLinejoin="round"
                            aria-hidden="true"
                          >
                            <path d="M12 8v5" />
                            <path d="M12 16h.01" />
                            <circle cx="12" cy="12" r="9" />
                          </svg>
                          Network error — reverted.
                        </span>
                        <button
                          type="button"
                          onClick={() => handleRetry(setting.id)}
                          className="rounded-md px-1.5 py-0.5 text-xs font-semibold text-indigo-600 underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-indigo-400 dark:focus-visible:ring-offset-zinc-900"
                        >
                          Retry
                        </button>
                      </div>
                    )}
                  </div>

                  <div className="mt-0.5 shrink-0">
                    <ToggleSwitch
                      checked={checked}
                      status={status}
                      reduce={reduce}
                      labelledBy={labelId}
                      describedBy={describedBy}
                      onToggle={() => handleToggle(setting.id)}
                    />
                  </div>
                </li>
              );
            })}
          </ul>

          {/* Footer */}
          <div className="border-t border-zinc-100 bg-zinc-50/60 px-6 py-4 dark:border-zinc-800 dark:bg-zinc-950/40 sm:px-8">
            <p className="text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
              Toggling a setting sends a{" "}
              <code className="rounded bg-zinc-200 px-1 py-0.5 font-mono text-[0.7rem] text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
                PATCH /api/workspace
              </code>{" "}
              request. On the Offline profile the request fails and the switch
              rolls back to its last saved value.
            </p>
          </div>
        </div>

        {/* Live region for assistive tech */}
        <p
          role="status"
          aria-live="polite"
          className="mt-3 min-h-[1.25rem] px-1 text-center text-xs text-zinc-500 dark:text-zinc-400"
        >
          {liveMessage}
        </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 →