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:

'use client';

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

'use client';

import { Checkbox } from '@/components/checkbox';
import {
  Disclosure,
  DisclosureChevron,
  DisclosureContent,
  DisclosureTrigger,
} 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) => {
      console.log('Selected items:', 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>

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

              <DisclosureContent>
                <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>
              </DisclosureContent>
            </Disclosure>
          );
        })}
      </div>
    </div>
  );
}

With Search Functionality

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

Selected countries:

None

'use client';

import { useState } from 'react';

import { Checkbox } from '@/components/checkbox';
import {
  Disclosure,
  DisclosureChevron,
  DisclosureContent,
  DisclosureTrigger,
} 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) => {
      console.log('Selected items:', 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 && (
                    <DisclosureTrigger
                      onClick={() => toggleParentOpen(parent.id)}
                      className="w-auto cursor-pointer rounded p-1"
                    >
                      <DisclosureChevron />
                    </DisclosureTrigger>
                  )}
                </div>

                <DisclosureContent>
                  <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>
                </DisclosureContent>
              </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

'use client';

import { useState } from 'react';

import { Button } from '@/components/button';
import { Checkbox } from '@/components/checkbox';
import {
  Disclosure,
  DisclosureChevron,
  DisclosureContent,
  DisclosureTrigger,
} 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) => {
      console.log('Selected items:', 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 && (
                    <DisclosureTrigger
                      onClick={() => toggleParentOpen(parent.id)}
                      className="w-auto cursor-pointer rounded p-1"
                    >
                      <DisclosureChevron />
                    </DisclosureTrigger>
                  )}
                </div>

                <DisclosureContent>
                  <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>
                </DisclosureContent>
              </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

'use client';

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,
  DisclosureChevron,
  DisclosureContent,
  DisclosureTrigger,
} from '@/components/disclosure';
import { Input } from '@/components/input';
import { Label } from '@/components/label';
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 && (
                    <DisclosureTrigger
                      onClick={() => toggleParentOpen(parent.id)}
                      className="w-auto cursor-pointer rounded p-1"
                    >
                      <DisclosureChevron />
                    </DisclosureTrigger>
                  )}
                </div>

                <DisclosureContent>
                  <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>
                </DisclosureContent>
              </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(', ')}`
    );

    console.log('Form data:', data);
  };

  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">
          <div className="space-y-2">
            <Label htmlFor="name">Name *</Label>
            <Input
              id="name"
              {...register('name')}
              placeholder="Enter your name"
            />
            {errors.name && (
              <p className="text-red-600 text-sm">{errors.name.message}</p>
            )}
          </div>

          <div className="space-y-2">
            <Label htmlFor="email">Email *</Label>
            <Input
              id="email"
              type="email"
              {...register('email')}
              placeholder="Enter your email"
            />
            {errors.email && (
              <p className="text-red-600 text-sm">{errors.email.message}</p>
            )}
          </div>
        </div>

        <div className="space-y-2">
          <Label>Countries of Interest *</Label>
          <p className="mb-3 text-foreground-secondary text-sm">
            Select the countries you are interested in for our services
          </p>

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

        <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-[120px]"
          >
            {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>

            <DisclosureTrigger onClick={() => toggleParentOpen(parent.id)}>
              <DisclosureChevron />
            </DisclosureTrigger>
          </div>

          <DisclosureContent>
            <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>
          </DisclosureContent>
        </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

Performance tracking & bundle analyzer