Tree
A tree component for displaying hierarchical data with expandable nodes.
A tree component for displaying hierarchical data with expandable nodes.
Basic tree with no extra features ∙ API
Follow these simple steps to add the Tree component to your project:
Install Dependencies
pnpm add @headless-tree/core @headless-tree/react @radix-ui/react-slot
Create a new file: components/ui/tree.tsx
and copy the code below:
"use client";
import * as React from "react";
import { ItemInstance } from "@headless-tree/core";
import { ChevronDownIcon } from "lucide-react";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@/lib/utils";
//eslint-disable-next-line @typescript-eslint/no-explicit-any
interface TreeContextValue<T = any> {
indent: number;
currentItem?: ItemInstance<T>;
//eslint-disable-next-line @typescript-eslint/no-explicit-any
tree?: any;
}
const TreeContext = React.createContext<TreeContextValue>({
indent: 20,
currentItem: undefined,
tree: undefined,
});
//eslint-disable-next-line @typescript-eslint/no-explicit-any
function useTreeContext<T = any>() {
return React.useContext(TreeContext) as TreeContextValue<T>;
}
interface TreeProps extends React.HTMLAttributes<HTMLDivElement> {
indent?: number;
//eslint-disable-next-line @typescript-eslint/no-explicit-any
tree?: any;
}
function Tree({ indent = 20, tree, className, ...props }: TreeProps) {
const containerProps =
tree && typeof tree.getContainerProps === "function"
? tree.getContainerProps()
: {};
const mergedProps = { ...props, ...containerProps };
// Extract style from mergedProps to merge with our custom styles
const { style: propStyle, ...otherProps } = mergedProps;
// Merge styles
const mergedStyle = {
...propStyle,
"--tree-indent": `${indent}px`,
} as React.CSSProperties;
return (
<TreeContext.Provider value={{ indent, tree }}>
<div
data-slot="tree"
style={mergedStyle}
className={cn("flex flex-col", className)}
{...otherProps}
/>
</TreeContext.Provider>
);
}
//eslint-disable-next-line @typescript-eslint/no-explicit-any
interface TreeItemProps<T = any>
extends React.HTMLAttributes<HTMLButtonElement> {
item: ItemInstance<T>;
indent?: number;
asChild?: boolean;
}
//eslint-disable-next-line @typescript-eslint/no-explicit-any
function TreeItem<T = any>({
item,
className,
asChild,
children,
...props
}: Omit<TreeItemProps<T>, "indent">) {
const { indent } = useTreeContext<T>();
const itemProps =
typeof item.getProps === "function" ? item.getProps() : {};
const mergedProps = { ...props, ...itemProps };
// Extract style from mergedProps to merge with our custom styles
const { style: propStyle, ...otherProps } = mergedProps;
// Merge styles
const mergedStyle = {
...propStyle,
"--tree-padding": `${item.getItemMeta().level * indent}px`,
} as React.CSSProperties;
const Comp = asChild ? Slot : "button";
return (
<TreeContext.Provider value={{ indent, currentItem: item }}>
<Comp
data-slot="tree-item"
style={mergedStyle}
className={cn(
"z-10 ps-(--tree-padding) outline-hidden select-none not-last:pb-0.5 focus:z-20 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
data-focus={
typeof item.isFocused === "function"
? item.isFocused() || false
: undefined
}
data-folder={
typeof item.isFolder === "function"
? item.isFolder() || false
: undefined
}
data-selected={
typeof item.isSelected === "function"
? item.isSelected() || false
: undefined
}
data-drag-target={
typeof item.isDragTarget === "function"
? item.isDragTarget() || false
: undefined
}
data-search-match={
typeof item.isMatchingSearch === "function"
? item.isMatchingSearch() || false
: undefined
}
aria-expanded={item.isExpanded()}
{...otherProps}
>
{children}
</Comp>
</TreeContext.Provider>
);
}
//eslint-disable-next-line @typescript-eslint/no-explicit-any
interface TreeItemLabelProps<T = any>
extends React.HTMLAttributes<HTMLSpanElement> {
item?: ItemInstance<T>;
}
//eslint-disable-next-line @typescript-eslint/no-explicit-any
function TreeItemLabel<T = any>({
item: propItem,
children,
className,
...props
}: TreeItemLabelProps<T>) {
const { currentItem } = useTreeContext<T>();
const item = propItem || currentItem;
if (!item) {
console.warn("TreeItemLabel: No item provided via props or context");
return null;
}
return (
<span
data-slot="tree-item-label"
className={cn(
"in-focus-visible:ring-ring/50 bg-background hover:bg-accent in-data-[selected=true]:bg-accent in-data-[selected=true]:text-accent-foreground in-data-[drag-target=true]:bg-accent flex items-center gap-1 rounded-sm px-2 py-1.5 text-sm transition-colors not-in-data-[folder=true]:ps-7 in-focus-visible:ring-[3px] in-data-[search-match=true]:bg-blue-400/20! [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
{item.isFolder() && (
<ChevronDownIcon className="text-muted-foreground size-4 in-aria-[expanded=false]:-rotate-90" />
)}
{children ||
(typeof item.getItemName === "function"
? item.getItemName()
: null)}
</span>
);
}
function TreeDragLine({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
const { tree } = useTreeContext();
if (!tree || typeof tree.getDragLineStyle !== "function") {
console.warn(
"TreeDragLine: No tree provided via context or tree does not have getDragLineStyle method",
);
return null;
}
const dragLine = tree.getDragLineStyle();
return (
<div
style={dragLine}
className={cn(
"bg-primary before:bg-background before:border-primary absolute z-30 -mt-px h-0.5 w-[unset] before:absolute before:-top-[3px] before:left-0 before:size-2 before:rounded-full before:border-2",
className,
)}
{...props}
/>
);
}
export { Tree, TreeItem, TreeItemLabel, TreeDragLine };
Adjust the import paths in both files according to your project's structure.
Basic tree with caret icon on the right ∙ API
Basic tree with vertical lines ∙ API