Web InnoventixFreeCode

Checkbox Indeterminate Toggle

Original · free

parent/child indeterminate checkboxes

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

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

/* ------------------------------------------------------------------ */
/* Types + tree helpers                                               */
/* ------------------------------------------------------------------ */

type CheckState = "checked" | "unchecked" | "indeterminate";

type TreeNode = {
  id: string;
  label: string;
  hint?: string;
  children?: TreeNode[];
};

function leafIds(node: TreeNode): string[] {
  if (!node.children || node.children.length === 0) return [node.id];
  return node.children.flatMap(leafIds);
}

function nodeState(node: TreeNode, checked: Record<string, boolean>): CheckState {
  const ids = leafIds(node);
  const on = ids.filter((id) => checked[id]).length;
  if (on === 0) return "unchecked";
  if (on === ids.length) return "checked";
  return "indeterminate";
}

/* ------------------------------------------------------------------ */
/* Presentational atoms                                               */
/* ------------------------------------------------------------------ */

function Card({
  children,
  className = "",
}: {
  children: ReactNode;
  className?: string;
}) {
  return (
    <div
      className={[
        "rounded-2xl border border-slate-200 bg-white p-6 shadow-sm shadow-slate-900/5",
        "dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/20",
        className,
      ].join(" ")}
    >
      {children}
    </div>
  );
}

function CheckMark() {
  return (
    <svg
      viewBox="0 0 16 16"
      className="tglcbi-mark h-3.5 w-3.5"
      fill="none"
      aria-hidden="true"
    >
      <path
        d="M3.5 8.4l3 3 6-6.6"
        stroke="currentColor"
        strokeWidth="2.4"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function DashMark() {
  return (
    <svg
      viewBox="0 0 16 16"
      className="tglcbi-mark h-3.5 w-3.5"
      fill="none"
      aria-hidden="true"
    >
      <path d="M4 8h8" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" />
    </svg>
  );
}

function FolderIcon({ open }: { open: boolean }) {
  return (
    <svg
      viewBox="0 0 20 20"
      className="h-4 w-4"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.5"
      aria-hidden="true"
    >
      {open ? (
        <path
          d="M2.6 6.2A1.4 1.4 0 014 4.8h2.9L8.3 6.2H16a1.4 1.4 0 011.4 1.4v.4H5.5a1.4 1.4 0 00-1.34 1L2.6 14z"
          strokeLinejoin="round"
        />
      ) : (
        <path
          d="M2.6 6A1.4 1.4 0 014 4.6h2.9L8.3 6H16a1.4 1.4 0 011.4 1.4v6A1.4 1.4 0 0116 15H4a1.4 1.4 0 01-1.4-1.4z"
          strokeLinejoin="round"
        />
      )}
    </svg>
  );
}

function FileIcon() {
  return (
    <svg
      viewBox="0 0 20 20"
      className="h-4 w-4"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.5"
      aria-hidden="true"
    >
      <path d="M5 2.6h6l4 4V17a.5.5 0 01-.5.5h-9A.5.5 0 015 17z" strokeLinejoin="round" />
      <path d="M11 2.6V6.6h4" strokeLinejoin="round" />
    </svg>
  );
}

/* ------------------------------------------------------------------ */
/* Tri-state checkbox (real <input>, real indeterminate DOM prop)     */
/* ------------------------------------------------------------------ */

function TriCheck({
  id,
  label,
  hint,
  state,
  onToggle,
  size = "md",
  strong = false,
  hideLabel = false,
}: {
  id: string;
  label: string;
  hint?: string;
  state: CheckState;
  onToggle: (next: boolean) => void;
  size?: "sm" | "md";
  strong?: boolean;
  hideLabel?: boolean;
}) {
  const ref = useRef<HTMLInputElement>(null);

  useEffect(() => {
    if (ref.current) ref.current.indeterminate = state === "indeterminate";
  }, [state]);

  const active = state !== "unchecked";
  const boxSize = size === "sm" ? "h-[18px] w-[18px]" : "h-5 w-5";

  return (
    <label
      htmlFor={id}
      className="group flex cursor-pointer select-none items-start gap-3"
    >
      <input
        ref={ref}
        id={id}
        type="checkbox"
        checked={state === "checked"}
        onChange={(e) => onToggle(e.currentTarget.checked)}
        className="peer sr-only"
      />
      <span
        aria-hidden="true"
        className={[
          "relative mt-px flex shrink-0 items-center justify-center rounded-[7px] border text-white transition-colors duration-200",
          boxSize,
          active
            ? "border-indigo-600 bg-indigo-600 dark:border-indigo-500 dark:bg-indigo-500"
            : "border-slate-300 bg-white group-hover:border-indigo-400 dark:border-slate-600 dark:bg-slate-800 dark:group-hover:border-indigo-500",
          "peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white dark:peer-focus-visible:ring-offset-slate-900",
        ].join(" ")}
      >
        {state === "indeterminate" ? (
          <DashMark />
        ) : state === "checked" ? (
          <CheckMark />
        ) : null}
      </span>
      <span className={hideLabel ? "sr-only" : "min-w-0"}>
        <span
          className={[
            "block text-sm leading-5",
            strong
              ? "font-semibold text-slate-900 dark:text-slate-50"
              : "font-medium text-slate-700 dark:text-slate-200",
          ].join(" ")}
        >
          {label}
        </span>
        {hint && !hideLabel ? (
          <span className="mt-0.5 block text-xs leading-4 text-slate-500 dark:text-slate-400">
            {hint}
          </span>
        ) : null}
      </span>
    </label>
  );
}

/* ------------------------------------------------------------------ */
/* Variant 1 — flat parent / child notification preferences           */
/* ------------------------------------------------------------------ */

const NOTIF_CHILDREN: { id: string; label: string; hint: string }[] = [
  { id: "product", label: "Product updates", hint: "New features and improvements" },
  { id: "security", label: "Security alerts", hint: "Sign-ins and password changes" },
  { id: "digest", label: "Weekly digest", hint: "A Monday summary of your workspace" },
  { id: "billing", label: "Billing receipts", hint: "Invoices and payment confirmations" },
];

function NotificationsDemo() {
  const uid = useId();
  const [on, setOn] = useState<Record<string, boolean>>({
    security: true,
    billing: true,
  });

  const count = NOTIF_CHILDREN.filter((c) => on[c.id]).length;
  const parentState: CheckState =
    count === 0 ? "unchecked" : count === NOTIF_CHILDREN.length ? "checked" : "indeterminate";

  const setAll = (v: boolean) =>
    setOn(Object.fromEntries(NOTIF_CHILDREN.map((c) => [c.id, v])));
  const setOne = (id: string, v: boolean) =>
    setOn((prev) => ({ ...prev, [id]: v }));

  return (
    <Card>
      <div className="flex items-start justify-between gap-4">
        <div>
          <h3 className="text-base font-semibold text-slate-900 dark:text-slate-50">
            Email notifications
          </h3>
          <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
            The parent reflects a mixed state until every option agrees.
          </p>
        </div>
        <span
          aria-live="polite"
          className="shrink-0 rounded-full bg-indigo-50 px-2.5 py-1 text-xs font-semibold tabular-nums text-indigo-700 ring-1 ring-inset ring-indigo-600/20 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20"
        >
          {count}/{NOTIF_CHILDREN.length}
        </span>
      </div>

      <div className="mt-5 rounded-xl border border-slate-200 bg-slate-50/70 p-3.5 dark:border-slate-800 dark:bg-slate-800/30">
        <TriCheck
          id={`${uid}-all`}
          label="All email notifications"
          hint="Toggle every category at once"
          state={parentState}
          onToggle={setAll}
          strong
        />
      </div>

      <ul className="mt-2 space-y-0.5">
        {NOTIF_CHILDREN.map((c) => (
          <li
            key={c.id}
            className="rounded-lg px-2.5 py-2 transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/50"
          >
            <TriCheck
              id={`${uid}-${c.id}`}
              label={c.label}
              hint={c.hint}
              state={on[c.id] ? "checked" : "unchecked"}
              onToggle={(v) => setOne(c.id, v)}
              size="sm"
            />
          </li>
        ))}
      </ul>
    </Card>
  );
}

/* ------------------------------------------------------------------ */
/* Variant 2 — nested, collapsible file tree (multi-level cascade)     */
/* ------------------------------------------------------------------ */

const FILE_TREE: TreeNode = {
  id: "root",
  label: "acme-dashboard",
  children: [
    {
      id: "src",
      label: "src",
      children: [
        { id: "src-app", label: "app.tsx", hint: "Application entry" },
        {
          id: "components",
          label: "components",
          children: [
            { id: "c-button", label: "Button.tsx" },
            { id: "c-modal", label: "Modal.tsx" },
            { id: "c-table", label: "DataTable.tsx" },
          ],
        },
      ],
    },
    {
      id: "public",
      label: "public",
      children: [
        { id: "p-logo", label: "logo.svg" },
        { id: "p-favicon", label: "favicon.ico" },
      ],
    },
    { id: "readme", label: "README.md", hint: "Project overview" },
  ],
};

function TreeItem({
  node,
  depth,
  uid,
  checked,
  setLeaves,
  expanded,
  toggleExpand,
  reduce,
}: {
  node: TreeNode;
  depth: number;
  uid: string;
  checked: Record<string, boolean>;
  setLeaves: (ids: string[], value: boolean) => void;
  expanded: Record<string, boolean>;
  toggleExpand: (id: string) => void;
  reduce: boolean;
}) {
  const hasChildren = !!node.children && node.children.length > 0;
  const state = nodeState(node, checked);
  const isOpen = expanded[node.id] ?? true;

  return (
    <li>
      <div
        className="flex items-center gap-1.5 rounded-lg py-1.5 pr-2 transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/60"
        style={{ paddingLeft: depth * 20 + 6 }}
      >
        {hasChildren ? (
          <button
            type="button"
            onClick={() => toggleExpand(node.id)}
            aria-expanded={isOpen}
            aria-label={`${isOpen ? "Collapse" : "Expand"} ${node.label}`}
            className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-slate-400 outline-none transition-colors hover:text-slate-700 focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-slate-500 dark:hover:text-slate-200"
          >
            <svg
              viewBox="0 0 16 16"
              className={[
                "h-3.5 w-3.5 transition-transform duration-200",
                isOpen ? "rotate-90" : "",
              ].join(" ")}
              fill="none"
              aria-hidden="true"
            >
              <path
                d="M6 4l4 4-4 4"
                stroke="currentColor"
                strokeWidth="1.75"
                strokeLinecap="round"
                strokeLinejoin="round"
              />
            </svg>
          </button>
        ) : (
          <span className="h-5 w-5 shrink-0" aria-hidden="true" />
        )}

        <span
          aria-hidden="true"
          className="shrink-0 text-slate-400 dark:text-slate-500"
        >
          {hasChildren ? <FolderIcon open={isOpen} /> : <FileIcon />}
        </span>

        <TriCheck
          id={`${uid}-${node.id}`}
          label={node.label}
          hint={node.hint}
          state={state}
          size="sm"
          onToggle={(v) => setLeaves(leafIds(node), v)}
        />
      </div>

      {hasChildren ? (
        <AnimatePresence initial={false}>
          {isOpen ? (
            <motion.ul
              key="children"
              initial={reduce ? false : { height: 0, opacity: 0 }}
              animate={{ height: "auto", opacity: 1 }}
              exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
              transition={{ duration: reduce ? 0 : 0.2, ease: "easeOut" }}
              className="overflow-hidden"
            >
              {node.children!.map((child) => (
                <TreeItem
                  key={child.id}
                  node={child}
                  depth={depth + 1}
                  uid={uid}
                  checked={checked}
                  setLeaves={setLeaves}
                  expanded={expanded}
                  toggleExpand={toggleExpand}
                  reduce={reduce}
                />
              ))}
            </motion.ul>
          ) : null}
        </AnimatePresence>
      ) : null}
    </li>
  );
}

function FileTreeDemo({ reduce }: { reduce: boolean }) {
  const uid = useId();
  const allLeaves = useMemo(() => leafIds(FILE_TREE), []);
  const [checked, setChecked] = useState<Record<string, boolean>>(() => ({
    "src-app": true,
    "c-button": true,
    "c-modal": true,
  }));
  const [expanded, setExpanded] = useState<Record<string, boolean>>({
    root: true,
    src: true,
    components: true,
    public: false,
  });

  const setLeaves = (ids: string[], value: boolean) =>
    setChecked((prev) => {
      const next = { ...prev };
      for (const id of ids) next[id] = value;
      return next;
    });

  const toggleExpand = (id: string) =>
    setExpanded((prev) => ({ ...prev, [id]: !(prev[id] ?? true) }));

  const selectedCount = allLeaves.filter((id) => checked[id]).length;

  return (
    <Card>
      <div className="flex items-start justify-between gap-4">
        <div>
          <h3 className="text-base font-semibold text-slate-900 dark:text-slate-50">
            Include files
          </h3>
          <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
            Checking a folder cascades to every file it contains.
          </p>
        </div>
        <span
          aria-live="polite"
          className="shrink-0 rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold tabular-nums text-slate-600 ring-1 ring-inset ring-slate-300/70 dark:bg-slate-800 dark:text-slate-300 dark:ring-slate-700"
        >
          {selectedCount}/{allLeaves.length} files
        </span>
      </div>

      <ul className="mt-4">
        <TreeItem
          node={FILE_TREE}
          depth={0}
          uid={uid}
          checked={checked}
          setLeaves={setLeaves}
          expanded={expanded}
          toggleExpand={toggleExpand}
          reduce={reduce}
        />
      </ul>
    </Card>
  );
}

/* ------------------------------------------------------------------ */
/* Variant 3 — table with a select-all header (bulk row selection)     */
/* ------------------------------------------------------------------ */

type Member = {
  id: string;
  name: string;
  email: string;
  role: string;
  status: "Active" | "Invited" | "Suspended";
};

const MEMBERS: Member[] = [
  { id: "m1", name: "Amara Okafor", email: "amara@northwind.io", role: "Owner", status: "Active" },
  { id: "m2", name: "Devon Reyes", email: "devon@northwind.io", role: "Admin", status: "Active" },
  { id: "m3", name: "Priya Nair", email: "priya@northwind.io", role: "Editor", status: "Invited" },
  { id: "m4", name: "Lars Andersen", email: "lars@northwind.io", role: "Viewer", status: "Suspended" },
];

function statusBadge(status: Member["status"]): string {
  switch (status) {
    case "Active":
      return "bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/20";
    case "Invited":
      return "bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-400/20";
    case "Suspended":
      return "bg-rose-50 text-rose-700 ring-rose-600/20 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-400/20";
  }
}

function TableDemo() {
  const uid = useId();
  const [sel, setSel] = useState<Set<string>>(() => new Set(["m2"]));

  const count = sel.size;
  const headState: CheckState =
    count === 0 ? "unchecked" : count === MEMBERS.length ? "checked" : "indeterminate";

  const setAll = (v: boolean) =>
    setSel(v ? new Set(MEMBERS.map((m) => m.id)) : new Set());
  const toggle = (id: string, v: boolean) =>
    setSel((prev) => {
      const next = new Set(prev);
      if (v) next.add(id);
      else next.delete(id);
      return next;
    });

  return (
    <Card>
      <div className="flex flex-wrap items-start justify-between gap-3">
        <div>
          <h3 className="text-base font-semibold text-slate-900 dark:text-slate-50">
            Team members
          </h3>
          <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
            The header checkbox goes mixed whenever some — but not all — rows are picked.
          </p>
        </div>
        {count > 0 ? (
          <div className="flex items-center gap-2">
            <span
              aria-live="polite"
              className="rounded-full bg-indigo-50 px-2.5 py-1 text-xs font-semibold tabular-nums text-indigo-700 ring-1 ring-inset ring-indigo-600/20 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20"
            >
              {count} selected
            </span>
            <button
              type="button"
              onClick={() => setAll(false)}
              className="rounded-md px-2 py-1 text-xs font-semibold text-slate-500 outline-none transition-colors hover:text-slate-800 focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-slate-400 dark:hover:text-slate-100"
            >
              Clear
            </button>
          </div>
        ) : null}
      </div>

      <div className="mt-4 overflow-x-auto">
        <table className="w-full min-w-[34rem] border-collapse text-left text-sm">
          <thead>
            <tr className="border-b border-slate-200 dark:border-slate-800">
              <th scope="col" className="w-10 px-3 py-3">
                <TriCheck
                  id={`${uid}-head`}
                  label="Select all members"
                  state={headState}
                  onToggle={setAll}
                  hideLabel
                />
              </th>
              <th
                scope="col"
                className="px-3 py-3 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"
              >
                Member
              </th>
              <th
                scope="col"
                className="px-3 py-3 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"
              >
                Role
              </th>
              <th
                scope="col"
                className="px-3 py-3 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"
              >
                Status
              </th>
            </tr>
          </thead>
          <tbody>
            {MEMBERS.map((m) => {
              const isOn = sel.has(m.id);
              return (
                <tr
                  key={m.id}
                  className={[
                    "border-b border-slate-100 transition-colors last:border-0 dark:border-slate-800/70",
                    isOn
                      ? "bg-indigo-50/60 dark:bg-indigo-500/5"
                      : "hover:bg-slate-50 dark:hover:bg-slate-800/40",
                  ].join(" ")}
                >
                  <td className="px-3 py-3 align-middle">
                    <TriCheck
                      id={`${uid}-${m.id}`}
                      label={`Select ${m.name}`}
                      state={isOn ? "checked" : "unchecked"}
                      onToggle={(v) => toggle(m.id, v)}
                      hideLabel
                    />
                  </td>
                  <td className="px-3 py-3 align-middle">
                    <div className="font-medium text-slate-800 dark:text-slate-100">
                      {m.name}
                    </div>
                    <div className="text-xs text-slate-500 dark:text-slate-400">
                      {m.email}
                    </div>
                  </td>
                  <td className="px-3 py-3 align-middle text-slate-600 dark:text-slate-300">
                    {m.role}
                  </td>
                  <td className="px-3 py-3 align-middle">
                    <span
                      className={[
                        "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset",
                        statusBadge(m.status),
                      ].join(" ")}
                    >
                      {m.status}
                    </span>
                  </td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>
    </Card>
  );
}

/* ------------------------------------------------------------------ */
/* Root                                                               */
/* ------------------------------------------------------------------ */

export default function IndeterminateCheckboxes() {
  const reduce = useReducedMotion() ?? false;

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes tglcbi-pop {
          0% { transform: scale(0.5); opacity: 0; }
          55% { transform: scale(1.15); }
          100% { transform: scale(1); opacity: 1; }
        }
        @keyframes tglcbi-glow {
          0%, 100% { opacity: 0.25; transform: translate3d(0, 0, 0) scale(1); }
          50% { opacity: 0.45; transform: translate3d(0, -6px, 0) scale(1.06); }
        }
        .tglcbi-mark { animation: tglcbi-pop 220ms cubic-bezier(0.34, 1.56, 0.64, 1); }
        .tglcbi-glow { animation: tglcbi-glow 7s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .tglcbi-mark, .tglcbi-glow { animation: none !important; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="tglcbi-glow pointer-events-none absolute -top-24 left-1/2 h-72 w-[42rem] max-w-full -translate-x-1/2 rounded-full bg-gradient-to-r from-indigo-300/40 via-violet-300/40 to-sky-300/40 blur-3xl dark:from-indigo-600/25 dark:via-violet-600/25 dark:to-sky-600/25"
      />

      <div className="relative mx-auto max-w-5xl px-6 py-16 sm:py-20">
        <header className="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-semibold text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
            <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
            Tri-state controls
          </span>
          <h2 className="mt-4 text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-slate-50">
            Parent &amp; child checkboxes with real indeterminate state
          </h2>
          <p className="mt-3 text-base text-slate-600 dark:text-slate-300">
            Each parent computes checked, unchecked, or mixed from its children — driven by
            native <code className="rounded bg-slate-200/70 px-1 py-0.5 text-sm dark:bg-slate-800">indeterminate</code>{" "}
            inputs, keyboard support, and visible focus rings.
          </p>
        </header>

        <div className="mt-10 grid grid-cols-1 gap-6 lg:grid-cols-2">
          <NotificationsDemo />
          <FileTreeDemo reduce={reduce} />
        </div>

        <div className="mt-6">
          <TableDemo />
        </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 →