Geometa
Components

Scene Hierarchy Tree

Scene hierarchy tree visualiser for React and Svelte.

The Scene Hierarchy Tree renders an outliner-style tree for entities in a scene graph. It is aimed at:

  • Level editors
  • Visualisation tools
  • Any app that needs a structured tree of nodes with selection

It focuses on multi-level nesting, clear selection styling, and a neutral presentation you can theme with shadcn.

Selected: none

Installation (shadcn-style CLI)

These commands assume you will expose Geometa components through a shadcn-style CLI. Adjust the exact names once your CLI is wired up.

# React / Next.js
npx shadcn@latest add geometa-scene-tree

# Svelte
npx shadcn-svelte@latest add geometa-scene-tree

Usage – React (Next.js)

'use client';

import * as React from 'react';

export type SceneNode = {
  id: string;
  name: string;
  children?: SceneNode[];
};

interface SceneTreeProps {
  nodes: SceneNode[];
  selectedId?: string;
  onSelect?(id: string): void;
}

export function SceneTree({ nodes, selectedId, onSelect }: SceneTreeProps) {
  return (
    <div className="rounded-md border bg-background p-2 text-sm">
      <ul className="space-y-0.5">
        {nodes.map((node) => (
          <SceneTreeNode
            key={node.id}
            node={node}
            depth={0}
            selectedId={selectedId}
            onSelect={onSelect}
          />
        ))}
      </ul>
    </div>
  );
}

interface SceneTreeNodeProps {
  node: SceneNode;
  depth: number;
  selectedId?: string;
  onSelect?(id: string): void;
}

function SceneTreeNode({
  node,
  depth,
  selectedId,
  onSelect,
}: SceneTreeNodeProps) {
  const [expanded, setExpanded] = React.useState(true);
  const isSelected = node.id === selectedId;
  const hasChildren = (node.children?.length ?? 0) > 0;

  return (
    <li>
      <button
        type="button"
        className={[
          'flex w-full items-center gap-1 rounded px-1 py-0.5 text-left outline-none',
          isSelected ? 'bg-primary/10 text-primary' : 'hover:bg-muted',
        ].join(' ')}
        style={{ paddingLeft: 4 + depth * 12 }}
        onClick={() => onSelect?.(node.id)}
      >
        {hasChildren && (
          <span
            className="inline-flex h-4 w-4 items-center justify-center rounded-sm border text-[10px]"
            onClick={(event) => {
              event.stopPropagation();
              setExpanded((prev) => !prev);
            }}
          >
            {expanded ? '-' : '+'}
          </span>
        )}

        {!hasChildren && <span className="inline-block h-4 w-4" />}

        <span className="truncate font-mono text-xs text-muted-foreground">
          {node.name}
        </span>
      </button>

      {hasChildren && expanded && (
        <ul className="mt-0.5 space-y-0.5">
          {node.children!.map((child) => (
            <SceneTreeNode
              key={child.id}
              node={child}
              depth={depth + 1}
              selectedId={selectedId}
              onSelect={onSelect}
            />
          ))}
        </ul>
      )}
    </li>
  );
}

// Example usage
const exampleScene: SceneNode[] = [
  {
    id: 'root',
    name: 'Root',
    children: [
      { id: 'camera', name: 'Camera' },
      {
        id: 'light',
        name: 'Light',
        children: [
          { id: 'spot', name: 'Spot Light' },
          { id: 'ambient', name: 'Ambient Light' },
        ],
      },
      { id: 'mesh', name: 'Mesh' },
    ],
  },
];

export function SceneTreeExample() {
  const [selectedId, setSelectedId] = React.useState<string | undefined>();

  return (
    <div className="flex flex-col gap-3">
      <SceneTree
        nodes={exampleScene}
        selectedId={selectedId}
        onSelect={setSelectedId}
      />

      <div className="text-xs text-muted-foreground">
        Selected:{' '}
        <span className="font-mono">
          {selectedId ?? 'none'}
        </span>
      </div>
    </div>
  );
}

On this page