Checkboxes Hierarchy

A hierarchical checkbox component that enables nested selection of parent and child items.

Dependencies

Source Code

"use client";
import { Checkbox } from "../checkbox";
import { Input } from "../input";
import { cn } from "@/lib/utils";
import { useMemo, useState } from "react";
import { CaretDown } from "@phosphor-icons/react/dist/ssr";
 
type CheckboxesHierarchyProps = {
  className?: string;
  levels?: Entry[];
  defaultChildrenSelected?: string[];
  defaultParentsOpened?: string[];
  disabledChildren?: string[];
  disabledParents?: string[];
  showSelectAllOption?: boolean;
  selectAllLabel?: string;
  searchable?: boolean;
  searchPlaceholder?: string;
  searchEmptyMessage?: string;
  onSelectedChange?: (selectedValues: string[]) => void;
};
 
export type Entry = {
  label: string;
  value: string;
  /**
   * Optional array of child entries. Note: While the type allows for recursive nesting,
   * the component only renders two levels (parents and their immediate children).
   * Any deeper nesting will be ignored.
   */
  children?: Omit<Entry, "children">[];
};
 
const CheckboxesHierarchy = ({
  className,
  levels,
  defaultChildrenSelected = [],
  defaultParentsOpened = [],
  disabledChildren = [],
  disabledParents = [],
  showSelectAllOption = false,
  selectAllLabel = "All",
  searchable = false,
  searchPlaceholder = "Search...",
  searchEmptyMessage = "No results found",
  onSelectedChange,
}: CheckboxesHierarchyProps) => {
  // states
  const [selectedChildren, setSelectedChildren] = useState<string[]>(
    defaultChildrenSelected || []
  );
  const [openedParents, setOpenedParents] = useState<string[]>(
    defaultParentsOpened || []
  );
  const [searchQuery, setSearchQuery] = useState("");
 
  // memos
  const possibleChildren = useMemo(() => {
    const children: Entry[] = [];
    levels?.forEach((parent) => {
      if (parent.children) {
        children.push(...parent.children);
      }
    });
    return children;
  }, [levels]);
 
  const filteredLevels = useMemo(() => {
    if (!searchQuery || !levels) return levels;
 
    return levels
      .map((parent) => {
        const matchingChildren = parent.children?.filter((child) =>
          child.label.toLowerCase().includes(searchQuery.toLowerCase())
        );
 
        if (parent.label.toLowerCase().includes(searchQuery.toLowerCase())) {
          return parent;
        } else if (matchingChildren?.length) {
          return { ...parent, children: matchingChildren };
        }
        return null;
      })
      .filter(Boolean) as Entry[];
  }, [levels, searchQuery]);
 
  // methods
  const isAllChecked = () => {
    // Filter out disabled children
    const enabledChildren = possibleChildren?.filter(
      (child) => !disabledChildren?.includes(child.value)
    );
 
    if (!enabledChildren?.length) {
      return {
        checked: false,
        indeterminate: false,
      };
    }
 
    const allChecked = enabledChildren.every((child) =>
      selectedChildren.includes(child.value)
    );
    const someChecked = enabledChildren.some((child) =>
      selectedChildren.includes(child.value)
    );
 
    return {
      checked: allChecked,
      indeterminate: !allChecked && someChecked,
    };
  };
 
  const isParentChecked = (
    parent: Entry
  ): { checked: boolean; indeterminate: boolean } => {
    if (disabledParents?.includes(parent.value)) {
      return {
        checked: false,
        indeterminate: false,
      };
    }
 
    // Filter out disabled children when checking parent state
    const enabledChildren = parent.children?.filter(
      (child) => !disabledChildren?.includes(child.value)
    );
 
    if (!enabledChildren?.length) {
      return {
        checked: false,
        indeterminate: false,
      };
    }
 
    const allChecked = enabledChildren.every((child) =>
      selectedChildren.includes(child.value)
    );
    const someChecked = enabledChildren.some((child) =>
      selectedChildren.includes(child.value)
    );
 
    return {
      checked: allChecked,
      indeterminate: !allChecked && someChecked,
    };
  };
 
  // handlers
  const onAllCheckedChange = (checked: boolean | string) => {
    const newSelection = checked
      ? possibleChildren
          .filter((c) => !disabledChildren?.includes(c.value))
          .map((child) => child.value)
      : [];
    setSelectedChildren(newSelection);
    onSelectedChange?.(newSelection);
  };
 
  const onParentCheckedChange = (parent: Entry, checked: boolean | string) => {
    if (!parent.children || disabledParents?.includes(parent.value)) return;
 
    const enabledChildren = parent.children.filter(
      (c) => !disabledChildren?.includes(c.value)
    );
    const enabledChildrenValues = enabledChildren.map((c) => c.value);
 
    setSelectedChildren((oldState) => {
      const newSelection = checked
        ? [...new Set([...oldState, ...enabledChildrenValues])]
        : oldState.filter((c) => {
            // When unchecking, only keep values that are not part of this parent's enabled children
            return !enabledChildrenValues.includes(c);
          });
      onSelectedChange?.(newSelection);
      return newSelection;
    });
  };
 
  const onParentClick = (region: Entry) => {
    setOpenedParents((oldState) => {
      return openedParents.includes(region.value)
        ? oldState.filter((r) => r != region.value)
        : [...new Set([...oldState, region.value])];
    });
  };
 
  const onChildCheckedChange = (checked: boolean | string, child: string) => {
    setSelectedChildren((oldState) => {
      const newSelection = checked
        ? [...new Set([...oldState, child])]
        : oldState.filter((c) => c != child);
      onSelectedChange?.(newSelection);
      return newSelection;
    });
  };
 
  return (
    <div className={className}>
      {/* Search Input */}
      {searchable && (
        <Input
          placeholder={searchPlaceholder}
          value={searchQuery}
          onChange={(e) => setSearchQuery(e.target.value)}
          className="mb-2"
        />
      )}
      {/* Empty State */}
      {searchQuery && filteredLevels?.length === 0 && (
        <p className="px-2 py-1.5 text-sm text-gray-500">
          {searchEmptyMessage}
        </p>
      )}
 
      {/* Select All Option */}
      {showSelectAllOption && !searchQuery && (
        <div className="flex w-full items-center justify-between">
          <label
            className={cn(
              "flex w-full cursor-pointer items-center gap-2 px-2 py-1.5"
            )}
          >
            <Checkbox
              checked={isAllChecked().checked}
              indeterminate={isAllChecked().indeterminate}
              onChange={(e) => onAllCheckedChange(e.target.checked)}
            />
            <p className="font-semibold">{selectAllLabel}</p>
          </label>
        </div>
      )}
 
      {/* Parent and Child Checkboxes */}
      {filteredLevels?.map((parent) => (
        <div key={parent.value}>
          {/* Parent Checkbox */}
          <div className="flex w-full items-center justify-between">
            <label
              className={cn(
                "flex w-full items-center gap-2 px-2 py-1.5",
                !disabledParents?.includes(parent.value) && "cursor-pointer"
              )}
            >
              <Checkbox
                className="peer"
                checked={isParentChecked(parent).checked}
                indeterminate={isParentChecked(parent).indeterminate}
                disabled={disabledParents?.includes(parent.value)}
                onChange={(e) =>
                  onParentCheckedChange(parent, e.target.checked)
                }
              />
              <p className="peer-disabled:opacity-32">{parent.label}</p>
            </label>
            {parent.children && (
              <button
                className="cursor-pointer hover:opacity-80"
                onClick={() => onParentClick(parent)}
              >
                <CaretDown
                  className={`transition-transform duration-200 ${openedParents.includes(parent.value) ? "-rotate-180" : ""}`}
                />
              </button>
            )}
          </div>
 
          {/* Child Checkboxes */}
          <div
            className={cn(
              "grid overflow-hidden transition-all duration-200",
              openedParents.includes(parent.value)
                ? "grid-rows-[1fr]"
                : "grid-rows-[0fr]"
            )}
          >
            <div className="min-h-0">
              {parent.children?.map((child) => (
                <div key={child.value}>
                  <label
                    className={cn(
                      "flex w-full items-center gap-2 px-2 py-1.5 pl-8",
                      !(
                        disabledChildren?.includes(child.value) ||
                        disabledParents?.includes(parent.value)
                      ) && "cursor-pointer"
                    )}
                  >
                    <Checkbox
                      checked={selectedChildren.includes(child.value)}
                      disabled={
                        disabledChildren?.includes(child.value) ||
                        disabledParents?.includes(parent.value)
                      }
                      onChange={(e) =>
                        onChildCheckedChange(e.target.checked, child.value)
                      }
                      className="peer"
                    />
                    <p className="peer-disabled:opacity-32">{child.label}</p>
                  </label>
                </div>
              ))}
            </div>
          </div>
        </div>
      ))}
    </div>
  );
};
 
CheckboxesHierarchy.displayName = "CheckboxesHierarchy";
 
export default CheckboxesHierarchy;

Features

  • Two-Level Hierarchy: Parent-child checkbox structure with collapsible sections (parents and their immediate children only)
  • Smart Selection: Parent checkboxes control all children, show indeterminate state for partial selection
  • State Persistence: Selected states persist when collapsing parents or filtering
  • Flexible Disabling: Support for disabling individual children or entire parent sections
  • Search Functionality: Filter by both parent and child labels, preserving matching hierarchies
  • Select All Option: One-click selection of all visible and enabled checkboxes

API Reference

PropDefaultTypeDescription

className

-

string

Additional CSS classes to apply to the component.

levels

-

Entry[]

Array of hierarchical entries defining the parent level. Each Entry contains label, value, and optional children array for the second level. The component only renders two levels (parents and their immediate children).

defaultChildrenSelected

[]

string[]

Array of child values that should be selected by default.

defaultParentsOpened

[]

string[]

Array of parent values that should be expanded by default.

disabledChildren

[]

string[]

Array of child values that should be disabled.

disabledParents

[]

string[]

Array of parent values that should be disabled.

showSelectAllOption

false

boolean

When true, displays a 'All' option at the top.

selectAllLabel

All

string

Label text for the select all option.

searchable

false

boolean

When true, displays a search input to filter options.

searchPlaceholder

Search...

string

Placeholder text for the search input.

searchEmptyMessage

No results found

string

Message shown when search returns no results.

onSelectedChange

-

(selectedValues: string[]) => void

Callback function that receives an array of selected values when selections change.

Entry Type

The Entry type is used to define the structure of each item in the hierarchy:

type Entry = {
  label: string; // Display text for the checkbox
  value: string; // Unique identifier for the checkbox
  children?: Omit<Entry, "children">[]; // Optional array of child entries (second level only)
};

Examples

Basic

With Default Parents Opened

With Default Children Selected

With Disabled Children and Disabled Parents

With Select All Option

Best Practices

  1. Initial Setup:

    • Pre-expand important sections using defaultParentsOpened
    • Set commonly used items as selected with defaultChildrenSelected
    • Disable rarely used options rather than hiding them
  2. Search Usage:

    • Enable search for hierarchies with more than 7-8 items
    • Provide clear searchEmptyMessage to guide users
  3. Select All Usage:

    • Add Select All for lists where bulk selection is common
    • Use clear selectAllLabel that matches your content
    • Consider disabling instead of hiding irrelevant items