Skip to content

Tree <l-tree>

Tree views present hierarchical data like file explorers, navigation menus, and taxonomies. Each node expands/collapses to reveal nested items and can be selected in several ways.

HTML tag<l-tree>
Native HTML
Progressive
Custom
Shadow DOM
Custom Element · Shadow DOM

Options

Basic

Wrap <l-tree-item> nodes inside <l-tree>. Nested <l-tree-item> children become sub-nodes automatically.

Documents Photos beach.jpg mountain.jpg Invoices january.pdf february.pdf Projects luxen-ui website Downloads
Code
html
<l-tree>
  <l-tree-item>
    Documents
    <l-tree-item>
      Photos
      <l-tree-item>beach.jpg</l-tree-item>
      <l-tree-item>mountain.jpg</l-tree-item>
    </l-tree-item>
    <l-tree-item>
      Invoices
      <l-tree-item>january.pdf</l-tree-item>
      <l-tree-item>february.pdf</l-tree-item>
    </l-tree-item>
  </l-tree-item>
  <l-tree-item>
    Projects
    <l-tree-item>luxen-ui</l-tree-item>
    <l-tree-item>website</l-tree-item>
  </l-tree-item>
  <l-tree-item>Downloads</l-tree-item>
</l-tree>

Custom icons

Place any element in the prefix slot to render a leading icon before the label. Icons inherit the current text color.

src components Button.ts Tree.ts package.json README.md public logo.svg
Code
html
<l-tree>
  <l-tree-item expanded>
    <l-icon
      slot="prefix"
      name="lucide:folder-open"
    ></l-icon>
    src
    <l-tree-item>
      <l-icon
        slot="prefix"
        name="lucide:folder"
      ></l-icon>
      components
      <l-tree-item>
        <l-icon
          slot="prefix"
          name="lucide:file-code"
        ></l-icon>
        Button.ts
      </l-tree-item>
      <l-tree-item>
        <l-icon
          slot="prefix"
          name="lucide:file-code"
        ></l-icon>
        Tree.ts
      </l-tree-item>
    </l-tree-item>
    <l-tree-item>
      <l-icon
        slot="prefix"
        name="lucide:file-json"
      ></l-icon>
      package.json
    </l-tree-item>
    <l-tree-item>
      <l-icon
        slot="prefix"
        name="lucide:file-text"
      ></l-icon>
      README.md
    </l-tree-item>
  </l-tree-item>
  <l-tree-item>
    <l-icon
      slot="prefix"
      name="lucide:folder"
    ></l-icon>
    public
    <l-tree-item>
      <l-icon
        slot="prefix"
        name="lucide:image"
      ></l-icon>
      logo.svg
    </l-tree-item>
  </l-tree-item>
</l-tree>

Custom expand icons

Override the expand-icon and collapse-icon slots to show a different icon per state — e.g. a closed folder when the branch is collapsed and an open folder when expanded. Leaves keep the prefix slot for their own icon.

src components Button.ts Tree.ts package.json README.md
Code
html
<l-tree>
  <l-tree-item expanded>
    <l-icon
      slot="expand-icon"
      name="lucide:folder"
    ></l-icon>
    <l-icon
      slot="collapse-icon"
      name="lucide:folder-open"
    ></l-icon>
    src
    <l-tree-item>
      <l-icon
        slot="expand-icon"
        name="lucide:folder"
      ></l-icon>
      <l-icon
        slot="collapse-icon"
        name="lucide:folder-open"
      ></l-icon>
      components
      <l-tree-item>
        <l-icon
          slot="prefix"
          name="lucide:file-code"
        ></l-icon>
        Button.ts
      </l-tree-item>
      <l-tree-item>
        <l-icon
          slot="prefix"
          name="lucide:file-code"
        ></l-icon>
        Tree.ts
      </l-tree-item>
    </l-tree-item>
    <l-tree-item>
      <l-icon
        slot="prefix"
        name="lucide:file-json"
      ></l-icon>
      package.json
    </l-tree-item>
    <l-tree-item>
      <l-icon
        slot="prefix"
        name="lucide:file-text"
      ></l-icon>
      README.md
    </l-tree-item>
  </l-tree-item>
</l-tree>

Multiple selection

Set selection="multiple" to render a native checkbox on every item. Toggling a parent cascades the selection to its descendants and sets the indeterminate state when only some children are selected.

Fruits Apple Banana Cherry Vegetables Carrot Lettuce
Code
html
<l-tree selection="multiple">
  <l-tree-item expanded>
    Fruits
    <l-tree-item>Apple</l-tree-item>
    <l-tree-item selected>Banana</l-tree-item>
    <l-tree-item>Cherry</l-tree-item>
  </l-tree-item>
  <l-tree-item>
    Vegetables
    <l-tree-item>Carrot</l-tree-item>
    <l-tree-item>Lettuce</l-tree-item>
  </l-tree-item>
</l-tree>

Independent selection

Add independent to decouple parents and children: a parent can be selected without ticking any of its descendants and vice-versa. Useful when nodes represent independent concepts (categories, tags, permissions) rather than aggregations.

Region: Europe France Germany Italy Region: Americas Brazil United States
Code
html
<l-tree
  selection="multiple"
  independent
>
  <l-tree-item expanded>
    Region: Europe
    <l-tree-item>France</l-tree-item>
    <l-tree-item>Germany</l-tree-item>
    <l-tree-item>Italy</l-tree-item>
  </l-tree-item>
  <l-tree-item expanded>
    Region: Americas
    <l-tree-item>Brazil</l-tree-item>
    <l-tree-item>United States</l-tree-item>
  </l-tree-item>
</l-tree>

Leaf-only selection

Set selection="leaf" when only terminal nodes represent selectable values. Clicking a branch only toggles its expansion.

Components Button Dropdown Tree Tokens Colors Spacing
Code
html
<l-tree selection="leaf">
  <l-tree-item expanded>
    Components
    <l-tree-item>Button</l-tree-item>
    <l-tree-item>Dropdown</l-tree-item>
    <l-tree-item>Tree</l-tree-item>
  </l-tree-item>
  <l-tree-item>
    Tokens
    <l-tree-item>Colors</l-tree-item>
    <l-tree-item>Spacing</l-tree-item>
  </l-tree-item>
</l-tree>

Disabled items

Add disabled to any item to prevent selection and interaction. The item remains visible and part of the structure.

Settings Profile Billing (locked) Notifications
Code
html
<l-tree selection="multiple">
  <l-tree-item expanded>
    Settings
    <l-tree-item>Profile</l-tree-item>
    <l-tree-item disabled>Billing (locked)</l-tree-item>
    <l-tree-item>Notifications</l-tree-item>
  </l-tree-item>
</l-tree>

Lazy loading

Add lazy to an item whose children will be fetched on first expand. The component emits lazy-load; set loading to render a spinner in place of the chevron, then append children and remove lazy.

Load async children
Code
vue
<script setup>
import { ref } from 'vue';

const loading = ref(false);
const lazy = ref(true);
const children = ref([]);
// Bumped on reset to force Vue to destroy + recreate the tree-item,
// which naturally wipes its internal `expanded` state.
const instance = ref(0);

async function onLazyLoad() {
  if (!lazy.value) return;
  loading.value = true;
  await new Promise((r) => setTimeout(r, 1200));
  children.value = ['Remote item 1', 'Remote item 2', 'Remote item 3'];
  loading.value = false;
  lazy.value = false;
}

function reset() {
  lazy.value = true;
  children.value = [];
  instance.value++;
}
</script>

<template>
  <div
    class="vp-raw component-wrapper bg-surface-sunken"
    style="padding-block: 24px; padding-inline: 12px; flex-direction: column; align-items: stretch"
  >
    <l-tree style="width: 100%">
      <l-tree-item
        :key="instance"
        :lazy="lazy || null"
        :loading="loading || null"
        @lazy-load="onLazyLoad"
      >
        Load async children
        <l-tree-item
          v-for="label in children"
          :key="label"
        >
          {{ label }}
        </l-tree-item>
      </l-tree-item>
    </l-tree>

    <button
      type="button"
      class="l-button"
      data-size="sm"
      style="align-self: flex-start; margin-top: 8px"
      @click="reset"
    >
      Reset
    </button>
  </div>
</template>

Examples

Row actions

Place any interactive element inside a <l-tree-item> to expose per-row actions (e.g. an <l-dropdown> menu, a button, a link). Clicks on <button>, <a>, <input> and elements with role="button" or role="menuitem" never toggle the row's selection or expansion.

This demo is controlled from Vue state: the yellow trigger is rendered only on the selected row via v-if, so clicking another row moves the button there without duplicating it in the DOM.

ACME Holding organisation Ajouter un service Renommer « ACME Holding » Modifier le niveau Déplacer vers… Supprimer Finance direction Ressources humaines direction Opérations direction Production département Usine Nord site Assemblage service
Code
vue
<script setup>
import { ref } from 'vue';

const tree = [
  {
    id: 'acme',
    label: 'ACME Holding',
    tag: 'organisation',
    icon: 'lucide:network',
    expanded: true,
    children: [
      { id: 'finance', label: 'Finance', tag: 'direction', icon: 'lucide:landmark' },
      { id: 'rh', label: 'Ressources humaines', tag: 'direction', icon: 'lucide:landmark' },
      {
        id: 'operations',
        label: 'Opérations',
        tag: 'direction',
        icon: 'lucide:landmark',
        expanded: true,
        children: [
          {
            id: 'production',
            label: 'Production',
            tag: 'département',
            icon: 'lucide:landmark',
            expanded: true,
            children: [
              {
                id: 'usine-nord',
                label: 'Usine Nord',
                tag: 'site',
                icon: 'lucide:landmark',
                expanded: true,
                children: [
                  {
                    id: 'assemblage',
                    label: 'Assemblage',
                    tag: 'service',
                    icon: 'lucide:landmark',
                  },
                ],
              },
            ],
          },
        ],
      },
    ],
  },
];

const selectedId = ref('acme');

function onSelectionChange(event) {
  selectedId.value = event.detail.selection[0]?.dataset?.id ?? null;
}
</script>

<template>
  <div
    class="vp-raw component-wrapper bg-surface-sunken"
    style="padding-block: 24px; padding-inline: 12px"
  >
    <l-tree
      selection="single"
      style="width: 100%"
      @selection-change="onSelectionChange"
    >
      <TreeActionsNode
        v-for="node in tree"
        :key="node.id"
        :node="node"
        :selected-id="selectedId"
      />
    </l-tree>
  </div>
</template>
vue
<script setup>
defineOptions({ name: 'TreeActionsNode' });
defineProps({
  node: { type: Object, required: true },
  selectedId: { type: [String, null], default: null },
});
</script>

<template>
  <l-tree-item
    :data-id="node.id"
    :selected="selectedId === node.id || null"
    :expanded="node.expanded || null"
  >
    <l-icon
      slot="prefix"
      :name="node.icon"
    />
    {{ node.label }}
    <span
      v-if="node.tag"
      class="tree-tag"
      >{{ node.tag }}</span
    >

    <l-dropdown
      v-if="selectedId === node.id"
      placement="bottom-start"
    >
      <button
        slot="trigger"
        class="inline-flex items-center justify-center w-7 h-6 rounded-md bg-yellow-300 hover:bg-yellow-400 text-yellow-900 cursor-pointer"
        aria-label="Actions"
      >
        <l-icon name="lucide:ellipsis" />
      </button>
      <l-dropdown-item>
        Ajouter un service
        <l-icon
          slot="suffix"
          name="lucide:plus"
        />
      </l-dropdown-item>
      <l-dropdown-item>
        Renommer « {{ node.label }} »
        <l-icon
          slot="suffix"
          name="lucide:type"
        />
      </l-dropdown-item>
      <l-dropdown-item>
        Modifier le niveau
        <l-icon
          slot="suffix"
          name="lucide:pencil"
        />
      </l-dropdown-item>
      <l-dropdown-item>
        Déplacer vers…
        <l-icon
          slot="suffix"
          name="lucide:corner-down-right"
        />
      </l-dropdown-item>
      <l-dropdown-item disabled>
        Supprimer
        <l-icon
          slot="suffix"
          name="lucide:trash-2"
        />
      </l-dropdown-item>
    </l-dropdown>

    <TreeActionsNode
      v-for="child in node.children"
      :key="child.id"
      :node="child"
      :selected-id="selectedId"
    />
  </l-tree-item>
</template>

<style scoped>
.tree-tag {
  font-size: 0.75rem;
  font-weight: 500;
  padding: 0 0.25rem;
  margin-inline: 0.125rem;
  border-radius: 0.25rem;
  color: rgb(194 65 12);
  background: rgb(255 237 213);
}
</style>

Project roadmap

A roadmap of phases and tasks, where collapsing folds away phase details for a high-level overview. Demonstrates the prefix slot (per-row icon), the default slot (title), and the content slot (block-level description below the row — visible for leaves, hidden when a branch is collapsed). The ::part(base) and ::part(label) are overridden to top-align rows and let descriptions wrap.

Phase 1 — Discovery

Map stakeholders, constraints, and success criteria before any design work begins.

Stakeholder interviews

Schedule 30-min calls with each domain owner. Capture pain points and existing workflows.

Existing system audit

Inventory the current stack. Note hard dependencies and pieces that block any migration.

Phase 2 — Design

Turn discovery insights into wireframes, a component inventory, and design tokens.

Low-fidelity wireframesDesign tokens
Phase 3 — Implementation

Ship in two-week increments behind feature flags. Each increment is independently shippable.

Code
vue
<template>
  <div
    class="vp-raw component-wrapper bg-surface-sunken roadmap-wrapper"
    style="padding: 20px 16px; align-items: stretch; flex-direction: column"
  >
    <l-tree
      class="roadmap-tree"
      selection="none"
    >
      <l-tree-item expanded>
        <l-icon
          slot="prefix"
          name="lucide:compass"
          class="text-lg"
          style="color: #38bdf8"
        ></l-icon>

        <span class="font-semibold">Phase 1 — Discovery</span>

        <p
          slot="content"
          class="m-0 pt-1 pb-2 text-[0.875rem] leading-[1.55] text-[color:var(--vp-c-text-2)]"
        >
          Map stakeholders, constraints, and success criteria before any design work begins.
        </p>

        <l-tree-item>
          <l-icon
            slot="prefix"
            name="lucide:square-check-big"
            class="text-base"
            style="color: var(--vp-c-text-3)"
          ></l-icon>
          <span>Stakeholder interviews</span>
          <p
            slot="content"
            class="m-0 pt-1 pb-2 text-[0.8125rem] leading-[1.55] text-[color:var(--vp-c-text-2)]"
          >
            Schedule 30-min calls with each domain owner. Capture pain points and existing
            workflows.
          </p>
        </l-tree-item>

        <l-tree-item>
          <l-icon
            slot="prefix"
            name="lucide:square-check-big"
            class="text-base"
            style="color: var(--vp-c-text-3)"
          ></l-icon>
          <span>Existing system audit</span>
          <p
            slot="content"
            class="m-0 pt-1 pb-2 text-[0.8125rem] leading-[1.55] text-[color:var(--vp-c-text-2)]"
          >
            Inventory the current stack. Note hard dependencies and pieces that block any migration.
          </p>
        </l-tree-item>
      </l-tree-item>

      <l-tree-item>
        <l-icon
          slot="prefix"
          name="lucide:pencil-ruler"
          class="text-lg"
          style="color: #a78bfa"
        ></l-icon>

        <span class="font-semibold">Phase 2 — Design</span>

        <p
          slot="content"
          class="m-0 pt-1 pb-2 text-[0.875rem] leading-[1.55] text-[color:var(--vp-c-text-2)]"
        >
          Turn discovery insights into wireframes, a component inventory, and design tokens.
        </p>

        <l-tree-item>
          <l-icon
            slot="prefix"
            name="lucide:square"
            class="text-base"
            style="color: var(--vp-c-text-3)"
          ></l-icon>
          <span>Low-fidelity wireframes</span>
        </l-tree-item>

        <l-tree-item>
          <l-icon
            slot="prefix"
            name="lucide:square"
            class="text-base"
            style="color: var(--vp-c-text-3)"
          ></l-icon>
          <span>Design tokens</span>
        </l-tree-item>
      </l-tree-item>

      <l-tree-item>
        <l-icon
          slot="prefix"
          name="lucide:hammer"
          class="text-lg"
          style="color: #4ade80"
        ></l-icon>

        <span class="font-semibold">Phase 3 — Implementation</span>

        <p
          slot="content"
          class="m-0 pt-1 pb-2 text-[0.875rem] leading-[1.55] text-[color:var(--vp-c-text-2)]"
        >
          Ship in two-week increments behind feature flags. Each increment is independently
          shippable.
        </p>
      </l-tree-item>
    </l-tree>
  </div>
</template>

<style>
/* Unscoped so ::part() reaches into the shadow DOM. */
.roadmap-tree {
  --indent-size: 1.5rem;
  --row-height: 2rem;
  --indent-guide-color: light-dark(rgb(0 0 0 / 12%), rgb(255 255 255 / 15%));
  width: 100%;
  max-width: 720px;
  margin: 0 auto;
}

.roadmap-tree l-tree-item::part(base) {
  padding-block: 0.375rem;
  padding-inline-end: 0;
  background: transparent;
  border-radius: 0;
}
</style>

Accessibility

Criteria

Role

Container has role="tree", items have role="treeitem", groups have role="group"

WCAG4.1.2
RGAA7.1
Expanded state

Branches expose aria-expanded reflecting open state. Leaf nodes omit the attribute.

WCAG4.1.2
Selected state

Selected items expose aria-selected="true"

WCAG4.1.2
Multi-selectable

Container sets aria-multiselectable="true" when selection="multiple"

WCAG4.1.2
Disabled state

Disabled items expose aria-disabled="true" and stay in the DOM for discoverability

WCAG4.1.2
Focus management

Roving tabindex: only one item is in the tab order at a time; arrow keys move focus within the tree

WCAG2.4.3
RGAA10.7
Motion

Chevron rotation and spinner respect prefers-reduced-motion

WCAG2.3.3

Keyboard interactions

ArrowDown
Moves focus to the next visible item
ArrowUp
Moves focus to the previous visible item
ArrowRight
Expands a collapsed branch, or moves to the first child when already expanded
ArrowLeft
Collapses an expanded branch, or moves to the parent item
Home
Moves focus to the first visible item
End
Moves focus to the last visible item
Enter
Activates the item (selects or toggles expansion depending on mode)
Space
Activates the item (selects or toggles expansion depending on mode)
*
Expands all sibling branches of the focused item

Selectors & testing

Roles and ARIA states are set on ElementInternals (the accessibility-tree source) and mirrored to DOM attributes, so both [role] and [aria-*] selectors keep matching in CSS, querySelector, and Cypress/Playwright. Selection state is additionally exposed as the reflected selected/expanded/disabled/indeterminate boolean attributes (the component's own API).

js
document.querySelectorAll('[role="treeitem"]'); // ✅ role is mirrored to an attribute
document.querySelectorAll('[aria-selected="true"]'); // ✅ ARIA state is mirrored too
tree.querySelectorAll('l-tree-item[selected]'); // ✅ reflected boolean attribute
screen.getByRole('treeitem', { selected: true, expanded: true }); // ✅ name + state
css
/* Style by ARIA state or the reflected boolean attribute — both work. */
l-tree-item[aria-selected='true']::part(base) {
  background: var(--l-color-bg-fill-brand-subtle);
}

API reference

Importing

js
import 'luxen-ui/tree';
import 'luxen-ui/tree-item';

Attributes & Properties

selectionTreeSelectiondefault:'single'Property
Selection behaviour: - single (default): at most one item selected via aria-selected. - multiple: any number of items selected. Checkboxes are rendered. - leaf: only leaf items can be selected (single). Branches just toggle. - none: purely navigable, no selection state.
independentbooleandefault:falseProperty
When set with selection="multiple", parent and children selection are decoupled: toggling a parent does NOT toggle its descendants and vice versa. Without it, selection cascades both ways and branches may become indeterminate.

Methods

getAllItems({ includeDisabled = true }: unknown)TreeItem[]Method
Returns all items in document (flat) order, including nested ones.
getSelection()TreeItem[]Method
Returns currently selected items.
expandAll()Method
Expands every item that has children.
collapseAll()Method
Collapses every item.

Events

selection-changeEvent
Fired when the selected items change. Detail: { selection: TreeItem[] }.

CSS custom properties

--indent-sizedefault:1remCustom property
Horizontal indent per depth level.
--indent-guide-widthdefault:1pxCustom property
Thickness of the vertical guide line between a parent and its children. Set to 0 to hide guides.
--indent-guide-styledefault:solidCustom property
Line style of the guide (solid, dashed, dotted, double…).
--indent-guide-colorCustom property
Color of the guide line.
--row-heightdefault:1.75remCustom property
Minimum row height.
--row-padding-inlinedefault:0.25remCustom property
Inner inline padding of the row; also drives the content slot left indent and the indent guide column.
--chevron-sizedefault:1.125remCustom property
Size of the expand/collapse chevron box.
--item-gapdefault:0.375remCustom property
Horizontal gap between chevron, prefix, label and suffix on the row; also drives the content slot left indent.

See <l-tree-item> for the per-item API.