Agents (llms.txt)
Octocat

Hierarchical Selection

Learn how to build hierarchical checkbox selection patterns using foundation components and reusable utilities.

Overview

Hierarchical selection allows users to select items in a parent-child relationship structure, where selecting a parent automatically selects all its children, and selecting all children automatically selects the parent. This pattern is commonly used for category selection, permissions management, location pickers, and filtering interfaces.

Why a Guide?

Rather than providing a rigid component with numerous props, this guide teaches you how to build hierarchical selection patterns using our existing foundation components. This approach:

  • Teaches the pattern instead of hiding complexity
  • Provides flexibility to adapt to your specific needs
  • Uses foundation components like Disclosure, Checkbox, and Input
  • Offers reusable utilities that can be copied to your project
  • Follows composition principles established in the foundation system

Core Concepts

Data Structure

Hierarchical data follows a simple recursive structure:

          interface HierarchicalItem {
  id: string;
  label: string;
  children?: HierarchicalItem[];
  disabled?: boolean;
}
        

Selection Logic

The key behaviors in hierarchical selection are:

  1. Parent Selection: Selecting a parent selects all enabled children
  2. Child Selection: When all children are selected, the parent becomes selected
  3. Indeterminate State: When some (but not all) children are selected, the parent shows indeterminate
  4. Disabled Items: Disabled items are excluded from parent-child selection logic

Source Code

Data Utilities

These utilities handle the hierarchical data structure and selection logic:

export interface HierarchicalItem {
  id: string;
  label: string;
  children?: HierarchicalItem[];
  disabled?: boolean;
}

export interface HierarchicalSelectionState {
  selectedIds: Set<string>;
  openParentIds: Set<string>;
}

export interface SelectionStatus {
  checked: boolean;
  indeterminate: boolean;
}

/**
 * Flattens a hierarchical data structure to get only leaf items (items without children)
 */
export function flattenHierarchicalData(items: HierarchicalItem[]): HierarchicalItem[] {
  return items.reduce<HierarchicalItem[]>((acc, item) => {
    if (item.children) {
      acc.push(...flattenHierarchicalData(item.children));
    } else {
      acc.push(item);
    }
    return acc;
  }, []);
}

/**
 * Gets all child IDs for a given parent item
 */
export function getChildIds(item: HierarchicalItem): string[] {
  if (!item.children) return [];
  return flattenHierarchicalData(item.children).map((child) => child.id);
}

/**
 * Gets all enabled child IDs for a given parent item
 */
export function getEnabledChildIds(item: HierarchicalItem): string[] {
  if (!item.children) return [];
  return flattenHierarchicalData(item.children)
    .filter((child) => !child.disabled)
    .map((child) => child.id);
}

/**
 * Determines the selection status of a parent item based on its children
 */
export function getParentSelectionStatus(
  parentItem: HierarchicalItem,
  selectedIds: Set<string>,
): SelectionStatus {
  if (parentItem.disabled) {
    return { checked: false, indeterminate: false };
  }

  const enabledChildIds = getEnabledChildIds(parentItem);

  if (enabledChildIds.length === 0) {
    return { checked: false, indeterminate: false };
  }

  const selectedChildCount = enabledChildIds.filter((id) => selectedIds.has(id)).length;

  if (selectedChildCount === 0) {
    return { checked: false, indeterminate: false };
  } else if (selectedChildCount === enabledChildIds.length) {
    return { checked: true, indeterminate: false };
  } else {
    return { checked: false, indeterminate: true };
  }
}

/**
 * Gets the selection status for "select all" functionality
 */
export function getSelectAllStatus(
  items: HierarchicalItem[],
  selectedIds: Set<string>,
): SelectionStatus {
  const allEnabledIds = flattenHierarchicalData(items)
    .filter((item) => !item.disabled)
    .map((item) => item.id);

  if (allEnabledIds.length === 0) {
    return { checked: false, indeterminate: false };
  }

  const selectedEnabledCount = allEnabledIds.filter((id) => selectedIds.has(id)).length;

  if (selectedEnabledCount === 0) {
    return { checked: false, indeterminate: false };
  } else if (selectedEnabledCount === allEnabledIds.length) {
    return { checked: true, indeterminate: false };
  } else {
    return { checked: false, indeterminate: true };
  }
}

/**
 * Filters hierarchical data based on a search query
 */
export function filterHierarchicalData(
  items: HierarchicalItem[],
  searchQuery: string,
): HierarchicalItem[] {
  if (!searchQuery.trim()) return items;

  const query = searchQuery.toLowerCase();

  return items
    .map((item) => {
      const matchingChildren = item.children?.filter((child) =>
        child.label.toLowerCase().includes(query),
      );

      if (item.label.toLowerCase().includes(query)) {
        return item;
      } else if (matchingChildren?.length) {
        return { ...item, children: matchingChildren };
      }
      return null;
    })
    .filter(Boolean) as HierarchicalItem[];
}

Selection Hook

The useHierarchicalSelection hook provides state management and selection methods:

import { useCallback, useMemo, useState } from "react";

import {
  flattenHierarchicalData,
  getEnabledChildIds,
  getParentSelectionStatus,
  getSelectAllStatus,
  type HierarchicalItem,
  type HierarchicalSelectionState,
  type SelectionStatus,
} from "./hierarchical-data";

export interface UseHierarchicalSelectionOptions {
  /** Initial selected item IDs */
  defaultSelected?: string[];
  /** Initial opened parent IDs */
  defaultOpened?: string[];
  /** Callback when selection changes */
  onSelectionChange?: (selectedIds: string[]) => void;
  /** Callback when opened parents change */
  onOpenChange?: (openedIds: string[]) => void;
}

export interface UseHierarchicalSelectionResult {
  /** Current selected item IDs as a Set for efficient lookup */
  selectedIds: Set<string>;
  /** Current opened parent IDs as a Set for efficient lookup */
  openParentIds: Set<string>;
  /** Current selected item IDs as an array */
  selectedArray: string[];
  /** Current opened parent IDs as an array */
  openedArray: string[];

  /** Toggle selection of a single item */
  toggleItem: (itemId: string) => void;
  /** Select/deselect a parent and all its enabled children */
  toggleParent: (parentItem: HierarchicalItem, checked: boolean) => void;
  /** Select/deselect all enabled items */
  toggleAll: (items: HierarchicalItem[], checked: boolean) => void;
  /** Toggle the opened state of a parent */
  toggleParentOpen: (parentId: string) => void;
  /** Clear all selections */
  clearSelection: () => void;
  /** Select specific items */
  selectItems: (itemIds: string[]) => void;

  /** Get selection status for a parent item */
  getParentStatus: (parentItem: HierarchicalItem) => SelectionStatus;
  /** Get selection status for "select all" */
  getSelectAllStatus: (items: HierarchicalItem[]) => SelectionStatus;
  /** Check if an item is selected */
  isSelected: (itemId: string) => boolean;
  /** Check if a parent is opened */
  isParentOpen: (parentId: string) => boolean;
}

export function useHierarchicalSelection(
  options: UseHierarchicalSelectionOptions = {},
): UseHierarchicalSelectionResult {
  const { defaultSelected = [], defaultOpened = [], onSelectionChange, onOpenChange } = options;

  // Internal state
  const [state, setState] = useState<HierarchicalSelectionState>({
    selectedIds: new Set(defaultSelected),
    openParentIds: new Set(defaultOpened),
  });

  // Derived values
  const selectedArray = useMemo(() => Array.from(state.selectedIds), [state.selectedIds]);
  const openedArray = useMemo(() => Array.from(state.openParentIds), [state.openParentIds]);

  // Update callbacks when arrays change
  const notifySelectionChange = useCallback(
    (newSelectedIds: Set<string>) => {
      const newArray = Array.from(newSelectedIds);
      onSelectionChange?.(newArray);
    },
    [onSelectionChange],
  );

  const notifyOpenChange = useCallback(
    (newOpenIds: Set<string>) => {
      const newArray = Array.from(newOpenIds);
      onOpenChange?.(newArray);
    },
    [onOpenChange],
  );

  // Selection methods
  const toggleItem = useCallback(
    (itemId: string) => {
      setState((prev) => {
        const newSelectedIds = new Set(prev.selectedIds);

        if (newSelectedIds.has(itemId)) {
          newSelectedIds.delete(itemId);
        } else {
          newSelectedIds.add(itemId);
        }

        notifySelectionChange(newSelectedIds);

        return {
          ...prev,
          selectedIds: newSelectedIds,
        };
      });
    },
    [notifySelectionChange],
  );

  const toggleParent = useCallback(
    (parentItem: HierarchicalItem, checked: boolean) => {
      if (parentItem.disabled) return;

      setState((prev) => {
        const newSelectedIds = new Set(prev.selectedIds);
        const enabledChildIds = getEnabledChildIds(parentItem);

        if (checked) {
          enabledChildIds.forEach((id) => {
            newSelectedIds.add(id);
          });
        } else {
          enabledChildIds.forEach((id) => {
            newSelectedIds.delete(id);
          });
        }

        notifySelectionChange(newSelectedIds);

        return {
          ...prev,
          selectedIds: newSelectedIds,
        };
      });
    },
    [notifySelectionChange],
  );

  const toggleAll = useCallback(
    (items: HierarchicalItem[], checked: boolean) => {
      setState((prev) => {
        const allEnabledIds = flattenHierarchicalData(items)
          .filter((item) => !item.disabled)
          .map((item) => item.id);

        let newSelectedIds: Set<string>;

        if (checked) {
          newSelectedIds = new Set([...prev.selectedIds, ...allEnabledIds]);
        } else {
          newSelectedIds = new Set(
            Array.from(prev.selectedIds).filter((id) => !allEnabledIds.includes(id)),
          );
        }

        notifySelectionChange(newSelectedIds);

        return {
          ...prev,
          selectedIds: newSelectedIds,
        };
      });
    },
    [notifySelectionChange],
  );

  const toggleParentOpen = useCallback(
    (parentId: string) => {
      setState((prev) => {
        const newOpenIds = new Set(prev.openParentIds);

        if (newOpenIds.has(parentId)) {
          newOpenIds.delete(parentId);
        } else {
          newOpenIds.add(parentId);
        }

        notifyOpenChange(newOpenIds);

        return {
          ...prev,
          openParentIds: newOpenIds,
        };
      });
    },
    [notifyOpenChange],
  );

  const clearSelection = useCallback(() => {
    setState((prev) => {
      const newSelectedIds = new Set<string>();
      notifySelectionChange(newSelectedIds);

      return {
        ...prev,
        selectedIds: newSelectedIds,
      };
    });
  }, [notifySelectionChange]);

  const selectItems = useCallback(
    (itemIds: string[]) => {
      setState((prev) => {
        const newSelectedIds = new Set(itemIds);
        notifySelectionChange(newSelectedIds);

        return {
          ...prev,
          selectedIds: newSelectedIds,
        };
      });
    },
    [notifySelectionChange],
  );

  // Status methods
  const getParentStatus = useCallback(
    (parentItem: HierarchicalItem): SelectionStatus => {
      return getParentSelectionStatus(parentItem, state.selectedIds);
    },
    [state.selectedIds],
  );

  const getSelectAllStatusResult = useCallback(
    (items: HierarchicalItem[]): SelectionStatus => {
      return getSelectAllStatus(items, state.selectedIds);
    },
    [state.selectedIds],
  );

  const isSelected = useCallback(
    (itemId: string): boolean => {
      return state.selectedIds.has(itemId);
    },
    [state.selectedIds],
  );

  const isParentOpen = useCallback(
    (parentId: string): boolean => {
      return state.openParentIds.has(parentId);
    },
    [state.openParentIds],
  );

  return {
    selectedIds: state.selectedIds,
    openParentIds: state.openParentIds,
    selectedArray,
    openedArray,

    toggleItem,
    toggleParent,
    toggleAll,
    toggleParentOpen,
    clearSelection,
    selectItems,

    getParentStatus,
    getSelectAllStatus: getSelectAllStatusResult,
    isSelected,
    isParentOpen,
  };
}

Examples

Basic Implementation

A simple parent-child checkbox structure with collapsible sections using the Disclosure component:

Selected: spain

import { Checkbox } from "@/components/checkbox";
import { Disclosure } from "@/components/disclosure";

import type { HierarchicalItem } from "../hierarchical-data";
import { useHierarchicalSelection } from "../use-hierarchical-selection";

const sampleData: HierarchicalItem[] = [
  {
    id: "europe",
    label: "Europe",
    children: [
      { id: "portugal", label: "Portugal" },
      { id: "spain", label: "Spain" },
      { id: "france", label: "France" },
    ],
  },
  {
    id: "asia",
    label: "Asia",
    children: [
      { id: "south-korea", label: "South Korea" },
      { id: "japan", label: "Japan" },
    ],
  },
];

export default function BasicHierarchicalSelection() {
  const {
    selectedArray,
    toggleItem,
    toggleParent,
    toggleParentOpen,
    getParentStatus,
    isSelected,
    isParentOpen,
  } = useHierarchicalSelection({
    defaultSelected: ["spain"],
    defaultOpened: ["europe"],
    onSelectionChange: (_selected) => {},
  });

  return (
    <div className="space-y-2">
      <div className="mb-4">
        <p className="text-foreground-secondary text-sm">
          Selected: {selectedArray.join(", ") || "None"}
        </p>
      </div>

      <div className="space-y-1">
        {sampleData.map((parent) => {
          const parentStatus = getParentStatus(parent);
          const isOpen = isParentOpen(parent.id);

          return (
            <Disclosure key={parent.id} open={isOpen}>
              <div className="flex items-center justify-between gap-2">
                <label className="flex flex-1 cursor-pointer items-center gap-2">
                  <Checkbox
                    checked={parentStatus.checked}
                    indeterminate={parentStatus.indeterminate}
                    onChange={(e) => toggleParent(parent, e.target.checked)}
                  />
                  <span className="cursor-pointer font-medium text-base">{parent.label}</span>
                </label>

                <Disclosure.Trigger
                  onClick={() => toggleParentOpen(parent.id)}
                  className="w-auto cursor-pointer rounded p-1"
                >
                  <Disclosure.Chevron />
                </Disclosure.Trigger>
              </div>

              <Disclosure.Content>
                <div className="mt-2 ml-6 space-y-2">
                  {parent.children?.map((child) => (
                    <label key={child.id} className="flex cursor-pointer items-center gap-2">
                      <Checkbox
                        checked={isSelected(child.id)}
                        onChange={() => toggleItem(child.id)}
                      />
                      <span className="cursor-pointer font-medium text-base">{child.label}</span>
                    </label>
                  ))}
                </div>
              </Disclosure.Content>
            </Disclosure>
          );
        })}
      </div>
    </div>
  );
}

With Search Functionality

Adding search capabilities to filter the hierarchy while preserving the selection state:

Selected countries:

None

import { useState } from "react";

import { Checkbox } from "@/components/checkbox";
import { Disclosure } from "@/components/disclosure";
import { Divider } from "@/components/divider";
import { Input } from "@/components/input";

import { filterHierarchicalData, type HierarchicalItem } from "../hierarchical-data";
import { useHierarchicalSelection } from "../use-hierarchical-selection";

const sampleData: HierarchicalItem[] = [
  {
    id: "europe",
    label: "Europe",
    children: [
      { id: "portugal", label: "Portugal" },
      { id: "spain", label: "Spain" },
      { id: "france", label: "France" },
      { id: "germany", label: "Germany" },
      { id: "italy", label: "Italy" },
    ],
  },
  {
    id: "asia",
    label: "Asia",
    children: [
      { id: "south-korea", label: "South Korea" },
      { id: "japan", label: "Japan" },
      { id: "china", label: "China" },
      { id: "thailand", label: "Thailand" },
    ],
  },
  {
    id: "north-america",
    label: "North America",
    children: [
      { id: "united-states", label: "United States" },
      { id: "canada", label: "Canada" },
      { id: "mexico", label: "Mexico" },
    ],
  },
];

export default function HierarchicalSelectionWithSearch() {
  const [searchQuery, setSearchQuery] = useState("");

  const {
    selectedArray,
    toggleItem,
    toggleParent,
    toggleParentOpen,
    toggleAll,
    getParentStatus,
    getSelectAllStatus,
    isSelected,
    isParentOpen,
  } = useHierarchicalSelection({
    defaultOpened: ["europe", "asia"],
    onSelectionChange: (_selected) => {},
  });

  // Filter data based on search query
  const filteredData = filterHierarchicalData(sampleData, searchQuery);
  const selectAllStatus = getSelectAllStatus(sampleData);

  return (
    <div className="w-full space-y-4">
      {/* Search input */}
      <Input
        placeholder="Search countries..."
        value={searchQuery}
        onChange={(e) => setSearchQuery(e.target.value)}
        className="w-full"
      />

      {/* Select all option - only show when not searching */}
      {!searchQuery && (
        <label className="flex cursor-pointer items-center gap-2">
          <Checkbox
            checked={selectAllStatus.checked}
            indeterminate={selectAllStatus.indeterminate}
            onChange={(e) => toggleAll(sampleData, e.target.checked)}
          />
          <span className="cursor-pointer font-medium text-base">All Countries</span>
        </label>
      )}

      <Divider />

      {/* Results */}
      {filteredData.length === 0 && searchQuery ? (
        <p className="p-2 text-foreground-secondary text-sm">No countries found</p>
      ) : (
        <div className="space-y-1">
          {filteredData.map((parent) => {
            const parentStatus = getParentStatus(parent);
            const isOpen = isParentOpen(parent.id);

            return (
              <Disclosure key={parent.id} open={isOpen || !!searchQuery}>
                <div className="flex items-center justify-between gap-2">
                  <label className="flex flex-1 cursor-pointer items-center gap-2">
                    <Checkbox
                      checked={parentStatus.checked}
                      indeterminate={parentStatus.indeterminate}
                      onChange={(e) => toggleParent(parent, e.target.checked)}
                    />
                    <span className="cursor-pointer font-medium text-base">{parent.label}</span>
                  </label>

                  {!searchQuery && (
                    <Disclosure.Trigger
                      onClick={() => toggleParentOpen(parent.id)}
                      className="w-auto cursor-pointer rounded p-1"
                    >
                      <Disclosure.Chevron />
                    </Disclosure.Trigger>
                  )}
                </div>

                <Disclosure.Content>
                  <div className="mt-2 ml-6 space-y-2">
                    {parent.children?.map((child) => (
                      <label key={child.id} className="flex cursor-pointer items-center gap-2">
                        <Checkbox
                          checked={isSelected(child.id)}
                          onChange={() => toggleItem(child.id)}
                        />
                        <span className="cursor-pointer font-medium text-base">{child.label}</span>
                      </label>
                    ))}
                  </div>
                </Disclosure.Content>
              </Disclosure>
            );
          })}
        </div>
      )}

      {/* Selected items display */}
      <div className="mt-4 rounded bg-background-secondary p-3">
        <p className="mb-1 font-medium text-sm">Selected countries:</p>
        <p className="text-foreground-secondary text-sm">
          {selectedArray.length > 0 ? selectedArray.join(", ") : "None"}
        </p>
      </div>
    </div>
  );
}

Complex Example

A full-featured implementation with disabled items, search, select-all, and action buttons:

Selected countries (2):

portugal, spain

Opened regions (1):

europe

import { useState } from "react";

import { Button } from "@/components/button";
import { Checkbox } from "@/components/checkbox";
import { Disclosure } from "@/components/disclosure";
import { Divider } from "@/components/divider";
import { Input } from "@/components/input";
import { cn } from "@/lib/utils/classnames";

import { filterHierarchicalData, type HierarchicalItem } from "../hierarchical-data";
import { useHierarchicalSelection } from "../use-hierarchical-selection";

const sampleData: HierarchicalItem[] = [
  {
    id: "europe",
    label: "Europe",
    children: [
      { id: "portugal", label: "Portugal" },
      { id: "spain", label: "Spain" },
      { id: "france", label: "France" },
      { id: "germany", label: "Germany", disabled: true },
      { id: "italy", label: "Italy" },
      { id: "netherlands", label: "Netherlands" },
    ],
  },
  {
    id: "asia",
    label: "Asia",
    disabled: true, // Entire region disabled
    children: [
      { id: "south-korea", label: "South Korea" },
      { id: "japan", label: "Japan" },
      { id: "china", label: "China" },
      { id: "thailand", label: "Thailand" },
    ],
  },
  {
    id: "north-america",
    label: "North America",
    children: [
      { id: "united-states", label: "United States" },
      { id: "canada", label: "Canada" },
      { id: "mexico", label: "Mexico", disabled: true },
    ],
  },
  {
    id: "oceania",
    label: "Oceania",
    children: [
      { id: "australia", label: "Australia" },
      { id: "new-zealand", label: "New Zealand" },
      { id: "fiji", label: "Fiji" },
    ],
  },
];

export default function ComplexHierarchicalSelection() {
  const [searchQuery, setSearchQuery] = useState("");

  const {
    selectedArray,
    openedArray,
    toggleItem,
    toggleParent,
    toggleParentOpen,
    toggleAll,
    clearSelection,
    selectItems,
    getParentStatus,
    getSelectAllStatus,
    isSelected,
    isParentOpen,
  } = useHierarchicalSelection({
    defaultSelected: ["portugal", "spain"],
    defaultOpened: ["europe"],
    onSelectionChange: (_selected) => {},
  });

  // Filter data based on search query
  const filteredData = filterHierarchicalData(sampleData, searchQuery);
  const selectAllStatus = getSelectAllStatus(sampleData);

  return (
    <div className="w-full space-y-4">
      {/* Quick actions */}
      <div className="flex gap-2">
        <Button
          variant="ghost"
          size="sm"
          onClick={clearSelection}
          disabled={selectedArray.length === 0}
        >
          Clear All
        </Button>

        <Button
          variant="ghost"
          size="sm"
          onClick={() => selectItems(["portugal", "spain", "france"])}
        >
          Select EU Core
        </Button>

        <Button
          variant="ghost"
          size="sm"
          onClick={() => selectItems(["united-states", "canada", "australia"])}
        >
          Select English Speaking
        </Button>
      </div>

      <Input
        placeholder="Search countries..."
        value={searchQuery}
        onChange={(e) => setSearchQuery(e.target.value)}
        className="flex-1"
      />

      {/* Select all option - only show when not searching */}
      {!searchQuery && (
        <label className="flex cursor-pointer items-center gap-2 rounded">
          <Checkbox
            checked={selectAllStatus.checked}
            indeterminate={selectAllStatus.indeterminate}
            onChange={(e) => toggleAll(sampleData, e.target.checked)}
          />
          <span className="cursor-pointer font-medium text-base">All Countries</span>
        </label>
      )}

      <Divider />

      {/* Results */}
      {filteredData.length === 0 && searchQuery ? (
        <p className="p-2 text-foreground-secondary text-sm">No countries found</p>
      ) : (
        <div className="space-y-1">
          {filteredData.map((parent) => {
            const parentStatus = getParentStatus(parent);
            const isOpen = isParentOpen(parent.id);
            const isParentDisabled = parent.disabled;

            return (
              <Disclosure key={parent.id} open={isOpen || !!searchQuery}>
                <div className="flex items-center justify-between gap-2">
                  <label
                    className={cn(
                      "flex flex-1 items-center gap-2",
                      isParentDisabled ? "opacity-50" : "cursor-pointer",
                    )}
                  >
                    <Checkbox
                      checked={parentStatus.checked}
                      indeterminate={parentStatus.indeterminate}
                      disabled={isParentDisabled}
                      onChange={(e) => toggleParent(parent, e.target.checked)}
                    />
                    <span
                      className={cn(
                        "font-medium text-base",
                        isParentDisabled ? "" : "cursor-pointer",
                      )}
                    >
                      {parent.label}
                      {isParentDisabled && (
                        <span className="ml-2 text-foreground-secondary text-xs">(Disabled)</span>
                      )}
                    </span>
                  </label>

                  {!searchQuery && (
                    <Disclosure.Trigger
                      onClick={() => toggleParentOpen(parent.id)}
                      className="w-auto cursor-pointer rounded p-1"
                    >
                      <Disclosure.Chevron />
                    </Disclosure.Trigger>
                  )}
                </div>

                <Disclosure.Content>
                  <div className="mt-2 ml-6 space-y-2 border-border border-l-2 pl-4">
                    {parent.children?.map((child) => {
                      const isChildDisabled = child.disabled || isParentDisabled;

                      return (
                        <label
                          key={child.id}
                          className={cn(
                            "flex items-center gap-2",
                            isChildDisabled ? "opacity-50" : "cursor-pointer",
                          )}
                        >
                          <Checkbox
                            checked={isSelected(child.id)}
                            disabled={isChildDisabled}
                            onChange={() => toggleItem(child.id)}
                          />
                          <span
                            className={cn(
                              "font-medium text-base",
                              isChildDisabled ? "" : "cursor-pointer",
                            )}
                          >
                            {child.label}
                            {child.disabled && (
                              <span className="ml-2 text-foreground-secondary text-xs">
                                (Disabled)
                              </span>
                            )}
                          </span>
                        </label>
                      );
                    })}
                  </div>
                </Disclosure.Content>
              </Disclosure>
            );
          })}
        </div>
      )}

      {/* Status display */}
      <div className="mt-4 space-y-2 rounded bg-background-secondary p-3">
        <div>
          <p className="mb-1 font-medium text-sm">Selected countries ({selectedArray.length}):</p>
          <p className="text-foreground-secondary text-sm">
            {selectedArray.length > 0 ? selectedArray.join(", ") : "None"}
          </p>
        </div>

        <div>
          <p className="mb-1 font-medium text-sm">Opened regions ({openedArray.length}):</p>
          <p className="text-foreground-secondary text-sm">
            {openedArray.length > 0 ? openedArray.join(", ") : "None"}
          </p>
        </div>
      </div>
    </div>
  );
}

Form Integration

Integration with react-hook-form showing how to use hierarchical selection in forms:

Registration Form

Select the countries you are interested in for our services

Selected countries (1):

spain

import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";

import { Button } from "@/components/button";
import { Checkbox } from "@/components/checkbox";
import { Disclosure } from "@/components/disclosure";
import { Field } from "@/components/field";
import { Input } from "@/components/input";
import { cn } from "@/lib/utils/classnames";

import { filterHierarchicalData, type HierarchicalItem } from "../hierarchical-data";
import { useHierarchicalSelection } from "../use-hierarchical-selection";

const schema = z.object({
  name: z.string().min(1, "Name is required"),
  email: z.string().email("Invalid email").min(1, "Email is required"),
  countries: z.array(z.string()).min(1, "Please select at least one country"),
});

type FormData = z.infer<typeof schema>;

const sampleData: HierarchicalItem[] = [
  {
    id: "europe",
    label: "Europe",
    children: [
      { id: "portugal", label: "Portugal" },
      { id: "spain", label: "Spain" },
      { id: "france", label: "France" },
      { id: "germany", label: "Germany" },
    ],
  },
  {
    id: "asia",
    label: "Asia",
    children: [
      { id: "south-korea", label: "South Korea" },
      { id: "japan", label: "Japan" },
      { id: "china", label: "China" },
    ],
  },
  {
    id: "north-america",
    label: "North America",
    children: [
      { id: "united-states", label: "United States" },
      { id: "canada", label: "Canada" },
      { id: "mexico", label: "Mexico" },
    ],
  },
];

interface HierarchicalSelectionFieldProps {
  value: string[];
  onChange: (value: string[]) => void;
  data: HierarchicalItem[];
  error?: string;
}

function HierarchicalSelectionField({
  value,
  onChange,
  data,
  error,
}: HierarchicalSelectionFieldProps) {
  const [searchQuery, setSearchQuery] = useState("");

  const { toggleItem, toggleParent, toggleParentOpen, getParentStatus, isSelected, isParentOpen } =
    useHierarchicalSelection({
      defaultSelected: value,
      defaultOpened: ["europe"],
      onSelectionChange: onChange,
    });

  const filteredData = filterHierarchicalData(data, searchQuery);

  return (
    <div className="space-y-3">
      <Input
        placeholder="Search countries..."
        value={searchQuery}
        onChange={(e) => setSearchQuery(e.target.value)}
        className="w-full"
      />

      <div
        className={cn(
          "max-h-64 space-y-1 overflow-y-auto rounded-md border p-3",
          error && "border-red-500",
        )}
      >
        {filteredData.length === 0 && searchQuery ? (
          <p className="p-2 text-foreground-secondary text-sm">No countries found</p>
        ) : (
          filteredData.map((parent) => {
            const parentStatus = getParentStatus(parent);
            const isOpen = isParentOpen(parent.id);

            return (
              <Disclosure key={parent.id} open={isOpen || !!searchQuery}>
                <div className="flex items-center justify-between gap-2">
                  <label className="flex flex-1 cursor-pointer items-center gap-2">
                    <Checkbox
                      checked={parentStatus.checked}
                      indeterminate={parentStatus.indeterminate}
                      onChange={(e) => toggleParent(parent, e.target.checked)}
                    />
                    <span className="cursor-pointer font-medium text-base">{parent.label}</span>
                  </label>

                  {!searchQuery && (
                    <Disclosure.Trigger
                      onClick={() => toggleParentOpen(parent.id)}
                      className="w-auto cursor-pointer rounded p-1"
                    >
                      <Disclosure.Chevron />
                    </Disclosure.Trigger>
                  )}
                </div>

                <Disclosure.Content>
                  <div className="mt-2 ml-6 space-y-2">
                    {parent.children?.map((child) => (
                      <label key={child.id} className="flex cursor-pointer items-center gap-2">
                        <Checkbox
                          checked={isSelected(child.id)}
                          onChange={() => toggleItem(child.id)}
                        />
                        <span className="cursor-pointer font-medium text-base">{child.label}</span>
                      </label>
                    ))}
                  </div>
                </Disclosure.Content>
              </Disclosure>
            );
          })
        )}
      </div>

      {error && <p className="text-red-600 text-sm">{error}</p>}
    </div>
  );
}

export default function HierarchicalSelectionForm() {
  const {
    register,
    handleSubmit,
    control,
    watch,
    formState: { errors, isSubmitting },
  } = useForm<FormData>({
    resolver: zodResolver(schema),
    defaultValues: {
      name: "",
      email: "",
      countries: ["spain"],
    },
  });

  const watchedCountries = watch("countries");

  const onSubmit = async (data: FormData) => {
    // Simulate API call
    await new Promise((resolve) => setTimeout(resolve, 1000));

    alert(
      `Form submitted!\n\nName: ${data.name}\nEmail: ${data.email}\nCountries: ${data.countries.join(", ")}`,
    );
  };

  return (
    <div className="mx-auto w-full p-6">
      <form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
        <h2 className="font-semibold text-xl">Registration Form</h2>

        <div className="grid grid-cols-2 gap-4">
          <Field invalid={!!errors.name}>
            <Field.Label>Name *</Field.Label>
            <Field.Control>
              <Input {...register("name")} placeholder="Enter your name" />
            </Field.Control>
            <Field.Error>{errors.name?.message}</Field.Error>
          </Field>

          <Field invalid={!!errors.email}>
            <Field.Label>Email *</Field.Label>
            <Field.Control>
              <Input type="email" {...register("email")} placeholder="Enter your email" />
            </Field.Control>
            <Field.Error>{errors.email?.message}</Field.Error>
          </Field>
        </div>

        <Field>
          <Field.Label>Countries of Interest *</Field.Label>
          <Field.Description>
            Select the countries you are interested in for our services
          </Field.Description>

          <Controller
            name="countries"
            control={control}
            render={({ field }) => (
              <HierarchicalSelectionField
                value={field.value}
                onChange={field.onChange}
                data={sampleData}
                error={errors.countries?.message}
              />
            )}
          />
        </Field>

        <div className="rounded bg-background-secondary p-3">
          <p className="mb-1 font-medium text-sm">
            Selected countries ({watchedCountries.length}):
          </p>
          <p className="text-foreground-secondary text-sm">
            {watchedCountries.length > 0 ? watchedCountries.join(", ") : "None"}
          </p>
        </div>

        <div className="flex gap-3">
          <Button type="submit" disabled={isSubmitting} className="min-w-30">
            {isSubmitting ? "Submitting..." : "Submit"}
          </Button>

          <Button type="button" variant="ghost" onClick={() => window.location.reload()}>
            Reset Form
          </Button>
        </div>
      </form>
    </div>
  );
}

Building Your Own

Step 1: Set Up the Data Structure

Define your hierarchical data using the HierarchicalItem interface:

          const data: HierarchicalItem[] = [
  {
    id: "frontend",
    label: "Frontend",
    children: [
      { id: "react", label: "React" },
      { id: "vue", label: "Vue" },
      { id: "angular", label: "Angular" },
    ],
  },
  {
    id: "backend",
    label: "Backend",
    children: [
      { id: "node", label: "Node.js" },
      { id: "python", label: "Python" },
      { id: "java", label: "Java" },
    ],
  },
];
        

Step 2: Initialize the Hook

Set up the selection hook with your desired options:

          const { selectedArray, toggleItem, toggleParent, toggleParentOpen, getParentStatus, isSelected, isParentOpen } =
  useHierarchicalSelection({
    defaultSelected: ["react"],
    defaultOpened: ["frontend"],
    onSelectionChange: (selected) => {
      console.log("Selected:", selected);
    },
  });
        

Step 3: Build the UI

Compose the UI using foundation components:

          return (
  <div className="space-y-2">
    {data.map((parent) => {
      const parentStatus = getParentStatus(parent);
      const isOpen = isParentOpen(parent.id);

      return (
        <Disclosure key={parent.id} open={isOpen}>
          <div className="flex items-center justify-between">
            <label className="flex items-center gap-2">
              <Checkbox
                checked={parentStatus.checked}
                indeterminate={parentStatus.indeterminate}
                onChange={(e) => toggleParent(parent, e.target.checked)}
              />
              <span>{parent.label}</span>
            </label>

            <Disclosure.Trigger onClick={() => toggleParentOpen(parent.id)}>
              <Disclosure.Chevron />
            </Disclosure.Trigger>
          </div>

          <Disclosure.Content>
            <div className="ml-6 space-y-2">
              {parent.children?.map((child) => (
                <label key={child.id} className="flex items-center gap-2">
                  <Checkbox
                    checked={isSelected(child.id)}
                    onChange={() => toggleItem(child.id)}
                  />
                  <span>{child.label}</span>
                </label>
              ))}
            </div>
          </Disclosure.Content>
        </Disclosure>
      );
    })}
  </div>
);
        

Advanced Features

Use the filterHierarchicalData utility to add search functionality:

          const [searchQuery, setSearchQuery] = useState("");
const filteredData = filterHierarchicalData(data, searchQuery);
        

Select All Functionality

Implement select-all with the toggleAll method and getSelectAllStatus utility:

          const selectAllStatus = getSelectAllStatus(data);

<Checkbox
  checked={selectAllStatus.checked}
  indeterminate={selectAllStatus.indeterminate}
  onChange={(e) => toggleAll(data, e.target.checked)}
/>
        

Disabled Items

Mark items as disabled in your data structure:

          const dataWithDisabled: HierarchicalItem[] = [
  {
    id: "features",
    label: "Features",
    disabled: true, // Entire section disabled
    children: [
      { id: "feature-a", label: "Feature A" },
      { id: "feature-b", label: "Feature B", disabled: true }, // Individual item disabled
    ],
  },
];
        

Form Integration

Create a controlled component for forms:

          function HierarchicalField({ value, onChange, data, error }) {
  const selection = useHierarchicalSelection({
    defaultSelected: value,
    onSelectionChange: onChange,
  });

  return (
    <div>
      {/* Your hierarchical selection UI */}
      {error && <p className="text-red-600">{error}</p>}
    </div>
  );
}

// In your form
<Controller
  name="selectedItems"
  control={control}
  render={({ field }) => (
    <HierarchicalField
      value={field.value}
      onChange={field.onChange}
      data={hierarchicalData}
      error={errors.selectedItems?.message}
    />
  )}
/>
        

Best Practices

Data Structure

  • Use meaningful IDs: Make IDs descriptive for better debugging
  • Keep data flat: Avoid deep nesting beyond 2-3 levels
  • Consider disabled states: Plan for items that shouldn’t be selectable
  • Separate display from logic: Keep business logic separate from display logic

Performance

  • Memoize filtered data: Use useMemo for filtered results in search
  • Debounce search: Prevent excessive filtering on every keystroke
  • Virtualize large lists: Consider virtualization for hundreds of items
  • Lazy load children: Load child data on demand for large datasets

Accessibility

  • Use proper labels: Associate labels with checkboxes correctly
  • Keyboard navigation: Ensure all interactions work with keyboard
  • Screen reader support: Use aria-expanded and proper roles
  • Focus management: Maintain logical focus flow

User Experience

  • Preserve selection: Maintain selections when searching or filtering
  • Show selection count: Display how many items are selected
  • Quick actions: Provide shortcuts like “Select All” or preset selections
  • Clear feedback: Show loading states and error messages clearly

Common Patterns

Location Picker

          const locationData = [
  {
    id: "north-america",
    label: "North America",
    children: [
      { id: "usa", label: "United States" },
      { id: "canada", label: "Canada" },
    ],
  },
];
        

Permissions Management

          const permissionData = [
  {
    id: "user-management",
    label: "User Management",
    children: [
      { id: "create-users", label: "Create Users" },
      { id: "edit-users", label: "Edit Users" },
      { id: "delete-users", label: "Delete Users" },
    ],
  },
];
        

Category Selection

          const categoryData = [
  {
    id: "electronics",
    label: "Electronics",
    children: [
      { id: "smartphones", label: "Smartphones" },
      { id: "laptops", label: "Laptops" },
      { id: "tablets", label: "Tablets" },
    ],
  },
];
        

Previous

Automated tests

Next

Loading States