Tree
A tree provides users with a way to navigate nested hierarchical information, with support for keyboard navigation and selection.
install | yarn add react-aria-components |
---|---|
version | 1.10.1 |
usage | import {Tree} from 'react-aria-components' |
Example#
This example's MyTreeItemContent is from the Reusable Wrappers section below.
import {Button, Collection, Tree, TreeItem, TreeItemContent} from 'react-aria-components';
<Tree
aria-label="Files"
style={{ height: '300px' }}
defaultExpandedKeys={['documents', 'photos', 'project']}
selectionMode="multiple"
defaultSelectedKeys={['photos']}
>
<TreeItem id="documents" textValue="Documents">
<MyTreeItemContent>
Documents
<Button aria-label="Info">ⓘ</Button>
</MyTreeItemContent>
<TreeItem id="project" textValue="Project">
<MyTreeItemContent>
Project
<Button aria-label="Info">ⓘ</Button>
</MyTreeItemContent>
<TreeItem id="report" textValue="Weekly Report">
<MyTreeItemContent>
Weekly Report
<Button aria-label="Info">ⓘ</Button>
</MyTreeItemContent>
</TreeItem>
</TreeItem>
</TreeItem>
<TreeItem id="photos" textValue="Photos">
<MyTreeItemContent>
Photos
<Button aria-label="Info">ⓘ</Button>
</MyTreeItemContent>
<TreeItem id="one" textValue="Image 1">
<MyTreeItemContent>
Image 1
<Button aria-label="Info">ⓘ</Button>
</MyTreeItemContent>
</TreeItem>
<TreeItem id="two" textValue="Image 2">
<MyTreeItemContent>
Image 2
<Button aria-label="Info">ⓘ</Button>
</MyTreeItemContent>
</TreeItem>
</TreeItem>
</Tree>
import {
Button,
Collection,
Tree,
TreeItem,
TreeItemContent
} from 'react-aria-components';
<Tree
aria-label="Files"
style={{ height: '300px' }}
defaultExpandedKeys={['documents', 'photos', 'project']}
selectionMode="multiple"
defaultSelectedKeys={['photos']}
>
<TreeItem id="documents" textValue="Documents">
<MyTreeItemContent>
Documents
<Button aria-label="Info">ⓘ</Button>
</MyTreeItemContent>
<TreeItem id="project" textValue="Project">
<MyTreeItemContent>
Project
<Button aria-label="Info">ⓘ</Button>
</MyTreeItemContent>
<TreeItem id="report" textValue="Weekly Report">
<MyTreeItemContent>
Weekly Report
<Button aria-label="Info">ⓘ</Button>
</MyTreeItemContent>
</TreeItem>
</TreeItem>
</TreeItem>
<TreeItem id="photos" textValue="Photos">
<MyTreeItemContent>
Photos
<Button aria-label="Info">ⓘ</Button>
</MyTreeItemContent>
<TreeItem id="one" textValue="Image 1">
<MyTreeItemContent>
Image 1
<Button aria-label="Info">ⓘ</Button>
</MyTreeItemContent>
</TreeItem>
<TreeItem id="two" textValue="Image 2">
<MyTreeItemContent>
Image 2
<Button aria-label="Info">ⓘ</Button>
</MyTreeItemContent>
</TreeItem>
</TreeItem>
</Tree>
import {
Button,
Collection,
Tree,
TreeItem,
TreeItemContent
} from 'react-aria-components';
<Tree
aria-label="Files"
style={{
height: '300px'
}}
defaultExpandedKeys={[
'documents',
'photos',
'project'
]}
selectionMode="multiple"
defaultSelectedKeys={[
'photos'
]}
>
<TreeItem
id="documents"
textValue="Documents"
>
<MyTreeItemContent>
Documents
<Button aria-label="Info">
ⓘ
</Button>
</MyTreeItemContent>
<TreeItem
id="project"
textValue="Project"
>
<MyTreeItemContent>
Project
<Button aria-label="Info">
ⓘ
</Button>
</MyTreeItemContent>
<TreeItem
id="report"
textValue="Weekly Report"
>
<MyTreeItemContent>
Weekly Report
<Button aria-label="Info">
ⓘ
</Button>
</MyTreeItemContent>
</TreeItem>
</TreeItem>
</TreeItem>
<TreeItem
id="photos"
textValue="Photos"
>
<MyTreeItemContent>
Photos
<Button aria-label="Info">
ⓘ
</Button>
</MyTreeItemContent>
<TreeItem
id="one"
textValue="Image 1"
>
<MyTreeItemContent>
Image 1
<Button aria-label="Info">
ⓘ
</Button>
</MyTreeItemContent>
</TreeItem>
<TreeItem
id="two"
textValue="Image 2"
>
<MyTreeItemContent>
Image 2
<Button aria-label="Info">
ⓘ
</Button>
</MyTreeItemContent>
</TreeItem>
</TreeItem>
</Tree>
Show CSS
.react-aria-Tree {
display: flex;
flex-direction: column;
gap: 2px;
overflow: auto;
padding: 4px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--overlay-background);
forced-color-adjust: none;
outline: none;
width: 250px;
max-height: 300px;
box-sizing: border-box;
&[data-focus-visible] {
outline: 2px solid var(--focus-ring-color);
outline-offset: -1px;
}
.react-aria-TreeItem {
display: flex;
align-items: center;
gap: 0.571rem;
min-height: 28px;
padding: 0.286rem 0.286rem 0.286rem 0.571rem;
--padding: 16px;
border-radius: 6px;
outline: none;
cursor: default;
color: var(--text-color);
font-size: 1.072rem;
position: relative;
transform: translateZ(0);
.react-aria-Button[slot=chevron] {
all: unset;
display: flex;
visibility: hidden;
align-items: center;
justify-content: center;
width: 1.3rem;
height: 100%;
padding-left: calc((var(--tree-item-level) - 1) * var(--padding));
svg {
rotate: 0deg;
transition: rotate 200ms;
width: 12px;
height: 12px;
fill: none;
stroke: currentColor;
stroke-width: 3px;
}
}
&[data-has-child-items] .react-aria-Button[slot=chevron] {
visibility: visible;
}
&[data-expanded] .react-aria-Button[slot=chevron] svg {
rotate: 90deg;
}
&[data-focus-visible] {
outline: 2px solid var(--focus-ring-color);
outline-offset: -2px;
}
&[data-pressed] {
background: var(--gray-100);
}
&[data-selected] {
background: var(--highlight-background);
color: var(--highlight-foreground);
--focus-ring-color: var(--highlight-foreground);
&[data-focus-visible] {
outline-color: var(--highlight-foreground);
outline-offset: -4px;
}
.react-aria-Button {
color: var(--highlight-foreground);
--highlight-hover: rgb(255 255 255 / 0.1);
--highlight-pressed: rgb(255 255 255 / 0.2);
}
}
&[data-disabled] {
color: var(--text-color-disabled);
}
.react-aria-Button:not([slot]) {
margin-left: auto;
background: transparent;
border: none;
font-size: 1.2rem;
line-height: 1.2em;
padding: 0.286rem 0.429rem;
transition: background 200ms;
&[data-hovered] {
background: var(--highlight-hover);
}
&[data-pressed] {
background: var(--highlight-pressed);
box-shadow: none;
}
}
}
/* join selected items if :has selector is supported */
@supports selector(:has(.foo)) {
gap: 0;
.react-aria-TreeItem[data-selected]:has(+ [data-selected]) {
border-end-start-radius: 0;
border-end-end-radius: 0;
}
.react-aria-TreeItem[data-selected] + [data-selected] {
border-start-start-radius: 0;
border-start-end-radius: 0;
}
}
:where(.react-aria-TreeItem) .react-aria-Checkbox {
--selected-color: var(--highlight-foreground);
--selected-color-pressed: var(--highlight-foreground-pressed);
--checkmark-color: var(--highlight-background);
--background-color: var(--highlight-background);
}
}
.react-aria-Tree {
display: flex;
flex-direction: column;
gap: 2px;
overflow: auto;
padding: 4px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--overlay-background);
forced-color-adjust: none;
outline: none;
width: 250px;
max-height: 300px;
box-sizing: border-box;
&[data-focus-visible] {
outline: 2px solid var(--focus-ring-color);
outline-offset: -1px;
}
.react-aria-TreeItem {
display: flex;
align-items: center;
gap: 0.571rem;
min-height: 28px;
padding: 0.286rem 0.286rem 0.286rem 0.571rem;
--padding: 16px;
border-radius: 6px;
outline: none;
cursor: default;
color: var(--text-color);
font-size: 1.072rem;
position: relative;
transform: translateZ(0);
.react-aria-Button[slot=chevron] {
all: unset;
display: flex;
visibility: hidden;
align-items: center;
justify-content: center;
width: 1.3rem;
height: 100%;
padding-left: calc((var(--tree-item-level) - 1) * var(--padding));
svg {
rotate: 0deg;
transition: rotate 200ms;
width: 12px;
height: 12px;
fill: none;
stroke: currentColor;
stroke-width: 3px;
}
}
&[data-has-child-items] .react-aria-Button[slot=chevron] {
visibility: visible;
}
&[data-expanded] .react-aria-Button[slot=chevron] svg {
rotate: 90deg;
}
&[data-focus-visible] {
outline: 2px solid var(--focus-ring-color);
outline-offset: -2px;
}
&[data-pressed] {
background: var(--gray-100);
}
&[data-selected] {
background: var(--highlight-background);
color: var(--highlight-foreground);
--focus-ring-color: var(--highlight-foreground);
&[data-focus-visible] {
outline-color: var(--highlight-foreground);
outline-offset: -4px;
}
.react-aria-Button {
color: var(--highlight-foreground);
--highlight-hover: rgb(255 255 255 / 0.1);
--highlight-pressed: rgb(255 255 255 / 0.2);
}
}
&[data-disabled] {
color: var(--text-color-disabled);
}
.react-aria-Button:not([slot]) {
margin-left: auto;
background: transparent;
border: none;
font-size: 1.2rem;
line-height: 1.2em;
padding: 0.286rem 0.429rem;
transition: background 200ms;
&[data-hovered] {
background: var(--highlight-hover);
}
&[data-pressed] {
background: var(--highlight-pressed);
box-shadow: none;
}
}
}
/* join selected items if :has selector is supported */
@supports selector(:has(.foo)) {
gap: 0;
.react-aria-TreeItem[data-selected]:has(+ [data-selected]) {
border-end-start-radius: 0;
border-end-end-radius: 0;
}
.react-aria-TreeItem[data-selected] + [data-selected] {
border-start-start-radius: 0;
border-start-end-radius: 0;
}
}
:where(.react-aria-TreeItem) .react-aria-Checkbox {
--selected-color: var(--highlight-foreground);
--selected-color-pressed: var(--highlight-foreground-pressed);
--checkmark-color: var(--highlight-background);
--background-color: var(--highlight-background);
}
}
.react-aria-Tree {
display: flex;
flex-direction: column;
gap: 2px;
overflow: auto;
padding: 4px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--overlay-background);
forced-color-adjust: none;
outline: none;
width: 250px;
max-height: 300px;
box-sizing: border-box;
&[data-focus-visible] {
outline: 2px solid var(--focus-ring-color);
outline-offset: -1px;
}
.react-aria-TreeItem {
display: flex;
align-items: center;
gap: 0.571rem;
min-height: 28px;
padding: 0.286rem 0.286rem 0.286rem 0.571rem;
--padding: 16px;
border-radius: 6px;
outline: none;
cursor: default;
color: var(--text-color);
font-size: 1.072rem;
position: relative;
transform: translateZ(0);
.react-aria-Button[slot=chevron] {
all: unset;
display: flex;
visibility: hidden;
align-items: center;
justify-content: center;
width: 1.3rem;
height: 100%;
padding-left: calc((var(--tree-item-level) - 1) * var(--padding));
svg {
rotate: 0deg;
transition: rotate 200ms;
width: 12px;
height: 12px;
fill: none;
stroke: currentColor;
stroke-width: 3px;
}
}
&[data-has-child-items] .react-aria-Button[slot=chevron] {
visibility: visible;
}
&[data-expanded] .react-aria-Button[slot=chevron] svg {
rotate: 90deg;
}
&[data-focus-visible] {
outline: 2px solid var(--focus-ring-color);
outline-offset: -2px;
}
&[data-pressed] {
background: var(--gray-100);
}
&[data-selected] {
background: var(--highlight-background);
color: var(--highlight-foreground);
--focus-ring-color: var(--highlight-foreground);
&[data-focus-visible] {
outline-color: var(--highlight-foreground);
outline-offset: -4px;
}
.react-aria-Button {
color: var(--highlight-foreground);
--highlight-hover: rgb(255 255 255 / 0.1);
--highlight-pressed: rgb(255 255 255 / 0.2);
}
}
&[data-disabled] {
color: var(--text-color-disabled);
}
.react-aria-Button:not([slot]) {
margin-left: auto;
background: transparent;
border: none;
font-size: 1.2rem;
line-height: 1.2em;
padding: 0.286rem 0.429rem;
transition: background 200ms;
&[data-hovered] {
background: var(--highlight-hover);
}
&[data-pressed] {
background: var(--highlight-pressed);
box-shadow: none;
}
}
}
/* join selected items if :has selector is supported */
@supports selector(:has(.foo)) {
gap: 0;
.react-aria-TreeItem[data-selected]:has(+ [data-selected]) {
border-end-start-radius: 0;
border-end-end-radius: 0;
}
.react-aria-TreeItem[data-selected] + [data-selected] {
border-start-start-radius: 0;
border-start-end-radius: 0;
}
}
:where(.react-aria-TreeItem) .react-aria-Checkbox {
--selected-color: var(--highlight-foreground);
--selected-color-pressed: var(--highlight-foreground-pressed);
--checkmark-color: var(--highlight-background);
--background-color: var(--highlight-background);
}
}
Features#
A tree can be built using the <ul>, <li>,
and <ol>, but is very limited in functionality especially when it comes to user interactions.
HTML lists are meant for static content, rather than hierarchies with rich interactions like focusable elements within cells, keyboard navigation, item selection, sorting, etc.
Tree
helps achieve accessible and interactive tree components that can be styled as needed.
- Item selection – Single or multiple selection, with optional checkboxes, disabled items, and both
toggle
andreplace
selection behaviors. - Interactive children – Tree items may include interactive elements such as buttons, menus, etc.
- Actions – Items support optional actions such as navigation via click, tap, double click, or Enter key.
- Keyboard navigation – Tree items and focusable children can be navigated using the arrow keys, along with page up/down, home/end, etc. Typeahead, auto scrolling, and selection modifier keys are supported as well.
- Drag and drop – Tree supports drag and drop to reorder, move, insert, or update items via mouse, touch, keyboard, and screen reader interactions.
- Virtualized scrolling – Use Virtualizer to improve performance of large lists by rendering only the visible items.
- Touch friendly – Selection and actions adapt their behavior depending on the device. For example, selection is activated via long press on touch when item actions are present.
- Accessible – Follows the ARIA treegrid pattern, with additional selection announcements via an ARIA live region. Extensively tested across many devices and assistive technologies to ensure announcements and behaviors are consistent.
Anatomy#
A Tree consists of a container element, with items containing data inside. The items within a tree may contain focusable elements or plain text content. Each item may also contain a button to toggle the expandable state of that item.
If the tree supports item selection, each item can optionally include a selection checkbox.
import {Button, Checkbox, Tree, TreeItem, TreeItemContent} from 'react-aria-components';
<Tree>
<TreeItem>
<TreeItemContent>
<Button slot="chevron" />
<Checkbox slot="selection" />
<Button slot="drag" />
</TreeItemContent>
<TreeItem>
{/* ... */}
</TreeItem>
</TreeItem>
</Tree>
import {
Button,
Checkbox,
Tree,
TreeItem,
TreeItemContent
} from 'react-aria-components';
<Tree>
<TreeItem>
<TreeItemContent>
<Button slot="chevron" />
<Checkbox slot="selection" />
<Button slot="drag" />
</TreeItemContent>
<TreeItem>
{/* ... */}
</TreeItem>
</TreeItem>
</Tree>
import {
Button,
Checkbox,
Tree,
TreeItem,
TreeItemContent
} from 'react-aria-components';
<Tree>
<TreeItem>
<TreeItemContent>
<Button slot="chevron" />
<Checkbox slot="selection" />
<Button slot="drag" />
</TreeItemContent>
<TreeItem>
{/* ... */}
</TreeItem>
</TreeItem>
</Tree>
Concepts#
Tree
makes use of the following concepts:
Composed components#
A Tree
uses the following components, which may also be used standalone or reused in other components.
Examples#

Starter kits#
To help kick-start your project, we offer starter kits that include example implementations of all React Aria components with various styling solutions. All components are fully styled, including support for dark mode, high contrast mode, and all UI states. Each starter comes with a pre-configured Storybook that you can experiment with, or use as a starting point for your own component library.
Reusable wrappers#
If you will use a Tree in multiple places in your app, you can wrap all of the pieces into a reusable component. This way, the DOM structure, styling code, and other logic are defined in a single place and reused everywhere to ensure consistency.
import type {TreeItemContentProps, TreeItemContentRenderProps} from 'react-aria-components';
import {Button} from 'react-aria-components';
function MyTreeItemContent(
props: Omit<TreeItemContentProps, 'children'> & { children?: React.ReactNode }
) {
return (
<TreeItemContent>
{(
{ hasChildItems, selectionBehavior, selectionMode, allowsDragging }:
TreeItemContentRenderProps
) => (
<>
{allowsDragging && <Button slot="drag">≡</Button>}
{selectionBehavior === 'toggle' && selectionMode !== 'none' && (
<MyCheckbox slot="selection" />
)}
<Button slot="chevron">
<svg viewBox="0 0 24 24">
<path d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
</Button>
{props.children}
</>
)}
</TreeItemContent>
);
}
import type {
TreeItemContentProps,
TreeItemContentRenderProps
} from 'react-aria-components';
import {Button} from 'react-aria-components';
function MyTreeItemContent(
props: Omit<TreeItemContentProps, 'children'> & {
children?: React.ReactNode;
}
) {
return (
<TreeItemContent>
{(
{
hasChildItems,
selectionBehavior,
selectionMode,
allowsDragging
}: TreeItemContentRenderProps
) => (
<>
{allowsDragging && <Button slot="drag">≡</Button>}
{selectionBehavior === 'toggle' &&
selectionMode !== 'none' && (
<MyCheckbox slot="selection" />
)}
<Button slot="chevron">
<svg viewBox="0 0 24 24">
<path d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
</Button>
{props.children}
</>
)}
</TreeItemContent>
);
}
import type {
TreeItemContentProps,
TreeItemContentRenderProps
} from 'react-aria-components';
import {Button} from 'react-aria-components';
function MyTreeItemContent(
props:
& Omit<
TreeItemContentProps,
'children'
>
& {
children?:
React.ReactNode;
}
) {
return (
<TreeItemContent>
{(
{
hasChildItems,
selectionBehavior,
selectionMode,
allowsDragging
}: TreeItemContentRenderProps
) => (
<>
{allowsDragging &&
(
<Button slot="drag">
≡
</Button>
)}
{selectionBehavior ===
'toggle' &&
selectionMode !==
'none' &&
(
<MyCheckbox slot="selection" />
)}
<Button slot="chevron">
<svg viewBox="0 0 24 24">
<path d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
</Button>
{props
.children}
</>
)}
</TreeItemContent>
);
}
The TreeItem
can also be wrapped. This example accepts a title
prop and renders the TreeItemContent
automatically.
import {TreeItemProps} from 'react-aria-components';
interface MyTreeItemProps extends Partial<TreeItemProps> {
title: string
}
function MyTreeItem(props: MyTreeItemProps) {
return (
<TreeItem textValue={props.title} {...props}>
<MyTreeItemContent>
{props.title}
</MyTreeItemContent>
{props.children}
</TreeItem>
);
}
import {TreeItemProps} from 'react-aria-components';
interface MyTreeItemProps extends Partial<TreeItemProps> {
title: string
}
function MyTreeItem(props: MyTreeItemProps) {
return (
<TreeItem textValue={props.title} {...props}>
<MyTreeItemContent>
{props.title}
</MyTreeItemContent>
{props.children}
</TreeItem>
);
}
import {TreeItemProps} from 'react-aria-components';
interface MyTreeItemProps
extends
Partial<
TreeItemProps
> {
title: string;
}
function MyTreeItem(
props: MyTreeItemProps
) {
return (
<TreeItem
textValue={props
.title}
{...props}
>
<MyTreeItemContent>
{props.title}
</MyTreeItemContent>
{props.children}
</TreeItem>
);
}
Now we can render a Tree using far less code.
<Tree
aria-label="Files"
style={{ height: '300px' }}
defaultExpandedKeys={['documents', 'photos', 'project']}
>
<MyTreeItem title="Documents">
<MyTreeItem title="Project">
<MyTreeItem title="Weekly Report" />
</MyTreeItem>
</MyTreeItem>
<MyTreeItem title="Photos">
<MyTreeItem title="Image 1" />
<MyTreeItem title="Image 2" />
</MyTreeItem>
</Tree>
<Tree
aria-label="Files"
style={{ height: '300px' }}
defaultExpandedKeys={['documents', 'photos', 'project']}
>
<MyTreeItem title="Documents">
<MyTreeItem title="Project">
<MyTreeItem title="Weekly Report" />
</MyTreeItem>
</MyTreeItem>
<MyTreeItem title="Photos">
<MyTreeItem title="Image 1" />
<MyTreeItem title="Image 2" />
</MyTreeItem>
</Tree>
<Tree
aria-label="Files"
style={{
height: '300px'
}}
defaultExpandedKeys={[
'documents',
'photos',
'project'
]}
>
<MyTreeItem title="Documents">
<MyTreeItem title="Project">
<MyTreeItem title="Weekly Report" />
</MyTreeItem>
</MyTreeItem>
<MyTreeItem title="Photos">
<MyTreeItem title="Image 1" />
<MyTreeItem title="Image 2" />
</MyTreeItem>
</Tree>
Content#
So far, our examples have shown static collections where the data is hard coded. Dynamic collections, as shown below, can be used when the tree data comes from an external data source such as an API, or updates over time. In the example below, data for each item is provided to the tree via a render function.
import type {TreeProps} from 'react-aria-components';
import {MyCheckbox} from './Checkbox';
let items = [
{id: '1', title: 'Documents', type: 'directory', children: [
{id: '2', title: 'Project', type: 'directory', children: [
{id: '3', title: 'Weekly Report', type: 'file', children: []},
{id: '4', title: 'Budget', type: 'file', children: []}
]}
]},
{id: '5', title: 'Photos', type: 'directory', children: [
{id: '6', title: 'Image 1', type: 'file', children: []},
{id: '7', title: 'Image 2', type: 'file', children: []}
]}
];
interface FileType {
id: string,
title: string,
type: 'directory' | 'file',
children: FileType[]
}
function FileTree(props: TreeProps<FileType>) {
return (
<Tree
aria-label="Files"
defaultExpandedKeys={[1, 4]}
items={items} selectionMode="multiple"
{...props}>
{function renderItem(item) { return (
<TreeItem textValue={item.title}>
<MyTreeItemContent>
{item.title}
<Button
aria-label="Info"
onPress={() => alert(`Info for ...`)}>
ⓘ
</Button>
</MyTreeItemContent>
<Collection items={item.children}>
{/* recursively render children */}
{renderItem} </Collection>
</TreeItem>
);
}}
</Tree>
)
}
import type {TreeProps} from 'react-aria-components';
import {MyCheckbox} from './Checkbox';
let items = [
{
id: '1',
title: 'Documents',
type: 'directory',
children: [
{
id: '2',
title: 'Project',
type: 'directory',
children: [
{
id: '3',
title: 'Weekly Report',
type: 'file',
children: []
},
{
id: '4',
title: 'Budget',
type: 'file',
children: []
}
]
}
]
},
{
id: '5',
title: 'Photos',
type: 'directory',
children: [
{
id: '6',
title: 'Image 1',
type: 'file',
children: []
},
{
id: '7',
title: 'Image 2',
type: 'file',
children: []
}
]
}
];
interface FileType {
id: string;
title: string;
type: 'directory' | 'file';
children: FileType[];
}
function FileTree(props: TreeProps<FileType>) {
return (
<Tree
aria-label="Files"
defaultExpandedKeys={[1, 4]}
items={items} selectionMode="multiple"
{...props}
>
{function renderItem(item) { return (
<TreeItem textValue={item.title}>
<MyTreeItemContent>
{item.title}
<Button
aria-label="Info"
onPress={() =>
alert(`Info for ...`)}
>
ⓘ
</Button>
</MyTreeItemContent>
<Collection items={item.children}>
{/* recursively render children */}
{renderItem} </Collection>
</TreeItem>
);
}}
</Tree>
);
}
import type {TreeProps} from 'react-aria-components';
import {MyCheckbox} from './Checkbox';
let items = [
{
id: '1',
title: 'Documents',
type: 'directory',
children: [
{
id: '2',
title: 'Project',
type:
'directory',
children: [
{
id: '3',
title:
'Weekly Report',
type: 'file',
children: []
},
{
id: '4',
title:
'Budget',
type: 'file',
children: []
}
]
}
]
},
{
id: '5',
title: 'Photos',
type: 'directory',
children: [
{
id: '6',
title: 'Image 1',
type: 'file',
children: []
},
{
id: '7',
title: 'Image 2',
type: 'file',
children: []
}
]
}
];
interface FileType {
id: string;
title: string;
type:
| 'directory'
| 'file';
children: FileType[];
}
function FileTree(
props: TreeProps<
FileType
>
) {
return (
<Tree
aria-label="Files"
defaultExpandedKeys={[
1,
4
]}
items={items} selectionMode="multiple"
{...props}
>
{function renderItem(
item
) { return (
<TreeItem
textValue={item
.title}
>
<MyTreeItemContent>
{item
.title}
<Button
aria-label="Info"
onPress={() =>
alert(
`Info for
...`)}
>
ⓘ
</Button>
</MyTreeItemContent>
<Collection
items={item
.children}
>
{/* recursively render children */}
{renderItem} </Collection>
</TreeItem>
);
}}
</Tree>
);
}
Selection#
Single selection#
By default, Tree
doesn't allow item selection but this can be enabled using the selectionMode
prop. Use defaultSelectedKeys
to provide a default set of selected items.
Note that the value of the selected keys must match the id
prop of the item.
The example below enables single selection mode and uses defaultSelectedKeys
to select the item with id equal to 2
.
A user can click on a different item to change the selection or click on the same item again to deselect it entirely.
// Using the example above
<FileTree
selectionMode="single"
defaultSelectedKeys={[2]}
defaultExpandedKeys={[1]}
/>
// Using the example above
<FileTree
selectionMode="single"
defaultSelectedKeys={[2]}
defaultExpandedKeys={[1]}
/>
// Using the example above
<FileTree
selectionMode="single"
defaultSelectedKeys={[
2
]}
defaultExpandedKeys={[
1
]}
/>
Multiple selection#
Multiple selection can be enabled by setting selectionMode
to multiple
.
// Using the example above
<FileTree
selectionMode="multiple"
defaultSelectedKeys={[2, 4]}
defaultExpandedKeys={[1]}
/>
// Using the example above
<FileTree
selectionMode="multiple"
defaultSelectedKeys={[2, 4]}
defaultExpandedKeys={[1]}
/>
// Using the example above
<FileTree
selectionMode="multiple"
defaultSelectedKeys={[
2,
4
]}
defaultExpandedKeys={[
1
]}
/>
Disallow empty selection#
Tree also supports a disallowEmptySelection
prop which forces the user to have at least one item in the Tree selected at all times.
In this mode, if a single item is selected and the user presses it, it will not be deselected.
// Using the example above
<FileTree
selectionMode="single"
defaultSelectedKeys={[2]}
defaultExpandedKeys={[1]}
disallowEmptySelection
/>
// Using the example above
<FileTree
selectionMode="single"
defaultSelectedKeys={[2]}
defaultExpandedKeys={[1]}
disallowEmptySelection
/>
// Using the example above
<FileTree
selectionMode="single"
defaultSelectedKeys={[
2
]}
defaultExpandedKeys={[
1
]}
disallowEmptySelection
/>
Controlled selection#
To programmatically control item selection, use the selectedKeys
prop paired with the onSelectionChange
callback. The id
prop from the selected items will
be passed into the callback when the item is pressed, allowing you to update state accordingly.
import type {Selection} from 'react-aria-components';
interface Pokemon {
id: number,
name: string,
children?: Pokemon[]
}
interface PokemonEvolutionTreeProps<T> extends TreeProps<T> {
items?: T[],
renderEmptyState?: () => string
}
function PokemonEvolutionTree(
props: PokemonEvolutionTreeProps<Pokemon>
) {
let items: Pokemon[] = props.items ?? [
{id: 1, name: 'Bulbasaur', children: [
{id: 2, name: 'Ivysaur', children: [
{id: 3, name: 'Venusaur'}
]}
]},
{id: 4, name: 'Charmander', children: [
{id: 5, name: 'Charmeleon', children: [
{id: 6, name: 'Charizard'}
]}
]},
{id: 7, name: 'Squirtle', children: [
{id: 8, name: 'Wartortle', children: [
{id: 9, name: 'Blastoise'}
]}
]}
];
let [selectedKeys, setSelectedKeys] =
React.useState<Selection>(new Set());
return (
<Tree
aria-label="Pokemon evolution tree"
style={{height: '300px'}}
items={items}
defaultExpandedKeys={[1, 2]}
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys} {...props}
>
{function renderItem(item) {
return (
<MyTreeItem title={item.name}>
<Collection items={item.children}>
{renderItem}
</Collection>
</MyTreeItem>
);
}}
</Tree>
);
}
<PokemonEvolutionTree selectionMode="multiple" />
import type {Selection} from 'react-aria-components';
interface Pokemon {
id: number;
name: string;
children?: Pokemon[];
}
interface PokemonEvolutionTreeProps<T>
extends TreeProps<T> {
items?: T[];
renderEmptyState?: () => string;
}
function PokemonEvolutionTree(
props: PokemonEvolutionTreeProps<Pokemon>
) {
let items: Pokemon[] = props.items ?? [
{
id: 1,
name: 'Bulbasaur',
children: [
{
id: 2,
name: 'Ivysaur',
children: [
{ id: 3, name: 'Venusaur' }
]
}
]
},
{
id: 4,
name: 'Charmander',
children: [
{
id: 5,
name: 'Charmeleon',
children: [
{ id: 6, name: 'Charizard' }
]
}
]
},
{
id: 7,
name: 'Squirtle',
children: [
{
id: 8,
name: 'Wartortle',
children: [
{ id: 9, name: 'Blastoise' }
]
}
]
}
];
let [selectedKeys, setSelectedKeys] = React.useState<
Selection
>(new Set());
return (
<Tree
aria-label="Pokemon evolution tree"
style={{ height: '300px' }}
items={items}
defaultExpandedKeys={[1, 2]}
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys} ...props}
>
{function renderItem(item) {
return (
<MyTreeItem title={item.name}>
<Collection items={item.children}>
{renderItem}
</Collection>
</MyTreeItem>
);
}}
</Tree>
);
}
<PokemonEvolutionTree selectionMode="multiple" />
import type {Selection} from 'react-aria-components';
interface Pokemon {
id: number;
name: string;
children?: Pokemon[];
}
interface PokemonEvolutionTreeProps<
T
> extends TreeProps<T> {
items?: T[];
renderEmptyState?:
() => string;
}
function PokemonEvolutionTree(
props:
PokemonEvolutionTreeProps<
Pokemon
>
) {
let items: Pokemon[] =
props.items ?? [
{
id: 1,
name:
'Bulbasaur',
children: [
{
id: 2,
name:
'Ivysaur',
children: [
{
id: 3,
name:
'Venusaur'
}
]
}
]
},
{
id: 4,
name:
'Charmander',
children: [
{
id: 5,
name:
'Charmeleon',
children: [
{
id: 6,
name:
'Charizard'
}
]
}
]
},
{
id: 7,
name: 'Squirtle',
children: [
{
id: 8,
name:
'Wartortle',
children: [
{
id: 9,
name:
'Blastoise'
}
]
}
]
}
];
let [
selectedKeys,
setSelectedKeys
] = React.useState<
Selection
>(new Set());
return (
<Tree
aria-label="Pokemon evolution tree"
style={{
height: '300px'
}}
items={items}
defaultExpandedKeys={[
1,
2
]}
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys} ...props}
>
{function renderItem(
item
) {
return (
<MyTreeItem
title={item
.name}
>
<Collection
items={item
.children}
>
{renderItem}
</Collection>
</MyTreeItem>
);
}}
</Tree>
);
}
<PokemonEvolutionTree selectionMode="multiple" />
Selection behavior#
By default, Tree
uses the "toggle"
selection behavior, which behaves like a checkbox group: clicking, tapping, or pressing the Space or Enter keys toggles selection for the focused item. Using the arrow keys moves focus but does not change selection. The "toggle"
selection mode is often paired with checkboxes in each item as an explicit affordance for selection.
When the selectionBehavior
prop is set to "replace"
, clicking an item with the mouse replaces the selection with only that item. Using the arrow keys moves both focus and selection. To select multiple items, modifier keys such as Ctrl, Cmd, and Shift can be used. To move focus without moving selection, the Ctrl key on Windows or the Option key on macOS can be held while pressing the arrow keys. Holding this modifier while pressing the Space key toggles selection for the focused item, which allows multiple selection of non-contiguous items. On touch screen devices, selection always behaves as toggle since modifier keys may not be available. This behavior emulates native platforms such as macOS and Windows and is often used when checkboxes in each item are not desired.
<PokemonEvolutionTree selectionMode="multiple" selectionBehavior="replace" />
<PokemonEvolutionTree
selectionMode="multiple"
selectionBehavior="replace"
/>
<PokemonEvolutionTree
selectionMode="multiple"
selectionBehavior="replace"
/>
Item actions#
Tree
supports item actions via the onAction
prop, which is useful for functionality such as navigation. In the default "toggle"
selection behavior, when nothing is selected, clicking or tapping the item triggers the item action.
When at least one item is selected, the tree is in selection mode, and clicking or tapping an item toggles the selection. Actions may also be triggered via the Enter key, and selection using the Space key.
This behavior is slightly different in the "replace"
selection behavior, where single clicking selects the item and actions are performed via double click. On touch devices, the action becomes the primary tap interaction,
and a long press enters into selection mode, which temporarily swaps the selection behavior to "toggle"
to perform selection (you may wish to display checkboxes when this happens). Deselecting all items exits selection mode
and reverts the selection behavior back to "replace"
. Keyboard behaviors are unaffected.
<div style={{display: 'flex', flexWrap: 'wrap', gap: '24px'}}>
<PokemonEvolutionTree
aria-label="Pokemon tree with item actions and toggle selection behavior"
onAction={key => alert(`Opening item ...`)} selectionMode="multiple" />
<PokemonEvolutionTree
aria-label="Pokemon tree with item actions and replace selection behavior"
onAction={key => alert(`Opening item ...`)}
selectionBehavior="replace" selectionMode="multiple" />
</div>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '24px'
}}
>
<PokemonEvolutionTree
aria-label="Pokemon tree with item actions and toggle selection behavior"
onAction={(key) => alert(`Opening item ...`)} selectionMode="multiple"
/>
<PokemonEvolutionTree
aria-label="Pokemon tree with item actions and replace selection behavior"
onAction={(key) => alert(`Opening item ...`)}
selectionBehavior="replace" selectionMode="multiple"
/>
</div>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '24px'
}}
>
<PokemonEvolutionTree
aria-label="Pokemon tree with item actions and toggle selection behavior"
onAction={(key) =>
alert(
`Opening item
...`)} selectionMode="multiple"
/>
<PokemonEvolutionTree
aria-label="Pokemon tree with item actions and replace selection behavior"
onAction={(key) =>
alert(
`Opening item
...`)}
selectionBehavior="replace" selectionMode="multiple"
/>
</div>
Items may also have an action specified by directly applying onAction
on the TreeItem
itself. This may be especially convenient in static collections. If onAction
is also provided to the Tree
, both the tree's and the item's onAction
are called.
<Tree
aria-label="Tree with onAction applied on the items directly"
style={{ height: '300px' }}
defaultExpandedKeys={['bulbasaur', 'ivysaur']}
>
<MyTreeItem
onAction={() => alert(`Opening Bulbasaur...`)} id="bulbasaur"
title="Bulbasaur"
>
<MyTreeItem
onAction={() => alert(`Opening Ivysaur...`)}
id="ivysaur"
title="Ivysaur"
>
<MyTreeItem
onAction={() => alert(`Opening Venusaur...`)}
id="venusaur"
title="Venusaur"
/>
</MyTreeItem>
</MyTreeItem>
</Tree>
<Tree
aria-label="Tree with onAction applied on the items directly"
style={{ height: '300px' }}
defaultExpandedKeys={['bulbasaur', 'ivysaur']}
>
<MyTreeItem
onAction={() => alert(`Opening Bulbasaur...`)} id="bulbasaur"
title="Bulbasaur"
>
<MyTreeItem
onAction={() => alert(`Opening Ivysaur...`)}
id="ivysaur"
title="Ivysaur"
>
<MyTreeItem
onAction={() => alert(`Opening Venusaur...`)}
id="venusaur"
title="Venusaur"
/>
</MyTreeItem>
</MyTreeItem>
</Tree>
<Tree
aria-label="Tree with onAction applied on the items directly"
style={{
height: '300px'
}}
defaultExpandedKeys={[
'bulbasaur',
'ivysaur'
]}
>
<MyTreeItem
onAction={() =>
alert(
`Opening Bulbasaur...`
)} id="bulbasaur"
title="Bulbasaur"
>
<MyTreeItem
onAction={() =>
alert(
`Opening Ivysaur...`
)}
id="ivysaur"
title="Ivysaur"
>
<MyTreeItem
onAction={() =>
alert(
`Opening Venusaur...`
)}
id="venusaur"
title="Venusaur"
/>
</MyTreeItem>
</MyTreeItem>
</Tree>
Links#
Tree items may also be links to another page or website. This can be achieved by passing the href
prop to the <TreeItem>
component. Links behave the same way as described above for item actions depending on the selectionMode
and selectionBehavior
.
<Tree
aria-label="Tree with onAction applied on the items directly"
style={{ height: '200px' }}
defaultExpandedKeys={['bulbasaur', 'ivysaur']}
>
<MyTreeItem
href="https://pokemondb.net/pokedex/bulbasaur"
target="_blank" id="bulbasaur"
title="Bulbasaur"
>
<MyTreeItem
id="ivysaur"
title="Ivysaur"
href="https://pokemondb.net/pokedex/ivysaur"
target="_blank"
>
<MyTreeItem
id="venusaur"
title="Venusaur"
href="https://pokemondb.net/pokedex/venusaur"
target="_blank"
/>
</MyTreeItem>
</MyTreeItem>
</Tree>
<Tree
aria-label="Tree with onAction applied on the items directly"
style={{ height: '200px' }}
defaultExpandedKeys={['bulbasaur', 'ivysaur']}
>
<MyTreeItem
href="https://pokemondb.net/pokedex/bulbasaur"
target="_blank" id="bulbasaur"
title="Bulbasaur"
>
<MyTreeItem
id="ivysaur"
title="Ivysaur"
href="https://pokemondb.net/pokedex/ivysaur"
target="_blank"
>
<MyTreeItem
id="venusaur"
title="Venusaur"
href="https://pokemondb.net/pokedex/venusaur"
target="_blank"
/>
</MyTreeItem>
</MyTreeItem>
</Tree>
<Tree
aria-label="Tree with onAction applied on the items directly"
style={{
height: '200px'
}}
defaultExpandedKeys={[
'bulbasaur',
'ivysaur'
]}
>
<MyTreeItem
href="https://pokemondb.net/pokedex/bulbasaur"
target="_blank" id="bulbasaur"
title="Bulbasaur"
>
<MyTreeItem
id="ivysaur"
title="Ivysaur"
href="https://pokemondb.net/pokedex/ivysaur"
target="_blank"
>
<MyTreeItem
id="venusaur"
title="Venusaur"
href="https://pokemondb.net/pokedex/venusaur"
target="_blank"
/>
</MyTreeItem>
</MyTreeItem>
</Tree>
Client side routing#
The <TreeItem>
component works with frameworks and client side routers like Next.js and React Router. As with other React Aria components that support links, this works via the RouterProvider
component at the root of your app. See the client side routing guide to learn how to set this up.
Disabled items#
A TreeItem
can be disabled with the isDisabled
prop. This will disable all interactions on the item
unless the disabledBehavior
prop on Tree
is used to change this behavior.
Note that you are responsible for the styling of disabled items, however, the selection checkbox will be automatically disabled.
<Tree
aria-label="Tree with disabled items"
style={{ height: '100px' }}
defaultExpandedKeys={['bulbasaur']}
>
<MyTreeItem id="bulbasaur" title="Bulbasaur">
<MyTreeItem id="ivysaur" title="Ivysaur" isDisabled> <MyTreeItem id="venusaur" title="Venusaur" />
</MyTreeItem>
</MyTreeItem>
</Tree>
<Tree
aria-label="Tree with disabled items"
style={{ height: '100px' }}
defaultExpandedKeys={['bulbasaur']}
>
<MyTreeItem id="bulbasaur" title="Bulbasaur">
<MyTreeItem id="ivysaur" title="Ivysaur" isDisabled> <MyTreeItem id="venusaur" title="Venusaur" />
</MyTreeItem>
</MyTreeItem>
</Tree>
<Tree
aria-label="Tree with disabled items"
style={{
height: '100px'
}}
defaultExpandedKeys={[
'bulbasaur'
]}
>
<MyTreeItem
id="bulbasaur"
title="Bulbasaur"
>
<MyTreeItem
id="ivysaur"
title="Ivysaur"
isDisabled
> <MyTreeItem
id="venusaur"
title="Venusaur"
/>
</MyTreeItem>
</MyTreeItem>
</Tree>
When disabledBehavior
is set to selection
, interactions such as focus, dragging, or actions can still be performed on disabled rows.
<Tree
aria-label="Tree with disabled items"
style={{height: '100px'}}
selectionMode="multiple"
defaultExpandedKeys={['bulbasaur']}
disabledBehavior="selection">
<MyTreeItem id="bulbasaur" title="Bulbasaur">
<MyTreeItem id="ivysaur" title="Ivysaur" isDisabled> <MyTreeItem id="venusaur" title="Venusaur" />
</MyTreeItem>
</MyTreeItem>
</Tree>
<Tree
aria-label="Tree with disabled items"
style={{height: '100px'}}
selectionMode="multiple"
defaultExpandedKeys={['bulbasaur']}
disabledBehavior="selection">
<MyTreeItem id="bulbasaur" title="Bulbasaur">
<MyTreeItem id="ivysaur" title="Ivysaur" isDisabled> <MyTreeItem id="venusaur" title="Venusaur" />
</MyTreeItem>
</MyTreeItem>
</Tree>
<Tree
aria-label="Tree with disabled items"
style={{
height: '100px'
}}
selectionMode="multiple"
defaultExpandedKeys={[
'bulbasaur'
]}
disabledBehavior="selection">
<MyTreeItem
id="bulbasaur"
title="Bulbasaur"
>
<MyTreeItem
id="ivysaur"
title="Ivysaur"
isDisabled
> <MyTreeItem
id="venusaur"
title="Venusaur"
/>
</MyTreeItem>
</MyTreeItem>
</Tree>
In dynamic collections, it may be more convenient to use the disabledKeys
prop at the Tree
level instead of isDisabled
on individual items.
This accepts a list of item ids that are disabled. An item is considered disabled if its key exists in disabledKeys
or if it has isDisabled
.
// Using the same tree as above
<PokemonEvolutionTree selectionMode="multiple" disabledKeys={[3]} />
// Using the same tree as above
<PokemonEvolutionTree
selectionMode="multiple"
disabledKeys={[3]}
/>
// Using the same tree as above
<PokemonEvolutionTree
selectionMode="multiple"
disabledKeys={[3]}
/>
Empty state#
Use the renderEmptyState
prop to customize what the Tree
will display if there are no items.
<Tree
aria-label="Search results"
renderEmptyState={() => 'No results found.'}
style={{ height: '100px' }}
>
{[]}
</Tree>
<Tree
aria-label="Search results"
renderEmptyState={() => 'No results found.'}
style={{ height: '100px' }}
>
{[]}
</Tree>
<Tree
aria-label="Search results"
renderEmptyState={() =>
'No results found.'}
style={{
height: '100px'
}}
>
{[]}
</Tree>
Show CSS
.react-aria-Tree {
&[data-empty] {
display: flex;
align-items: center;
justify-content: center;
font-style: italic;
}
}
.react-aria-Tree {
&[data-empty] {
display: flex;
align-items: center;
justify-content: center;
font-style: italic;
}
}
.react-aria-Tree {
&[data-empty] {
display: flex;
align-items: center;
justify-content: center;
font-style: italic;
}
}
Drag and drop beta#
Tree supports drag and drop interactions when the dragAndDropHooks
prop is provided using the useDragAndDrop
hook. Users can drop data on the tree as a whole, on individual items, insert new items between existing ones, or move items within the tree hierarchy.
React Aria supports traditional mouse and touch based drag and drop, but also implements keyboard and screen reader friendly interactions. Users can press Enter on a draggable element to enter drag and drop mode. Then, they can press Tab to navigate between drop targets. A droppable collection is treated as a single drop target, so that users can easily tab past it to get to the next drop target. Within a droppable tree, keys such as ArrowDown and ArrowUp can be used to select a drop position, such as on an item, or between items.
Items with children can be expanded or collapsed during keyboard drag and drop mode by using the ArrowRight and ArrowLeft keys, or the Option (Alt on Windows) + Enter keys. Screen reader users can expand and collapse items with children by navigating to and activating a visually hidden button that gets announced.
Draggable tree items must include a focusable drag handle using a <Button slot="drag">
. This enables keyboard and screen reader users to initiate drag and drop. The MyTreeItemContent
component defined in the reusable wrappers section above can be extended to include this automatically when the tree allows dragging.
See the drag and drop introduction to learn more.
Moving between levels#
The onMove
event handler allows reordering items within a level, as well as moving items to a different level. It supports dropping both on and between items. The getItems
function must also be implemented for items to become draggable. See below for more details.
This uses useTreeData from React Stately to manage the tree data. Note that useTreeData
is a convenience hook, not a requirement. You can manage your state however you wish.
function Example() {
let tree = useTreeData({
initialItems: items
});
let { dragAndDropHooks } = useDragAndDrop({
getItems: (keys) =>
[...keys].map((key) => ({
'text/plain': tree.getItem(key).value.title
})),
onMove(e) {
if (e.target.dropPosition === 'before') {
tree.moveBefore(e.target.key, e.keys);
} else if (e.target.dropPosition === 'after') {
tree.moveAfter(e.target.key, e.keys);
} else if (e.target.dropPosition === 'on') {
// Move items to become children of the target
let targetNode = tree.getItem(e.target.key);
if (targetNode) {
let targetIndex = targetNode.children
? targetNode.children.length
: 0;
let keyArray = Array.from(e.keys);
for (let i = 0; i < keyArray.length; i++) {
tree.move(keyArray[i], e.target.key, targetIndex + i);
}
}
}
} });
return (
<Tree
aria-label="Tree with hierarchical drag and drop"
selectionMode="multiple"
items={tree.items}
dragAndDropHooks={dragAndDropHooks}
>
{function renderItem(item) {
return (
<MyTreeItem title={item.value.title}>
<Collection items={item.children}>
{renderItem}
</Collection>
</MyTreeItem>
);
}}
</Tree>
);
}
function Example() {
let tree = useTreeData({
initialItems: items
});
let { dragAndDropHooks } = useDragAndDrop({
getItems: (keys) =>
[...keys].map((key) => ({
'text/plain': tree.getItem(key).value.title
})),
onMove(e) {
if (e.target.dropPosition === 'before') {
tree.moveBefore(e.target.key, e.keys);
} else if (e.target.dropPosition === 'after') {
tree.moveAfter(e.target.key, e.keys);
} else if (e.target.dropPosition === 'on') {
// Move items to become children of the target
let targetNode = tree.getItem(e.target.key);
if (targetNode) {
let targetIndex = targetNode.children
? targetNode.children.length
: 0;
let keyArray = Array.from(e.keys);
for (let i = 0; i < keyArray.length; i++) {
tree.move(
keyArray[i],
e.target.key,
targetIndex + i
);
}
}
}
} });
return (
<Tree
aria-label="Tree with hierarchical drag and drop"
selectionMode="multiple"
items={tree.items}
dragAndDropHooks={dragAndDropHooks}
>
{function renderItem(item) {
return (
<MyTreeItem title={item.value.title}>
<Collection items={item.children}>
{renderItem}
</Collection>
</MyTreeItem>
);
}}
</Tree>
);
}
function Example() {
let tree = useTreeData(
{
initialItems: items
}
);
let {
dragAndDropHooks
} = useDragAndDrop({
getItems: (keys) =>
[...keys].map(
(key) => ({
'text/plain':
tree.getItem(
key
).value.title
})
),
onMove(e) {
if (
e.target
.dropPosition ===
'before'
) {
tree.moveBefore(
e.target.key,
e.keys
);
} else if (
e.target
.dropPosition ===
'after'
) {
tree.moveAfter(
e.target.key,
e.keys
);
} else if (
e.target
.dropPosition ===
'on'
) {
// Move items to become children of the target
let targetNode =
tree.getItem(
e.target.key
);
if (targetNode) {
let targetIndex =
targetNode
.children
? targetNode
.children
.length
: 0;
let keyArray =
Array.from(
e.keys
);
for (
let i = 0;
i <
keyArray
.length;
i++
) {
tree.move(
keyArray[
i
],
e.target
.key,
targetIndex +
i
);
}
}
}
} });
return (
<Tree
aria-label="Tree with hierarchical drag and drop"
selectionMode="multiple"
items={tree.items}
dragAndDropHooks={dragAndDropHooks}
>
{function renderItem(
item
) {
return (
<MyTreeItem
title={item
.value
.title}
>
<Collection
items={item
.children}
>
{renderItem}
</Collection>
</MyTreeItem>
);
}}
</Tree>
);
}
Reordering within a level#
The onReorder
event handler allows reordering items within the same level. Unlike onMove
it does not allow moving items to a different level.
import {useTreeData} from 'react-stately';
import {Button, useDragAndDrop} from 'react-aria-components';
function Example() {
let tree = useTreeData({
initialItems: items
});
let { dragAndDropHooks } = useDragAndDrop({
getItems: (keys) =>
[...keys].map((key) => ({
'text/plain': tree.getItem(key).value.title
})),
onReorder(e) {
if (e.target.dropPosition === 'before') {
tree.moveBefore(e.target.key, e.keys);
} else if (e.target.dropPosition === 'after') {
tree.moveAfter(e.target.key, e.keys);
}
}
});
return (
<Tree
aria-label="Reorderable tree"
selectionMode="multiple"
items={tree.items}
dragAndDropHooks={dragAndDropHooks} >
{function renderItem(item) {
return (
<MyTreeItem title={item.value.title}>
<Collection items={item.children}>
{renderItem}
</Collection>
</MyTreeItem>
);
}}
</Tree>
);
}
import {useTreeData} from 'react-stately';
import {
Button,
useDragAndDrop
} from 'react-aria-components';
function Example() {
let tree = useTreeData({
initialItems: items
});
let { dragAndDropHooks } = useDragAndDrop({
getItems: (keys) =>
[...keys].map((key) => ({
'text/plain': tree.getItem(key).value.title
})),
onReorder(e) {
if (e.target.dropPosition === 'before') {
tree.moveBefore(e.target.key, e.keys);
} else if (e.target.dropPosition === 'after') {
tree.moveAfter(e.target.key, e.keys);
}
}
});
return (
<Tree
aria-label="Reorderable tree"
selectionMode="multiple"
items={tree.items}
dragAndDropHooks={dragAndDropHooks} >
{function renderItem(item) {
return (
<MyTreeItem title={item.value.title}>
<Collection items={item.children}>
{renderItem}
</Collection>
</MyTreeItem>
);
}}
</Tree>
);
}
import {useTreeData} from 'react-stately';
import {
Button,
useDragAndDrop
} from 'react-aria-components';
function Example() {
let tree = useTreeData(
{
initialItems: items
}
);
let {
dragAndDropHooks
} = useDragAndDrop({
getItems: (keys) =>
[...keys].map(
(key) => ({
'text/plain':
tree.getItem(
key
).value.title
})
),
onReorder(e) {
if (
e.target
.dropPosition ===
'before'
) {
tree.moveBefore(
e.target.key,
e.keys
);
} else if (
e.target
.dropPosition ===
'after'
) {
tree.moveAfter(
e.target.key,
e.keys
);
}
}
});
return (
<Tree
aria-label="Reorderable tree"
selectionMode="multiple"
items={tree.items}
dragAndDropHooks={dragAndDropHooks} >
{function renderItem(
item
) {
return (
<MyTreeItem
title={item
.value
.title}
>
<Collection
items={item
.children}
>
{renderItem}
</Collection>
</MyTreeItem>
);
}}
</Tree>
);
}
Show CSS
.react-aria-TreeItem {
&[data-allows-dragging] {
padding-left: 4px;
}
&[data-dragging] {
opacity: 0.6;
}
&[data-drop-target] {
outline: 2px solid var(--highlight-background);
background: var(--highlight-overlay);
}
[slot=drag] {
all: unset;
width: 15px;
text-align: center;
&[data-focus-visible] {
border-radius: 4px;
outline: 2px solid var(--focus-ring-color);
}
}
}
.react-aria-Tree {
&[data-selection-mode=multiple] {
--checkbox-width: 28px;
}
&[data-allows-dragging] {
--drag-button-width: 23px;
}
.react-aria-DropIndicator {
&[data-drop-target] {
outline: 1px solid var(--highlight-background);
margin-left: calc(8px + var(--checkbox-width, 0px) + var(--drag-button-width, 0px) + 26px + (var(--tree-item-level) - 1) * 16px);
}
}
}
.react-aria-TreeItem {
&[data-allows-dragging] {
padding-left: 4px;
}
&[data-dragging] {
opacity: 0.6;
}
&[data-drop-target] {
outline: 2px solid var(--highlight-background);
background: var(--highlight-overlay);
}
[slot=drag] {
all: unset;
width: 15px;
text-align: center;
&[data-focus-visible] {
border-radius: 4px;
outline: 2px solid var(--focus-ring-color);
}
}
}
.react-aria-Tree {
&[data-selection-mode=multiple] {
--checkbox-width: 28px;
}
&[data-allows-dragging] {
--drag-button-width: 23px;
}
.react-aria-DropIndicator {
&[data-drop-target] {
outline: 1px solid var(--highlight-background);
margin-left: calc(8px + var(--checkbox-width, 0px) + var(--drag-button-width, 0px) + 26px + (var(--tree-item-level) - 1) * 16px);
}
}
}
.react-aria-TreeItem {
&[data-allows-dragging] {
padding-left: 4px;
}
&[data-dragging] {
opacity: 0.6;
}
&[data-drop-target] {
outline: 2px solid var(--highlight-background);
background: var(--highlight-overlay);
}
[slot=drag] {
all: unset;
width: 15px;
text-align: center;
&[data-focus-visible] {
border-radius: 4px;
outline: 2px solid var(--focus-ring-color);
}
}
}
.react-aria-Tree {
&[data-selection-mode=multiple] {
--checkbox-width: 28px;
}
&[data-allows-dragging] {
--drag-button-width: 23px;
}
.react-aria-DropIndicator {
&[data-drop-target] {
outline: 1px solid var(--highlight-background);
margin-left: calc(8px + var(--checkbox-width, 0px) + var(--drag-button-width, 0px) + 26px + (var(--tree-item-level) - 1) * 16px);
}
}
}
Custom drag preview#
By default, the drag preview shown under the user's pointer or finger is a copy of the original element that started the drag. A custom preview can be rendered by implementing the renderDragPreview
function, passed to useDragAndDrop
. This receives the dragged data that was returned by getItems
, and returns a rendered preview for those items.
This example renders a custom drag preview which shows the number of items being dragged.
function Example() {
let tree = useTreeData({
initialItems: items
});
let {dragAndDropHooks} = useDragAndDrop({
getItems: (keys) => [...keys].map(key => ({
'text/plain': tree.getItem(key).value.title
})),
renderDragPreview(items) {
return (
<div className="drag-preview">
{items[0]['text/plain']}
<span className="badge">{items.length}</span>
</div>
);
} });
return (
<Tree
aria-label="Tree with custom drag preview"
selectionMode="multiple"
items={tree.items}
dragAndDropHooks={dragAndDropHooks}
>
{function renderItem(item) {
return (
<MyTreeItem title={item.value.title}>
<Collection items={item.children}>
{renderItem}
</Collection>
</MyTreeItem>
)
}}
</Tree>
);
}
function Example() {
let tree = useTreeData({
initialItems: items
});
let {dragAndDropHooks} = useDragAndDrop({
getItems: (keys) => [...keys].map(key => ({
'text/plain': tree.getItem(key).value.title
})),
renderDragPreview(items) {
return (
<div className="drag-preview">
{items[0]['text/plain']}
<span className="badge">{items.length}</span>
</div>
);
} });
return (
<Tree
aria-label="Tree with custom drag preview"
selectionMode="multiple"
items={tree.items}
dragAndDropHooks={dragAndDropHooks}
>
{function renderItem(item) {
return (
<MyTreeItem title={item.value.title}>
<Collection items={item.children}>
{renderItem}
</Collection>
</MyTreeItem>
)
}}
</Tree>
);
}
function Example() {
let tree = useTreeData(
{
initialItems: items
}
);
let {
dragAndDropHooks
} = useDragAndDrop({
getItems: (keys) =>
[...keys].map(
(key) => ({
'text/plain':
tree.getItem(
key
).value.title
})
),
renderDragPreview(
items
) {
return (
<div className="drag-preview">
{items[0][
'text/plain'
]}
<span className="badge">
{items
.length}
</span>
</div>
);
} });
return (
<Tree
aria-label="Tree with custom drag preview"
selectionMode="multiple"
items={tree.items}
dragAndDropHooks={dragAndDropHooks}
>
{function renderItem(
item
) {
return (
<MyTreeItem
title={item
.value
.title}
>
<Collection
items={item
.children}
>
{renderItem}
</Collection>
</MyTreeItem>
);
}}
</Tree>
);
}
Show CSS
.drag-preview {
padding: 4px 8px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
background: var(--highlight-background);
color: var(--highlight-foreground);
border-radius: 4px;
.badge {
background: var(--highlight-foreground);
color: var(--highlight-background);
padding: 0 8px;
border-radius: 4px;
}
}
.drag-preview {
padding: 4px 8px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
background: var(--highlight-background);
color: var(--highlight-foreground);
border-radius: 4px;
.badge {
background: var(--highlight-foreground);
color: var(--highlight-background);
padding: 0 8px;
border-radius: 4px;
}
}
.drag-preview {
padding: 4px 8px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
background: var(--highlight-background);
color: var(--highlight-foreground);
border-radius: 4px;
.badge {
background: var(--highlight-foreground);
color: var(--highlight-background);
padding: 0 8px;
border-radius: 4px;
}
}
Drag data#
Data for draggable items can be provided in multiple formats at once. This allows drop targets to choose data in a format that they understand. For example, you could serialize a complex object as JSON in a custom format for use within your own application, and also provide plain text and/or rich HTML fallbacks that can be used when a user drops data in an external application (e.g. an email message).
This can be done by returning multiple keys for an item from the getItems
function. Types can either be a standard mime type for interoperability with external applications, or a custom string for use within your own app.
This example provides representations of each item as plain text, HTML, and a custom app-specific data format. Dropping on the drop targets in this page will use the custom data format to render formatted items. If you drop in an external application supporting rich text, the HTML representation will be used. Dropping in a text editor will use the plain text format.
function DraggableTree() {
let tree = useTreeData({
initialItems: items
});
let {dragAndDropHooks} = useDragAndDrop({
getItems(keys) {
return [...keys].map(key => {
let item = tree.getItem(key).value;
return {
'text/plain': ` `,
'text/html': `<strong> </strong>`,
'custom-app-type': JSON.stringify(item)
};
});
}, renderDragPreview(items) {
return (
<div className="drag-preview">
{items[0]['text/plain']}
<span className="badge">{items.length}</span>
</div>
);
}
});
return (
<Tree
aria-label="Draggable tree"
selectionMode="multiple"
items={tree.items}
dragAndDropHooks={dragAndDropHooks}
>
{function renderItem(item) {
return (
<MyTreeItem title={item.value.title}>
<Collection items={item.children}>
{renderItem}
</Collection>
</MyTreeItem>
)
}}
</Tree>
);
}
<div style={{display: 'flex', gap: 12, flexWrap: 'wrap'}}>
<DraggableTree />
{/* see below */}
<DroppableTree />
</div>
function DraggableTree() {
let tree = useTreeData({
initialItems: items
});
let {dragAndDropHooks} = useDragAndDrop({
getItems(keys) {
return [...keys].map(key => {
let item = tree.getItem(key).value;
return {
'text/plain': ` `,
'text/html': `<strong> </strong>`,
'custom-app-type': JSON.stringify(item)
};
});
}, renderDragPreview(items) {
return (
<div className="drag-preview">
{items[0]['text/plain']}
<span className="badge">{items.length}</span>
</div>
);
}
});
return (
<Tree
aria-label="Draggable tree"
selectionMode="multiple"
items={tree.items}
dragAndDropHooks={dragAndDropHooks}
>
{function renderItem(item) {
return (
<MyTreeItem title={item.value.title}>
<Collection items={item.children}>
{renderItem}
</Collection>
</MyTreeItem>
)
}}
</Tree>
);
}
<div style={{display: 'flex', gap: 12, flexWrap: 'wrap'}}>
<DraggableTree />
{/* see below */}
<DroppableTree />
</div>
function DraggableTree() {
let tree = useTreeData(
{
initialItems: items
}
);
let {
dragAndDropHooks
} = useDragAndDrop({
getItems(keys) {
return [...keys]
.map((key) => {
let item =
tree.getItem(
key
).value;
return {
'text/plain':
` `,
'text/html':
`<strong> </strong>`,
'custom-app-type':
JSON
.stringify(
item
)
};
});
}, renderDragPreview(
items
) {
return (
<div className="drag-preview">
{items[0][
'text/plain'
]}
<span className="badge">
{items
.length}
</span>
</div>
);
}
});
return (
<Tree
aria-label="Draggable tree"
selectionMode="multiple"
items={tree.items}
dragAndDropHooks={dragAndDropHooks}
>
{function renderItem(
item
) {
return (
<MyTreeItem
title={item
.value
.title}
>
<Collection
items={item
.children}
>
{renderItem}
</Collection>
</MyTreeItem>
);
}}
</Tree>
);
}
<div
style={{
display: 'flex',
gap: 12,
flexWrap: 'wrap'
}}
>
<DraggableTree />
{/* see below */}
<DroppableTree />
</div>
Dropping on the collection#
Dropping on the Tree as a whole can be enabled using the onRootDrop
event. When a valid drag hovers over the Tree, it receives the isDropTarget
state and can be styled using the [data-drop-target]
CSS selector.
import {isTextDropItem} from 'react-aria-components';
interface DroppableItem {
id: string | number;
title: string;
children?: DroppableItem[];
}
function Example() {
let [items, setItems] = React.useState<any[]>([]);
let {dragAndDropHooks} = useDragAndDrop({
async onRootDrop(e) {
let items = await Promise.all(
e.items
.filter(isTextDropItem)
.map(async item => JSON.parse(await item.getText('custom-app-type')))
);
setItems(items);
} });
return (
<div style={{display: 'flex', gap: 12, flexWrap: 'wrap'}}>
<DraggableTree />
<Tree
aria-label="Drop target tree"
items={items}
dragAndDropHooks={dragAndDropHooks}
renderEmptyState={() => 'Drop items here'}
>
{function renderItem(item: DroppableItem) {
return (
<MyTreeItem title={item.title}>
<Collection items={item.children}>
{renderItem}
</Collection>
</MyTreeItem>
)
}}
</Tree>
</div>
);
}
import {isTextDropItem} from 'react-aria-components';
interface DroppableItem {
id: string | number;
title: string;
children?: DroppableItem[];
}
function Example() {
let [items, setItems] = React.useState<any[]>([]);
let { dragAndDropHooks } = useDragAndDrop({
async onRootDrop(e) {
let items = await Promise.all(
e.items
.filter(isTextDropItem)
.map(async (item) =>
JSON.parse(
await item.getText('custom-app-type')
)
)
);
setItems(items);
} });
return (
<div
style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}
>
<DraggableTree />
<Tree
aria-label="Drop target tree"
items={items}
dragAndDropHooks={dragAndDropHooks}
renderEmptyState={() => 'Drop items here'}
>
{function renderItem(item: DroppableItem) {
return (
<MyTreeItem title={item.title}>
<Collection items={item.children}>
{renderItem}
</Collection>
</MyTreeItem>
);
}}
</Tree>
</div>
);
}
import {isTextDropItem} from 'react-aria-components';
interface DroppableItem {
id: string | number;
title: string;
children?:
DroppableItem[];
}
function Example() {
let [items, setItems] =
React.useState<
any[]
>([]);
let {
dragAndDropHooks
} = useDragAndDrop({
async onRootDrop(e) {
let items =
await Promise
.all(
e.items
.filter(
isTextDropItem
)
.map(
async (item) =>
JSON
.parse(
await item
.getText(
'custom-app-type'
)
)
)
);
setItems(items);
} });
return (
<div
style={{
display: 'flex',
gap: 12,
flexWrap: 'wrap'
}}
>
<DraggableTree />
<Tree
aria-label="Drop target tree"
items={items}
dragAndDropHooks={dragAndDropHooks}
renderEmptyState={() =>
'Drop items here'}
>
{function renderItem(
item:
DroppableItem
) {
return (
<MyTreeItem
title={item
.title}
>
<Collection
items={item
.children}
>
{renderItem}
</Collection>
</MyTreeItem>
);
}}
</Tree>
</div>
);
}
Show CSS
.react-aria-Tree[data-drop-target] {
outline: 2px solid var(--highlight-background);
outline-offset: -1px;
background: var(--highlight-overlay);
}
.react-aria-Tree[data-drop-target] {
outline: 2px solid var(--highlight-background);
outline-offset: -1px;
background: var(--highlight-overlay);
}
.react-aria-Tree[data-drop-target] {
outline: 2px solid var(--highlight-background);
outline-offset: -1px;
background: var(--highlight-overlay);
}
Dropping on items#
Dropping on items can be enabled using the onItemDrop
event. When a valid drag hovers over an item, it receives the isDropTarget
state and can be styled using the [data-drop-target]
CSS selector.
const droppableItems = [
{
id: '8',
title: 'Videos',
type: 'directory',
children: [
{ id: '9', title: 'Movie.mp4', type: 'file' }
]
}
// ...
];
function DroppableTreeExample() {
let tree = useTreeData({
initialItems: droppableItems
});
let serializeItem = (nodeItem) => ({
...nodeItem,
children: nodeItem.children.map(serializeItem),
// Assign a unique ID to avoid duplicates when the same item is dropped multiple times.
id: Math.random().toString(36).slice(2)
});
let { dragAndDropHooks } = useDragAndDrop({
async onItemDrop(e) {
let items = await Promise.all(
e.items
.filter(isTextDropItem)
.map(async (item) => {
let parsed = JSON.parse(await item.getText('custom-app-type'));
return serializeItem(parsed);
})
);
tree.insert(e.target.key, 0, ...items);
} });
return (
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<DraggableTree />
<Tree
aria-label="Tree with item drop targets"
items={tree.items}
dragAndDropHooks={dragAndDropHooks}
>
{function renderItem(item) {
return (
<MyTreeItem title={item.value.title}>
<Collection items={item.children}>
{renderItem}
</Collection>
</MyTreeItem>
);
}}
</Tree>
</div>
);
}
const droppableItems = [
{
id: '8',
title: 'Videos',
type: 'directory',
children: [
{ id: '9', title: 'Movie.mp4', type: 'file' }
]
}
// ...
];
function DroppableTreeExample() {
let tree = useTreeData({
initialItems: droppableItems
});
let serializeItem = (nodeItem) => ({
...nodeItem,
children: nodeItem.children.map(serializeItem),
// Assign a unique ID to avoid duplicates when the same item is dropped multiple times.
id: Math.random().toString(36).slice(2)
});
let { dragAndDropHooks } = useDragAndDrop({
async onItemDrop(e) {
let items = await Promise.all(
e.items
.filter(isTextDropItem)
.map(async (item) => {
let parsed = JSON.parse(
await item.getText('custom-app-type')
);
return serializeItem(parsed);
})
);
tree.insert(e.target.key, 0, ...items);
} });
return (
<div
style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}
>
<DraggableTree />
<Tree
aria-label="Tree with item drop targets"
items={tree.items}
dragAndDropHooks={dragAndDropHooks}
>
{function renderItem(item) {
return (
<MyTreeItem title={item.value.title}>
<Collection items={item.children}>
{renderItem}
</Collection>
</MyTreeItem>
);
}}
</Tree>
</div>
);
}
const droppableItems = [
{
id: '8',
title: 'Videos',
type: 'directory',
children: [
{
id: '9',
title:
'Movie.mp4',
type: 'file'
}
]
}
// ...
];
function DroppableTreeExample() {
let tree = useTreeData(
{
initialItems:
droppableItems
}
);
let serializeItem = (
nodeItem
) => ({
...nodeItem,
children: nodeItem
.children.map(
serializeItem
),
// Assign a unique ID to avoid duplicates when the same item is dropped multiple times.
id: Math.random()
.toString(36)
.slice(2)
});
let {
dragAndDropHooks
} = useDragAndDrop({
async onItemDrop(e) {
let items =
await Promise
.all(
e.items
.filter(
isTextDropItem
)
.map(
async (item) => {
let parsed =
JSON
.parse(
await item
.getText(
'custom-app-type'
)
);
return serializeItem(
parsed
);
}
)
);
tree.insert(
e.target.key,
0,
...items
);
} });
return (
<div
style={{
display: 'flex',
gap: 12,
flexWrap: 'wrap'
}}
>
<DraggableTree />
<Tree
aria-label="Tree with item drop targets"
items={tree
.items}
dragAndDropHooks={dragAndDropHooks}
>
{function renderItem(
item
) {
return (
<MyTreeItem
title={item
.value
.title}
>
<Collection
items={item
.children}
>
{renderItem}
</Collection>
</MyTreeItem>
);
}}
</Tree>
</div>
);
}
Dropping between items#
Dropping between items can be enabled using the onInsert
event. Tree renders a DropIndicator
between items to indicate the insertion position, which can be styled using the .react-aria-DropIndicator
selector. When it is active, it receives the [data-drop-target]
state.
import {isTextDropItem} from 'react-aria-components';
function Example() {
let tree = useTreeData({
initialItems: droppableItems
});
let serializeItem = (nodeItem) => ({
...nodeItem,
children: nodeItem.children.map(serializeItem),
// Assign a unique ID to avoid duplicates when the same item is dropped multiple times.
id: Math.random().toString(36).slice(2)
});
let { dragAndDropHooks } = useDragAndDrop({
async onInsert(e) {
let items = await Promise.all(
e.items
.filter(isTextDropItem)
.map(async (item) => {
let parsed = JSON.parse(await item.getText('custom-app-type'));
return serializeItem(parsed);
})
);
if (e.target.dropPosition === 'before') {
tree.insertBefore(e.target.key, ...items);
} else if (e.target.dropPosition === 'after') {
tree.insertAfter(e.target.key, ...items);
}
} });
return (
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<DraggableTree />
<Tree
aria-label="Tree with insertion"
items={tree.items}
dragAndDropHooks={dragAndDropHooks}
>
{function renderItem(item) {
return (
<MyTreeItem title={item.value.title}>
<Collection items={item.children}>
{renderItem}
</Collection>
</MyTreeItem>
);
}}
</Tree>
</div>
);
}
import {isTextDropItem} from 'react-aria-components';
function Example() {
let tree = useTreeData({
initialItems: droppableItems
});
let serializeItem = (nodeItem) => ({
...nodeItem,
children: nodeItem.children.map(serializeItem),
// Assign a unique ID to avoid duplicates when the same item is dropped multiple times.
id: Math.random().toString(36).slice(2)
});
let { dragAndDropHooks } = useDragAndDrop({
async onInsert(e) {
let items = await Promise.all(
e.items
.filter(isTextDropItem)
.map(async (item) => {
let parsed = JSON.parse(
await item.getText('custom-app-type')
);
return serializeItem(parsed);
})
);
if (e.target.dropPosition === 'before') {
tree.insertBefore(e.target.key, ...items);
} else if (e.target.dropPosition === 'after') {
tree.insertAfter(e.target.key, ...items);
}
} });
return (
<div
style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}
>
<DraggableTree />
<Tree
aria-label="Tree with insertion"
items={tree.items}
dragAndDropHooks={dragAndDropHooks}
>
{function renderItem(item) {
return (
<MyTreeItem title={item.value.title}>
<Collection items={item.children}>
{renderItem}
</Collection>
</MyTreeItem>
);
}}
</Tree>
</div>
);
}
import {isTextDropItem} from 'react-aria-components';
function Example() {
let tree = useTreeData(
{
initialItems:
droppableItems
}
);
let serializeItem = (
nodeItem
) => ({
...nodeItem,
children: nodeItem
.children.map(
serializeItem
),
// Assign a unique ID to avoid duplicates when the same item is dropped multiple times.
id: Math.random()
.toString(36)
.slice(2)
});
let {
dragAndDropHooks
} = useDragAndDrop({
async onInsert(e) {
let items =
await Promise
.all(
e.items
.filter(
isTextDropItem
)
.map(
async (item) => {
let parsed =
JSON
.parse(
await item
.getText(
'custom-app-type'
)
);
return serializeItem(
parsed
);
}
)
);
if (
e.target
.dropPosition ===
'before'
) {
tree
.insertBefore(
e.target.key,
...items
);
} else if (
e.target
.dropPosition ===
'after'
) {
tree.insertAfter(
e.target.key,
...items
);
}
} });
return (
<div
style={{
display: 'flex',
gap: 12,
flexWrap: 'wrap'
}}
>
<DraggableTree />
<Tree
aria-label="Tree with insertion"
items={tree
.items}
dragAndDropHooks={dragAndDropHooks}
>
{function renderItem(
item
) {
return (
<MyTreeItem
title={item
.value
.title}
>
<Collection
items={item
.children}
>
{renderItem}
</Collection>
</MyTreeItem>
);
}}
</Tree>
</div>
);
}
Drop data#
Tree
allows users to drop one or more drag items, each of which contains data to be transferred from the drag source to drop target. There are three kinds of drag items:
text
– represents data inline as a string in one or more formatsfile
– references a file on the user's devicedirectory
– references the contents of a directory
Text#
A TextDropItem
represents textual data in one or more different formats. These may be either standard mime types or custom app-specific formats. Representing data in multiple formats allows drop targets both within and outside an application to choose data in a format that they understand. For example, a complex object may be serialized in a custom format for use within an application, with fallbacks in plain text and/or rich HTML that can be used when a user drops data from an external application.
The example below uses the acceptedDragTypes
prop to accept items that include a custom app-specific type, which is retrieved using the item's getText
method. The same draggable component as used in the above example is used here, but rather than displaying the plain text representation, the custom format is used instead. When acceptedDragTypes
is specified, the dropped items are filtered to include only items that include the accepted types.
import {isTextDropItem} from 'react-aria-components';
interface DroppableItem {
id: string | number;
title: string;
children?: DroppableItem[];
}
function DroppableTree() {
let [items, setItems] = React.useState<any[]>([]);
let serializeItem = (nodeItem) => ({
...nodeItem,
children: nodeItem.children.map(serializeItem),
// Assign a unique ID to avoid duplicates when the same item is dropped multiple times.
id: Math.random().toString(36).slice(2)
});
let { dragAndDropHooks } = useDragAndDrop({
acceptedDragTypes: ['custom-app-type'],
async onRootDrop(e) {
let items = await Promise.all(
e.items
.filter(isTextDropItem)
.map(async (item) => {
let parsed = JSON.parse(await item.getText('custom-app-type'));
return serializeItem(parsed);
})
);
setItems(items);
} });
return (
<Tree
aria-label="Droppable tree"
items={items}
dragAndDropHooks={dragAndDropHooks}
renderEmptyState={() => 'Drop items here'}
>
{function renderItem(item: DroppableItem) {
return (
<MyTreeItem title={item.title}>
<Collection items={item.children}>
{renderItem}
</Collection>
</MyTreeItem>
);
}}
</Tree>
);
}
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<DraggableTree />
<DroppableTree />
</div>
import {isTextDropItem} from 'react-aria-components';
interface DroppableItem {
id: string | number;
title: string;
children?: DroppableItem[];
}
function DroppableTree() {
let [items, setItems] = React.useState<any[]>([]);
let serializeItem = (nodeItem) => ({
...nodeItem,
children: nodeItem.children.map(serializeItem),
// Assign a unique ID to avoid duplicates when the same item is dropped multiple times.
id: Math.random().toString(36).slice(2)
});
let { dragAndDropHooks } = useDragAndDrop({
acceptedDragTypes: ['custom-app-type'],
async onRootDrop(e) {
let items = await Promise.all(
e.items
.filter(isTextDropItem)
.map(async (item) => {
let parsed = JSON.parse(
await item.getText('custom-app-type')
);
return serializeItem(parsed);
})
);
setItems(items);
} });
return (
<Tree
aria-label="Droppable tree"
items={items}
dragAndDropHooks={dragAndDropHooks}
renderEmptyState={() => 'Drop items here'}
>
{function renderItem(item: DroppableItem) {
return (
<MyTreeItem title={item.title}>
<Collection items={item.children}>
{renderItem}
</Collection>
</MyTreeItem>
);
}}
</Tree>
);
}
<div
style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}
>
<DraggableTree />
<DroppableTree />
</div>
import {isTextDropItem} from 'react-aria-components';
interface DroppableItem {
id: string | number;
title: string;
children?:
DroppableItem[];
}
function DroppableTree() {
let [items, setItems] =
React.useState<
any[]
>([]);
let serializeItem = (
nodeItem
) => ({
...nodeItem,
children: nodeItem
.children.map(
serializeItem
),
// Assign a unique ID to avoid duplicates when the same item is dropped multiple times.
id: Math.random()
.toString(36)
.slice(2)
});
let {
dragAndDropHooks
} = useDragAndDrop({
acceptedDragTypes: [
'custom-app-type'
],
async onRootDrop(e) {
let items =
await Promise
.all(
e.items
.filter(
isTextDropItem
)
.map(
async (item) => {
let parsed =
JSON
.parse(
await item
.getText(
'custom-app-type'
)
);
return serializeItem(
parsed
);
}
)
);
setItems(items);
} });
return (
<Tree
aria-label="Droppable tree"
items={items}
dragAndDropHooks={dragAndDropHooks}
renderEmptyState={() =>
'Drop items here'}
>
{function renderItem(
item:
DroppableItem
) {
return (
<MyTreeItem
title={item
.title}
>
<Collection
items={item
.children}
>
{renderItem}
</Collection>
</MyTreeItem>
);
}}
</Tree>
);
}
<div
style={{
display: 'flex',
gap: 12,
flexWrap: 'wrap'
}}
>
<DraggableTree />
<DroppableTree />
</div>
Files#
A FileDropItem
references a file on the user's device. It includes the name and mime type of the file, and methods to read the contents as plain text, or retrieve a native File object which can be attached to form data for uploading.
This example accepts JPEG and PNG image files, and renders them in a tree structure by creating a local object URL. When the tree is empty, you can drop on the whole collection, and otherwise items can be inserted.
import {isFileDropItem} from 'react-aria-components';
interface ImageItem {
id: number;
url: string;
name: string;
type: string;
lastModified: number;
}
function Example() {
let [items, setItems] = React.useState<ImageItem[]>([]);
let { dragAndDropHooks } = useDragAndDrop({
acceptedDragTypes: ['image/jpeg', 'image/png'],
async onRootDrop(e) {
let items = await Promise.all(
e.items.filter(isFileDropItem).map(async (item) => {
let file = await item.getFile();
return {
id: Math.random(),
url: URL.createObjectURL(file),
name: item.name,
type: file.type,
lastModified: file.lastModified
};
})
);
setItems(items);
} });
return (
<Tree
aria-label="Droppable tree"
items={items}
dragAndDropHooks={dragAndDropHooks}
renderEmptyState={() => 'Drop images here'}
>
{(item) => (
<TreeItem textValue={item.name}>
<TreeItemContent>
<img
src={item.url}
style={{ width: 20, height: 20, marginRight: 8 }}
/>
{item.name}
</TreeItemContent>
</TreeItem>
)}
</Tree>
);
}
import {isFileDropItem} from 'react-aria-components';
interface ImageItem {
id: number;
url: string;
name: string;
type: string;
lastModified: number;
}
function Example() {
let [items, setItems] = React.useState<ImageItem[]>([]);
let { dragAndDropHooks } = useDragAndDrop({
acceptedDragTypes: ['image/jpeg', 'image/png'],
async onRootDrop(e) {
let items = await Promise.all(
e.items.filter(isFileDropItem).map(async (item) => {
let file = await item.getFile();
return {
id: Math.random(),
url: URL.createObjectURL(file),
name: item.name,
type: file.type,
lastModified: file.lastModified
};
})
);
setItems(items);
} });
return (
<Tree
aria-label="Droppable tree"
items={items}
dragAndDropHooks={dragAndDropHooks}
renderEmptyState={() => 'Drop images here'}
>
{(item) => (
<TreeItem textValue={item.name}>
<TreeItemContent>
<img
src={item.url}
style={{
width: 20,
height: 20,
marginRight: 8
}}
/>
{item.name}
</TreeItemContent>
</TreeItem>
)}
</Tree>
);
}
import {isFileDropItem} from 'react-aria-components';
interface ImageItem {
id: number;
url: string;
name: string;
type: string;
lastModified: number;
}
function Example() {
let [items, setItems] =
React.useState<
ImageItem[]
>([]);
let {
dragAndDropHooks
} = useDragAndDrop({
acceptedDragTypes: [
'image/jpeg',
'image/png'
],
async onRootDrop(e) {
let items =
await Promise
.all(
e.items
.filter(
isFileDropItem
).map(
async (item) => {
let file =
await item
.getFile();
return {
id:
Math
.random(),
url:
URL
.createObjectURL(
file
),
name:
item
.name,
type:
file
.type,
lastModified:
file
.lastModified
};
}
)
);
setItems(items);
} });
return (
<Tree
aria-label="Droppable tree"
items={items}
dragAndDropHooks={dragAndDropHooks}
renderEmptyState={() =>
'Drop images here'}
>
{(item) => (
<TreeItem
textValue={item
.name}
>
<TreeItemContent>
<img
src={item
.url}
style={{
width:
20,
height:
20,
marginRight:
8
}}
/>
{item.name}
</TreeItemContent>
</TreeItem>
)}
</Tree>
);
}
Directories#
A DirectoryDropItem
references the contents of a directory on the user's device. It includes the name of the directory, as well as a method to iterate through the files and folders within the directory. The contents of any folders within the directory can be accessed recursively.
The getEntries
method returns an async iterable object, which can be used in a for await...of
loop. This provides each item in the directory as either a FileDropItem
or DirectoryDropItem
, and you can access the contents of each file as discussed above.
This example accepts directory drops over the whole collection, and renders the contents as items in the tree. DIRECTORY_DRAG_TYPE
is imported from react-aria-components
and included in the acceptedDragTypes
prop to limit the accepted items to only directories.
import {DIRECTORY_DRAG_TYPE, isDirectoryDropItem} from 'react-aria-components';
interface DirItem {
name: string;
kind: string;
type: string;
children?: DirItem[];
}
function Example() {
let [files, setFiles] = React.useState<DirItem[]>([]);
let { dragAndDropHooks } = useDragAndDrop({
acceptedDragTypes: [DIRECTORY_DRAG_TYPE],
async onRootDrop(e) {
// Read entries in directory and update state with relevant info.
let getFiles = async (dir) => {
let files = [];
for await (let entry of dir.getEntries()) {
files.push({
id: Math.random().toString(36).slice(2),
name: entry.name,
kind: entry.kind,
children: entry.kind === 'directory' ? await getFiles(entry) : []
});
}
return files;
};
let dir = e.items.find(isDirectoryDropItem)!;
setFiles(await getFiles(dir));
} });
return (
<Tree
aria-label="Droppable tree"
items={files}
dragAndDropHooks={dragAndDropHooks}
renderEmptyState={() => 'Drop directory here'}
>
{function renderItem(item) {
return (
<MyTreeItem
title={` `}
>
<Collection items={item.children}>
{renderItem}
</Collection>
</MyTreeItem>
);
}}
</Tree>
);
}
import {
DIRECTORY_DRAG_TYPE,
isDirectoryDropItem
} from 'react-aria-components';
interface DirItem {
name: string;
kind: string;
type: string;
children?: DirItem[];
}
function Example() {
let [files, setFiles] = React.useState<DirItem[]>([]);
let { dragAndDropHooks } = useDragAndDrop({
acceptedDragTypes: [DIRECTORY_DRAG_TYPE],
async onRootDrop(e) {
// Read entries in directory and update state with relevant info.
let getFiles = async (dir) => {
let files = [];
for await (let entry of dir.getEntries()) {
files.push({
id: Math.random().toString(36).slice(2),
name: entry.name,
kind: entry.kind,
children: entry.kind === 'directory'
? await getFiles(entry)
: []
});
}
return files;
};
let dir = e.items.find(isDirectoryDropItem)!;
setFiles(await getFiles(dir));
} });
return (
<Tree
aria-label="Droppable tree"
items={files}
dragAndDropHooks={dragAndDropHooks}
renderEmptyState={() => 'Drop directory here'}
>
{function renderItem(item) {
return (
<MyTreeItem
title={` `}
>
<Collection items={item.children}>
{renderItem}
</Collection>
</MyTreeItem>
);
}}
</Tree>
);
}
import {
DIRECTORY_DRAG_TYPE,
isDirectoryDropItem
} from 'react-aria-components';
interface DirItem {
name: string;
kind: string;
type: string;
children?: DirItem[];
}
function Example() {
let [files, setFiles] =
React.useState<
DirItem[]
>([]);
let {
dragAndDropHooks
} = useDragAndDrop({
acceptedDragTypes: [
DIRECTORY_DRAG_TYPE
],
async onRootDrop(e) {
// Read entries in directory and update state with relevant info.
let getFiles =
async (dir) => {
let files = [];
for await (
let entry
of dir
.getEntries()
) {
files.push({
id: Math
.random()
.toString(
36
).slice(
2
),
name:
entry
.name,
kind:
entry
.kind,
children:
entry
.kind ===
'directory'
? await getFiles(
entry
)
: []
});
}
return files;
};
let dir = e.items
.find(
isDirectoryDropItem
)!;
setFiles(
await getFiles(
dir
)
);
} });
return (
<Tree
aria-label="Droppable tree"
items={files}
dragAndDropHooks={dragAndDropHooks}
renderEmptyState={() =>
'Drop directory here'}
>
{function renderItem(
item
) {
return (
<MyTreeItem
title={` `}
>
<Collection
items={item
.children}
>
{renderItem}
</Collection>
</MyTreeItem>
);
}}
</Tree>
);
}
Drop operations#
A DropOperation
is an indication of what will happen when dragged data is dropped on a particular drop target. These are:
move
– indicates that the dragged data will be moved from its source location to the target location.copy
– indicates that the dragged data will be copied to the target destination.link
– indicates that there will be a relationship established between the source and target locations.cancel
– indicates that the drag and drop operation will be canceled, resulting in no changes made to the source or target.
Many operating systems display these in the form of a cursor change, e.g. a plus sign to indicate a copy operation. The user may also be able to use a modifier key to choose which drop operation to perform, such as Option or Alt to switch from move to copy.
onDragEnd#
The onDragEnd
event allows the drag source to respond when a drag that it initiated ends, either because it was dropped or because it was canceled by the user. The dropOperation
property of the event object indicates the operation that was performed. For example, when data is moved, the UI could be updated to reflect this change by removing the original dragged items.
This example removes the dragged items from the UI when a move operation is completed. Try holding the Option or Alt keys to change the operation to copy, and see how the behavior changes.
function Example() {
let tree = useTreeData({
initialItems: items
});
let { dragAndDropHooks } = useDragAndDrop({
// ...
onDragEnd(e) {
if (e.dropOperation === 'move') {
tree.remove(...e.keys);
}
} });
return (
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<Tree
aria-label="Draggable tree"
selectionMode="multiple"
items={tree.items}
dragAndDropHooks={dragAndDropHooks}
>
{function renderItem(item) {
return (
<MyTreeItem title={item.value.title}>
<Collection items={item.children}>
{renderItem}
</Collection>
</MyTreeItem>
);
}}
</Tree>
<DroppableTree />
</div>
);
}
function Example() {
let tree = useTreeData({
initialItems: items
});
let { dragAndDropHooks } = useDragAndDrop({
// ...
onDragEnd(e) {
if (e.dropOperation === 'move') {
tree.remove(...e.keys);
}
} });
return (
<div
style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}
>
<Tree
aria-label="Draggable tree"
selectionMode="multiple"
items={tree.items}
dragAndDropHooks={dragAndDropHooks}
>
{function renderItem(item) {
return (
<MyTreeItem title={item.value.title}>
<Collection items={item.children}>
{renderItem}
</Collection>
</MyTreeItem>
);
}}
</Tree>
<DroppableTree />
</div>
);
}
function Example() {
let tree = useTreeData(
{
initialItems: items
}
);
let {
dragAndDropHooks
} = useDragAndDrop({
// ...
onDragEnd(e) {
if (
e.dropOperation ===
'move'
) {
tree.remove(
...e.keys
);
}
} });
return (
<div
style={{
display: 'flex',
gap: 12,
flexWrap: 'wrap'
}}
>
<Tree
aria-label="Draggable tree"
selectionMode="multiple"
items={tree
.items}
dragAndDropHooks={dragAndDropHooks}
>
{function renderItem(
item
) {
return (
<MyTreeItem
title={item
.value
.title}
>
<Collection
items={item
.children}
>
{renderItem}
</Collection>
</MyTreeItem>
);
}}
</Tree>
<DroppableTree />
</div>
);
}
getAllowedDropOperations#
The drag source can also control which drop operations are allowed for the data. For example, if moving data is not allowed, and only copying is supported, the getAllowedDropOperations
function could be implemented to indicate this. When you drag the element below, the cursor now shows the copy affordance by default, and pressing a modifier to switch drop operations results in the drop being canceled.
function Example() {
// ...
let { dragAndDropHooks } = useDragAndDrop({
// ...
getAllowedDropOperations: () => ['copy'] });
return (
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<Tree
aria-label="Draggable tree"
selectionMode="multiple"
items={tree.items}
dragAndDropHooks={dragAndDropHooks}
>
{function renderItem(item) {
return (
<MyTreeItem title={item.value.title}>
<Collection items={item.children}>
{renderItem}
</Collection>
</MyTreeItem>
);
}}
</Tree>
<DroppableTree />
</div>
);
}
function Example() {
// ...
let { dragAndDropHooks } = useDragAndDrop({
// ...
getAllowedDropOperations: () => ['copy'] });
return (
<div
style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}
>
<Tree
aria-label="Draggable tree"
selectionMode="multiple"
items={tree.items}
dragAndDropHooks={dragAndDropHooks}
>
{function renderItem(item) {
return (
<MyTreeItem title={item.value.title}>
<Collection items={item.children}>
{renderItem}
</Collection>
</MyTreeItem>
);
}}
</Tree>
<DroppableTree />
</div>
);
}
function Example() {
// ...
let {
dragAndDropHooks
} = useDragAndDrop({
// ...
getAllowedDropOperations:
() => ['copy'] });
return (
<div
style={{
display: 'flex',
gap: 12,
flexWrap: 'wrap'
}}
>
<Tree
aria-label="Draggable tree"
selectionMode="multiple"
items={tree
.items}
dragAndDropHooks={dragAndDropHooks}
>
{function renderItem(
item
) {
return (
<MyTreeItem
title={item
.value
.title}
>
<Collection
items={item
.children}
>
{renderItem}
</Collection>
</MyTreeItem>
);
}}
</Tree>
<DroppableTree />
</div>
);
}
getDropOperation#
The getDropOperation
function passed to useDragAndDrop
can be used to provide appropriate feedback to the user when a drag hovers over the drop target. This function receives the drop target, set of types contained in the drag, and a list of allowed drop operations as specified by the drag source. It should return one of the drop operations in allowedOperations
, or a specific drop operation if only that drop operation is supported. It may also return 'cancel'
to reject the drop. If the returned operation is not in allowedOperations
, then the drop target will act as if 'cancel'
was returned.
In the below example, the drop target only supports dropping PNG images. If a PNG is dragged over the target, it will be highlighted and the operating system displays a copy cursor. If another type is dragged over the target, then there is no visual feedback, indicating that a drop is not accepted there. If the user holds a modifier key such as Control while dragging over the drop target in order to change the drop operation, then the drop target does not accept the drop.
function Example() {
let [items, setItems] = React.useState<ImageItem[]>([]);
let { dragAndDropHooks } = useDragAndDrop({
getDropOperation: () => 'copy', acceptedDragTypes: ['image/png'],
async onRootDrop(e) {
// ...
}
});
// See "Files" example above...
}
function Example() {
let [items, setItems] = React.useState<ImageItem[]>([]);
let { dragAndDropHooks } = useDragAndDrop({
getDropOperation: () => 'copy', acceptedDragTypes: ['image/png'],
async onRootDrop(e) {
// ...
}
});
// See "Files" example above...
}
function Example() {
let [items, setItems] =
React.useState<
ImageItem[]
>([]);
let {
dragAndDropHooks
} = useDragAndDrop({
getDropOperation:
() => 'copy', acceptedDragTypes: [
'image/png'
],
async onRootDrop(e) {
// ...
}
});
// See "Files" example above...
}
Drop events#
Drop events such as onInsert
, onItemDrop
, etc. also include the dropOperation
. This can be used to perform different actions accordingly, for example, when communicating with a backend API.
let onItemDrop = async (e) => {
let data = JSON.parse(await e.items[0].getText('my-app-file'));
switch (e.dropOperation) {
case 'move':
MyAppFileService.move(data.filePath, props.filePath);
break;
case 'copy':
MyAppFileService.copy(data.filePath, props.filePath);
break;
case 'link':
MyAppFileService.link(data.filePath, props.filePath);
break;
}};
let onItemDrop = async (e) => {
let data = JSON.parse(
await e.items[0].getText('my-app-file')
);
switch (e.dropOperation) {
case 'move':
MyAppFileService.move(data.filePath, props.filePath);
break;
case 'copy':
MyAppFileService.copy(data.filePath, props.filePath);
break;
case 'link':
MyAppFileService.link(data.filePath, props.filePath);
break;
}};
let onItemDrop = async (
e
) => {
let data = JSON.parse(
await e.items[0]
.getText(
'my-app-file'
)
);
switch (
e.dropOperation
) {
case 'move':
MyAppFileService
.move(
data.filePath,
props.filePath
);
break;
case 'copy':
MyAppFileService
.copy(
data.filePath,
props.filePath
);
break;
case 'link':
MyAppFileService
.link(
data.filePath,
props.filePath
);
break;
}};
Drag between trees#
This example puts together many of the concepts described above, allowing users to drag items between trees bidirectionally. It also supports moving items within the same tree hierarchy. When a tree is empty, it accepts drops on the whole collection. getDropOperation
ensures that items are always moved rather than copied, which avoids duplicate items.
import {isTextDropItem} from 'react-aria-components';
interface FileItem {
id: string;
title: string;
type: string;
children?: FileItem[];
}
interface DndTreeProps {
initialItems: FileItem[];
'aria-label': string;
}
function DndTree(props: DndTreeProps) {
let tree = useTreeData({
initialItems: props.initialItems,
getKey: (item) => item.id,
getChildren: (item) => item.children || []
});
let { dragAndDropHooks } = useDragAndDrop({
// Provide drag data in a custom format as well as plain text.
getItems(keys) {
return [...keys].map((key) => {
let item = tree.getItem(key);
let serializeItem = (nodeItem) => ({
...nodeItem.value,
children: nodeItem.children
? [...nodeItem.children].map(serializeItem)
: []
});
return {
'custom-app-type': JSON.stringify(serializeItem(item)),
'text/plain': item.value.title
};
});
},
// Accept drops with the custom format.
acceptedDragTypes: ['custom-app-type'],
// Ensure items are always moved rather than copied.
getDropOperation: () => 'move',
// Handle drops between items from other trees.
async onInsert(e) {
let processedItems = await Promise.all(
e.items
.filter(isTextDropItem)
.map(async (item) =>
JSON.parse(await item.getText('custom-app-type'))
)
);
if (e.target.dropPosition === 'before') {
tree.insertBefore(e.target.key, ...processedItems);
} else if (e.target.dropPosition === 'after') {
tree.insertAfter(e.target.key, ...processedItems);
}
},
// Handle drops on the collection when empty.
async onRootDrop(e) {
let processedItems = await Promise.all(
e.items
.filter(isTextDropItem)
.map(async (item) =>
JSON.parse(await item.getText('custom-app-type'))
)
);
tree.insert(null, 0, ...processedItems);
},
// Handle moving items within the same tree or to different levels.
onMove(e) {
if (e.target.dropPosition === 'before') {
tree.moveBefore(e.target.key, e.keys);
} else if (e.target.dropPosition === 'after') {
tree.moveAfter(e.target.key, e.keys);
} else if (e.target.dropPosition === 'on') {
let targetNode = tree.getItem(e.target.key);
if (targetNode) {
let targetIndex = targetNode.children
? targetNode.children.length
: 0;
let keyArray = Array.from(e.keys);
for (let i = 0; i < keyArray.length; i++) {
tree.move(keyArray[i], e.target.key, targetIndex + i);
}
}
}
},
// Remove the items from the source tree on drop
// if they were moved to a different tree.
onDragEnd(e) {
if (e.dropOperation === 'move' && !e.isInternal) {
tree.remove(...e.keys);
}
}
});
return (
<Tree
aria-label={props['aria-label']}
selectionMode="multiple"
items={tree.items}
dragAndDropHooks={dragAndDropHooks}
renderEmptyState={() => 'Drop items here'}
>
{function renderItem(item) {
return (
<MyTreeItem title={item.value.title}>
<Collection items={item.children}>
{renderItem}
</Collection>
</MyTreeItem>
);
}}
</Tree>
);
}
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<DndTree
initialItems={items}
aria-label="First Tree"
/>
<DndTree
initialItems={droppableItems}
aria-label="Second Tree"
/>
</div>
import {isTextDropItem} from 'react-aria-components';
interface FileItem {
id: string;
title: string;
type: string;
children?: FileItem[];
}
interface DndTreeProps {
initialItems: FileItem[];
'aria-label': string;
}
function DndTree(props: DndTreeProps) {
let tree = useTreeData({
initialItems: props.initialItems,
getKey: (item) => item.id,
getChildren: (item) => item.children || []
});
let { dragAndDropHooks } = useDragAndDrop({
// Provide drag data in a custom format as well as plain text.
getItems(keys) {
return [...keys].map((key) => {
let item = tree.getItem(key);
let serializeItem = (nodeItem) => ({
...nodeItem.value,
children: nodeItem.children
? [...nodeItem.children].map(serializeItem)
: []
});
return {
'custom-app-type': JSON.stringify(
serializeItem(item)
),
'text/plain': item.value.title
};
});
},
// Accept drops with the custom format.
acceptedDragTypes: ['custom-app-type'],
// Ensure items are always moved rather than copied.
getDropOperation: () => 'move',
// Handle drops between items from other trees.
async onInsert(e) {
let processedItems = await Promise.all(
e.items
.filter(isTextDropItem)
.map(async (item) =>
JSON.parse(
await item.getText('custom-app-type')
)
)
);
if (e.target.dropPosition === 'before') {
tree.insertBefore(e.target.key, ...processedItems);
} else if (e.target.dropPosition === 'after') {
tree.insertAfter(e.target.key, ...processedItems);
}
},
// Handle drops on the collection when empty.
async onRootDrop(e) {
let processedItems = await Promise.all(
e.items
.filter(isTextDropItem)
.map(async (item) =>
JSON.parse(
await item.getText('custom-app-type')
)
)
);
tree.insert(null, 0, ...processedItems);
},
// Handle moving items within the same tree or to different levels.
onMove(e) {
if (e.target.dropPosition === 'before') {
tree.moveBefore(e.target.key, e.keys);
} else if (e.target.dropPosition === 'after') {
tree.moveAfter(e.target.key, e.keys);
} else if (e.target.dropPosition === 'on') {
let targetNode = tree.getItem(e.target.key);
if (targetNode) {
let targetIndex = targetNode.children
? targetNode.children.length
: 0;
let keyArray = Array.from(e.keys);
for (let i = 0; i < keyArray.length; i++) {
tree.move(
keyArray[i],
e.target.key,
targetIndex + i
);
}
}
}
},
// Remove the items from the source tree on drop
// if they were moved to a different tree.
onDragEnd(e) {
if (e.dropOperation === 'move' && !e.isInternal) {
tree.remove(...e.keys);
}
}
});
return (
<Tree
aria-label={props['aria-label']}
selectionMode="multiple"
items={tree.items}
dragAndDropHooks={dragAndDropHooks}
renderEmptyState={() => 'Drop items here'}
>
{function renderItem(item) {
return (
<MyTreeItem title={item.value.title}>
<Collection items={item.children}>
{renderItem}
</Collection>
</MyTreeItem>
);
}}
</Tree>
);
}
<div
style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}
>
<DndTree
initialItems={items}
aria-label="First Tree"
/>
<DndTree
initialItems={droppableItems}
aria-label="Second Tree"
/>
</div>
import {isTextDropItem} from 'react-aria-components';
interface FileItem {
id: string;
title: string;
type: string;
children?: FileItem[];
}
interface DndTreeProps {
initialItems:
FileItem[];
'aria-label': string;
}
function DndTree(
props: DndTreeProps
) {
let tree = useTreeData(
{
initialItems:
props
.initialItems,
getKey: (item) =>
item.id,
getChildren:
(item) =>
item
.children ||
[]
}
);
let {
dragAndDropHooks
} = useDragAndDrop({
// Provide drag data in a custom format as well as plain text.
getItems(keys) {
return [...keys]
.map((key) => {
let item = tree
.getItem(
key
);
let serializeItem =
(
nodeItem
) => ({
...nodeItem
.value,
children:
nodeItem
.children
? [
...nodeItem
.children
].map(
serializeItem
)
: []
});
return {
'custom-app-type':
JSON
.stringify(
serializeItem(
item
)
),
'text/plain':
item.value
.title
};
});
},
// Accept drops with the custom format.
acceptedDragTypes: [
'custom-app-type'
],
// Ensure items are always moved rather than copied.
getDropOperation:
() => 'move',
// Handle drops between items from other trees.
async onInsert(e) {
let processedItems =
await Promise
.all(
e.items
.filter(
isTextDropItem
)
.map(
async (item) =>
JSON
.parse(
await item
.getText(
'custom-app-type'
)
)
)
);
if (
e.target
.dropPosition ===
'before'
) {
tree
.insertBefore(
e.target.key,
...processedItems
);
} else if (
e.target
.dropPosition ===
'after'
) {
tree.insertAfter(
e.target.key,
...processedItems
);
}
},
// Handle drops on the collection when empty.
async onRootDrop(e) {
let processedItems =
await Promise
.all(
e.items
.filter(
isTextDropItem
)
.map(
async (item) =>
JSON
.parse(
await item
.getText(
'custom-app-type'
)
)
)
);
tree.insert(
null,
0,
...processedItems
);
},
// Handle moving items within the same tree or to different levels.
onMove(e) {
if (
e.target
.dropPosition ===
'before'
) {
tree.moveBefore(
e.target.key,
e.keys
);
} else if (
e.target
.dropPosition ===
'after'
) {
tree.moveAfter(
e.target.key,
e.keys
);
} else if (
e.target
.dropPosition ===
'on'
) {
let targetNode =
tree.getItem(
e.target.key
);
if (targetNode) {
let targetIndex =
targetNode
.children
? targetNode
.children
.length
: 0;
let keyArray =
Array.from(
e.keys
);
for (
let i = 0;
i <
keyArray
.length;
i++
) {
tree.move(
keyArray[
i
],
e.target
.key,
targetIndex +
i
);
}
}
}
},
// Remove the items from the source tree on drop
// if they were moved to a different tree.
onDragEnd(e) {
if (
e.dropOperation ===
'move' &&
!e.isInternal
) {
tree.remove(
...e.keys
);
}
}
});
return (
<Tree
aria-label={props[
'aria-label'
]}
selectionMode="multiple"
items={tree.items}
dragAndDropHooks={dragAndDropHooks}
renderEmptyState={() =>
'Drop items here'}
>
{function renderItem(
item
) {
return (
<MyTreeItem
title={item
.value
.title}
>
<Collection
items={item
.children}
>
{renderItem}
</Collection>
</MyTreeItem>
);
}}
</Tree>
);
}
<div
style={{
display: 'flex',
gap: 12,
flexWrap: 'wrap'
}}
>
<DndTree
initialItems={items}
aria-label="First Tree"
/>
<DndTree
initialItems={droppableItems}
aria-label="Second Tree"
/>
</div>
Props#
Tree#
Name | Type | Default | Description |
selectionBehavior | SelectionBehavior | — | How multiple selection should behave in the tree. |
renderEmptyState | (
(props: TreeEmptyStateRenderProps
)) => ReactNode | — | Provides content to display when there are no items in the list. |
disabledBehavior | DisabledBehavior | 'all' | Whether disabledKeys applies to all interactions, or only selection. |
dragAndDropHooks | DragAndDropHooks | — | The drag and drop hooks returned by useDragAndDrop used to enable drag and drop behavior for the Tree. |
escapeKeyBehavior | 'clearSelection' | 'none' | 'clearSelection' | Whether pressing the escape key should clear selection in the grid list or not. Most experiences should not modify this option as it eliminates a keyboard user's ability to easily clear selection. Only use if the escape key is being handled externally or should not trigger selection clearing contextually. |
autoFocus | boolean | FocusStrategy | — | Whether to auto focus the gridlist or an option. |
shouldSelectOnPressUp | boolean | — | Whether selection should occur on press up instead of press down. |
items | Iterable<T> | — | Item objects in the collection. |
disabledKeys | Iterable<Key> | — | The item keys that are disabled. These items cannot be selected, focused, or otherwise interacted with. |
selectionMode | SelectionMode | — | The type of selection that is allowed in the collection. |
disallowEmptySelection | boolean | — | Whether the collection allows empty selection. |
selectedKeys | 'all' | Iterable<Key> | — | The currently selected keys in the collection (controlled). |
defaultSelectedKeys | 'all' | Iterable<Key> | — | The initial selected keys in the collection (uncontrolled). |
children | ReactNode | (
(item: object
)) => ReactNode | — | The contents of the collection. |
dependencies | ReadonlyArray<any> | — | Values that should invalidate the item cache when using dynamic collections. |
className | string | (
(values: TreeRenderProps
& & {}
)) => string | — | The CSS className for the element. A function may be provided to compute the class based on component state. |
style | CSSProperties | (
(values: TreeRenderProps
& & {}
)) => CSSProperties | undefined | — | The inline style for the element. A function may be provided to compute the style based on component state. |
expandedKeys | Iterable<Key> | — | The currently expanded keys in the collection (controlled). |
defaultExpandedKeys | Iterable<Key> | — | The initial expanded keys in the collection (uncontrolled). |
Events
Name | Type | Description |
onAction | (
(key: Key
)) => void | Handler that is called when a user performs an action on an item. The exact user event depends on
the collection's |
onSelectionChange | (
(keys: Selection
)) => void | Handler that is called when the selection changes. |
onScroll | (
(e: UIEvent<Element>
)) => void | Handler that is called when a user scrolls. See MDN. |
onExpandedChange | (
(keys: Set<Key>
)) => any | Handler that is called when items are expanded or collapsed. |
Layout
Name | Type | Description |
slot | string | null | A slot name for the component. Slots allow the component to receive props from a parent component.
An explicit |
Accessibility
Name | Type | Description |
id | string | The element's unique identifier. See MDN. |
aria-label | string | Defines a string value that labels the current element. |
aria-labelledby | string | Identifies the element (or elements) that labels the current element. |
aria-describedby | string | Identifies the element (or elements) that describes the object. |
aria-details | string | Identifies the element (or elements) that provide a detailed, extended description for the object. |
TreeItem#
Name | Type | Description |
textValue | string | A string representation of the tree item's contents, used for features like typeahead. |
children | ReactNode | The content of the tree item along with any nested children. Supports static nested tree items or use of a Collection to dynamically render nested tree items. |
id | Key | The unique id of the tree row. |
value | object | The object value that this tree item represents. When using dynamic collections, this is set automatically. |
isDisabled | boolean | Whether the item is disabled. |
className | string | (
(values: TreeItemRenderProps
& & {}
)) => string | The CSS className for the element. A function may be provided to compute the class based on component state. |
style | CSSProperties | (
(values: TreeItemRenderProps
& & {}
)) => CSSProperties | undefined | The inline style for the element. A function may be provided to compute the style based on component state. |
href | Href | A URL to link to. See MDN. |
hrefLang | string | Hints at the human language of the linked URL. SeeMDN. |
target | HTMLAttributeAnchorTarget | The target window for the link. See MDN. |
rel | string | The relationship between the linked resource and the current page. See MDN. |
download | boolean | string | Causes the browser to download the linked URL. A string may be provided to suggest a file name. See MDN. |
ping | string | A space-separated list of URLs to ping when the link is followed. See MDN. |
referrerPolicy | HTMLAttributeReferrerPolicy | How much of the referrer to send when following the link. See MDN. |
routerOptions | RouterOptions | Options for the configured client side router. |
hasChildItems | boolean | Whether this item has children, even if not loaded yet. |
Events
Name | Type | Description |
onAction | () => void | Handler that is called when a user performs an action on this tree item. The exact user event depends on
the collection's |
onHoverStart | (
(e: HoverEvent
)) => void | Handler that is called when a hover interaction starts. |
onHoverEnd | (
(e: HoverEvent
)) => void | Handler that is called when a hover interaction ends. |
onHoverChange | (
(isHovering: boolean
)) => void | Handler that is called when the hover state changes. |
Accessibility
Name | Type | Description |
aria-label | string | An accessibility label for this tree item. |
TreeItemContent#
Name | Type | Description |
children | ReactNode | (
(values: T
& & {}
)) => ReactNode | The children of the component. A function may be provided to alter the children based on component state. |
Styling#
React Aria components can be styled in many ways, including using CSS classes, inline styles, utility classes (e.g. Tailwind), CSS-in-JS (e.g. Styled Components), etc. By default, all components include a builtin className
attribute which can be targeted using CSS selectors. These follow the react-aria-ComponentName
naming convention.
.react-aria-Tree {
/* ... */
}
.react-aria-Tree {
/* ... */
}
.react-aria-Tree {
/* ... */
}
A custom className
can also be specified on any component. This overrides the default className
provided by React Aria with your own.
<TreeItem className="my-tree-item">
{/* ... */}
</TreeItem>
<TreeItem className="my-tree-item">
{/* ... */}
</TreeItem>
<TreeItem className="my-tree-item">
{/* ... */}
</TreeItem>
In addition, some components support multiple UI states (e.g. focused, placeholder, readonly, etc.). React Aria components expose states using data attributes, which you can target in CSS selectors. For example:
.react-aria-TreeItem[data-expanded] {
/* ... */
}
.react-aria-TreeItem[data-selected] {
/* ... */
}
.react-aria-TreeItem[data-expanded] {
/* ... */
}
.react-aria-TreeItem[data-selected] {
/* ... */
}
.react-aria-TreeItem[data-expanded] {
/* ... */
}
.react-aria-TreeItem[data-selected] {
/* ... */
}
The className
and style
props also accept functions which receive states for styling. This lets you dynamically determine the classes or styles to apply, which is useful when using utility CSS libraries like Tailwind.
<TreeItem
className={({ isSelected }) => isSelected ? 'bg-blue-400' : 'bg-gray-100'}
/>
<TreeItem
className={({ isSelected }) =>
isSelected ? 'bg-blue-400' : 'bg-gray-100'}
/>
<TreeItem
className={(
{ isSelected }
) =>
isSelected
? 'bg-blue-400'
: 'bg-gray-100'}
/>
Render props may also be used as children to alter what elements are rendered based on the current state. For example, you could render a checkbox only when selection is enabled.
<TreeItem>
<TreeItemContent>
{({selectionMode}) => (
<>
{selectionMode !== 'none' && <Checkbox />}
Item
</>
)}
</TreeItemContent>
</TreeItem>
<TreeItem>
<TreeItemContent>
{({selectionMode}) => (
<>
{selectionMode !== 'none' && <Checkbox />}
Item
</>
)}
</TreeItemContent>
</TreeItem>
<TreeItem>
<TreeItemContent>
{(
{ selectionMode }
) => (
<>
{selectionMode !==
'none' && (
<Checkbox />
)}
Item
</>
)}
</TreeItemContent>
</TreeItem>
The states, selectors, and render props for each component used in a Tree
are documented below.
Tree#
A Tree
can be targeted with the .react-aria-Tree
CSS selector, or by overriding with a custom className
. It supports the following states:
Name | CSS Selector | Description |
isEmpty | [data-empty] | Whether the tree has no items and should display its empty state. |
isFocused | [data-focused] | Whether the tree is currently focused. |
isFocusVisible | [data-focus-visible] | Whether the tree is currently keyboard focused. |
selectionMode | [data-selection-mode="single | multiple"] | The type of selection that is allowed in the collection. |
allowsDragging | [data-allows-dragging] | Whether the tree allows dragging. |
state | — | State of the tree. |
TreeItem#
A TreeItem
can be targeted with the .react-aria-TreeItem
CSS selector, or by overriding with a custom className
. It supports the following states:
Name | CSS Selector | Description |
isExpanded | [data-expanded] | Whether the tree item is expanded. |
hasChildItems | [data-has-child-items] | Whether the tree item has child tree items. |
level | [data-level="number"] | What level the tree item has within the tree. |
isFocusVisibleWithin | [data-focus-visible-within] | Whether the tree item's children have keyboard focus. |
state | — | The state of the tree. |
id | — | The unique id of the tree row. |
isHovered | [data-hovered] | Whether the item is currently hovered with a mouse. |
isPressed | [data-pressed] | Whether the item is currently in a pressed state. |
isSelected | [data-selected] | Whether the item is currently selected. |
isFocused | [data-focused] | Whether the item is currently focused. |
isFocusVisible | [data-focus-visible] | Whether the item is currently keyboard focused. |
isDisabled | [data-disabled] | Whether the item is non-interactive, i.e. both selection and actions are disabled and the item may
not be focused. Dependent on |
selectionMode | [data-selection-mode="single | multiple"] | The type of selection that is allowed in the collection. |
selectionBehavior | — | The selection behavior for the collection. |
allowsDragging | [data-allows-dragging] | Whether the item allows dragging. |
isDragging | [data-dragging] | Whether the item is currently being dragged. |
isDropTarget | [data-drop-target] | Whether the item is currently an active drop target. |
TreeItem also exposes a --tree-item-level
CSS custom property, which you can use to adjust the indentation.
.react-aria-TreeItem {
padding-left: calc((var(--tree-item-level) - 1) * 20px);
}
.react-aria-TreeItem {
padding-left: calc((var(--tree-item-level) - 1) * 20px);
}
.react-aria-TreeItem {
padding-left: calc((var(--tree-item-level) - 1) * 20px);
}
TreeItemContent#
TreeItemContent
does not render a DOM node. It supports the following render props:
Name | Description |
isExpanded | Whether the tree item is expanded. |
hasChildItems | Whether the tree item has child tree items. |
level | What level the tree item has within the tree. |
isFocusVisibleWithin | Whether the tree item's children have keyboard focus. |
state | The state of the tree. |
id | The unique id of the tree row. |
isHovered | Whether the item is currently hovered with a mouse. |
isPressed | Whether the item is currently in a pressed state. |
isSelected | Whether the item is currently selected. |
isFocused | Whether the item is currently focused. |
isFocusVisible | Whether the item is currently keyboard focused. |
isDisabled | Whether the item is non-interactive, i.e. both selection and actions are disabled and the item may
not be focused. Dependent on |
selectionMode | The type of selection that is allowed in the collection. |
selectionBehavior | The selection behavior for the collection. |
allowsDragging | Whether the item allows dragging. |
isDragging | Whether the item is currently being dragged. |
isDropTarget | Whether the item is currently an active drop target. |
Advanced customization#
Contexts#
All React Aria Components export a corresponding context that can be used to send props to them from a parent element. This enables you to build your own compositional APIs similar to those found in React Aria Components itself. You can send any prop or ref via context that you could pass to the corresponding component. The local props and ref on the component are merged with the ones passed via context, with the local props taking precedence (following the rules documented in mergeProps).
Component | Context | Props | Ref |
Tree | TreeContext | TreeProps | HTMLDivElement |
This example shows a component that accepts a Tree
and a ToggleButton as children, and allows the user to turn selection mode for the tree on and off by pressing the button.
import type {SelectionMode} from 'react-aria-components';
import {ToggleButtonContext, TreeContext} from 'react-aria-components';
function Selectable({children}) {
let [isSelected, onChange] = React.useState(false);
let selectionMode: SelectionMode = isSelected ? 'multiple' : 'none';
return (
<ToggleButtonContext.Provider value={{isSelected, onChange}}>
<TreeContext.Provider value={{selectionMode}}> {children}
</TreeContext.Provider>
</ToggleButtonContext.Provider>
);
}
import type {SelectionMode} from 'react-aria-components';
import {
ToggleButtonContext,
TreeContext
} from 'react-aria-components';
function Selectable({ children }) {
let [isSelected, onChange] = React.useState(false);
let selectionMode: SelectionMode = isSelected
? 'multiple'
: 'none';
return (
<ToggleButtonContext.Provider
value={{ isSelected, onChange }}
>
<TreeContext.Provider value={{ selectionMode }}> {children}
</TreeContext.Provider>
</ToggleButtonContext.Provider>
);
}
import type {SelectionMode} from 'react-aria-components';
import {
ToggleButtonContext,
TreeContext
} from 'react-aria-components';
function Selectable(
{ children }
) {
let [
isSelected,
onChange
] = React.useState(
false
);
let selectionMode:
SelectionMode =
isSelected
? 'multiple'
: 'none';
return (
<ToggleButtonContext.Provider
value={{
isSelected,
onChange
}}
>
<TreeContext.Provider
value={{
selectionMode
}}
> {children}
</TreeContext.Provider>
</ToggleButtonContext.Provider>
);
}
The Selectable
component can be reused to make the selection mode of any nested Tree
controlled by a ToggleButton
.
import {ToggleButton} from 'react-aria-components';
<Selectable>
<ToggleButton>Select</ToggleButton>
<PokemonEvolutionTree />
</Selectable>
import {ToggleButton} from 'react-aria-components';
<Selectable>
<ToggleButton>Select</ToggleButton>
<PokemonEvolutionTree />
</Selectable>
import {ToggleButton} from 'react-aria-components';
<Selectable>
<ToggleButton>
Select
</ToggleButton>
<PokemonEvolutionTree />
</Selectable>
Show CSS
.react-aria-ToggleButton {
margin-bottom: 8px;
}
.react-aria-ToggleButton {
margin-bottom: 8px;
}
.react-aria-ToggleButton {
margin-bottom: 8px;
}
Custom children#
Tree passes props to its child components, such as the selection checkboxes, via their associated contexts. These contexts are exported so you can also consume them in your own custom components. This enables you to reuse existing components from your app or component library together with React Aria Components.
Component | Context | Props | Ref |
Checkbox | CheckboxContext | CheckboxProps | HTMLLabelElement |
Button | ButtonContext | ButtonProps | HTMLButtonElement |
This example consumes from CheckboxContext
in an existing styled checkbox component to make it compatible with React Aria Components. The useContextProps
hook merges the local props and ref with the ones provided via context by Tree. See useCheckbox for more details about the hooks used in this example.
import type {CheckboxProps, useContextProps} from 'react-aria-components';
import {CheckboxContext} from 'react-aria-components';
import {useToggleState} from 'react-stately';
import {useCheckbox} from 'react-aria';
const MyCustomCheckbox = React.forwardRef(
(props: CheckboxProps, ref: React.ForwardedRef<HTMLInputElement>) => {
// Merge the local props and ref with the ones provided via context.
[props, ref] = useContextProps(props, ref, CheckboxContext);
let state = useToggleState(props);
let { inputProps } = useCheckbox(props, state, ref);
return <input {...inputProps} ref={ref} />;
}
);
import type {
CheckboxProps,
useContextProps
} from 'react-aria-components';
import {CheckboxContext} from 'react-aria-components';
import {useToggleState} from 'react-stately';
import {useCheckbox} from 'react-aria';
const MyCustomCheckbox = React.forwardRef(
(
props: CheckboxProps,
ref: React.ForwardedRef<HTMLInputElement>
) => {
// Merge the local props and ref with the ones provided via context.
[props, ref] = useContextProps(
props,
ref,
CheckboxContext
);
let state = useToggleState(props);
let { inputProps } = useCheckbox(props, state, ref);
return <input {...inputProps} ref={ref} />;
}
);
import type {
CheckboxProps,
useContextProps
} from 'react-aria-components';
import {CheckboxContext} from 'react-aria-components';
import {useToggleState} from 'react-stately';
import {useCheckbox} from 'react-aria';
const MyCustomCheckbox =
React.forwardRef(
(
props:
CheckboxProps,
ref:
React.ForwardedRef<
HTMLInputElement
>
) => {
// Merge the local props and ref with the ones provided via context.
[props, ref] =
useContextProps(
props,
ref,
CheckboxContext
);
let state =
useToggleState(
props
);
let {
inputProps
} = useCheckbox(
props,
state,
ref
);
return (
<input
{...inputProps}
ref={ref}
/>
);
}
);
Now you can use MyCustomCheckbox
within a Tree
, in place of the builtin React Aria Components Checkbox
.
<Tree>
<TreeItem>
<TreeItemContent>
<MyCustomCheckbox slot="selection" /> {/* ... */}
</TreeItemContent>
</TreeItem>
</Tree>
<Tree>
<TreeItem>
<TreeItemContent>
<MyCustomCheckbox slot="selection" /> {/* ... */}
</TreeItemContent>
</TreeItem>
</Tree>
<Tree>
<TreeItem>
<TreeItemContent>
<MyCustomCheckbox slot="selection" /> {/* ... */}
</TreeItemContent>
</TreeItem>
</Tree>
Testing#
Test utils alpha#
@react-aria/test-utils
offers common tree interaction utilities which you may find helpful when writing tests. See here for more information on how to setup these utilities
in your tests. Below is the full definition of the tree tester and a sample of how you could use it in your test suite.
// Tree.test.ts
import {render, within} from '@testing-library/react';
import {User} from '@react-aria/test-utils';
let testUtilUser = new User({ interactionType: 'mouse' });
// ...
it('Tree can select a item via keyboard', async function () {
// Render your test component/app and initialize the Tree tester
let { getByTestId } = render(
<Tree data-testid="test-tree" selectionMode="multiple">
...
</Tree>
);
let treeTester = testUtilUser.createTester('Tree', {
root: getByTestId('test-tree'),
interactionType: 'keyboard'
});
await treeTester.toggleRowSelection({ row: 0 });
expect(treeTester.selectedRows).toHaveLength(1);
expect(within(treeTester.rows[0]).getByRole('checkbox')).toBeChecked();
await treeTester.toggleRowSelection({ row: 1 });
expect(treeTester.selectedRows).toHaveLength(2);
expect(within(treeTester.rows[1]).getByRole('checkbox')).toBeChecked();
await treeTester.toggleRowSelection({ row: 0 });
expect(treeTester.selectedRows).toHaveLength(1);
expect(within(treeTester.rows[0]).getByRole('checkbox')).not.toBeChecked();
});
// Tree.test.ts
import {render, within} from '@testing-library/react';
import {User} from '@react-aria/test-utils';
let testUtilUser = new User({ interactionType: 'mouse' });
// ...
it('Tree can select a item via keyboard', async function () {
// Render your test component/app and initialize the Tree tester
let { getByTestId } = render(
<Tree data-testid="test-tree" selectionMode="multiple">
...
</Tree>
);
let treeTester = testUtilUser.createTester('Tree', {
root: getByTestId('test-tree'),
interactionType: 'keyboard'
});
await treeTester.toggleRowSelection({ row: 0 });
expect(treeTester.selectedRows).toHaveLength(1);
expect(within(treeTester.rows[0]).getByRole('checkbox'))
.toBeChecked();
await treeTester.toggleRowSelection({ row: 1 });
expect(treeTester.selectedRows).toHaveLength(2);
expect(within(treeTester.rows[1]).getByRole('checkbox'))
.toBeChecked();
await treeTester.toggleRowSelection({ row: 0 });
expect(treeTester.selectedRows).toHaveLength(1);
expect(within(treeTester.rows[0]).getByRole('checkbox'))
.not.toBeChecked();
});
// Tree.test.ts
import {
render,
within
} from '@testing-library/react';
import {User} from '@react-aria/test-utils';
let testUtilUser =
new User({
interactionType:
'mouse'
});
// ...
it('Tree can select a item via keyboard', async function () {
// Render your test component/app and initialize the Tree tester
let { getByTestId } =
render(
<Tree
data-testid="test-tree"
selectionMode="multiple"
>
...
</Tree>
);
let treeTester =
testUtilUser
.createTester(
'Tree',
{
root:
getByTestId(
'test-tree'
),
interactionType:
'keyboard'
}
);
await treeTester
.toggleRowSelection({
row: 0
});
expect(
treeTester
.selectedRows
).toHaveLength(1);
expect(
within(
treeTester.rows[0]
).getByRole(
'checkbox'
)
).toBeChecked();
await treeTester
.toggleRowSelection({
row: 1
});
expect(
treeTester
.selectedRows
).toHaveLength(2);
expect(
within(
treeTester.rows[1]
).getByRole(
'checkbox'
)
).toBeChecked();
await treeTester
.toggleRowSelection({
row: 0
});
expect(
treeTester
.selectedRows
).toHaveLength(1);
expect(
within(
treeTester.rows[0]
).getByRole(
'checkbox'
)
).not.toBeChecked();
});
Properties
Name | Type | Description |
tree | HTMLElement | Returns the tree. |
rows | HTMLElement[] | Returns the tree's rows if any. |
selectedRows | HTMLElement[] | Returns the tree's selected rows if any. |
Methods
Method | Description |
constructor(
(opts: TreeTesterOpts
)): void | |
setInteractionType(
(type: UserOpts['interactionType']
)): void | Set the interaction type used by the tree tester. |
findRow(
(opts: {}
)): HTMLElement | Returns a row matching the specified index or text content. |
toggleRowSelection(
(opts: TreeToggleRowOpts
)): Promise<void> | Toggles the selection for the specified tree row. Defaults to using the interaction type set on the tree tester. Note that this will endevor to always add/remove JUST the provided row to the set of selected rows. |
toggleRowExpansion(
(opts: TreeToggleExpansionOpts
)): Promise<void> | Toggles the expansion for the specified tree row. Defaults to using the interaction type set on the tree tester. |
triggerRowAction(
(opts: TreeRowActionOpts
)): Promise<void> | Triggers the action for the specified tree row. Defaults to using the interaction type set on the tree tester. |
cells(
(opts: {}
)): HTMLElement[] | Returns the tree's cells if any. Can be filtered against a specific row if provided via element . |