Tree
A WAI-ARIA TreeView for data you explore - files, orgs, nested settings. Lazy-load branches from the server or run it fully client-side; keyboard works end to end.
A project tree
Nested maps in, arbitrary depth out. Branches get a chevron and folder icons, leaves get a document icon and the reserved chevron column so labels stay aligned. default_expanded seeds which branches open at first render; after that a click anywhere on a folder row toggles it, chevron included, client-side and with no round-trip.
-
lib
-
petal_components
-
tree.ex
-
-
petal_components.ex
-
-
assets
-
default.css
-
-
mix.exs
-
README.md
<.tree
id="sx-tree-basic"
label="Project files"
default_expanded={["lib", "petal_components"]}
items={[
%{
id: "lib",
label: "lib",
children: [
%{
id: "petal_components",
label: "petal_components",
children: [
%{id: "button.ex", label: "button.ex"},
%{id: "tree.ex", label: "tree.ex"}
]
},
%{id: "petal_components.ex", label: "petal_components.ex"}
]
},
%{
id: "assets",
label: "assets",
children: [%{id: "default.css", label: "default.css"}]
},
%{id: "mix.exs", label: "mix.exs"},
%{id: "README.md", label: "README.md"}
]}
/>
Indent guides and a selected node
show_guides draws a hairline down each open branch so a deep tree still reads as a hierarchy. The guides are a decorative aria-hidden layer, so screen readers never meet them. selected marks one node with aria-selected and the soft primary fill; selection is single-select and the server owns which id is current.
-
accounts
-
user.ex
-
billing
-
checkout.ex
-
invoice.ex
-
-
-
application.ex
-
_build
<.tree
id="sx-tree-guides"
label="Explorer"
show_guides
selected="checkout.ex"
default_expanded={:all}
items={[
%{
id: "accounts",
label: "accounts",
children: [
%{id: "user.ex", label: "user.ex"},
%{
id: "billing",
label: "billing",
children: [
%{id: "checkout.ex", label: "checkout.ex"},
%{id: "invoice.ex", label: "invoice.ex"}
]
}
]
},
%{id: "application.ex", label: "application.ex"},
%{id: "_build", label: "_build", disabled: true}
]}
/>
Custom icons and a lazy branch
Any node can name its own heroicon with :icon. A node marked :lazy is a branch before its children exist: expand it and the tree shows the loading row until the server hands over the children. Lazy branches need the server-controlled expansion model, so this one passes :expanded and an :on_expand event. It also turns expand_on_click off: this is a settings nav where clicking a row opens that page, so expansion belongs to the chevron alone rather than riding along with every pick.
-
Workspace
-
General
-
Members
-
Billing
-
-
Integrations
- Loading...
-
Danger zone
<.tree
id="sx-tree-icons"
label="Settings"
show_guides
expand_on_click={false}
expanded={["workspace", "integrations"]}
on_expand="toggle_branch"
select_event="open_setting"
selected="members"
items={[
%{
id: "workspace",
label: "Workspace",
icon: "hero-building-office-2",
children: [
%{id: "general", label: "General", icon: "hero-cog-6-tooth"},
%{id: "members", label: "Members", icon: "hero-users"},
%{id: "billing-plan", label: "Billing", icon: "hero-credit-card"}
]
},
%{
id: "integrations",
label: "Integrations",
icon: "hero-puzzle-piece",
lazy: true
},
%{id: "danger", label: "Danger zone", icon: "hero-exclamation-triangle"}
]}
/>
Custom rows with the :item slot
The :item slot replaces the icon and label content of every row and receives the whole node map, so extra keys on your data are yours to render. The chevron, the indent, the guides and every ARIA attribute stay owned by the component - you are styling a row, not rebuilding a tree.
-
D Dana Okafor VP Engineering
-
S Sam Reyes Platform lead
-
K Kit Alvarez Senior engineer
-
N Noor Haddad Engineer
-
-
W Wren Costa Design lead
-
<.tree
id="sx-tree-slot"
label="Reporting lines"
show_guides
default_expanded={:all}
items={[
%{
id: "dana",
label: "Dana Okafor",
title: "VP Engineering",
children: [
%{
id: "sam",
label: "Sam Reyes",
title: "Platform lead",
children: [
%{id: "kit", label: "Kit Alvarez", title: "Senior engineer"},
%{id: "noor", label: "Noor Haddad", title: "Engineer"}
]
},
%{id: "wren", label: "Wren Costa", title: "Design lead"}
]
}
]}
>
<:item :let={person}>
<span class="flex items-center justify-center w-5 h-5 text-[10px] font-semibold rounded-full shrink-0 bg-primary-100 text-primary-700 dark:bg-primary-500/15 dark:text-primary-300">
{String.first(person.label)}
</span>
<span class="font-medium">{person.label}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{person.title}</span>
</:item>
</.tree>
The empty state
Nothing to show gets its own row rather than a blank box. Override the wording with the :empty slot.
- No archived files. Anything you archive shows up here.
<.tree id="sx-tree-empty" label="Archived files" items={[]}>
<:empty>No archived files. Anything you archive shows up here.</:empty>
</.tree>
Properties
| Attribute | Type | Default | Description |
|---|---|---|---|
class
|
any |
nil
|
extra classes for the tree container |
default_expanded
|
any |
[]
|
ids of branches expanded at first render, or `:all` to expand everything. Client-side model only |
expand_on_click
|
boolean |
true
|
whether clicking anywhere on a branch row toggles it, selecting it too when selection is wired. Set false to leave expansion to the chevron alone, for rows whose click has another job. Leaves and the keyboard map behave the same either way |
expanded
|
any |
nil
|
ids of the currently expanded branches (list or MapSet), or `:all`. Setting this switches the tree to the server-controlled expansion model; leave it nil for the client-side default |
id*
|
string |
unique id; the PetalTree hook mounts here for roving focus | |
items
|
list |
[]
|
nested node maps, e.g. `%{id: "lib", label: "lib", children: [...]}`. `:id` and `:label` are required; `:children` makes a node a branch, `:icon` overrides the default icon, `:disabled` makes it non-selectable and `:lazy` marks a branch whose children load async. Extra keys pass through to the `:item` slot |
label
|
string |
nil
|
accessible name for the tree, rendered as aria-label. Skip it if you label the tree with aria-labelledby via rest |
on_expand
|
string |
nil
|
event name pushed by the chevron in the server-controlled model, with the node id in phx-value-id. Ignored when :expanded is nil |
on_select
|
{:struct, Phoenix.LiveView.JS} |
%Phoenix.LiveView.JS{ops: []}
|
extra JS commands composed onto the built-in selection behaviour when a node is chosen |
rest
|
global |
||
select_event
|
string |
nil
|
event name pushed when a node is chosen; the node id rides in phx-value-id |
selected
|
string |
nil
|
id of the currently selected node (single selection) |
show_guides
|
boolean |
false
|
render connecting indent guide lines down each expanded branch |
target
|
any |
nil
|
phx-target for the select and expand events, for trees inside a LiveComponent |
:empty
slot
|
slot |
rendered when :items is empty | |
:item
slot
|
slot |
custom node rendering; receives the node map via :let. The chevron, indent and ARIA wiring stay owned by the component - the slot replaces the icon and label row content only | |
:loading
slot
|
slot |
rendered inside a :lazy branch while its children are pending (default: a small spinner row) |