2026-09-24 - false
An expand/collapse menu component
NavTree renders a JavaScript object into a HMTL using detail/summary tags to management the expansion. No JavaScript is required to expand/collapse the menu.
Using NavTree's basic CSS, the following images show its output.
NavTree with no expansion

NavTree with partial expansion

NavTree with full expansion

Figure 1. NavTree's rendered menu with its basic CSS.
Defining the menu structure
The menu shown in figure 1 is defined with this JavaScript object structure. Submenu items can be nested as deeply as needed, but generally three-deep is the rational limit.
export const exampleMenu = {
title: 'Menu Main Title',
menuItems: [
{
title: 'Meats',
submenu: [
{
title: 'Read about beef',
url: '/meats/beef'
},
{
title: 'Read about lamb',
url: '/meats/lamb'
}
]
},
{
title: 'Fruits',
submenu: [
{
title: 'Read about fruit',
url: '/general/fruit'
},
{
title: 'Favorite fruits',
submenu: [
{
title: 'Bananna',
url: '/fruit/bananna'
},
{
title: 'Blueberry',
url: '/fruit/blueberry'
},
{
title: 'Other fruits',
submenu: [
{
title: 'Melon',
url: '/general/Melon'
},
{
title: 'Tomato',
url: '/general/tomato'
}
]
},
{
title: 'Lemon',
url: '/fruit/lemnon'
}
]
}
]
}
]
};
Figure 2a. TypeScript object that defines a menu.
Object menu types:
export type TreeMenu = {
title: string;
menuItems: SubMenuItem[];
};
export type SubMenuItem = {
title: string;
url?: string | undefined;
submenu?: SubMenuItem[] | undefined;
};
Figure 2b. TypeScript types for Figure 1a.
Rendered HTML
<div>
<div>Menu Main Title</div>
<div>
<details>
<summary>Meats</summary>
<ul>
<li><a href="/meats/beef">Read about beef</a></li>
<li><a href="/meats/lamb">Read about lamb</a></li>
</ul>
</details>
<details open="">
<summary>Fruits</summary>
<ul>
<li><a href="/general/fruit">Read about fruit</a></li>
<details open="">
<summary>Favorite fruits</summary>
<ul>
<li><a href="/fruit/bananna">Bananna</a></li>
<li><a href="/fruit/blueberry">Blueberry</a></li>
<details>
<summary>Other fruits</summary>
<ul>
<li><a href="/general/Melon">Melon</a></li>
<li><a href="/general/tomato">Tomato</a></li>
</ul>
</details>
<li><a href="/fruit/lemnon">Lemon</a></li>
</ul>
</details>
</ul>
</details>
</div>
</div>
Figure 2c. Rendered HTML for object in Figure 1a.
The Svelte 5 component source
The MenuItem1 snippet recursively renders the menu structure.
<script lang="ts">
export type TreeMenu = {
title: string;
menuItems: SubMenuItem[];
};
export type SubMenuItem = {
title: string;
url?: string | undefined;
submenu?: SubMenuItem[] | undefined;
};
interface props {
loadLink: (event: MouseEvent, url: string) => Promise<void>;
menu: TreeMenu;
}
let { loadLink, menu }: props = $props();
</script>
{#snippet MenuItem(menuObj: SubMenuItem)}
{#if menuObj.submenu}
<details>
<summary>{menuObj.title}</summary>
<ul>
{#each menuObj.submenu as item (item.title)}
{#if item.submenu}
{@render MenuItem(item)}
{:else}
<li class="deep-dive-article">
<!-- if loadLink function provided, call it. -->
{#if loadLink}
<a href={item.url ?? '/'} onclick={(event) => loadLink(event, item.url ?? '/')}
>{item.title}</a
>
{:else}
<!-- Otherwise emit a standard anchor tag. -->
<a href={item.url ?? '/'}>{item.title}</a>
{/if}
</li>
{/if}
{/each}
</ul>
</details>
{:else}
<!-- This a top-level menu object that has only title and url properties. -->
<a href={menuObj.url ?? '/'} onclick={(event) => loadLink(event, menuObj.url ?? '/')}
>{menuObj.title}</a
>
{/if}
{/snippet}
<div class="tree-wrapper">
<div>
{menu.title}
</div>
<div class="menu-content">
{#each menu.menuItems as item (item.title)}
{@render MenuItem(item)}
{/each}
</div>
</div>
Figure 3. Svelte 5 NavTree component.
Describe the optional loadLink function.
NavTree basic CSS
@scope (div.tree-wrapper) {
:scope {
font-size: 0.9rem;
}
/* 1. Clear out default markers completely */
details summary {
list-style: none;
margin-top: 0.3rem;
}
details summary::-webkit-details-marker,
details summary::marker {
display: none !important;
}
details > summary + * {
margin-top: 0.2rem;
margin-bottom: 0.2rem;
margin-left: 1.2rem;
}
div.sidebar > details details {
margin-left: 1rem;
}
div.sidebar > details details details {
margin-left: 1rem;
}
div.sidebar > details:not(:first-child) {
margin-top: 0.5rem;
}
/* 2. Setup the Flexbox container */
summary {
cursor: pointer;
/*
Use inline-flex for tight wrappper or
flex for full width
*/
display: inline-flex;
align-items: center;
gap: 5px; /* Gap between icon and text */
}
li.deep-dive-article {
margin-left: 1rem;
text-indent: -0.7rem;
display: list-item;
list-style: disc;
}
li {
display: flex;
align-items: center;
gap: 5px;
}
li::before {
content: '';
display: inline-block;
width: 14px;
height: 14px;
flex-shrink: 0;
background-color: #9b9a96;
/* See note below on SVG icons. */
-webkit-mask-image: url("data:image/svg+xml...");
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
-webkit-mask-size: contain;
mask-image: url("data:image/svg+xml...");
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
}
ul {
padding-left: 5px;
}
ul > li:not(:first-child) {
margin-top: 0.3rem;
}
ul > li:last-child {
margin-bottom: 0.5rem;
}
details[open] > details {
margin-bottom: 3rem;
}
summary {
margin-top: 0;
}
/* 3. Render the SVG Icon */
details > summary::before,
details summary summary::before {
content: '';
display: inline-block;
width: 14px;
height: 14px;
flex-shrink: 0;
background-color: #9b9a96;
-webkit-mask-image: url("data:image/svg+xml...");
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
-webkit-mask-size: contain;
mask-image: url("data:image/svg+xml...");
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
}
details[open] > summary::before {
background-color: #9b9a96;
-webkit-mask-image: url("data:image/svg+xml...");
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
-webkit-mask-size: contain;
mask-image: url("data:image/svg+xml...");
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
}
}
Figure 4. NavTree's basic CSS (use it or brew your own)
The plus and minus icons are from lucide.dev.
NavTree usage
import { exampleMenu } from '#lib/data/persistent/example-menu.ts';
const menu = exampleMenu as TreeMenu;
...
<NavTree {menu}></NavTree>
Figure 5a. NavTree usage without the `loadLink` function.
async function loadLink(event: MouseEvent, url: string): Promise<void> {
event.preventDefault();
isLoading = true;
errorMessage = '';
try {
contentHtml = await fetchContent(url, 'div.content-wrapper');
} catch (error) {
errorMessage = error instanceof Error ? error.message : 'Unable to load document';
} finally {
isLoading = false;
}
}
...
<NavTree {loadLink} {menu}></NavTree>
Figure 5b. NavTree usage with the `loadLink` function.