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>

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>

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

Comment thread

A discussion tree styled like a Reddit comment thread. Demonstrates stacking prefix (avatar), the default slot (multi-line content + action buttons), and swapped expand-icon / collapse-icon slots (lucide:circle-plus / lucide:circle-minus). The ::part(base) and ::part(label) are overridden to align content to the top and allow wrapping; --indent-size and --indent-guide-width are tuned for the denser look.

Thin-Brother6266· il y a 9 m.

Toutes les touches de cette maquette correspondent au clavier Apple, certains caractères ne sont juste pas imprimés sur les majuscules.

2
BlutDeCombatAO· il y a 9 m.

Mais pour un clavier Windows qu'en est-il?

0
Thin-Brother6266· il y a 9 m.

Normalement, tu vas devoir remplacer les touches majuscules et la plupart des touches majuscules Windows se vendent avec le clavier (voir le post de bachifu).

1
Code
vue
<script setup>
const thread = [
  {
    id: 1,
    author: 'Thin-Brother6266',
    color: '#38bdf8',
    time: 'il y a 9 m.',
    body: 'Toutes les touches de cette maquette correspondent au clavier Apple, certains caractères ne sont juste pas imprimés sur les majuscules.',
    votes: 2,
    children: [
      {
        id: 2,
        author: 'BlutDeCombat',
        color: '#4ade80',
        isOp: true,
        time: 'il y a 9 m.',
        body: "Mais pour un clavier Windows qu'en est-il?",
        votes: 0,
        children: [
          {
            id: 3,
            author: 'Thin-Brother6266',
            color: '#38bdf8',
            time: 'il y a 9 m.',
            body: 'Normalement, tu vas devoir remplacer les touches majuscules et la plupart des touches majuscules Windows se vendent avec le clavier (voir le post de bachifu).',
            votes: 1,
            children: [],
          },
        ],
      },
    ],
  },
];
</script>

<template>
  <div
    class="vp-raw component-wrapper bg-surface-sunken reddit-wrapper"
    style="padding: 20px 16px; align-items: stretch; flex-direction: column"
  >
    <l-tree
      class="reddit-tree"
      selection="none"
    >
      <TreeRedditComment
        v-for="c in thread"
        :key="c.id"
        :comment="c"
      />
    </l-tree>
  </div>
</template>

<style>
/* Unscoped so ::part() reaches into the shadow DOM. */
.reddit-tree {
  --indent-size: 2rem;
  --indent-guide-width: 2px;
  --indent-guide-color: light-dark(rgb(0 0 0 / 12%), rgb(255 255 255 / 15%));
  --chevron-size: 1.5rem; /* matches <l-avatar size="xs"> = 24px */
  --item-gap: 0.625rem; /* more breathing room between avatar and author */
  width: 100%;
  max-width: 720px;
  margin: 0 auto;
}

.reddit-tree l-tree-item::part(base) {
  padding-block: 0.25rem;
  padding-inline-end: 0;
  background: transparent;
  cursor: default;
  border-radius: 0;
}

.reddit-tree l-tree-item::part(expand-button) {
  color: var(--vp-c-text-2);
}
</style>
vue
<script setup>
defineOptions({ name: 'TreeRedditComment' });
defineProps({
  comment: { type: Object, required: true },
});
</script>

<template>
  <l-tree-item expanded>
    <l-icon
      slot="expand-icon"
      name="lucide:circle-plus"
      class="text-2xl"
    ></l-icon>
    <l-avatar
      slot="collapse-icon"
      size="xs"
      :name="comment.author"
      :style="{ '--color': comment.color, '--appearance': 'circle' }"
    ></l-avatar>

    <div class="flex items-center gap-1 text-sm">
      <strong>{{ comment.author }}</strong>
      <span
        v-if="comment.isOp"
        class="ms-1 rounded-[3px] bg-blue-600 px-[5px] py-[1px] text-[0.625rem] font-semibold tracking-wide text-white"
        >AO</span
      >
      <span class="text-[color:var(--vp-c-text-3)] text-[0.8125rem] font-normal"
        >&middot; {{ comment.time }}</span
      >
    </div>

    <div
      slot="content"
      class="flex flex-col gap-2 pt-1 pb-2"
      @click.stop
    >
      <p class="m-0 text-[0.9375rem] leading-[1.55] text-[color:var(--vp-c-text-1)]">
        {{ comment.body }}
      </p>
      <div
        class="flex flex-wrap items-center gap-1 text-[0.8125rem] text-[color:var(--vp-c-text-2)]"
      >
        <button
          type="button"
          class="inline-flex cursor-pointer items-center gap-1.5 rounded-full border-0 bg-transparent p-1 font-medium hover:bg-[var(--l-color-bg-state-hover)] hover:text-[color:var(--vp-c-text-1)]"
          aria-label="Upvote"
        >
          <l-icon name="lucide:arrow-big-up"></l-icon>
        </button>
        <span class="min-w-4 text-center text-[0.8125rem] font-semibold">{{ comment.votes }}</span>
        <button
          type="button"
          class="inline-flex cursor-pointer items-center gap-1.5 rounded-full border-0 bg-transparent p-1 font-medium hover:bg-[var(--l-color-bg-state-hover)] hover:text-[color:var(--vp-c-text-1)]"
          aria-label="Downvote"
        >
          <l-icon name="lucide:arrow-big-down"></l-icon>
        </button>
        <button
          type="button"
          class="inline-flex cursor-pointer items-center gap-1.5 rounded-full border-0 bg-transparent px-2.5 py-1 font-medium hover:bg-[var(--l-color-bg-state-hover)] hover:text-[color:var(--vp-c-text-1)]"
        >
          <l-icon name="lucide:message-circle"></l-icon> Répondre
        </button>
        <button
          type="button"
          class="inline-flex cursor-pointer items-center gap-1.5 rounded-full border-0 bg-transparent px-2.5 py-1 font-medium hover:bg-[var(--l-color-bg-state-hover)] hover:text-[color:var(--vp-c-text-1)]"
        >
          <l-icon name="lucide:award"></l-icon> Récompenser
        </button>
        <button
          type="button"
          class="inline-flex cursor-pointer items-center gap-1.5 rounded-full border-0 bg-transparent px-2.5 py-1 font-medium hover:bg-[var(--l-color-bg-state-hover)] hover:text-[color:var(--vp-c-text-1)]"
        >
          <l-icon name="lucide:share"></l-icon> Partager
        </button>
        <button
          type="button"
          class="inline-flex cursor-pointer items-center gap-1.5 rounded-full border-0 bg-transparent p-1 font-medium hover:bg-[var(--l-color-bg-state-hover)] hover:text-[color:var(--vp-c-text-1)]"
          aria-label="More"
        >
          <l-icon name="lucide:more-horizontal"></l-icon>
        </button>
      </div>
    </div>

    <TreeRedditComment
      v-for="child in comment.children"
      :key="child.id"
      :comment="child"
    />
  </l-tree-item>
</template>

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

API reference

Importing

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

Attributes & Properties

selectionAttribute
Selection mode: single (default), multiple, leaf, or none
independentAttribute
When set with selection="multiple", parent and descendants selection are decoupled (no cascade, no indeterminate)

Methods

getAllItems()Method
Returns every <l-tree-item> in document order (including nested)
getSelection()Method
Returns the currently selected items
expandAll()Method
Expands every branch
collapseAll()Method
Collapses every item

Events

selection-changeEvent
Fired when the selection changes. Detail: { selection }

CSS custom properties

--indent-sizeName
Horizontal indent per depth level. Default 1rem
--indent-guide-widthName
Thickness of the vertical guide line between a parent and its children. Default 1px. Set to 0 to hide guides
--indent-guide-styleName
Line style of the guide: solid (default), dashed, dotted, double
--indent-guide-colorName
Color of the guide line
--row-heightName
Minimum row height. Default 1.75rem
--row-padding-inlineName
Inner inline padding of the row; also drives the content slot left indent and the guide column. Default 0.25rem
--chevron-sizeName
Size of the expand/collapse chevron/avatar box. Default 1.125rem
--item-gapName
Horizontal gap between chevron, prefix, label and suffix on the row; also drives the content slot left indent. Default 0.375rem

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