Skip to content

Early previewhamur is not on npm yet. The components are being built in the open, follow along on GitHub.

InputsReact

Tree

A hierarchy of items whose parents open and close, such as a file explorer or a table of contents.

Rendered by React 19.3.1

import * as React from 'react'
import { Tree } from '@hamur/react/tree'

interface Node {
  value: string
  label: string
  children?: Node[]
}

const files: Node[] = [
  {
    value: 'src',
    label: 'src',
    children: [
      {
        value: 'components',
        label: 'components',
        children: [
          { value: 'button', label: 'Button.tsx' },
          { value: 'dialog', label: 'Dialog.tsx' },
        ],
      },
      { value: 'index', label: 'index.ts' },
    ],
  },
  { value: 'public', label: 'public', children: [{ value: 'favicon', label: 'favicon.svg' }] },
  { value: 'package', label: 'package.json' },
  { value: 'readme', label: 'README.md' },
]

function renderNodes(nodes: Node[]): React.ReactNode {
  return nodes.map((node) => (
    <Tree.Item key={node.value} value={node.value} className="tree-item">
      <Tree.ItemContent className="tree-row">
        <Tree.ItemToggle keepMounted className="tree-toggle">
          <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
            <path d="M6 4l4 4-4 4" />
          </svg>
        </Tree.ItemToggle>
        <Tree.ItemText className="tree-text">
          {node.label}
        </Tree.ItemText>
      </Tree.ItemContent>
      {node.children ? (
        <Tree.Group className="tree-group">
          {renderNodes(node.children)}
        </Tree.Group>
      ) : null}
    </Tree.Item>
  ))
}

export default function TreeExample() {
  return (
    <Tree.Root
      className="tree"
      aria-label="Files"
      defaultExpanded={['src']}
      defaultValue="index"
    >
      {renderNodes(files)}
    </Tree.Root>
  )
}
Not measured yetTests passing in React
Not measured yetSize
Not measured yetMemory
Not measured yetSpeed
[N] commits

Installation

npm i @hamur/react

Not on npm yet. The package goes out under the chosen name.

Anatomy

Import the component and assemble its parts:

<Tree.Item>
  <Tree.ItemContent>
    <Tree.ItemToggle />
    <Tree.ItemText />
  </Tree.ItemContent>
  <Tree.Group />
</Tree.Item>
<Tree.Root />

// Not in this example: Tree.Empty, Tree.ItemIndicator

Examples

Checkbox

multiple and cascade: Mozzarella starts checked, so Cheese is mixed; checking a group checks its toppings, and the checked values are listed below.

TreeCheckbox.tsx
import * as React from 'react'
import { Tree } from '@hamur/react/tree'

const toppings = [
  {
    value: 'cheese',
    label: 'Cheese',
    children: [
      { value: 'mozzarella', label: 'Mozzarella' },
      { value: 'parmesan', label: 'Parmesan' },
    ],
  },
  {
    value: 'vegetables',
    label: 'Vegetables',
    children: [
      { value: 'olives', label: 'Olives' },
      { value: 'peppers', label: 'Peppers' },
      { value: 'onions', label: 'Onions' },
    ],
  },
]

function Row(props: { label: string }) {
  return (
    <Tree.ItemContent className="tree-row">
      <Tree.ItemToggle keepMounted className="tree-toggle">
        <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
          <path d="M6 4l4 4-4 4" />
        </svg>
      </Tree.ItemToggle>
      <Tree.ItemIndicator keepMounted className="tree-check">
        <svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">
          <path className="tree-check-mark" d="M3 8.5l3 3 7-7" />
          <path className="tree-check-dash" d="M4 8h8" />
        </svg>
      </Tree.ItemIndicator>
      <Tree.ItemText className="tree-text">
        {props.label}
      </Tree.ItemText>
    </Tree.ItemContent>
  )
}

export default function TreeCheckboxExample() {
  const [value, setValue] = React.useState<string[]>(['mozzarella'])
  return (
    <div className="stack">
      <Tree.Root
        className="tree"
        aria-label="Toppings"
        multiple
        cascade
        defaultExpanded={['cheese', 'vegetables']}
        value={value}
        onValueChange={setValue}
      >
        {toppings.map((group) => (
          <Tree.Item key={group.value} value={group.value} className="tree-item">
            <Row label={group.label} />
            <Tree.Group className="tree-group">
              {group.children.map((topping) => (
                <Tree.Item
                  key={topping.value}
                  value={topping.value}
                  className="tree-item"
                >
                  <Row label={topping.label} />
                </Tree.Item>
              ))}
            </Tree.Group>
          </Tree.Item>
        ))}
      </Tree.Root>
      <p className="muted">Checked: {value.join(', ') || 'none'}</p>
    </div>
  )
}

Lazy

loadChildren fetches each folder's files on its first open; the chevron pulses while it loads, and the empty Drafts turns into a leaf.

TreeLazy.tsx
import * as React from 'react'
import { Tree } from '@hamur/react/tree'

const folders = [
  { value: 'reports', label: 'Reports' },
  { value: 'invoices', label: 'Invoices' },
  { value: 'drafts', label: 'Drafts' },
]

// What the server holds: Drafts is empty, so it turns into a leaf once loaded.
const server: Record<string, { value: string; label: string }[]> = {
  reports: [
    { value: 'q1', label: 'Q1 2026.pdf' },
    { value: 'q2', label: 'Q2 2026.pdf' },
  ],
  invoices: [{ value: 'inv-104', label: 'Invoice 104.pdf' }],
  drafts: [],
}

function fetchChildren(value: string, signal: AbortSignal) {
  return new Promise<{ value: string; label: string }[]>((resolve, reject) => {
    const timer = setTimeout(() => resolve(server[value]), 600)
    signal.addEventListener('abort', () => {
      clearTimeout(timer)
      reject(signal.reason)
    })
  })
}

export default function TreeLazyExample() {
  const [loaded, setLoaded] = React.useState<Record<string, { value: string; label: string }[]>>({})
  return (
    <Tree.Root
      className="tree"
      aria-label="Documents"
      loadChildren={async (value: string, { signal }) => {
        const children = await fetchChildren(value, signal)
        setLoaded((previous) => ({ ...previous, [value]: children }))
        return children.map((child) => child.value)
      }}
    >
      {folders.map((folder) => (
        <Tree.Item
          key={folder.value}
          value={folder.value}
          expandable
          className="tree-item"
        >
          <Tree.ItemContent className="tree-row">
            <Tree.ItemToggle keepMounted className="tree-toggle">
              <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
                <path d="M6 4l4 4-4 4" />
              </svg>
            </Tree.ItemToggle>
            <Tree.ItemText className="tree-text">
              {folder.label}
            </Tree.ItemText>
          </Tree.ItemContent>
          {loaded[folder.value]?.length ? (
            <Tree.Group className="tree-group">
              {loaded[folder.value].map((file) => (
                <Tree.Item
                  key={file.value}
                  value={file.value}
                  className="tree-item"
                >
                  <Tree.ItemContent className="tree-row">
                    <Tree.ItemToggle keepMounted className="tree-toggle" />
                    <Tree.ItemText className="tree-text">
                      {file.label}
                    </Tree.ItemText>
                  </Tree.ItemContent>
                </Tree.Item>
              ))}
            </Tree.Group>
          ) : null}
        </Tree.Item>
      ))}
    </Tree.Root>
  )
}

Move

allowMove: drag a row, or Control+X and Control+V; canMove keeps the folders at the top level and onItemMove applies the move to the app's lists.

TreeMove.tsx
import * as React from 'react'
import { Tree } from '@hamur/react/tree'

const labels: Record<string, string> = {
  inbox: 'Inbox',
  archive: 'Archive',
  report: 'Report.pdf',
  invoice: 'Invoice.pdf',
  minutes: 'Minutes.txt',
  notes: 'Notes.md',
}
// Folders stay at the top level; mail moves between them or to the top level.
const folders = ['inbox', 'archive']

// The children of each folder, and of the top level (`root`).
const initial: Record<string, string[]> = {
  root: ['inbox', 'archive', 'notes'],
  inbox: ['report', 'invoice'],
  archive: ['minutes'],
}

interface Move {
  items: string[]
  target: string | null
  position: 'before' | 'after' | 'inside'
}

function allowed(lists: Record<string, string[]>, { items, target, position }: Move) {
  const movesFolder = items.some((value) => folders.includes(value))
  if (position === 'inside') {
    return target !== null && folders.includes(target) && !movesFolder
  }
  return !movesFolder || lists.root.includes(target!)
}

/** The app applies a move to its own data: out of the old lists, then into the new place. */
function applyMove(lists: Record<string, string[]>, { items, target, position }: Move) {
  const next: Record<string, string[]> = {}
  for (const [key, list] of Object.entries(lists)) {
    next[key] = list.filter((value) => !items.includes(value))
  }
  if (position === 'inside') {
    const key = target ?? 'root'
    next[key] = [...(next[key] ?? []), ...items]
    return next
  }
  const parent = Object.keys(next).find((key) => next[key].includes(target!))!
  const index = next[parent].indexOf(target!) + (position === 'after' ? 1 : 0)
  next[parent] = [...next[parent].slice(0, index), ...items, ...next[parent].slice(index)]
  return next
}

export default function TreeMoveExample() {
  const [lists, setLists] = React.useState(initial)
  function renderNodes(values: string[]): React.ReactNode {
    return values.map((value) => (
      <Tree.Item key={value} value={value} className="tree-item">
        <Tree.ItemContent className="tree-row">
          <Tree.ItemToggle keepMounted className="tree-toggle">
            <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
              <path d="M6 4l4 4-4 4" />
            </svg>
          </Tree.ItemToggle>
          <Tree.ItemText className="tree-text">
            {labels[value]}
          </Tree.ItemText>
        </Tree.ItemContent>
        {lists[value]?.length ? (
          <Tree.Group className="tree-group">
            {renderNodes(lists[value])}
          </Tree.Group>
        ) : null}
      </Tree.Item>
    ))
  }
  return (
    <div className="stack">
      <Tree.Root
        className="tree"
        aria-label="Mail"
        allowMove
        defaultExpanded={folders}
        canMove={(move: Move) => allowed(lists, move)}
        onItemMove={(move: Move) => setLists((previous) => applyMove(previous, move))}
      >
        {renderNodes(lists.root)}
      </Tree.Root>
      <p className="muted">Drag a row, or press Ctrl+X on it, move to a place and press Ctrl+V.</p>
    </div>
  )
}

Virtualized

50 folders of 20 files as data, virtualized, with about 30 rows mounted; Home, End and the page keys reach rows that are not mounted.

TreeVirtualized.tsx
import * as React from 'react'
import { Tree } from '@hamur/react/tree'

// Rows of a fixed height, windowed by hand: only the rows near the scroll position render.
const ROW = 32
const VIEWPORT = 260
const OVERSCAN = 10

// 50 folders of 20 files: every item, mounted or not, through `items` and `getItemChildren`.
const folders = Array.from({ length: 50 }, (_, index) => `folder-${index + 1}`)
const filesOf = (folder: string) =>
  Array.from({ length: 20 }, (_, index) => `${folder}/file-${index + 1}`)
const getItemChildren = (value: string) => (value.includes('/') ? undefined : filesOf(value))
const getLabel = (value: string) =>
  value.includes('/')
    ? `File ${value.split('/file-')[1]}.txt`
    : `Folder ${value.slice('folder-'.length)}`

/** The visible rows, as the app computes them from its data and the open folders. */
const visibleRows = (expanded: readonly string[]) =>
  folders.flatMap((folder) => (expanded.includes(folder) ? [folder, ...filesOf(folder)] : [folder]))

export default function TreeVirtualizedExample() {
  const scrollerRef = React.useRef<HTMLDivElement | null>(null)
  const [scrollTop, setScrollTop] = React.useState(0)
  const [expanded, setExpanded] = React.useState<string[]>(['folder-1'])
  const rows = visibleRows(expanded)
  const first = Math.max(0, Math.floor(scrollTop / ROW) - OVERSCAN)
  const last = Math.min(rows.length, Math.ceil((scrollTop + VIEWPORT) / ROW) + OVERSCAN)
  // The tree asks for the row at `index` to be scrolled into view, then focuses it once it mounts.
  function scrollToIndex(index: number) {
    const scroller = scrollerRef.current
    if (!scroller) {
      return
    }
    const top = index * ROW
    if (top < scroller.scrollTop) {
      scroller.scrollTop = top
    } else if (top + ROW > scroller.scrollTop + scroller.clientHeight) {
      scroller.scrollTop = top + ROW - scroller.clientHeight
    }
    setScrollTop(scroller.scrollTop)
  }

  return (
    <div className="field">
      <div id="tree-virtual-label" className="tree-label">
        1,050 files
      </div>
      <div
        ref={scrollerRef}
        className="tree-scroller"
        onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}
      >
        <Tree.Root
          className="tree tree-virtual"
          aria-labelledby="tree-virtual-label"
          virtualized
          items={folders}
          getItemChildren={getItemChildren}
          itemToStringLabel={getLabel}
          expanded={expanded}
          onExpandedChange={setExpanded}
          onItemHighlighted={(_value: string, { index }: { index: number }) => scrollToIndex(index)}
          style={{ height: rows.length * ROW }}
        >
          {rows.slice(first, last).map((value, offset) => (
            <Tree.Item
              key={value}
              value={value}
              index={first + offset}
              className="tree-item"
              style={{ transform: `translateY(${(first + offset) * ROW}px)` }}
            >
              <Tree.ItemContent className="tree-row">
                <Tree.ItemToggle keepMounted className="tree-toggle">
                  <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
                    <path d="M6 4l4 4-4 4" />
                  </svg>
                </Tree.ItemToggle>
                <Tree.ItemText className="tree-text">
                  {getLabel(value)}
                </Tree.ItemText>
              </Tree.ItemContent>
            </Tree.Item>
          ))}
        </Tree.Root>
      </div>
    </div>
  )
}

API reference

50 props across 8 parts, in React syntax. className, style, render are listed once, under Shared props.

EmptyTree.Empty

Content shown while the tree has no items. Renders nothing otherwise. It has no ARIA role, since only items may sit inside a tree. Renders a <div>.

GroupTree.Group

The children of an item, shown while it is open. Renders a <div>.

Group props
PropTypeDefault
keepMountedWhether to keep the element in the DOM while the item is closed.booleanfalse
hiddenUntilFoundAllows the browser's built-in page search to find and open the group's items.booleanfalse

ItemTree.Item

An item of the tree, with its row (Tree.ItemContent) and its children (Tree.Group). Renders a <div>.

Item props
PropTypeDefault
childrenReact.ReactNode—
valueA unique value that identifies this item.anynull
labelOverrides the text that typeahead matches.string—
disabledWhether the component should ignore user interaction.booleanfalse
selectableWhether the item can be selected.booleantrue
expandableWhether the item is a parent whose Tree.Group is not rendered yet, so expanding it callsbooleanfalse
activeWhether the item is the current one (the page shown), as aria-current="page".booleanfalse
indexVirtualized only: the position of the row among the visible rows.number—
Item data attributes
Data attributeDescription
data-expandedPresent when the item is open.
data-selectedPresent when the item is selected (without cascade).
data-checkedPresent when the item is checked (with cascade).
data-indeterminatePresent when some of the item's descendants are checked, not all (with cascade).
data-disabledPresent when the item is disabled.
data-highlightedPresent when the item is highlighted (holds focus, or the input's active descendant).
data-activePresent when the item is the current one (active).
data-loadingPresent while the item's children load.
data-load-errorPresent when the last load of the item's children failed.
data-leafPresent when the item has no children.
data-levelThe depth of the item, from 1 at the top level.
data-matchPresent when the item matches TreeSelect.Input's query.
data-movingPresent while the item is being moved.
data-drop-targetWhere a dragged item would land: before, after or inside this one.

ItemContentTree.ItemContent

The row of an item: its toggle, indicator and text. Its text names the item. Renders a <div>.

ItemIndicatorTree.ItemIndicator

Shows whether the item is selected or checked. A press toggles the item in a multiple tree. Renders a <span>.

ItemIndicator props
PropTypeDefault
childrenReact.ReactNode—
keepMountedWhether to keep the element in the DOM when the item is not selected.booleanfalse

ItemTextTree.ItemText

The text of an item: what typeahead matches and what TreeSelect.Value shows. Renders a <span>.

ItemToggleTree.ItemToggle

Opens and closes its item without selecting it. Hidden from assistive technology and not focusable: the item's keys do the same. Renders a <span>.

ItemToggle props
PropTypeDefault
keepMountedWhether to render the toggle for leaves too, to align the rows.booleanfalse

RootTree.Root

Groups all parts of the tree: a hierarchical list whose parents expand and collapse., followed by the status region and the form inputs. Renders a <div>.

Root props
PropTypeDefault
valueThe value of the tree.TreeInputValue<Value, Multiple> | null—
defaultValueThe uncontrolled value of the tree when it's initially rendered.TreeInputValue<Value, Multiple> | null—
onValueChangeEvent handler called when the value changes.((value: TreeOutputValue<Value, Multiple>, eventDetails: TreeRootChangeEventDetails) => void)—
multipleWhether multiple items can be selected.Multiplefalse
cascadeWith multiple: checking an item checks its enabled descendants, a parent shows checked,booleanfalse
cascadeValueWhat the value holds under cascade: every checked item ('all'), only the topmostTreeCascadeValue'all'
selectableWhether items can be selected.booleantrue
disallowEmptySelectionWhether the last selected item of a multiple tree cannot be deselected.booleanfalse
expandedThe values of the open items.readonly Value[]—
defaultExpandedThe values of the items open when the tree is initially rendered.readonly Value[]—
onExpandedChangeEvent handler called when items open or close.((expanded: Value[], eventDetails: TreeRootExpandedChangeEventDetails) => void)—
expandOnPressWhether a press on a parent's row toggles it as well as selecting it.booleantrue
loadChildrenCalled when an item whose children are not loaded is expanded (an expandable item without((item: Value, options: { signal: AbortSignal; }) => Promise<void | readonly Value[]>)—
onLoadChildrenErrorCalled when loadChildren rejects.((item: Value, error: unknown) => void)—
itemsThe whole tree as data, mounted or not.readonly Value[]—
getItemChildrenThe children of an item of items: an array (a parent, possibly empty), null (a parent((item: Value) => readonly Value[] | null | undefined)—
virtualizedWhether the app renders only some rows (a virtualizer): the visible rows of items as abooleanfalse
onItemHighlightedVirtualized only: called when navigation is about to move focus to the row at((item: Value, eventDetails: TreeRootHighlightEventDetails) => void)—
itemToStringLabelConverts an item to its text, for typeahead and items that are not mounted.((item: Value) => string)—
itemToStringValueConverts an item value to a string for form submission.((item: Value) => string)—
isItemEqualToValueCustom comparison logic used to determine if an item value matches the current value.((itemValue: Value, value: Value) => boolean)—
isItemDisabledWhether an item of items is disabled, for items that are not mounted.((item: Value) => boolean)—
highlightedValueThe value of the focused item.Value | null—
onHighlightChangeEvent handler called when the focused item changes.((value: Value | null) => void)—
orientationThe orientation of the tree.TreeOrientation'vertical'
loopFocusWhether Down on the last item moves to the first, and Up on the first to the last.booleanfalse
allowMoveWhether items can be moved by dragging their row, or with Ctrl/Cmd+X and Ctrl/Cmd+V.booleanfalse
canMoveWhether a move may happen.((details: TreeMoveDetails<Value>) => boolean)—
onItemMoveCalled with a move, in terms of the tree after it.((details: TreeMoveDetails<Value>, eventDetails: TreeRootItemMoveEventDetails) => void)—
actionsRefA ref to start, complete and cancel a move from the app (a context menu).React.RefObject<TreeRootActions | null>—
disabledWhether the component should ignore user interaction.booleanfalse
readOnlyWhether the user should be unable to change the selection.booleanfalse
requiredWhether the user must choose a value before submitting a form.booleanfalse
nameIdentifies the field when a form is submitted.string—
formIdentifies the form that owns the hidden inputs.string—
inputRefA ref to access the hidden input element.React.Ref<HTMLInputElement>—
labelsThe texts of the status region.Partial<TreeLabels>—
Root data attributes
Data attributeDescription
data-disabledPresent when the tree is disabled.
data-readonlyPresent when the tree does not allow changing the selection.
data-requiredPresent when the tree is required.
data-multiplePresent when the tree allows multiple selected items.
data-cascadePresent when checking an item checks its descendants.
data-orientationIndicates the orientation of the tree.
data-emptyPresent when the tree has no items.
data-movingPresent while a keyboard move is pending.
data-validPresent when the tree is in a valid state (when wrapped in Field.Root).
data-invalidPresent when the tree is in an invalid state (when wrapped in Field.Root).
data-dirtyPresent when the tree's value has changed (when wrapped in Field.Root).
data-touchedPresent when the tree has been touched (when wrapped in Field.Root).
data-filledPresent when the tree has a value (when wrapped in Field.Root).
data-focusedPresent when an item of the tree is focused (when wrapped in Field.Root).

Shared props

Every part that renders an element takes these.

Shared props
PropType
classNameCSS class applied to the element, or a function that returns a class based on the component's state.string | ((state: State) => string | undefined)
styleStyle applied to the element, or a function that returns a style object based on the component's state.React.CSSProperties | ((state: State) => React.CSSProperties | undefined)
renderAllows you to replace the component's HTML element with a different tag, or compose it with another component.React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, State>

Keyboard and accessibility

Keyboard

Keyboard
KeysDoesPart
ArrowDownArrowUpMoves focus to the next or previous visible item, past the children of closed parentsRoot
ArrowRightArrowLeftOpens a closed parent, then moves to its first child; moves to the parent, then closes itItem
HomeEndMoves focus to the first item, or the last visible oneRoot
PageDownPageUpMoves focus ten visible items and stops at the endsRoot
*Opens every closed sibling of the focused itemRoot
Moves focus to the next visible item whose text starts with what was typedRoot
EnterSpaceSelects the focused item; with multiple, toggles itItem
ShiftArrowDownShiftArrowUpWith multiple, extends the selection from the anchorRoot
ControlAMetaAWith multiple, selects the visible enabled items, or clears them when all are selectedRoot
ControlXControlVWith allowMove, takes the item and puts it into the focused parent, or after a focused leafItem
EscapeCancels a move in progressRoot
TabLeaves the tree from any itemRoot

Accessibility

What the parts set
PartSetsSo that
Rootrole="tree", aria-orientation, aria-multiselectable with multiple, aria-required and aria-disabled, never aria-activedescendant; named by aria-label, aria-labelledby or Field.Labelthe tree is read as one widget with its name; a development build warns without one
Itemrole="treeitem" with aria-level, aria-posinset and aria-setsize, from the data when virtualizedeach item says where it is, also when its siblings are not mounted
Itemaria-expanded on a parent only, aria-selected on a selectable item, aria-checked (true, false, mixed) instead with cascadethe state is read with the item; a leaf says nothing about opening
Itemnamed by its ItemContent, not by the text of its Groupan open folder is read as "src", not as every file inside it
Itemaria-busy while loadChildren runs, aria-current="page" with active, aria-disabled on a disabled item and its descendantsloading, the current page and disabled branches are read; a disabled item keeps focus
Grouprole="group" inside its itemthe children belong to their parent
ItemTogglearia-hidden, never focusablethe chevron is for sight; the item's state says it
ItemIndicatoraria-hidden, never focusablethe check is for sight; the item's state says it
Rootone polite role="status" region next to the tree, always mounted"Reports loaded, 2 items", "Reports is empty", a load error, *, Control+A and moves are announced; expanding and selecting are silent unless labels sets their texts

Focus order

  1. The tree is one Tab stop: the active item, or the selected one, or the first enabled item. Nothing takes focus on mount; with the selected item inside a closed parent, that parent takes the stop.
  2. Inside, the arrow keys, Home, End, the page keys and typeahead move focus; Tab leaves the tree from any item, and coming back returns to the last focused item.
  3. Closing the parent of the focused item moves focus to that parent; removing the focused item moves it to the next sibling, else the previous one.
  4. A required, empty tree in a Form gets focus on submit, on its Tab stop item.