Kosmesis
Components

Tree View

A recursive expand/collapse/select tree. Purely presentational — no Morphos equivalent.

Installation

npx kosmesis add tree-view
pnpm dlx kosmesis add tree-view
yarn dlx kosmesis add tree-view
bunx kosmesis add tree-view

Install the following dependencies:

npm install @morphos/icons
pnpm add @morphos/icons
yarn add @morphos/icons
bun add @morphos/icons

Copy and paste the following code into your project.

tree-view.tsx
import { StatefulComponent } from "@praxisjs/core";import { Component, Emit, FunctionProp, Prop, Selector, State } from "@praxisjs/decorators";import { For } from "@praxisjs/runtime";import { Icon } from "@morphos/icons";import { cn } from "@/lib/utils";export interface TreeNode {  id: string;  label: string;  icon?: string;  children?: TreeNode[];}export interface TreeViewProps {  data: TreeNode[];  defaultExpanded?: string[];  selected?: string;  onSelect?: (id: string) => void;  showLines?: boolean;  class?: string;  id?: string;}const INDENT = 20;/** Horizontal offset of a level's icon column from the row's true left edge (unaffected by `paddingLeft`). */function lineOffset(level: number): number {  return level * INDENT + 14;}/** * `_expandedIds` is a plain field, not `@State()` — each row renders inside a reactive thunk, and * an `@State` read there would rebuild the row's whole subtree on toggle, killing the CSS expand * transition and resetting manually expanded folders. Toggling writes straight to the DOM instead. */@Component()export class TreeView extends StatefulComponent {  @Prop() data: TreeNode[] = [];  @Prop() defaultExpanded: string[] = [];  @Prop() selected?: string;  @Prop() showLines = false;  @Prop() class?: string;  @Prop() id?: string;  @FunctionProp() onSelect?: TreeViewProps["onSelect"];  @State() _selected: string | undefined = undefined;  @Selector("selectedId") _isSelected!: Selector<string>;  private _expandedIds = new Set<string>();  onBeforeMount() {    this._expandedIds = new Set(this.defaultExpanded);    this._selected = this.selected;  }  get selectedId(): string | undefined {    return this.selected ?? this._selected;  }  @Emit("onSelect")  select(id: string): string {    if (this.selected === undefined) this._selected = id;    return id;  }  private readonly _toggleNode = (row: HTMLElement) => {    const nodeId = row.dataset.nodeId;    if (!nodeId) return;    const expanded = !this._expandedIds.has(nodeId);    if (expanded) this._expandedIds.add(nodeId);    else this._expandedIds.delete(nodeId);    row.setAttribute("aria-expanded", String(expanded));    const toggleButton = row.querySelector<HTMLElement>("[data-slot=tree-toggle]");    toggleButton?.setAttribute("aria-label", expanded ? "Collapse" : "Expand");    const chevron = row.querySelector<HTMLElement>("[data-slot=tree-chevron]");    if (chevron) chevron.style.transform = expanded ? "rotate(90deg)" : "rotate(0deg)";    const folderIcon = row.querySelector<HTMLElement>("[data-slot=tree-icon-folder]");    if (folderIcon) folderIcon.style.display = expanded ? "none" : "inline-flex";    const folderOpenIcon = row.querySelector<HTMLElement>("[data-slot=tree-icon-folder-open]");    if (folderOpenIcon) folderOpenIcon.style.display = expanded ? "inline-flex" : "none";    const wrapper = row.nextElementSibling as HTMLElement | null;    if (wrapper) wrapper.style.gridTemplateRows = expanded ? "1fr" : "0fr";  };  private readonly _handleToggleClick = (event: MouseEvent) => {    event.stopPropagation();    const row = (event.currentTarget as HTMLElement).closest<HTMLElement>("[data-slot=tree-row]");    if (row) this._toggleNode(row);  };  private readonly _handleRowClick = (event: MouseEvent, node: TreeNode) => {    if ((node.children?.length ?? 0) > 0) this._toggleNode(event.currentTarget as HTMLElement);    this.select(node.id);  };  private renderNodes(nodes: () => TreeNode[], depth: number, parentPath: boolean[] = []) {    return (      <For each={nodes} by="id">        {(item, index) => () => {          const node = item();          const hasChildren = (node.children?.length ?? 0) > 0;          const expanded = this._expandedIds.has(node.id);          const isLast = index() === nodes().length - 1;          return (            <li>              <div                data-slot="tree-row"                data-node-id={node.id}                role="treeitem"                aria-expanded={hasChildren ? expanded : undefined}                aria-selected={() => this._isSelected(node.id)}                data-selected={() => (this._isSelected(node.id) ? "" : undefined)}                class={cn(                  "relative flex cursor-pointer items-center gap-1.5 rounded-md py-1.5 pr-2 text-sm text-foreground transition-colors hover:bg-accent/60",                  "data-[selected]:bg-accent data-[selected]:font-medium data-[selected]:text-accent-foreground",                )}                style={{ paddingLeft: `${String(depth * 20 + 6)}px` }}                onClick={(event: MouseEvent) => { this._handleRowClick(event, node); }}              >                {this.showLines && depth > 0 && (                  <div aria-hidden class="pointer-events-none absolute inset-y-0 left-0">                    {parentPath.slice(0, depth - 1).map(                      (ancestorIsLast, level) =>                        !ancestorIsLast && <span key={level} class="absolute inset-y-0 w-px bg-border" style={{ left: `${String(lineOffset(level))}px` }} />,                    )}                    <span class="absolute h-px w-2.5 bg-border" style={{ left: `${String(lineOffset(depth - 1))}px`, top: "50%" }} />                    <span                      class="absolute w-px bg-border"                      style={{ left: `${String(lineOffset(depth - 1))}px`, top: 0, height: isLast ? "50%" : "100%" }}                    />                  </div>                )}                {hasChildren ? (                  <button                    type="button"                    data-slot="tree-toggle"                    aria-label={expanded ? "Collapse" : "Expand"}                    class="flex size-4 shrink-0 items-center justify-center text-muted-foreground"                    onClick={this._handleToggleClick}                  >                    <span                      data-slot="tree-chevron"                      class="inline-flex transition-transform duration-200 ease-out"                      style={{ transform: expanded ? "rotate(90deg)" : "rotate(0deg)" }}                    >                      <Icon name="ChevronRight" size={14} />                    </span>                  </button>                ) : (                  <span class="size-4 shrink-0" />                )}                <span class="relative flex size-4 shrink-0 items-center justify-center">                  {node.icon ? (                    <Icon name={node.icon} size={15} class="text-muted-foreground" />                  ) : hasChildren ? (                    <>                      <span data-slot="tree-icon-folder" class="inline-flex" style={{ display: expanded ? "none" : "inline-flex" }}>                        <Icon name="Folder" size={15} class="text-sky-500" />                      </span>                      <span data-slot="tree-icon-folder-open" class="absolute inset-0 inline-flex" style={{ display: expanded ? "inline-flex" : "none" }}>                        <Icon name="FolderOpen" size={15} class="text-sky-500" />                      </span>                    </>                  ) : (                    <Icon name="File" size={15} class="text-muted-foreground" />                  )}                </span>                <span class="truncate">{node.label}</span>              </div>              {hasChildren && (                <div                  class="grid overflow-hidden transition-[grid-template-rows] duration-200 ease-out"                  style={{ gridTemplateRows: expanded ? "1fr" : "0fr" }}                >                  <ul class="overflow-hidden" role="group">                    {this.renderNodes(() => node.children ?? [], depth + 1, [...parentPath, isLast])}                  </ul>                </div>              )}            </li>          );        }}      </For>    );  }  render() {    return (      <ul id={this.id} role="tree" class={cn("flex flex-col gap-0.5 text-sm", this.class)}>        {this.renderNodes(() => this.data, 0)}      </ul>    );  }}

Install the following dependencies:

npm install @praxisjs/css @morphos/icons
pnpm add @praxisjs/css @morphos/icons
yarn add @praxisjs/css @morphos/icons
bun add @praxisjs/css @morphos/icons

Copy and paste the following code into your project.

tree-view.tsx
import { StatefulComponent } from "@praxisjs/core";import { cx, Stylesheet, Styled, tokenVars } from "@praxisjs/css";import { Component, Emit, FunctionProp, Prop, Selector, State } from "@praxisjs/decorators";import { For } from "@praxisjs/runtime";import { Icon } from "@morphos/icons";import { KosmesisTokens } from "@/lib/kosmesis-theme";const t = tokenVars(KosmesisTokens);class TreeViewStyles extends Stylesheet {  $root = this.css({ display: "flex", flexDirection: "column", gap: "0.125rem", fontSize: "0.875rem" });  $row = this.css({    position: "relative",    display: "flex",    cursor: "pointer",    alignItems: "center",    gap: "0.375rem",    borderRadius: "0.375rem",    padding: "0.375rem 0.5rem 0.375rem 0",    color: t.foreground,    transition: "background-color 150ms ease, color 150ms ease",  })    .on("&:hover", { backgroundColor: `color-mix(in oklab, ${t.accent} 60%, transparent)` })    .on("&[data-selected]", { backgroundColor: t.accent, color: t.accentForeground, fontWeight: 500 });  $lines = this.css({ pointerEvents: "none", position: "absolute", insetBlock: "0", left: "0" });  $lineVertical = this.css({ position: "absolute", insetBlock: "0", width: "1px", backgroundColor: t.border });  $lineHorizontal = this.css({ position: "absolute", top: "50%", height: "1px", width: "0.625rem", backgroundColor: t.border });  $lineElbow = this.css({ position: "absolute", top: "0", width: "1px", backgroundColor: t.border });  $toggle = this.css({    display: "flex",    height: "1rem",    width: "1rem",    flexShrink: 0,    alignItems: "center",    justifyContent: "center",    color: t.mutedForeground,    border: "none",    background: "none",    padding: "0",    cursor: "pointer",  });  $chevron = this.css({ display: "inline-flex", transition: "transform 200ms ease-out" });  $spacer = this.css({ height: "1rem", width: "1rem", flexShrink: 0 });  $iconWrap = this.css({ position: "relative", display: "flex", height: "1rem", width: "1rem", flexShrink: 0, alignItems: "center", justifyContent: "center" });  $iconLayer = this.css({ display: "inline-flex" });  $iconLayerAbsolute = this.css({ position: "absolute", inset: "0", display: "inline-flex" });  $folderIcon = this.css({ color: "#0ea5e9" });  $mutedIcon = this.css({ color: t.mutedForeground });  $label = this.css({ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" });  $childrenWrapper = this.css({ display: "grid", overflow: "hidden", transition: "grid-template-rows 200ms ease-out" });  $childrenList = this.css({ overflow: "hidden" });}export interface TreeNode {  id: string;  label: string;  icon?: string;  children?: TreeNode[];}export interface TreeViewProps {  data: TreeNode[];  defaultExpanded?: string[];  selected?: string;  onSelect?: (id: string) => void;  showLines?: boolean;  class?: string;  id?: string;}const INDENT = 20;/** Horizontal offset of a level's icon column from the row's true left edge (unaffected by `paddingLeft`). */function lineOffset(level: number): number {  return level * INDENT + 14;}/** * `_expandedIds` is a plain field, not `@State()` — each row renders inside a reactive thunk, and * an `@State` read there would rebuild the row's whole subtree on toggle, killing the CSS expand * transition and resetting manually expanded folders. Toggling writes straight to the DOM instead. */@Component()export class TreeView extends StatefulComponent {  @Styled(TreeViewStyles) $s!: TreeViewStyles;  @Prop() data: TreeNode[] = [];  @Prop() defaultExpanded: string[] = [];  @Prop() selected?: string;  @Prop() showLines = false;  @Prop() class?: string;  @Prop() id?: string;  @FunctionProp() onSelect?: TreeViewProps["onSelect"];  @State() _selected: string | undefined = undefined;  @Selector("selectedId") _isSelected!: Selector<string>;  private _expandedIds = new Set<string>();  onBeforeMount() {    this._expandedIds = new Set(this.defaultExpanded);    this._selected = this.selected;  }  get selectedId(): string | undefined {    return this.selected ?? this._selected;  }  @Emit("onSelect")  select(id: string): string {    if (this.selected === undefined) this._selected = id;    return id;  }  private readonly _toggleNode = (row: HTMLElement) => {    const nodeId = row.dataset.nodeId;    if (!nodeId) return;    const expanded = !this._expandedIds.has(nodeId);    if (expanded) this._expandedIds.add(nodeId);    else this._expandedIds.delete(nodeId);    row.setAttribute("aria-expanded", String(expanded));    const toggleButton = row.querySelector<HTMLElement>("[data-slot=tree-toggle]");    toggleButton?.setAttribute("aria-label", expanded ? "Collapse" : "Expand");    const chevron = row.querySelector<HTMLElement>("[data-slot=tree-chevron]");    if (chevron) chevron.style.transform = expanded ? "rotate(90deg)" : "rotate(0deg)";    const folderIcon = row.querySelector<HTMLElement>("[data-slot=tree-icon-folder]");    if (folderIcon) folderIcon.style.display = expanded ? "none" : "inline-flex";    const folderOpenIcon = row.querySelector<HTMLElement>("[data-slot=tree-icon-folder-open]");    if (folderOpenIcon) folderOpenIcon.style.display = expanded ? "inline-flex" : "none";    const wrapper = row.nextElementSibling as HTMLElement | null;    if (wrapper) wrapper.style.gridTemplateRows = expanded ? "1fr" : "0fr";  };  private readonly _handleToggleClick = (event: MouseEvent) => {    event.stopPropagation();    const row = (event.currentTarget as HTMLElement).closest<HTMLElement>("[data-slot=tree-row]");    if (row) this._toggleNode(row);  };  private readonly _handleRowClick = (event: MouseEvent, node: TreeNode) => {    if ((node.children?.length ?? 0) > 0) this._toggleNode(event.currentTarget as HTMLElement);    this.select(node.id);  };  private renderNodes(nodes: () => TreeNode[], depth: number, parentPath: boolean[] = []) {    return (      <For each={nodes} by="id">        {(item, index) => () => {          const node = item();          const hasChildren = (node.children?.length ?? 0) > 0;          const expanded = this._expandedIds.has(node.id);          const isLast = index() === nodes().length - 1;          return (            <li>              <div                data-slot="tree-row"                data-node-id={node.id}                role="treeitem"                aria-expanded={hasChildren ? expanded : undefined}                aria-selected={() => this._isSelected(node.id)}                data-selected={() => (this._isSelected(node.id) ? "" : undefined)}                class={this.$s.$row}                style={{ paddingLeft: `${String(depth * 20 + 6)}px` }}                onClick={(event: MouseEvent) => { this._handleRowClick(event, node); }}              >                {this.showLines && depth > 0 && (                  <div aria-hidden class={this.$s.$lines}>                    {parentPath.slice(0, depth - 1).map(                      (ancestorIsLast, level) =>                        !ancestorIsLast && <span key={level} class={this.$s.$lineVertical} style={{ left: `${String(lineOffset(level))}px` }} />,                    )}                    <span class={this.$s.$lineHorizontal} style={{ left: `${String(lineOffset(depth - 1))}px` }} />                    <span class={this.$s.$lineElbow} style={{ left: `${String(lineOffset(depth - 1))}px`, height: isLast ? "50%" : "100%" }} />                  </div>                )}                {hasChildren ? (                  <button type="button" data-slot="tree-toggle" aria-label={expanded ? "Collapse" : "Expand"} class={this.$s.$toggle} onClick={this._handleToggleClick}>                    <span data-slot="tree-chevron" class={this.$s.$chevron} style={{ transform: expanded ? "rotate(90deg)" : "rotate(0deg)" }}>                      <Icon name="ChevronRight" size={14} />                    </span>                  </button>                ) : (                  <span class={this.$s.$spacer} />                )}                <span class={this.$s.$iconWrap}>                  {node.icon ? (                    <Icon name={node.icon} size={15} class={this.$s.$mutedIcon} />                  ) : hasChildren ? (                    <>                      <span data-slot="tree-icon-folder" class={this.$s.$iconLayer} style={{ display: expanded ? "none" : "inline-flex" }}>                        <Icon name="Folder" size={15} class={this.$s.$folderIcon} />                      </span>                      <span data-slot="tree-icon-folder-open" class={this.$s.$iconLayerAbsolute} style={{ display: expanded ? "inline-flex" : "none" }}>                        <Icon name="FolderOpen" size={15} class={this.$s.$folderIcon} />                      </span>                    </>                  ) : (                    <Icon name="File" size={15} class={this.$s.$mutedIcon} />                  )}                </span>                <span class={this.$s.$label}>{node.label}</span>              </div>              {hasChildren && (                <div class={this.$s.$childrenWrapper} style={{ gridTemplateRows: expanded ? "1fr" : "0fr" }}>                  <ul class={this.$s.$childrenList} role="group">                    {this.renderNodes(() => node.children ?? [], depth + 1, [...parentPath, isLast])}                  </ul>                </div>              )}            </li>          );        }}      </For>    );  }  render() {    return (      <ul id={this.id} role="tree" class={cx(this.$s.$root, this.class)}>        {this.renderNodes(() => this.data, 0)}      </ul>    );  }}

Examples

About

Purely presentational + expand/select state — no Morphos equivalent. A single component recurses over data itself (rather than a separate item component per node managing its own expand state) since every node needs to read/write the same shared expanded set anyway. Every node without an explicit icon renders a folder icon (open or closed, following its expand state) or a file icon depending on whether it has children — the same default kibo-ui's own Tree uses.

Expanding a node animates its children open/closed via a grid-template-rows 0fr→1fr transition (the standard CSS-only technique for animating to an auto height), and the chevron/folder icons swap and rotate in step. None of this goes through component state — it's written straight to the DOM on click, the same direct-DOM convention Kanban uses for anything that changes on every interaction, so a folder you've expanded stays expanded even if selecting another node elsewhere in the tree causes a re-render.

Clicking anywhere on a folder's row — not just its chevron — both selects and toggles it, same as kibo-ui's own Tree. Clicking the chevron itself toggles only, without selecting.

Set showLines to draw the ancestor guide lines connecting each node back to its parent's icon column (kibo-ui's own default look). Each row draws its own copy of the lines for every ancestor level it belongs to, so they read as continuous lines running down the whole subtree — a line stops short instead of running past an ancestor that has no more siblings below it.

Usage

import { TreeView, type TreeNode } from "@/components/ui/tree-view";

const data: TreeNode[] = [
  { id: "src", label: "src", children: [{ id: "src/app.tsx", label: "app.tsx" }] },
  { id: "package.json", label: "package.json" },
];

<TreeView data={data} defaultExpanded={["src"]} showLines onSelect={(id) => console.log(id)} />

Pass icon on a node (any name accepted by @morphos/icons' Icon) to override the default folder/file icon — useful for e.g. a distinct icon per file extension.

Props

PropTypeDefault
dataTreeNode[]—
defaultExpandedstring[][]
selectedstring— (uncontrolled when omitted)
onSelect(id: string) => void—
showLinesbooleanfalse
classstring—

TreeNode is { id: string; label: string; icon?: string; children?: TreeNode[] }.

On this page