Web InnoventixFreeCode

Checkbox List Toggle

Original · free

checkbox list with select-all

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

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

type ToneKey = "indigo" | "emerald" | "violet";
type BoxState = "on" | "off" | "mixed";

type Tone = {
  fill: string;
  ring: string;
  bar: string;
  track: string;
  text: string;
  chip: string;
  btn: string;
};

const TONES: Record<ToneKey, Tone> = {
  indigo: {
    fill: "border-indigo-500 bg-indigo-500 dark:border-indigo-400 dark:bg-indigo-500",
    ring: "peer-focus-visible:ring-indigo-500 dark:peer-focus-visible:ring-indigo-400",
    bar: "bg-indigo-500 dark:bg-indigo-400",
    track: "bg-indigo-100 dark:bg-indigo-500/20",
    text: "text-indigo-600 dark:text-indigo-400",
    chip: "bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-500/30",
    btn: "bg-indigo-600 hover:bg-indigo-500 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:hover:bg-indigo-400 dark:focus-visible:outline-indigo-400",
  },
  emerald: {
    fill: "border-emerald-500 bg-emerald-500 dark:border-emerald-400 dark:bg-emerald-500",
    ring: "peer-focus-visible:ring-emerald-500 dark:peer-focus-visible:ring-emerald-400",
    bar: "bg-emerald-500 dark:bg-emerald-400",
    track: "bg-emerald-100 dark:bg-emerald-500/20",
    text: "text-emerald-600 dark:text-emerald-400",
    chip: "bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-500/30",
    btn: "bg-emerald-600 hover:bg-emerald-500 focus-visible:outline-emerald-600 dark:bg-emerald-500 dark:hover:bg-emerald-400 dark:focus-visible:outline-emerald-400",
  },
  violet: {
    fill: "border-violet-500 bg-violet-500 dark:border-violet-400 dark:bg-violet-500",
    ring: "peer-focus-visible:ring-violet-500 dark:peer-focus-visible:ring-violet-400",
    bar: "bg-violet-500 dark:bg-violet-400",
    track: "bg-violet-100 dark:bg-violet-500/20",
    text: "text-violet-600 dark:text-violet-400",
    chip: "bg-violet-50 text-violet-700 ring-violet-200 dark:bg-violet-500/10 dark:text-violet-300 dark:ring-violet-500/30",
    btn: "bg-violet-600 hover:bg-violet-500 focus-visible:outline-violet-600 dark:bg-violet-500 dark:hover:bg-violet-400 dark:focus-visible:outline-violet-400",
  },
};

const AVATAR: Record<string, string> = {
  Design: "bg-sky-100 text-sky-700 dark:bg-sky-500/20 dark:text-sky-300",
  Engineering: "bg-violet-100 text-violet-700 dark:bg-violet-500/20 dark:text-violet-300",
  Product: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300",
  Marketing: "bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300",
};

const ROLE_CHIP: Record<string, string> = {
  Design: "bg-sky-50 text-sky-700 ring-sky-200 dark:bg-sky-500/10 dark:text-sky-300 dark:ring-sky-500/30",
  Engineering: "bg-violet-50 text-violet-700 ring-violet-200 dark:bg-violet-500/10 dark:text-violet-300 dark:ring-violet-500/30",
  Product: "bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-500/30",
  Marketing: "bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-500/30",
};

const KEYFRAMES = `
@keyframes tglcl-pop { 0% { transform: scale(.55); } 60% { transform: scale(1.12); } 100% { transform: scale(1); } }
@keyframes tglcl-rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.tglcl-pop-anim { animation: tglcl-pop .26s ease-out both; }
.tglcl-rise { animation: tglcl-rise .55s cubic-bezier(.22,1,.36,1) both; }
@media (prefers-reduced-motion: reduce) {
  .tglcl-pop-anim, .tglcl-rise { animation: none !important; }
}
`;

function cn(...parts: Array<string | false | null | undefined>): string {
  return parts.filter(Boolean).join(" ");
}

function initials(name: string): string {
  return name
    .split(" ")
    .map((w) => w.charAt(0))
    .slice(0, 2)
    .join("")
    .toUpperCase();
}

type Selection = {
  has: (id: string) => boolean;
  count: number;
  all: boolean;
  none: boolean;
  mixed: boolean;
  toggle: (id: string) => void;
  setAll: (on: boolean) => void;
};

function useSelection(ids: string[], initial: string[]): Selection {
  const [selected, setSelected] = useState<Set<string>>(() => new Set(initial));

  const toggle = (id: string) => {
    setSelected((prev) => {
      const next = new Set(prev);
      if (next.has(id)) {
        next.delete(id);
      } else {
        next.add(id);
      }
      return next;
    });
  };

  const setAll = (on: boolean) => {
    setSelected(on ? new Set(ids) : new Set<string>());
  };

  const count = selected.size;
  const all = count === ids.length && count > 0;
  const none = count === 0;

  return {
    has: (id: string) => selected.has(id),
    count,
    all,
    none,
    mixed: !all && !none,
    toggle,
    setAll,
  };
}

function CheckIcon({ reduce }: { reduce: boolean }) {
  return (
    <svg
      viewBox="0 0 20 20"
      fill="none"
      aria-hidden="true"
      className={cn("size-3.5 text-white", !reduce && "tglcl-pop-anim")}
    >
      <motion.path
        d="M4.5 10.5 8.25 14.25 15.5 6.25"
        stroke="currentColor"
        strokeWidth={2.4}
        strokeLinecap="round"
        strokeLinejoin="round"
        initial={reduce ? false : { pathLength: 0, opacity: 0 }}
        animate={{ pathLength: 1, opacity: 1 }}
        transition={{ duration: reduce ? 0 : 0.24, ease: "easeOut" }}
      />
    </svg>
  );
}

function Box({
  state,
  tone,
  reduce,
  className,
}: {
  state: BoxState;
  tone: ToneKey;
  reduce: boolean;
  className?: string;
}) {
  const t = TONES[tone];
  return (
    <span
      aria-hidden="true"
      className={cn(
        "pointer-events-none relative grid size-5 shrink-0 place-items-center rounded-[6px] border transition-colors duration-150",
        "peer-focus-visible:ring-2 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white dark:peer-focus-visible:ring-offset-slate-900",
        t.ring,
        state === "off"
          ? "border-slate-300 bg-white group-hover:border-slate-400 dark:border-slate-600 dark:bg-slate-800 dark:group-hover:border-slate-500"
          : t.fill,
        className,
      )}
    >
      {state === "on" && <CheckIcon reduce={reduce} />}
      {state === "mixed" && <span className="h-[2px] w-2.5 rounded-full bg-white" />}
    </span>
  );
}

function MasterCheckbox({
  label,
  text,
  state,
  tone,
  reduce,
  onToggle,
}: {
  label: string;
  text: string;
  state: BoxState;
  tone: ToneKey;
  reduce: boolean;
  onToggle: (on: boolean) => void;
}) {
  const ref = useRef<HTMLInputElement>(null);
  useEffect(() => {
    if (ref.current) ref.current.indeterminate = state === "mixed";
  }, [state]);

  return (
    <label className="group inline-flex cursor-pointer select-none items-center gap-2.5">
      <input
        ref={ref}
        type="checkbox"
        className="peer sr-only"
        checked={state === "on"}
        onChange={(e) => onToggle(e.target.checked)}
        aria-label={label}
      />
      <Box state={state} tone={tone} reduce={reduce} />
      <span className="text-sm font-semibold text-slate-700 dark:text-slate-200">{text}</span>
    </label>
  );
}

function CheckboxRow({
  label,
  description,
  tone,
  checked,
  strike,
  reduce,
  onToggle,
}: {
  label: string;
  description?: string;
  tone: ToneKey;
  checked: boolean;
  strike: boolean;
  reduce: boolean;
  onToggle: (next: boolean) => void;
}) {
  return (
    <li>
      <label className="group flex cursor-pointer select-none items-start gap-3 rounded-xl px-3 py-2.5 transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/60">
        <input
          type="checkbox"
          className="peer sr-only"
          checked={checked}
          onChange={(e) => onToggle(e.target.checked)}
        />
        <Box state={checked ? "on" : "off"} tone={tone} reduce={reduce} className="mt-0.5" />
        <span className="min-w-0">
          <span
            className={cn(
              "block text-sm font-medium transition-colors",
              checked && strike
                ? "text-slate-400 line-through dark:text-slate-500"
                : "text-slate-800 dark:text-slate-100",
            )}
          >
            {label}
          </span>
          {description ? (
            <span className="mt-0.5 block text-xs text-slate-500 dark:text-slate-400">
              {description}
            </span>
          ) : null}
        </span>
      </label>
    </li>
  );
}

type Item = { id: string; label: string; description?: string };

function ChecklistCard({
  title,
  subtitle,
  items,
  tone,
  strike,
  reduce,
  initial,
  delay,
}: {
  title: string;
  subtitle: string;
  items: Item[];
  tone: ToneKey;
  strike: boolean;
  reduce: boolean;
  initial: string[];
  delay: number;
}) {
  const ids = useMemo(() => items.map((i) => i.id), [items]);
  const sel = useSelection(ids, initial);
  const t = TONES[tone];
  const total = items.length;
  const pct = total === 0 ? 0 : Math.round((sel.count / total) * 100);
  const barId = useId();
  const masterState: BoxState = sel.all ? "on" : sel.mixed ? "mixed" : "off";

  return (
    <div
      className="tglcl-rise rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900"
      style={{ animationDelay: `${delay}ms` }}
    >
      <div className="mb-3 flex items-start justify-between gap-3">
        <div>
          <h3 className="text-base font-semibold text-slate-900 dark:text-white">{title}</h3>
          <p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">{subtitle}</p>
        </div>
        <span
          className={cn(
            "shrink-0 rounded-full px-2.5 py-1 text-xs font-semibold tabular-nums ring-1 ring-inset",
            t.chip,
          )}
        >
          {sel.count}/{total}
        </span>
      </div>

      <div className="flex items-center justify-between border-y border-slate-100 py-2 dark:border-slate-800">
        <MasterCheckbox
          label={`Select all — ${title}`}
          text="Select all"
          state={masterState}
          tone={tone}
          reduce={reduce}
          onToggle={sel.setAll}
        />
        <button
          type="button"
          onClick={() => sel.setAll(false)}
          disabled={sel.none}
          className="rounded-md px-2 py-1 text-xs font-medium text-slate-500 transition hover:text-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 disabled:pointer-events-none disabled:opacity-40 dark:text-slate-400 dark:hover:text-slate-200 dark:focus-visible:ring-slate-500"
        >
          Clear
        </button>
      </div>

      <div className="mt-3">
        <div
          role="progressbar"
          aria-valuenow={sel.count}
          aria-valuemin={0}
          aria-valuemax={total}
          aria-labelledby={barId}
          className={cn("h-1.5 w-full overflow-hidden rounded-full", t.track)}
        >
          <div
            className={cn("h-full rounded-full transition-[width] duration-300 ease-out", t.bar)}
            style={{ width: `${pct}%` }}
          />
        </div>
      </div>

      <ul role="list" className="mt-2">
        {items.map((it) => (
          <CheckboxRow
            key={it.id}
            label={it.label}
            description={it.description}
            tone={tone}
            checked={sel.has(it.id)}
            strike={strike}
            reduce={reduce}
            onToggle={() => sel.toggle(it.id)}
          />
        ))}
      </ul>

      <p
        id={barId}
        aria-live="polite"
        className="mt-1 px-3 text-xs text-slate-500 dark:text-slate-400"
      >
        <span className={cn("font-semibold tabular-nums", t.text)}>{sel.count}</span> of {total} selected
      </p>
    </div>
  );
}

function PlusIcon() {
  return (
    <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="size-4">
      <path
        d="M10 4.5v11M4.5 10h11"
        stroke="currentColor"
        strokeWidth={2}
        strokeLinecap="round"
      />
    </svg>
  );
}

type Member = { id: string; name: string; email: string; role: string };

const RELEASE: Item[] = [
  { id: "tests", label: "Run the full test suite", description: "1,284 tests across 96 files" },
  { id: "changelog", label: "Update CHANGELOG.md", description: "Everything since v2.3.1" },
  { id: "version", label: "Bump version in package.json", description: "2.3.1 → 2.4.0" },
  { id: "tag", label: "Tag the release on GitHub", description: "Signed annotated tag" },
  { id: "slack", label: "Announce in #engineering", description: "Slack, with release notes" },
  { id: "deploy", label: "Deploy to production", description: "Vercel · primary region" },
];

const NOTIFY: Item[] = [
  { id: "product", label: "Product updates", description: "New features, improvements, and fixes" },
  { id: "security", label: "Security alerts", description: "Sign-ins from new devices and password changes" },
  { id: "billing", label: "Billing receipts", description: "Monthly invoices and payment confirmations" },
  { id: "digest", label: "Weekly digest", description: "A Monday summary of your workspace activity" },
  { id: "events", label: "Community & events", description: "Webinars, meetups, and beta invitations" },
];

const TEAM: Member[] = [
  { id: "ava", name: "Ava Chen", email: "ava.chen@northwind.io", role: "Design" },
  { id: "marcus", name: "Marcus Reed", email: "m.reed@northwind.io", role: "Engineering" },
  { id: "priya", name: "Priya Nair", email: "priya.nair@northwind.io", role: "Product" },
  { id: "leo", name: "Leo Fischer", email: "leo.fischer@northwind.io", role: "Marketing" },
  { id: "sofia", name: "Sofia Duarte", email: "s.duarte@northwind.io", role: "Engineering" },
];

const TEAM_IDS = TEAM.map((m) => m.id);

export default function TglCheckboxList() {
  const reduce = !!useReducedMotion();
  const team = useSelection(TEAM_IDS, ["ava", "marcus"]);
  const headingId = useId();
  const teamMasterState: BoxState = team.all ? "on" : team.mixed ? "mixed" : "off";

  return (
    <section
      aria-labelledby={headingId}
      className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 sm:px-6 sm:py-24 dark:bg-slate-950"
    >
      <style>{KEYFRAMES}</style>

      <div className="mx-auto max-w-6xl">
        <header className="mb-10 max-w-2xl">
          <span className="inline-flex items-center gap-2 rounded-full bg-slate-200/70 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-slate-600 dark:bg-slate-800 dark:text-slate-300">
            Toggles
          </span>
          <h2
            id={headingId}
            className="mt-4 text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-white"
          >
            Checkbox lists with select-all
          </h2>
          <p className="mt-3 text-sm leading-relaxed text-slate-600 sm:text-base dark:text-slate-400">
            A master control drives the whole set and shows a true mixed (indeterminate) state
            when only some rows are checked. Fully keyboard-operable, screen-reader labelled, and
            built on real native checkboxes.
          </p>
        </header>

        <div className="grid gap-6 lg:grid-cols-2">
          <ChecklistCard
            title="Release checklist"
            subtitle="Shipping v2.4.0 — check items off as you go"
            items={RELEASE}
            tone="indigo"
            strike
            reduce={reduce}
            initial={["tests", "changelog"]}
            delay={0}
          />
          <ChecklistCard
            title="Email notifications"
            subtitle="Pick which emails Northwind can send you"
            items={NOTIFY}
            tone="emerald"
            strike={false}
            reduce={reduce}
            initial={["security", "billing"]}
            delay={60}
          />
        </div>

        <div
          className="tglcl-rise mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900"
          style={{ animationDelay: "120ms" }}
        >
          <div className="mb-3 flex flex-wrap items-center justify-between gap-3">
            <div>
              <h3 className="text-base font-semibold text-slate-900 dark:text-white">
                Invite your team
              </h3>
              <p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">
                Choose who gets access to the Northwind workspace.
              </p>
            </div>
            <span
              className={cn(
                "shrink-0 rounded-full px-2.5 py-1 text-xs font-semibold tabular-nums ring-1 ring-inset",
                TONES.violet.chip,
              )}
            >
              {team.count}/{TEAM.length} selected
            </span>
          </div>

          <div className="overflow-hidden rounded-xl border border-slate-200 dark:border-slate-800">
            <div className="flex items-center gap-3 border-b border-slate-200 bg-slate-50 px-3 py-2.5 dark:border-slate-800 dark:bg-slate-800/50">
              <MasterCheckbox
                label="Select all team members"
                text="Select all"
                state={teamMasterState}
                tone="violet"
                reduce={reduce}
                onToggle={team.setAll}
              />
              <span className="ml-auto hidden text-xs font-medium uppercase tracking-wide text-slate-400 sm:inline dark:text-slate-500">
                Role
              </span>
            </div>

            <ul role="list" className="divide-y divide-slate-100 dark:divide-slate-800">
              {TEAM.map((m) => {
                const checked = team.has(m.id);
                return (
                  <li key={m.id}>
                    <label className="group flex cursor-pointer select-none items-center gap-3 px-3 py-3 transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/50">
                      <input
                        type="checkbox"
                        className="peer sr-only"
                        checked={checked}
                        onChange={() => team.toggle(m.id)}
                        aria-label={`Invite ${m.name}, ${m.role}`}
                      />
                      <Box state={checked ? "on" : "off"} tone="violet" reduce={reduce} />
                      <span
                        aria-hidden="true"
                        className={cn(
                          "grid size-9 shrink-0 place-items-center rounded-full text-xs font-semibold",
                          AVATAR[m.role],
                        )}
                      >
                        {initials(m.name)}
                      </span>
                      <span className="min-w-0 flex-1">
                        <span className="block truncate text-sm font-medium text-slate-800 dark:text-slate-100">
                          {m.name}
                        </span>
                        <span className="block truncate text-xs text-slate-500 dark:text-slate-400">
                          {m.email}
                        </span>
                      </span>
                      <span
                        className={cn(
                          "hidden shrink-0 rounded-full px-2.5 py-1 text-xs font-medium ring-1 ring-inset sm:inline-block",
                          ROLE_CHIP[m.role],
                        )}
                      >
                        {m.role}
                      </span>
                    </label>
                  </li>
                );
              })}
            </ul>
          </div>

          <div className="mt-4 flex flex-wrap items-center justify-between gap-3">
            <p aria-live="polite" className="text-xs text-slate-500 dark:text-slate-400">
              {team.none ? (
                "No one selected yet."
              ) : (
                <>
                  <span className="font-semibold text-violet-600 dark:text-violet-400">
                    {team.count}
                  </span>{" "}
                  {team.count === 1 ? "person" : "people"} will be invited.
                </>
              )}
            </p>
            <button
              type="button"
              disabled={team.none}
              className={cn(
                "inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-semibold text-white shadow-sm transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-40",
                TONES.violet.btn,
              )}
            >
              <PlusIcon />
              Invite {team.count > 0 ? `${team.count} ` : ""}
              {team.count === 1 ? "member" : "members"}
            </button>
          </div>
        </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 →