component — display

Tree

Collapsible tree view built on react-aria-components, styled with the quebi design system. Supports nesting, expand/collapse chevrons, and single or multi (checkbox) selection.

  • tree
  • navigation
  • hierarchy
  • list
  • interactive

03 examples

Default

A nested file tree with expand/collapse chevrons.

src
components
button.tsx
checkbox.tsx
tree.tsx
index.ts
package.json

Single selection

Click a row to select it; a selected row is pressed into the page.

src
components
button.tsx
checkbox.tsx
tree.tsx
index.ts
package.json

Multiple selection

Multi-select mode shows a checkbox on each row.

src
components
button.tsx
checkbox.tsx
tree.tsx
index.ts
package.json

source

tree.tsx

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

"use client"

import { ChevronRight } from "lucide-react"
import type {
  TreeItemContentProps,
  TreeItemContentRenderProps,
  TreeItemProps,
  TreeProps,
} from "react-aria-components"
import {
  Button,
  composeRenderProps,
  TreeItemContent,
  TreeItem as TreeItemPrimitive,
  Tree as TreePrimitive,
} from "react-aria-components"
import { Checkbox } from "@/components/checkbox"
import { cn } from "@/lib/utils"

/**
 * Tree — quebi design system
 *
 * Built on react-aria-components. A collapsible, optionally multi-selectable
 * tree view of flat rows at the control radius: a hovered row is raised, a
 * selected one takes the `selected` ground with its icon in signal, and focus
 * is an inset ring because the rows touch. Expand chevrons
 * are muted and rotate on open. Supports checkbox selection via the Checkbox
 * sibling.
 */
const Tree = <T extends object>({ className, ...props }: TreeProps<T>) => {
  return (
    <TreePrimitive
      className={composeRenderProps(className, (resolved) =>
        cn(
          "flex cursor-default flex-col overflow-auto outline-hidden forced-color-adjust-none",
          resolved,
        ),
      )}
      {...props}
    />
  )
}

const TreeItem = <T extends object>({ className, ...props }: TreeItemProps<T>) => {
  return (
    <TreeItemPrimitive
      className={composeRenderProps(className, (resolved) =>
        cn(
          "group/tree-item relative flex shrink-0 select-none rounded-(--q-radius-control) px-2 py-1.5",
          "text-sm/6 text-quebi-fg transition-colors duration-150 focus:outline-hidden",
          "hover:bg-quebi-raised",
          "selected:bg-quebi-selected selected:text-quebi-on-selected selected:hover:bg-quebi-selected",
          "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-quebi-focus",
          "**:data-[slot=icon]:me-1 **:data-[slot=icon]:size-4 **:data-[slot=icon]:shrink-0 **:data-[slot=icon]:text-quebi-fg-muted selected:**:data-[slot=icon]:text-quebi-signal",
          "disabled:opacity-50",
          "href" in props ? "cursor-pointer" : "cursor-default",
          resolved,
        ),
      )}
      {...props}
    />
  )
}

interface TreeContentProps extends TreeItemContentProps {
  className?: string
}

const TreeContent = ({ className, children, ...props }: TreeContentProps) => {
  return (
    <TreeItemContent {...props}>
      {(values) => (
        <div
          className={cn(
            "relative flex w-full min-w-0 items-center gap-x-1 truncate text-sm/6",
            className,
          )}
        >
          {values.selectionMode === "multiple" && values.selectionBehavior === "toggle" && (
            <Checkbox slot="selection" />
          )}
          <div
            aria-hidden
            className="relative w-[calc(calc(var(--tree-item-level)-1)*calc(var(--spacing)*5))] shrink-0"
          />
          {values.hasChildItems ? (
            <TreeIndicator
              values={{
                isDisabled: values.isDisabled,
                isExpanded: values.isExpanded,
              }}
            />
          ) : (
            <span aria-hidden className="block w-5 shrink-0" />
          )}
          {typeof children === "function" ? children(values) : children}
        </div>
      )}
    </TreeItemContent>
  )
}

const TreeIndicator = ({
  values,
}: {
  values: Pick<TreeItemContentRenderProps, "isDisabled" | "isExpanded">
}) => {
  return (
    <Button
      slot="chevron"
      isDisabled={values.isDisabled}
      className={cn(
        "shrink-0 content-center text-quebi-fg-muted transition-colors duration-150 hover:text-quebi-fg",
        values.isExpanded && "text-quebi-fg",
      )}
    >
      <ChevronRight
        data-slot="chevron"
        className={cn(
          "size-4 transition-transform duration-200 ease-in-out sm:size-5",
          values.isExpanded && "rotate-90",
        )}
      />
    </Button>
  )
}

export type { TreeItemProps, TreeProps }
export { Tree, TreeContent, TreeIndicator, TreeItem }