Tree#
Component <neo-tree> is a disclosure tree with collapsible subtrees. Click a row or press Enter / Space on a focused branch to toggle. Expand/collapse rides the kit's --neo-easing / --neo-duration-scale tokens, so each theme paces it on its own.
The preview routes through the in-browser server simulator. Its current Settings (network latency, a non-200 response, or an unreachable server) are in effect, so the live preview can look like it is misbehaving. Adjust them in the Settings panel.
The selected state's markup and its scoped CSS. The CSS is wrapped in <style>@scope { ... }</style> and applies to the preview only. Edits change the preview above, applied live while Sync is on, or when you click Patch or Replace. Patch morphs the preview; Replace swaps it outright.
Signals
Datastar signals declared in the selected state's markup. Change a value to drive the preview, applied live while Sync is on, or when you click Patch.
Reference#
Attributes #
| Name | Type | Default | Description |
|---|---|---|---|
expanded | boolean | false | On <neo-tree-item>: branch disclosure state. |
Slots #
| Name | Description |
|---|---|
| default | Nested <neo-tree-item> children inside <neo-tree>. |
[data-neo-tree-label] | Row content of a <neo-tree-item>. |
[data-neo-tree-text] | Label text inside [data-neo-tree-label]; fills the row and truncates with an ellipsis. |
[data-neo-tree-actions] | Trailing action controls inside [data-neo-tree-label]; pinned to the row's end and don't shrink. |
slot="icon" | Optional leading icon for a <neo-tree-item> row, projected before the label. |
Events #
| Name | Detail | Bubbles | Description |
|---|---|---|---|
neo-tree-item-toggle | { expanded: boolean, id: string, item: HTMLElement } | yes | Fires when a branch expands or collapses. |
Parts #
| Name | Description |
|---|---|
::part(row) | Each item's row: the chevron plus the slotted label. |
::part(children) | Wrapper around a branch's nested items, animated open and closed. |
CSS variables #
| Name | Default | Description |
|---|---|---|
--neo-tree-row-padding | calc(var(--page-spacing, 0.25rem) * 1.25) calc(var(--page-spacing, 0.25rem) * 2) | Padding inside each row. |
--neo-tree-row-gap | calc(var(--page-spacing, 0.25rem) * 2) | Gap between the chevron, label, and actions in a row. |
--neo-tree-indent | calc(var(--page-spacing, 0.25rem) * 5) | Indentation added per nesting level. |
--neo-tree-row-radius | var(--page-radius, 0.25rem) | Corner radius of a row's hover and focus background. |
--neo-tree-row-hover-bg | rgba(127, 127, 127, 0.12) | Row background on hover. |
--neo-tree-chevron-size | 0.85rem | Chevron icon size. |
--neo-tree-duration | calc(200ms * var(--neo-duration-scale, 1)) | Expand and collapse animation duration. |
Examples#
File system#
The canonical shape: the first level and one nested branch start expanded, the rest collapsed. Click any branch to watch the disclosure animate.
<neo-tree role="tree">
<neo-tree-item role="treeitem" tabindex="-1" expanded>
<div data-neo-tree-label>src</div>
<neo-tree-item role="treeitem" tabindex="-1" expanded>
<div data-neo-tree-label>components</div>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>Button.tsx</div>
</neo-tree-item>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>Card.tsx</div>
</neo-tree-item>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>Modal.tsx</div>
</neo-tree-item>
</neo-tree-item>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>hooks</div>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>useDebounce.ts</div>
</neo-tree-item>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>useMediaQuery.ts</div>
</neo-tree-item>
</neo-tree-item>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>App.tsx</div>
</neo-tree-item>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>main.tsx</div>
</neo-tree-item>
</neo-tree-item>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>public</div>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>favicon.svg</div>
</neo-tree-item>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>robots.txt</div>
</neo-tree-item>
</neo-tree-item>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>package.json</div>
</neo-tree-item>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>tsconfig.json</div>
</neo-tree-item>
</neo-tree>package examples
import "github.com/romshark/morpheus/neo"
// treeFsNode is one node of the file-system demo tree. Branches carry
// Children; leaves leave it nil.
type treeFsNode struct {
Label string
Expanded bool
Children []treeFsNode
}
var treeFsData = []treeFsNode{
{Label: "src", Expanded: true, Children: []treeFsNode{
{Label: "components", Expanded: true, Children: []treeFsNode{
{Label: "Button.tsx"},
{Label: "Card.tsx"},
{Label: "Modal.tsx"},
}},
{Label: "hooks", Children: []treeFsNode{
{Label: "useDebounce.ts"},
{Label: "useMediaQuery.ts"},
}},
{Label: "App.tsx"},
{Label: "main.tsx"},
}},
{Label: "public", Children: []treeFsNode{
{Label: "favicon.svg"},
{Label: "robots.txt"},
}},
{Label: "package.json"},
{Label: "tsconfig.json"},
}
// treeFsItem renders any depth; recursion lives in the same file so the
// embedded source is self-contained.
templ treeFsItem(n treeFsNode) {
if len(n.Children) == 0 {
@neo.TreeItem(false) {
@neo.TreeLabel() {
{ n.Label }
}
}
} else {
@neo.TreeItem(n.Expanded) {
@neo.TreeLabel() {
{ n.Label }
}
for _, c := range n.Children {
@treeFsItem(c)
}
}
}
}
templ TreeFileSystem() {
@neo.Tree() {
for _, n := range treeFsData {
@treeFsItem(n)
}
}
}
Categories (deep nesting)#
Six levels under one expanded root, with uniform per-level indentation. Click a collapsed inner branch to see the chevron rotate.
<neo-tree role="tree">
<neo-tree-item role="treeitem" tabindex="-1" expanded>
<div data-neo-tree-label>Animals</div>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>Mammals</div>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>Carnivores</div>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>Felidae</div>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>Lion</div>
</neo-tree-item>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>Tiger</div>
</neo-tree-item>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>Cheetah</div>
</neo-tree-item>
</neo-tree-item>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>Canidae</div>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>Wolf</div>
</neo-tree-item>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>Fox</div>
</neo-tree-item>
</neo-tree-item>
</neo-tree-item>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>Primates</div>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>Hominidae</div>
</neo-tree-item>
</neo-tree-item>
</neo-tree-item>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>Birds</div>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>Raptors</div>
</neo-tree-item>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>Songbirds</div>
</neo-tree-item>
</neo-tree-item>
</neo-tree-item>
</neo-tree>package examples
import "github.com/romshark/morpheus/neo"
// treeCatNode is one taxonomy node. Branches carry Children; leaves
// leave it nil.
type treeCatNode struct {
Label string
Expanded bool
Children []treeCatNode
}
var treeCatData = []treeCatNode{
{Label: "Animals", Expanded: true, Children: []treeCatNode{
{Label: "Mammals", Children: []treeCatNode{
{Label: "Carnivores", Children: []treeCatNode{
{Label: "Felidae", Children: []treeCatNode{
{Label: "Lion"},
{Label: "Tiger"},
{Label: "Cheetah"},
}},
{Label: "Canidae", Children: []treeCatNode{
{Label: "Wolf"},
{Label: "Fox"},
}},
}},
{Label: "Primates", Children: []treeCatNode{
{Label: "Hominidae"},
}},
}},
{Label: "Birds", Children: []treeCatNode{
{Label: "Raptors"},
{Label: "Songbirds"},
}},
}},
}
// treeCatItem renders any depth; same-file recursion keeps the embedded
// source self-contained.
templ treeCatItem(n treeCatNode) {
if len(n.Children) == 0 {
@neo.TreeItem(false) {
@neo.TreeLabel() {
{ n.Label }
}
}
} else {
@neo.TreeItem(n.Expanded) {
@neo.TreeLabel() {
{ n.Label }
}
for _, c := range n.Children {
@treeCatItem(c)
}
}
}
}
templ TreeCategories() {
@neo.Tree() {
for _, n := range treeCatData {
@treeCatItem(n)
}
}
}
Single root, all collapsed#
Every branch starts closed, confirming the disclosure handle draws on the first row before any state is touched.
<neo-tree role="tree">
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>Documents</div>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>Invoices</div>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>2024-Q4.pdf</div>
</neo-tree-item>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>2025-Q1.pdf</div>
</neo-tree-item>
</neo-tree-item>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>Reports</div>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>Annual.docx</div>
</neo-tree-item>
</neo-tree-item>
</neo-tree-item>
</neo-tree>package examples
import "github.com/romshark/morpheus/neo"
templ TreeSingleRoot() {
@neo.Tree() {
@neo.TreeItem(false) {
@neo.TreeLabel() {
Documents
}
@neo.TreeItem(false) {
@neo.TreeLabel() {
Invoices
}
@neo.TreeItem(false) {
@neo.TreeLabel() {
2024-Q4.pdf
}
}
@neo.TreeItem(false) {
@neo.TreeLabel() {
2025-Q1.pdf
}
}
}
@neo.TreeItem(false) {
@neo.TreeLabel() {
Reports
}
@neo.TreeItem(false) {
@neo.TreeLabel() {
Annual.docx
}
}
}
}
}
}
File explorer in a sidebar#
Compose rich rows from an icon, text, and trailing actions. Wrapped in a <neo-sidebar> so the actions hug the content edge at any width.
- The folder / file icon is authored; the kit doesn't infer it.
[data-neo-tree-text]marks the row text so it consumes the available width.[data-neo-tree-actions]pins trailing controls (here an inert eye button) to the tree's right edge.
The eye buttons stay flush with the sidebar's right edge no matter how deep the tree nests: that's the [data-neo-tree-actions] wrapper plus margin-left: auto.
<neo-layout class="sidebar-preview sidebar-preview--fill">
<neo-sidebar id="tree-sidebar" overlay-breakpoint="24rem">
<header data-neo-sidebar-header><span>Files</span></header>
<div data-neo-sidebar-content>
<neo-tree role="tree" aria-label="Project files">
<neo-tree-item role="treeitem" tabindex="-1" expanded>
<div data-neo-tree-label>
<neo-icon name="folder"></neo-icon>
<span data-neo-tree-text>src</span>
<span data-neo-tree-actions>
<neo-button size="sm" aria-label="Preview src">
<neo-icon name="eye"></neo-icon>
</neo-button>
</span>
</div>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>
<neo-icon name="folder"></neo-icon>
<span data-neo-tree-text>components</span>
<span data-neo-tree-actions>
<neo-button size="sm" aria-label="Preview components">
<neo-icon name="eye"></neo-icon>
</neo-button>
</span>
</div>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>
<neo-icon name="file"></neo-icon>
<span data-neo-tree-text>Button.tsx</span>
<span data-neo-tree-actions>
<neo-button size="sm" aria-label="Preview Button.tsx">
<neo-icon name="eye"></neo-icon>
</neo-button>
</span>
</div>
</neo-tree-item>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>
<neo-icon name="file"></neo-icon>
<span data-neo-tree-text>Card.tsx</span>
<span data-neo-tree-actions>
<neo-button size="sm" aria-label="Preview Card.tsx">
<neo-icon name="eye"></neo-icon>
</neo-button>
</span>
</div>
</neo-tree-item>
</neo-tree-item>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>
<neo-icon name="file"></neo-icon>
<span data-neo-tree-text>App.tsx</span>
<span data-neo-tree-actions>
<neo-button size="sm" aria-label="Preview App.tsx">
<neo-icon name="eye"></neo-icon>
</neo-button>
</span>
</div>
</neo-tree-item>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>
<neo-icon name="file"></neo-icon>
<span data-neo-tree-text>main.tsx</span>
<span data-neo-tree-actions>
<neo-button size="sm" aria-label="Preview main.tsx">
<neo-icon name="eye"></neo-icon>
</neo-button>
</span>
</div>
</neo-tree-item>
</neo-tree-item>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>
<neo-icon name="folder"></neo-icon>
<span data-neo-tree-text>public</span>
<span data-neo-tree-actions>
<neo-button size="sm" aria-label="Preview public">
<neo-icon name="eye"></neo-icon>
</neo-button>
</span>
</div>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>
<neo-icon name="file"></neo-icon>
<span data-neo-tree-text>favicon.svg</span>
<span data-neo-tree-actions>
<neo-button size="sm" aria-label="Preview favicon.svg">
<neo-icon name="eye"></neo-icon>
</neo-button>
</span>
</div>
</neo-tree-item>
</neo-tree-item>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>
<neo-icon name="file"></neo-icon>
<span data-neo-tree-text>package.json</span>
<span data-neo-tree-actions>
<neo-button size="sm" aria-label="Preview package.json">
<neo-icon name="eye"></neo-icon>
</neo-button>
</span>
</div>
</neo-tree-item>
<neo-tree-item role="treeitem" tabindex="-1">
<div data-neo-tree-label>
<neo-icon name="file"></neo-icon>
<span data-neo-tree-text>tsconfig.json</span>
<span data-neo-tree-actions>
<neo-button size="sm" aria-label="Preview tsconfig.json">
<neo-icon name="eye"></neo-icon>
</neo-button>
</span>
</div>
</neo-tree-item>
</neo-tree>
</div>
</neo-sidebar>
<neo-layout class="sidebar-preview-main" column>
<header class="sidebar-preview-bar">
<neo-button
aria-label="Toggle sidebar"
data-on:click="document.getElementById('tree-sidebar').toggle()"
>
<neo-icon name="menu"></neo-icon>
</neo-button>
<strong>Editor</strong>
</header>
<div class="sidebar-preview-body">
<p>The eye buttons stay flush with the sidebar's right edge no matter how deep the tree nests: that's the <code>[data-neo-tree-actions]</code> wrapper plus <code>margin-left: auto</code>.</p>
</div>
</neo-layout>
</neo-layout>package examples
import "github.com/romshark/morpheus/neo"
// treeExplorerNode is one file-explorer node. Icon names the leading
// glyph; Branches carry Children, leaves leave it nil.
type treeExplorerNode struct {
Label string
Icon string
Expanded bool
Children []treeExplorerNode
}
var treeExplorerData = []treeExplorerNode{
{Label: "src", Icon: "folder", Expanded: true, Children: []treeExplorerNode{
{Label: "components", Icon: "folder", Children: []treeExplorerNode{
{Label: "Button.tsx", Icon: "file"},
{Label: "Card.tsx", Icon: "file"},
}},
{Label: "App.tsx", Icon: "file"},
{Label: "main.tsx", Icon: "file"},
}},
{Label: "public", Icon: "folder", Children: []treeExplorerNode{
{Label: "favicon.svg", Icon: "file"},
}},
{Label: "package.json", Icon: "file"},
{Label: "tsconfig.json", Icon: "file"},
}
// treeExplorerItem composes a rich row: icon + text + trailing actions.
// [data-neo-tree-text] takes the available width; [data-neo-tree-actions]
// pins the trailing button to the tree's right edge.
templ treeExplorerItem(n treeExplorerNode) {
if len(n.Children) == 0 {
@neo.TreeItem(false) {
@treeExplorerRow(n)
}
} else {
@neo.TreeItem(n.Expanded) {
@treeExplorerRow(n)
for _, c := range n.Children {
@treeExplorerItem(c)
}
}
}
}
templ treeExplorerRow(n treeExplorerNode) {
@neo.TreeLabel() {
@neo.Icon(n.Icon)
@neo.TreeText() {
{ n.Label }
}
@neo.TreeActions() {
<neo-button size="sm" aria-label={ "Preview " + n.Label }>
@neo.Icon("eye")
</neo-button>
}
}
}
templ TreeExplorer() {
<neo-layout class="sidebar-preview sidebar-preview--fill">
<neo-sidebar id="tree-sidebar" overlay-breakpoint="24rem">
@neo.SidebarHeader() {
<span>Files</span>
}
<div { neo.SidebarContent()... }>
@neo.TreeAttrs(templ.Attributes{"aria-label": "Project files"}) {
for _, n := range treeExplorerData {
@treeExplorerItem(n)
}
}
</div>
</neo-sidebar>
<neo-layout class="sidebar-preview-main" column>
<header class="sidebar-preview-bar">
<neo-button
aria-label="Toggle sidebar"
data-on:click="document.getElementById('tree-sidebar').toggle()"
>
@neo.Icon("menu")
</neo-button>
<strong>Editor</strong>
</header>
<div class="sidebar-preview-body">
<p>
The eye buttons stay flush with the sidebar's right
edge no matter how deep the tree nests: that's the
<code>[data-neo-tree-actions]</code> wrapper plus
<code>{ "margin-left: auto" }</code>.
</p>
</div>
</neo-layout>
</neo-layout>
}
Examples: Datastar#
Async loading (Datastar morph)#
Load branch children lazily on first open. Each unloaded branch renders a skeleton placeholder as its only child.
- Opening a branch bubbles
neo-tree-item-toggleto the root<neo-tree>, whose action posts the opened item's id. - The server patches that one
<neo-tree-item>back with its real children.
Loading states only show with simulated delay. Raise network latency or handler delay in the Settings panel.
<div data-signals:tree_async_node="">
<neo-tree
role="tree"
aria-label="Async-loaded tree"
data-on:neo-tree-item-toggle="if (evt.detail.expanded && evt.target.hasAttribute('data-tree-async')) { $tree_async_node = evt.detail.id; @post('/tree/loadnode/', {requestCancellation: 'disabled'}) }"
id="lazy-tree"
>
<neo-tree-item
role="treeitem"
tabindex="-1"
data-tree-async
data-tree-path="src"
id="lazy-tree-node-src"
>
<div data-neo-tree-label>src</div>
<neo-tree-item role="treeitem" tabindex="-1" aria-disabled="true">
<div data-neo-tree-label class="tree-async-placeholder-label">
<neo-skeleton
variant="line"
class="tree-async-placeholder-skeleton"
></neo-skeleton>
</div>
</neo-tree-item>
</neo-tree-item>
<neo-tree-item
role="treeitem"
tabindex="-1"
data-tree-async
data-tree-path="docs"
id="lazy-tree-node-docs"
>
<div data-neo-tree-label>docs</div>
<neo-tree-item role="treeitem" tabindex="-1" aria-disabled="true">
<div data-neo-tree-label class="tree-async-placeholder-label">
<neo-skeleton
variant="line"
class="tree-async-placeholder-skeleton"
></neo-skeleton>
</div>
</neo-tree-item>
</neo-tree-item>
<neo-tree-item
role="treeitem"
tabindex="-1"
data-tree-async
data-tree-path="tests"
id="lazy-tree-node-tests"
>
<div data-neo-tree-label>tests</div>
<neo-tree-item role="treeitem" tabindex="-1" aria-disabled="true">
<div data-neo-tree-label class="tree-async-placeholder-label">
<neo-skeleton
variant="line"
class="tree-async-placeholder-skeleton"
></neo-skeleton>
</div>
</neo-tree-item>
</neo-tree-item>
</neo-tree>
</div>package examples
import (
"strings"
"github.com/romshark/morpheus/neo"
)
// treeLazyNode is one node of the async-loaded tree. Path uniquely
// identifies the node; the DOM id is derived from it and posted back to
// the server on first open.
type treeLazyNode struct {
Path string
Label string
Children []treeLazyNode
}
var treeLazyData = []treeLazyNode{
{Path: "src", Label: "src", Children: []treeLazyNode{
{Path: "src/components", Label: "components", Children: []treeLazyNode{
{Path: "src/components/Button.tsx", Label: "Button.tsx"},
{Path: "src/components/Card.tsx", Label: "Card.tsx"},
{Path: "src/components/Modal.tsx", Label: "Modal.tsx"},
}},
{Path: "src/hooks", Label: "hooks", Children: []treeLazyNode{
{Path: "src/hooks/useDebounce.ts", Label: "useDebounce.ts"},
{Path: "src/hooks/useMediaQuery.ts", Label: "useMediaQuery.ts"},
}},
{Path: "src/App.tsx", Label: "App.tsx"},
}},
{Path: "docs", Label: "docs", Children: []treeLazyNode{
{Path: "docs/intro.md", Label: "intro.md"},
{Path: "docs/guide", Label: "guide", Children: []treeLazyNode{
{Path: "docs/guide/setup.md", Label: "setup.md"},
{Path: "docs/guide/usage.md", Label: "usage.md"},
}},
{Path: "docs/api.md", Label: "api.md"},
}},
{Path: "tests", Label: "tests", Children: []treeLazyNode{
{Path: "tests/unit", Label: "unit", Children: []treeLazyNode{
{Path: "tests/unit/parser.test.ts", Label: "parser.test.ts"},
{Path: "tests/unit/router.test.ts", Label: "router.test.ts"},
}},
{Path: "tests/integration.test.ts", Label: "integration.test.ts"},
}},
}
// treeLazyNodeID slugifies a path into a stable DOM id the server posts
// back to.
func treeLazyNodeID(path string) string {
var b strings.Builder
b.WriteString("lazy-tree-node-")
lastDash := false
for _, r := range path {
if (r >= 'A' && r <= 'Z') ||
(r >= 'a' && r <= 'z') ||
(r >= '0' && r <= '9') {
b.WriteRune(r)
lastDash = false
continue
}
if !lastDash {
b.WriteByte('-')
lastDash = true
}
}
return b.String()
}
// treeLazyItem renders an unloaded branch (skeleton placeholder child)
// or, when loaded, the branch with its real children. Same-file
// recursion keeps the embedded source self-contained.
templ treeLazyItem(n treeLazyNode, loaded bool) {
if len(n.Children) == 0 {
@neo.TreeItemAttrs(false, templ.Attributes{
"id": treeLazyNodeID(n.Path),
"data-tree-path": n.Path,
}) {
@neo.TreeLabel() {
{ n.Label }
}
}
} else if loaded {
@neo.TreeItemAttrs(true, templ.Attributes{
"id": treeLazyNodeID(n.Path),
"data-tree-path": n.Path,
}) {
@neo.TreeLabel() {
{ n.Label }
}
for _, c := range n.Children {
@treeLazyItem(c, false)
}
}
} else {
@neo.TreeItemAttrs(false, templ.Attributes{
"id": treeLazyNodeID(n.Path),
"data-tree-path": n.Path,
"data-tree-async": true,
}) {
@neo.TreeLabel() {
{ n.Label }
}
@treeLazyPlaceholder()
}
}
}
templ treeLazyPlaceholder() {
@neo.TreeItemAttrs(false, templ.Attributes{"aria-disabled": "true"}) {
@neo.TreeLabelAttrs(templ.Attributes{"class": "tree-async-placeholder-label"}) {
<neo-skeleton variant="line" class="tree-async-placeholder-skeleton"></neo-skeleton>
}
}
}
templ TreeAsync() {
<div data-signals:tree_async_node="">
@neo.TreeAttrs(templ.Attributes{
"id": "lazy-tree",
"aria-label": "Async-loaded tree",
"data-on:neo-tree-item-toggle": "if (evt.detail.expanded && evt.target.hasAttribute('data-tree-async')) " +
"{ $tree_async_node = evt.detail.id; @post('/tree/loadnode/', {requestCancellation: 'disabled'}) }",
}) {
for _, n := range treeLazyData {
@treeLazyItem(n, false)
}
}
</div>
}
.tree-async-placeholder-label {
cursor: default;
}
.tree-async-placeholder-skeleton {
width: 8em;
}
// Server handler for the "Async lazy load" tree example. On expand of
// a `[data-tree-async]` branch, the host @posts here with the focused
// branch id on `$tree_async_node`; the handler looks up the node in
// the seeded JSON, renders its children as a fresh `<neo-tree-item>`
// subtree, and ships it as an element patch. The kit's tree observer
// rewires the new descendants into the focus / expansion model.
//
// Authoring is post-shadow-DOM: a tree-item's structure (chevron +
// animation wrapper) lives in shadow. The HTML emitted here only
// carries the author's content — a `[data-neo-tree-label]` div and
// nested `<neo-tree-item>`s as direct children.
import sim from "/static/datasim.js";
const TREE_DATA = JSON.parse(
document.getElementById("lazy-tree-data").textContent,
);
const TREE_BY_ID = new Map();
function nodeID(prefix, path) {
return prefix + "-" + String(path).replace(/[^A-Za-z0-9]+/g, "-");
}
function indexNode(node) {
TREE_BY_ID.set(nodeID("lazy-tree-node", node.path), node);
for (const child of node.children || []) indexNode(child);
}
for (const node of TREE_DATA) indexNode(node);
function esc(s) {
return String(s)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function placeholder() {
return `<neo-tree-item aria-disabled="true">` +
`<div data-neo-tree-label style="cursor: default;">` +
`<neo-skeleton shape="line" style="width: 8em;"></neo-skeleton>` +
`</div></neo-tree-item>`;
}
function renderNode(node, loaded = false) {
const id = nodeID("lazy-tree-node", node.path);
const label = `<div data-neo-tree-label>${esc(node.label)}</div>`;
if (!node.children || node.children.length === 0) {
return `<neo-tree-item id="${esc(id)}" ` +
`data-tree-path="${esc(node.path)}">${label}</neo-tree-item>`;
}
if (loaded) {
let children = "";
for (const c of node.children) children += renderNode(c);
return `<neo-tree-item expanded id="${esc(id)}" ` +
`data-tree-path="${esc(node.path)}">${label}${children}` +
`</neo-tree-item>`;
}
return `<neo-tree-item id="${esc(id)}" ` +
`data-tree-path="${esc(node.path)}" data-tree-async>` +
`${label}${placeholder()}</neo-tree-item>`;
}
sim.post("/tree/loadnode/", async (ctx, sse) => {
const id = String((ctx.signals || {}).tree_async_node || "");
const node = TREE_BY_ID.get(id);
if (!node) return;
sse.patchElements(renderNode(node, true));
});
Bookmarks library#
Each leaf carries a star button that toggles a Datastar signal local to the button, with no server round-trip. The button's data-attr:data-neo-active binding flips the icon to the accent colour when the signal is true. The row layout is the same icon + text + actions split as the file-explorer demo above.
<neo-tree role="tree" aria-label="Bookmarks">
<neo-tree-item role="treeitem" tabindex="-1" expanded>
<neo-icon name="menu" slot="icon"></neo-icon>
<div data-neo-tree-label>
<neo-icon name="folder"></neo-icon>
<span data-neo-tree-text>Reading</span>
</div>
<neo-tree-item role="treeitem" tabindex="-1">
<neo-icon name="more-horizontal" slot="icon"></neo-icon>
<div data-neo-tree-label>
<neo-icon name="link"></neo-icon>
<span data-neo-tree-text>Frontend Trends 2025</span>
<span data-neo-tree-actions>
<neo-button
class="bookmark-star"
size="sm"
aria-label="Star Frontend Trends 2025"
data-signals="{bookmark_star_trends: true}"
data-on:click="$bookmark_star_trends = !$bookmark_star_trends; evt.stopPropagation()"
data-attr:data-neo-active="$bookmark_star_trends"
data-attr:aria-pressed="$bookmark_star_trends"
>
<neo-icon name="star"></neo-icon>
</neo-button>
</span>
</div>
</neo-tree-item>
<neo-tree-item role="treeitem" tabindex="-1">
<neo-icon name="more-horizontal" slot="icon"></neo-icon>
<div data-neo-tree-label>
<neo-icon name="link"></neo-icon>
<span data-neo-tree-text>CSS Container Queries</span>
<span data-neo-tree-actions>
<neo-button
class="bookmark-star"
size="sm"
aria-label="Star CSS Container Queries"
data-signals="{bookmark_star_queries: false}"
data-on:click="$bookmark_star_queries = !$bookmark_star_queries; evt.stopPropagation()"
data-attr:data-neo-active="$bookmark_star_queries"
data-attr:aria-pressed="$bookmark_star_queries"
>
<neo-icon name="star"></neo-icon>
</neo-button>
</span>
</div>
</neo-tree-item>
<neo-tree-item role="treeitem" tabindex="-1">
<neo-icon name="more-horizontal" slot="icon"></neo-icon>
<div data-neo-tree-label>
<neo-icon name="link"></neo-icon>
<span data-neo-tree-text>Modern TypeScript Patterns</span>
<span data-neo-tree-actions>
<neo-button
class="bookmark-star"
size="sm"
aria-label="Star Modern TypeScript Patterns"
data-signals="{bookmark_star_tspat: false}"
data-on:click="$bookmark_star_tspat = !$bookmark_star_tspat; evt.stopPropagation()"
data-attr:data-neo-active="$bookmark_star_tspat"
data-attr:aria-pressed="$bookmark_star_tspat"
>
<neo-icon name="star"></neo-icon>
</neo-button>
</span>
</div>
</neo-tree-item>
</neo-tree-item>
<neo-tree-item role="treeitem" tabindex="-1" expanded>
<neo-icon name="menu" slot="icon"></neo-icon>
<div data-neo-tree-label>
<neo-icon name="folder"></neo-icon>
<span data-neo-tree-text>Tools</span>
</div>
<neo-tree-item role="treeitem" tabindex="-1">
<neo-icon name="more-horizontal" slot="icon"></neo-icon>
<div data-neo-tree-label>
<neo-icon name="link"></neo-icon>
<span data-neo-tree-text>Excalidraw</span>
<span data-neo-tree-actions>
<neo-button
class="bookmark-star"
size="sm"
aria-label="Star Excalidraw"
data-signals="{bookmark_star_excal: true}"
data-on:click="$bookmark_star_excal = !$bookmark_star_excal; evt.stopPropagation()"
data-attr:data-neo-active="$bookmark_star_excal"
data-attr:aria-pressed="$bookmark_star_excal"
>
<neo-icon name="star"></neo-icon>
</neo-button>
</span>
</div>
</neo-tree-item>
<neo-tree-item role="treeitem" tabindex="-1">
<neo-icon name="more-horizontal" slot="icon"></neo-icon>
<div data-neo-tree-label>
<neo-icon name="link"></neo-icon>
<span data-neo-tree-text>Linear</span>
<span data-neo-tree-actions>
<neo-button
class="bookmark-star"
size="sm"
aria-label="Star Linear"
data-signals="{bookmark_star_linear: false}"
data-on:click="$bookmark_star_linear = !$bookmark_star_linear; evt.stopPropagation()"
data-attr:data-neo-active="$bookmark_star_linear"
data-attr:aria-pressed="$bookmark_star_linear"
>
<neo-icon name="star"></neo-icon>
</neo-button>
</span>
</div>
</neo-tree-item>
<neo-tree-item role="treeitem" tabindex="-1">
<neo-icon name="more-horizontal" slot="icon"></neo-icon>
<div data-neo-tree-label>
<neo-icon name="link"></neo-icon>
<span data-neo-tree-text>Notion</span>
<span data-neo-tree-actions>
<neo-button
class="bookmark-star"
size="sm"
aria-label="Star Notion"
data-signals="{bookmark_star_notion: false}"
data-on:click="$bookmark_star_notion = !$bookmark_star_notion; evt.stopPropagation()"
data-attr:data-neo-active="$bookmark_star_notion"
data-attr:aria-pressed="$bookmark_star_notion"
>
<neo-icon name="star"></neo-icon>
</neo-button>
</span>
</div>
</neo-tree-item>
</neo-tree-item>
<neo-tree-item role="treeitem" tabindex="-1">
<neo-icon name="menu" slot="icon"></neo-icon>
<div data-neo-tree-label>
<neo-icon name="folder"></neo-icon>
<span data-neo-tree-text>References</span>
</div>
<neo-tree-item role="treeitem" tabindex="-1">
<neo-icon name="more-horizontal" slot="icon"></neo-icon>
<div data-neo-tree-label>
<neo-icon name="link"></neo-icon>
<span data-neo-tree-text>MDN Web Docs</span>
<span data-neo-tree-actions>
<neo-button
class="bookmark-star"
size="sm"
aria-label="Star MDN Web Docs"
data-signals="{bookmark_star_mdn: false}"
data-on:click="$bookmark_star_mdn = !$bookmark_star_mdn; evt.stopPropagation()"
data-attr:data-neo-active="$bookmark_star_mdn"
data-attr:aria-pressed="$bookmark_star_mdn"
>
<neo-icon name="star"></neo-icon>
</neo-button>
</span>
</div>
</neo-tree-item>
<neo-tree-item role="treeitem" tabindex="-1">
<neo-icon name="more-horizontal" slot="icon"></neo-icon>
<div data-neo-tree-label>
<neo-icon name="link"></neo-icon>
<span data-neo-tree-text>TC39 Proposals</span>
<span data-neo-tree-actions>
<neo-button
class="bookmark-star"
size="sm"
aria-label="Star TC39 Proposals"
data-signals="{bookmark_star_tc39: false}"
data-on:click="$bookmark_star_tc39 = !$bookmark_star_tc39; evt.stopPropagation()"
data-attr:data-neo-active="$bookmark_star_tc39"
data-attr:aria-pressed="$bookmark_star_tc39"
>
<neo-icon name="star"></neo-icon>
</neo-button>
</span>
</div>
</neo-tree-item>
</neo-tree-item>
</neo-tree>package examples
import "github.com/romshark/morpheus/neo"
// treeBookmarkNode is one node of the bookmarks library. Branches are
// folders; leaves are bookmarks carrying a star toggle. ID namespaces
// the per-row signal; Starred seeds its initial state.
type treeBookmarkNode struct {
Label string
ID string
Starred bool
Expanded bool
Children []treeBookmarkNode
}
var treeBookmarkData = []treeBookmarkNode{
{Label: "Reading", ID: "reading", Expanded: true, Children: []treeBookmarkNode{
{Label: "Frontend Trends 2025", ID: "trends", Starred: true},
{Label: "CSS Container Queries", ID: "queries"},
{Label: "Modern TypeScript Patterns", ID: "tspat"},
}},
{Label: "Tools", ID: "tools", Expanded: true, Children: []treeBookmarkNode{
{Label: "Excalidraw", ID: "excal", Starred: true},
{Label: "Linear", ID: "linear"},
{Label: "Notion", ID: "notion"},
}},
{Label: "References", ID: "refs", Children: []treeBookmarkNode{
{Label: "MDN Web Docs", ID: "mdn"},
{Label: "TC39 Proposals", ID: "tc39"},
}},
}
// treeBookmarkStarred is the JS literal seeded into a star button's
// data-signals for the per-row toggle.
func treeBookmarkStarred(starred bool) string {
if starred {
return "true"
}
return "false"
}
// treeBookmarkItem renders any depth; same-file recursion keeps the
// embedded source self-contained.
templ treeBookmarkItem(n treeBookmarkNode) {
if len(n.Children) == 0 {
@neo.TreeItem(false) {
@neo.IconAttrs("more-horizontal", templ.Attributes{"slot": "icon"})
@treeBookmarkRow(n, false)
}
} else {
@neo.TreeItem(n.Expanded) {
@neo.IconAttrs("menu", templ.Attributes{"slot": "icon"})
@treeBookmarkRow(n, true)
for _, c := range n.Children {
@treeBookmarkItem(c)
}
}
}
}
templ treeBookmarkRow(n treeBookmarkNode, branch bool) {
@neo.TreeLabel() {
if branch {
@neo.Icon("folder")
} else {
@neo.Icon("link")
}
@neo.TreeText() {
{ n.Label }
}
if !branch {
@neo.TreeActions() {
<neo-button
class="bookmark-star"
size="sm"
aria-label={ "Star " + n.Label }
data-signals={ "{bookmark_star_" + n.ID + ": " + treeBookmarkStarred(n.Starred) + "}" }
data-on:click={ "$bookmark_star_" + n.ID + " = !$bookmark_star_" + n.ID + "; evt.stopPropagation()" }
data-attr:data-neo-active={ "$bookmark_star_" + n.ID }
data-attr:aria-pressed={ "$bookmark_star_" + n.ID }
>
@neo.Icon("star")
</neo-button>
}
}
}
}
templ TreeBookmarks() {
@neo.TreeAttrs(templ.Attributes{"aria-label": "Bookmarks"}) {
for _, n := range treeBookmarkData {
@treeBookmarkItem(n)
}
}
}