Shadcn Treeview
Getting Started

Installation

How to install Shadcn Treeview

Use the shadcn CLI to add the tree view component directly to your project:

npx shadcn@latest add https://shadcn-treeview.achromatic.dev/registry/tree-view.json

This will:

  • Install react-accessible-treeview as a dependency
  • Add tree-view.tsx to your components/ui directory

Package Manager

Alternatively, install as an npm package:

npm install shadcn-treeview

Peer Dependencies

Shadcn Treeview requires React 18 or later:

{
  "peerDependencies": {
    "react": ">=18.0.0",
    "react-dom": ">=18.0.0"
  }
}

Dependencies

The package includes react-accessible-treeview as a dependency, which provides the accessible tree view primitives. You don't need to install it separately.

Manual Installation

If you prefer to copy the component code directly into your project:

1. Install the dependency

npm install react-accessible-treeview

2. Copy the component

Create components/ui/tree-view.tsx and paste the following code:

"use client";

import * as React from "react";
import * as TreeViewPrimitive from "react-accessible-treeview";
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";

function cn(...inputs: (string | undefined | false)[]) {
  return twMerge(clsx(inputs));
}

export type TreeViewProps = React.ComponentPropsWithoutRef<
  typeof TreeViewPrimitive.default
>;

const treeViewStyles = `
[role="tree"],
[role="tree"] ul,
[role="group"] {
  list-style: none;
  margin: 0;
  padding: 0;
}
[role="treeitem"] {
  list-style: none;
}
`;

const TreeView = React.forwardRef<HTMLUListElement, TreeViewProps>(
  (props, ref) => (
    <>
      <style>{treeViewStyles}</style>
      <TreeViewPrimitive.default ref={ref} {...props} />
    </>
  ),
);
TreeView.displayName = "TreeView";

export type TreeViewItemProps = React.ComponentPropsWithoutRef<"div"> & {
  level: number;
  isExpanded?: boolean;
  isBranch?: boolean;
  isSelected?: boolean;
  indentation?: number;
  levelIndentation?: number;
  name: string;
  icon?: React.ReactNode;
  isEditing?: boolean;
  onEditSubmit?: (value: string) => void;
  isLoading?: boolean;
  expandIcon?: React.ReactNode;
  loadingIcon?: React.ReactNode;
};

const TreeViewItem = React.forwardRef<HTMLDivElement, TreeViewItemProps>(
  (
    {
      level = 1,
      isExpanded = false,
      isBranch = false,
      isSelected = false,
      isLoading = false,
      indentation = 16,
      levelIndentation = 48,
      name = "",
      icon,
      isEditing = false,
      onEditSubmit,
      expandIcon,
      loadingIcon,
      className,
      style,
      ...props
    },
    ref,
  ) => {
    const [localValueState, setLocalValueState] = React.useState(name);
    const inputRef = React.useRef<HTMLInputElement>(null);

    React.useEffect(() => {
      const handleClickOutside = (event: MouseEvent) => {
        if (!inputRef.current?.contains(event.target as Node)) {
          onEditSubmit?.(localValueState);
        }
      };
      if (isEditing) {
        document.addEventListener("mousedown", handleClickOutside);
      }
      return () => {
        if (isEditing) {
          document.removeEventListener("mousedown", handleClickOutside);
        }
      };
    }, [isEditing, localValueState, onEditSubmit]);

    React.useEffect(() => {
      if (isEditing) {
        inputRef.current?.focus();
      }
    }, [isEditing]);

    const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
      e.preventDefault();
      onEditSubmit?.(localValueState);
    };

    const paddingLeft =
      level === 1 && !isBranch
        ? indentation
        : levelIndentation * (level - 1) + indentation;

    const defaultExpandIcon = (
      <svg
        aria-hidden="true"
        className="text-muted-foreground transition-transform duration-200 group-aria-expanded:rotate-90"
        width="14"
        height="14"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        strokeWidth="2"
        strokeLinecap="round"
        strokeLinejoin="round"
      >
        <path d="m9 18 6-6-6-6" />
      </svg>
    );

    const defaultLoadingIcon = (
      <svg
        aria-hidden="true"
        className="animate-spin text-muted-foreground"
        width="14"
        height="14"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        strokeWidth="2"
        strokeLinecap="round"
        strokeLinejoin="round"
      >
        <path d="M21 12a9 9 0 1 1-6.219-8.56" />
      </svg>
    );

    return (
      <div
        ref={ref}
        aria-expanded={!isEditing && isExpanded}
        className={cn(
          "group relative flex h-8 cursor-pointer select-none items-center gap-3 text-sm text-muted-foreground transition-colors hover:bg-muted",
          isSelected && "!bg-muted text-foreground",
          className,
        )}
        style={{
          paddingLeft,
          ...style,
        }}
        data-treeview-is-branch={isBranch}
        data-treeview-level={level}
        {...props}
      >
        {level > 1 && (
          <div
            style={{
              left: (levelIndentation / 2 + 4) * (level - 1) + indentation,
            }}
            className="absolute h-full w-px group-data-[treeview-is-branch=false]:border"
          />
        )}
        {isSelected && (
          <div className="absolute left-0 h-full w-0.5 bg-foreground" />
        )}
        {isBranch &&
          (isLoading
            ? (loadingIcon ?? defaultLoadingIcon)
            : (expandIcon ?? defaultExpandIcon))}
        {icon}
        <span
          className={cn("truncate text-sm", isEditing && "hidden")}
          title={name}
        >
          {name}
        </span>
        {isEditing && (
          <form onSubmit={handleSubmit}>
            <input
              ref={inputRef}
              onChange={(e) => {
                setLocalValueState(e.target.value);
              }}
              onKeyDownCapture={(e) => {
                if (e.key === "Enter") {
                  onEditSubmit?.(localValueState);
                } else if (e.key === "Escape") {
                  setLocalValueState(name);
                  onEditSubmit?.(name);
                } else {
                  e.stopPropagation();
                }
              }}
              className="block h-7 w-full rounded border bg-background px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-ring"
              value={localValueState}
            />
          </form>
        )}
      </div>
    );
  },
);
TreeViewItem.displayName = "TreeViewItem";

export { TreeView, TreeViewItem };
export { flattenTree } from "react-accessible-treeview";
export type { INode, INodeRendererProps } from "react-accessible-treeview";

3. Install utility dependencies (if not already installed)

npm install clsx tailwind-merge

Styling

Shadcn Treeview uses Tailwind CSS classes for styling. Make sure you have Tailwind CSS configured in your project.

The component uses these CSS variables for theming (compatible with Shadcn UI):

  • --muted / --muted-foreground - For item backgrounds and text
  • --foreground - For selected item indicator
  • --background - For input backgrounds
  • --ring - For focus rings

If you're using Shadcn UI, these variables are already configured.

On this page