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.
<l-tree>Options
Basic
Wrap <l-tree-item> nodes inside <l-tree>. Nested <l-tree-item> children become sub-nodes automatically.
Code
<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.
Code
<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.
Code
<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.
Code
<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><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.
Code
<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.
Code
<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.
Code
<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.
Code
<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.
Code
<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.
Toutes les touches de cette maquette correspondent au clavier Apple, certains caractères ne sont juste pas imprimés sur les majuscules.
Mais pour un clavier Windows qu'en est-il?
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).
Code
<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><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"
>· {{ 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
- Expanded state
Branches expose
aria-expandedreflecting 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"whenselection="multiple"WCAG4.1.2- Disabled state
Disabled items expose
aria-disabled="true"and stay in the DOM for discoverabilityWCAG4.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.3RGAA10.7- Motion
Chevron rotation and spinner respect
prefers-reduced-motionWCAG2.3.3
Keyboard interactions
API reference
Importing
import 'luxen-ui/tree';
import 'luxen-ui/tree-item';Attributes & Properties
selectionAttribute- Selection mode:
single(default),multiple,leaf, ornone 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 to0to 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.