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>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
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>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.
Map stakeholders, constraints, and success criteria before any design work begins.
Schedule 30-min calls with each domain owner. Capture pain points and existing workflows.
Inventory the current stack. Note hard dependencies and pieces that block any migration.
Turn discovery insights into wireframes, a component inventory, and design tokens.
Ship in two-week increments behind feature flags. Each increment is independently shippable.
Code
<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
- 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
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).
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/* 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
import 'luxen-ui/tree';
import 'luxen-ui/tree-item';Attributes & Properties
selectionTreeSelectiondefault:'single'Property- Selection behaviour:
-
single(default): at most one item selected viaaria-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
0to 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.