Web InnoventixFreeCode

Checkbox Set Toggle

Original · free

default/checked/indeterminate/disabled checkboxes

byWeb InnoventixReact + Tailwind
tglcheckboxsettoggles
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-checkbox-set.json
tgl-checkbox-set.tsx
"use client";

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

type CheckboxProps = {
  label: ReactNode;
  description?: string;
  checked: boolean;
  indeterminate?: boolean;
  disabled?: boolean;
  onChange?: (checked: boolean) => void;
};

function Checkbox({
  label,
  description,
  checked,
  indeterminate = false,
  disabled = false,
  onChange,
}: CheckboxProps) {
  const reduce = useReducedMotion();
  const inputRef = useRef<HTMLInputElement | null>(null);
  const autoId = useId();
  const descId = description ? `${autoId}-desc` : undefined;

  const isIndeterminate = indeterminate && !checked;
  const active = checked || isIndeterminate;

  useEffect(() => {
    if (inputRef.current) {
      inputRef.current.indeterminate = isIndeterminate;
    }
  }, [isIndeterminate]);

  const boxClasses = [
    "flex h-5 w-5 shrink-0 items-center justify-center rounded-[7px] border",
    "transition-[background-color,border-color,box-shadow] duration-200 ease-out",
    active
      ? "border-indigo-500 bg-indigo-500 text-white dark:border-indigo-400 dark:bg-indigo-500"
      : "border-slate-300 bg-white text-transparent dark:border-slate-600 dark:bg-slate-900",
    !disabled && !active
      ? "peer-hover:border-indigo-400 dark:peer-hover:border-indigo-500"
      : "",
    "peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500/60 peer-focus-visible:ring-offset-2",
    "peer-focus-visible:ring-offset-white dark:peer-focus-visible:ring-offset-slate-900",
  ]
    .filter(Boolean)
    .join(" ");

  return (
    <label
      className={[
        "group flex select-none items-start gap-3",
        disabled ? "cursor-not-allowed opacity-60" : "cursor-pointer",
      ].join(" ")}
    >
      <span className="relative mt-0.5 inline-flex">
        <input
          ref={inputRef}
          type="checkbox"
          className="peer sr-only"
          checked={checked}
          disabled={disabled}
          aria-describedby={descId}
          onChange={(event) => onChange?.(event.target.checked)}
        />
        <span aria-hidden="true" className={boxClasses}>
          {isIndeterminate ? (
            <motion.svg
              viewBox="0 0 16 16"
              className="h-3.5 w-3.5"
              fill="none"
              stroke="currentColor"
              strokeWidth={2.5}
              strokeLinecap="round"
            >
              <motion.path
                d="M4 8 H12"
                initial={reduce ? false : { pathLength: 0, opacity: 0 }}
                animate={{ pathLength: 1, opacity: 1 }}
                transition={{ duration: 0.2, ease: "easeOut" }}
              />
            </motion.svg>
          ) : active ? (
            <motion.svg
              viewBox="0 0 16 16"
              className="h-3.5 w-3.5"
              fill="none"
              stroke="currentColor"
              strokeWidth={2.5}
              strokeLinecap="round"
              strokeLinejoin="round"
            >
              <motion.path
                d="M3.5 8.5 L6.5 11.5 L12.5 4.5"
                initial={reduce ? false : { pathLength: 0 }}
                animate={{ pathLength: 1 }}
                transition={{ duration: 0.25, ease: "easeOut" }}
              />
            </motion.svg>
          ) : null}
        </span>
      </span>

      <span className="flex min-w-0 flex-col">
        <span className="text-sm font-medium leading-5 text-slate-900 dark:text-slate-100">
          {label}
        </span>
        {description ? (
          <span
            id={descId}
            className="mt-0.5 text-[13px] leading-5 text-slate-500 dark:text-slate-400"
          >
            {description}
          </span>
        ) : null}
      </span>
    </label>
  );
}

type PrefKey = "product" | "security" | "billing";

const NOTIFY_ITEMS: { key: PrefKey; label: string; description: string }[] = [
  {
    key: "product",
    label: "Product updates",
    description: "New features, improvements, and roadmap notes.",
  },
  {
    key: "security",
    label: "Security alerts",
    description: "Sign-in attempts and password changes.",
  },
  {
    key: "billing",
    label: "Billing & invoices",
    description: "Receipts, renewals, and payment failures.",
  },
];

function Card({
  title,
  eyebrow,
  live,
  children,
}: {
  title: string;
  eyebrow: string;
  live?: boolean;
  children: ReactNode;
}) {
  return (
    <div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
      <div className="mb-5 flex items-center gap-2">
        {live ? (
          <span className="tglcbx-dot h-2 w-2 rounded-full bg-emerald-500" />
        ) : (
          <span className="h-2 w-2 rounded-full bg-slate-300 dark:bg-slate-600" />
        )}
        <span className="text-[11px] font-semibold uppercase tracking-[0.14em] text-indigo-600 dark:text-indigo-400">
          {eyebrow}
        </span>
      </div>
      <h3 className="mb-4 text-base font-semibold text-slate-900 dark:text-slate-100">
        {title}
      </h3>
      {children}
    </div>
  );
}

export default function TglCheckboxSet() {
  const [prefs, setPrefs] = useState<Record<PrefKey, boolean>>({
    product: true,
    security: true,
    billing: false,
  });

  const values = Object.values(prefs);
  const allChecked = values.every(Boolean);
  const noneChecked = values.every((value) => !value);
  const someChecked = !allChecked && !noneChecked;
  const selectedCount = values.filter(Boolean).length;

  const setPref = (key: PrefKey, value: boolean) =>
    setPrefs((prev) => ({ ...prev, [key]: value }));

  const toggleAll = () => {
    const next = !allChecked;
    setPrefs({ product: next, security: next, billing: next });
  };

  const [refDefault, setRefDefault] = useState(false);
  const [refChecked, setRefChecked] = useState(true);
  const [refMixed, setRefMixed] = useState(false);

  const [agreed, setAgreed] = useState(false);
  const [submitted, setSubmitted] = useState(false);

  return (
    <section className="relative w-full bg-slate-50 px-6 py-16 sm:py-20 dark:bg-slate-950">
      <style>{`
        @keyframes tglcbx-pulse {
          0%, 100% { opacity: 0.45; transform: scale(1); }
          50% { opacity: 1; transform: scale(1.4); }
        }
        .tglcbx-dot { animation: tglcbx-pulse 2.4s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .tglcbx-dot { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto w-full max-w-4xl">
        <header className="mb-10 max-w-2xl">
          <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-indigo-600 dark:text-indigo-400">
            Form controls
          </p>
          <h2 className="mt-2 text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-slate-50">
            Checkbox set
          </h2>
          <p className="mt-3 text-sm leading-6 text-slate-600 dark:text-slate-400">
            Accessible checkboxes with default, checked, indeterminate, and
            disabled states. Keyboard operable, screen-reader friendly, and
            tuned for light and dark themes.
          </p>
        </header>

        <div className="grid gap-6 md:grid-cols-2">
          <Card eyebrow="Indeterminate" title="Email notifications" live>
            <fieldset className="m-0 border-0 p-0">
              <legend className="sr-only">Email notification preferences</legend>

              <Checkbox
                label="All notifications"
                checked={allChecked}
                indeterminate={someChecked}
                onChange={toggleAll}
              />

              <div className="mt-4 space-y-4 border-l border-slate-200 pl-4 dark:border-slate-800">
                {NOTIFY_ITEMS.map((item) => (
                  <Checkbox
                    key={item.key}
                    label={item.label}
                    description={item.description}
                    checked={prefs[item.key]}
                    onChange={(value) => setPref(item.key, value)}
                  />
                ))}
              </div>
            </fieldset>

            <p
              aria-live="polite"
              className="mt-5 text-[13px] text-slate-500 dark:text-slate-400"
            >
              {selectedCount} of {NOTIFY_ITEMS.length} channels enabled
            </p>
          </Card>

          <Card eyebrow="Reference" title="All states">
            <div className="space-y-4">
              <Checkbox
                label="Default (unchecked)"
                description="Toggle me on and off."
                checked={refDefault}
                onChange={setRefDefault}
              />
              <Checkbox
                label="Checked"
                description="Starts selected."
                checked={refChecked}
                onChange={setRefChecked}
              />
              <Checkbox
                label="Indeterminate"
                description="Click to resolve to checked."
                checked={refMixed}
                indeterminate={!refMixed}
                onChange={setRefMixed}
              />
              <Checkbox
                label="Disabled (unchecked)"
                description="Not interactive."
                checked={false}
                disabled
              />
              <Checkbox
                label="Disabled (checked)"
                description="Not interactive."
                checked
                disabled
              />
            </div>
          </Card>
        </div>

        <div className="mt-6 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
          <div className="flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between">
            <Checkbox
              label="I agree to the Terms of Service"
              description="You can review the full terms before continuing."
              checked={agreed}
              onChange={(value) => {
                setAgreed(value);
                setSubmitted(false);
              }}
            />

            <button
              type="button"
              disabled={!agreed}
              onClick={() => setSubmitted(true)}
              className={[
                "inline-flex shrink-0 items-center justify-center rounded-xl px-5 py-2.5 text-sm font-semibold",
                "transition-colors duration-200",
                "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/60 focus-visible:ring-offset-2",
                "focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900",
                agreed
                  ? "bg-indigo-600 text-white hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
                  : "cursor-not-allowed bg-slate-100 text-slate-400 dark:bg-slate-800 dark:text-slate-500",
              ].join(" ")}
            >
              Create account
            </button>
          </div>

          <p
            aria-live="polite"
            className="mt-4 text-[13px] text-emerald-600 dark:text-emerald-400"
          >
            {submitted ? "Account created — welcome aboard." : " "}
          </p>
        </div>
      </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 →