Checkbox Indeterminate Toggle
Original · freeparent/child indeterminate checkboxes
byWeb InnoventixReact + Tailwind
tglcheckboxindeterminatetoggles
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.jsontgl-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 & 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 quoteSimilar components
Browse all →
Checkbox Set Toggle
Originaldefault/checked/indeterminate/disabled checkboxes

Checkbox Custom Toggle
Originalcustom animated tick checkboxes

Checkbox Card Toggle
Originalselectable card checkboxes

Checkbox List Toggle
Originalcheckbox list with select-all

Radio Set Toggle
Originalstyled radio button group

Radio Cards Toggle
Originalselectable radio cards

Radio Segmented Toggle
Originalsegmented control radios

Switch Basic Toggle
Originalbasic on/off switches in sizes

Switch iOS Toggle
OriginaliOS-style switches
Switch Icons Toggle
Originalswitches with sun/moon style icons

Switch Labels Toggle
Originalswitches with on/off labels inside

Switch Group Toggle
Originala settings list of switches

