Tree

Composable file tree component with customizable icons and multiple line variants.

app
public
package.json
Default Tree
app
public
package.json
Icon Tree
app
public
package.json
Lined Tree

Code

"use client";

import { AnimatePresence, Variants, motion } from "motion/react";

import {
  PiCaretDownBold,
  PiCaretUpBold,
  PiFileDuotone,
  PiFolderDuotone,
  PiFolderOpenDuotone,
} from "react-icons/pi";

import React, { createContext, useCallback, useContext, useState } from "react";

import { cn } from "@/lib/utils";

interface TreeContextValue {
  variant: "lines" | "no-lines";
  border: "solid" | "dashed";
  expandedFolders: Set<string>;
  toggleFolder: (id: string) => void;
}

interface TreeProps {
  data?: TreeData;
  children?: React.ReactNode;
  variant?: "lines" | "no-lines";
  border?: "solid" | "dashed";
  className?: string;
  onNodeClick?: (node: TreeNode) => void;
}

interface TreeItemProps {
  id: string;
  name: string;
  icon?: React.ReactNode;
  children?: React.ReactNode;
  className?: string;
  onClick?: () => void;
}

interface TreeFolderProps {
  id: string;
  name: string;
  icon?: React.ReactNode;
  openIcon?: React.ReactNode;
  children?: React.ReactNode;
  defaultExpanded?: boolean;
  className?: string;
  onClick?: () => void;
}

interface TreeFileProps {
  id: string;
  name: string;
  icon?: React.ReactNode;
  className?: string;
  onClick?: () => void;
}

interface TreeNode {
  id: string;
  name: string;
  type: "file" | "folder";
  icon?: React.ReactNode;
  openIcon?: React.ReactNode;
  children?: TreeNode[];
  defaultExpanded?: boolean;
}

interface TreeData {
  nodes: TreeNode[];
}

const TreeContext = createContext<TreeContextValue | undefined>(undefined);

const useTree = () => {
  const context = useContext(TreeContext);
  if (!context) throw new Error("Tree components must be used within a Tree");
  return context;
};

const containerVariants = {
  open: {
    height: "auto",
    opacity: 1,
    transition: {
      height: {
        duration: 0.2,
        ease: "easeOut",
      },
      opacity: {
        duration: 0.15,
        ease: "easeOut",
      },
    },
  },
  closed: {
    height: 0,
    opacity: 0,
    transition: {
      height: {
        duration: 0.2,
        ease: "easeOut",
      },
      opacity: {
        duration: 0.1,
        ease: "easeOut",
      },
    },
  },
} as Variants;

const chevronVariants = {
  open: {
    rotate: 0,
    transition: {
      duration: 0.2,
      ease: "easeOut",
    },
  },
  closed: {
    rotate: -90,
    transition: {
      duration: 0.2,
      ease: "easeOut",
    },
  },
} as Variants;

const Tree = ({
  data,
  children,
  variant = "lines",
  border = "solid",
  className,
  onNodeClick,
}: TreeProps) => {
  const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
    new Set(),
  );

  const toggleFolder = useCallback((id: string) => {
    setExpandedFolders((prev) => {
      const newSet = new Set(prev);
      newSet.has(id) ? newSet.delete(id) : newSet.add(id);
      return newSet;
    });
  }, []);

  const renderNode = (
    node: TreeNode,
    depth = 0,
    isLast = false,
    parentPath: boolean[] = [],
  ) => {
    const isExpanded = expandedFolders.has(node.id);
    const currentPath = [...parentPath, isLast];

    if (node.type === "folder") {
      const childrenArray = node.children || [];
      return (
        <div key={node.id}>
          <TreeFolderInternal
            node={node}
            depth={depth}
            isExpanded={isExpanded}
            isLast={isLast}
            parentPath={parentPath}
            onToggle={() => toggleFolder(node.id)}
            onNodeClick={onNodeClick}
          />
          <AnimatePresence initial={false}>
            {isExpanded && (
              <motion.div
                initial="closed"
                animate="open"
                exit="closed"
                variants={containerVariants}
                style={{ overflow: "hidden" }}
              >
                <div>
                  {childrenArray.map((child, index) =>
                    renderNode(
                      child,
                      depth + 1,
                      index === childrenArray.length - 1,
                      currentPath,
                    ),
                  )}
                </div>
              </motion.div>
            )}
          </AnimatePresence>
        </div>
      );
    } else {
      return (
        <TreeFileInternal
          key={node.id}
          node={node}
          depth={depth}
          isLast={isLast}
          parentPath={parentPath}
          onNodeClick={onNodeClick}
        />
      );
    }
  };

  React.useEffect(() => {
    if (data) {
      const initializeExpanded = (nodes: TreeNode[]) => {
        nodes.forEach((node) => {
          if (node.type === "folder" && node.defaultExpanded) {
            setExpandedFolders((prev) => new Set(prev).add(node.id));
          }
          if (node.children) initializeExpanded(node.children);
        });
      };
      initializeExpanded(data.nodes);
    }
  }, [data]);

  return (
    <TreeContext.Provider
      value={{ variant, expandedFolders, toggleFolder, border }}
    >
      <div className={cn("w-full font-mono text-sm select-none", className)}>
        {data
          ? data.nodes.map((node, index) =>
              renderNode(node, 0, index === data.nodes.length - 1),
            )
          : children}
      </div>
    </TreeContext.Provider>
  );
};

const TreeItem = ({
  id,
  name,
  icon,
  children,
  className,
  onClick,
}: TreeItemProps) => {
  const { variant, border } = useTree();
  return (
    <div className={cn("relative", className)}>
      <div
        className={cn(
          "hover:bg-accent hover:text-accent-foreground flex cursor-pointer items-center gap-2 rounded-sm py-1 transition-colors",
          variant === "no-lines" && "pl-4",
        )}
        onClick={onClick}
      >
        {icon}
        <span className="truncate">{name}</span>
      </div>
      {children && (
        <div className={cn(variant === "no-lines" ? "pl-4" : "relative pl-6")}>
          {variant === "lines" && (
            <div
              className={cn(
                "absolute top-0 bottom-0 left-2 w-[0.5px] border-l",
                `border-${border}`,
              )}
            />
          )}
          {children}
        </div>
      )}
    </div>
  );
};

const TreeFolder = ({
  id,
  name,
  icon,
  openIcon,
  children,
  defaultExpanded = false,
  className,
  onClick,
}: TreeFolderProps) => {
  const { variant, expandedFolders, toggleFolder, border } = useTree();
  React.useEffect(() => {
    if (defaultExpanded) toggleFolder(id);
  }, [defaultExpanded, id, toggleFolder]);

  const isExpanded = expandedFolders.has(id);
  const defaultIcon = isExpanded ? (
    <PiFolderOpenDuotone className="text-muted-foreground size-3 shrink-0" />
  ) : (
    <PiFolderDuotone className="text-muted-foreground size-3 shrink-0" />
  );
  const displayIcon = icon || (isExpanded && openIcon) || defaultIcon;

  const handleClick = () => {
    toggleFolder(id);
    onClick?.();
  };

  return (
    <div className={cn("relative", className)}>
      <div
        className={cn(
          "hover:bg-accent hover:text-accent-foreground flex cursor-pointer items-center gap-2 rounded-sm py-1 transition-colors",
          variant === "lines" && "relative",
          variant === "no-lines" && "pl-4",
        )}
        onClick={handleClick}
      >
        {variant === "lines" && (
          <>
            <div className="bg-border absolute top-1/2 left-2 h-px w-3" />
            <div className="w-3" />
          </>
        )}
        <div
          className={cn(
            "flex items-center gap-1.5",
            variant === "lines" && "pl-1.5",
            variant === "no-lines" && "pl-1",
          )}
        >
          <motion.div
            variants={chevronVariants}
            animate={isExpanded ? "open" : "closed"}
            style={{ originX: 0.5, originY: 0.5 }}
          >
            <PiCaretDownBold className="text-muted-foreground size-3" />
          </motion.div>
          {displayIcon}
        </div>
        <span className="truncate font-medium">{name}</span>
      </div>
      <AnimatePresence initial={false}>
        {isExpanded && children && (
          <motion.div
            initial="closed"
            animate="open"
            exit="closed"
            variants={containerVariants}
            style={{ overflow: "hidden" }}
          >
            <div
              className={cn(variant === "no-lines" ? "pl-4" : "relative pl-6")}
            >
              {variant === "lines" && (
                <div
                  className={cn(
                    "absolute top-0 bottom-0 left-2 w-[0.5px] border-l",
                    `border-${border}`,
                  )}
                />
              )}
              {children}
            </div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
};

const TreeFile = ({ id, name, icon, className, onClick }: TreeFileProps) => {
  const { variant } = useTree();
  const defaultIcon = (
    <PiFileDuotone className="text-muted-foreground size-3 shrink-0" />
  );

  return (
    <div
      className={cn(
        "hover:bg-accent hover:text-accent-foreground relative flex cursor-pointer items-center gap-2 rounded-sm py-1 transition-colors",
        variant === "lines" && "relative",
        variant === "no-lines" && "pl-4",
        className,
      )}
      onClick={onClick}
    >
      {variant === "lines" && (
        <>
          <div className="bg-border absolute top-1/2 left-2 h-px w-3" />
          <div className="w-4" />
        </>
      )}
      {icon || defaultIcon}
      <span className="truncate">{name}</span>
    </div>
  );
};

const TreeFolderInternal = ({
  node,
  depth,
  isExpanded,
  isLast,
  parentPath,
  onToggle,
  onNodeClick,
}: {
  node: TreeNode;
  depth: number;
  isExpanded: boolean;
  isLast: boolean;
  parentPath: boolean[];
  onToggle: () => void;
  onNodeClick?: (node: TreeNode) => void;
}) => {
  const { variant, border } = useTree();
  const defaultIcon = isExpanded ? (
    <PiFolderOpenDuotone className="text-muted-foreground size-3 shrink-0" />
  ) : (
    <PiFolderDuotone className="text-muted-foreground size-3 shrink-0" />
  );
  const displayIcon = node.icon || (isExpanded && node.openIcon) || defaultIcon;

  const handleClick = () => {
    onToggle();
    onNodeClick?.(node);
  };

  const getItemClasses = () =>
    cn(
      "flex items-center gap-1.5 cursor-pointer transition-colors relative py-1 hover:bg-accent hover:text-accent-foreground",
    );

  const renderLines = () => {
    if (variant !== "lines") return null;
    return (
      <div className="absolute top-0 left-0 flex h-full items-center">
        {parentPath.map((isParentLast, index) => (
          <div key={index} className="flex h-full w-4 justify-center">
            {!isParentLast && (
              <div
                className={cn("h-full w-[0.5px] border-l", `border-${border}`)}
              />
            )}
          </div>
        ))}
        <div className="relative flex h-full w-4 justify-center">
          <div
            className={cn(
              "w-[0.5px] border-l",
              isLast ? "h-1/2" : "h-full",
              `border-${border}`,
            )}
          />
        </div>
      </div>
    );
  };

  const getIndentation = () =>
    variant === "lines" ? (depth + 1) * 16 + 2 : depth * 16;

  return (
    <div className="relative">
      {renderLines()}
      <div
        className={getItemClasses()}
        style={{ paddingLeft: `${getIndentation()}px` }}
        onClick={handleClick}
      >
        <div
          className={cn(
            "flex items-center gap-1.5",
            variant === "lines" && "pl-0.5",
            variant === "no-lines" && "pl-1",
          )}
        >
          <motion.div
            variants={chevronVariants}
            animate={isExpanded ? "open" : "closed"}
            style={{ originX: 0.5, originY: 0.5 }}
          >
            <PiCaretDownBold className="text-muted-foreground size-3 flex-shrink-0" />
          </motion.div>
          {displayIcon}
        </div>
        <span className="truncate font-medium">{node.name}</span>
      </div>
    </div>
  );
};

const TreeFileInternal = ({
  node,
  depth,
  isLast,
  parentPath,
  onNodeClick,
}: {
  node: TreeNode;
  depth: number;
  isLast: boolean;
  parentPath: boolean[];
  onNodeClick?: (node: TreeNode) => void;
}) => {
  const { variant, border } = useTree();
  const defaultIcon = (
    <PiFileDuotone className="text-muted-foreground size-3 shrink-0" />
  );

  const getItemClasses = () =>
    cn(
      "flex items-center gap-1.5 cursor-pointer transition-colors relative py-1 hover:bg-accent hover:text-accent-foreground",
    );

  const renderLines = () => {
    if (variant !== "lines") return null;
    return (
      <div className="absolute top-0 left-0 flex h-full items-center">
        {parentPath.map((isParentLast, index) => (
          <div key={index} className="flex h-full w-4 justify-center">
            {!isParentLast && (
              <div
                className={cn("h-full w-[0.5px] border-l", `border-${border}`)}
              />
            )}
          </div>
        ))}
        <div className="relative flex h-full w-4 justify-center">
          <div
            className={cn(
              "w-[0.5px] border-l",
              isLast ? "h-1/2" : "h-full",
              `border-${border}`,
            )}
          />
        </div>
      </div>
    );
  };

  const getIndentation = () =>
    variant === "lines" ? (depth + 1) * 16 : depth * 16;

  return (
    <div className="relative">
      {renderLines()}
      <div
        className={getItemClasses()}
        style={{ paddingLeft: `${getIndentation()}px` }}
        onClick={() => onNodeClick?.(node)}
      >
        <div className="w-4" />
        {node.icon || defaultIcon}
        <span className="truncate">{node.name}</span>
      </div>
    </div>
  );
};

export {
  Tree,
  TreeItem,
  TreeFolder,
  TreeFile,
  type TreeProps,
  type TreeItemProps,
  type TreeFolderProps,
  type TreeFileProps,
  type TreeNode,
  type TreeData,
};