Navigation
Nav
Vertical navigation composed from parts: a landmark named by its title, lists of links with the current one marked by a line and weight, related links folded into native disclosures or dropped down from a header's line, and a hook that tells a table of contents which section the reader is in.
Import
import { Nav, useScrollSpy, Menu } from "@loamui/core";Usage
Every example has a CSS tab. That’s the real, complete stylesheet for the component: plain, static CSS, with nothing running in the browser.
Nested lists
A List inside an Item indents a level, by --loam-nav-indent. The nesting is real list nesting, so a screen reader reports the level and the count at each.
Several sections
One Root carries one name, so a sidebar with several titled sections is one Root per section, stacked. The stylesheet spaces one Root from the next, and a screen reader lists each section as its own landmark, named by its Title.
Groups
A Group is a native details, so it folds without JavaScript and its title is a summary the keyboard already knows. Controlled here so the two groups share one open state; uncontrolled, pass defaultOpen, or share a name across groups and the browser keeps one open at a time. Close Reports and its title takes the marker: the current page is never folded out of sight.
Router links and other words
Link takes a router's link through render and the wiring (aria-current, the part's class) merges onto it. Without a Title the nav is named by labels.navigation, so a page in another language names its landmark in its own words.
Inline, for a header
The primitive is vertical; a horizontal nav is your flex row on the List, and --loam-nav-current-edge: block-end on the Root moves the current marker under the link. Everything else holds: the landmark's name, the current page's weight, the icons. Give a page's second nav an aria-label so the two are told apart.
Header with dropdowns
A Dropdown in an Item: the DropdownTrigger is a button set like the links beside it, with a chevron that turns, and the DropdownPanel is a native popover of ordinary links, anchored under the trigger and flipped by the browser at a viewport edge. Click opens it; Escape, a click outside or a Tab past the last link is the way out. Plants holds the current page, so its trigger takes the weight and, while closed, the marker. Learn is a wide panel: the same part, --loam-nav-dropdown-size raised on it and a grid of two Lists inside.
A table of contents that follows the reader
useScrollSpy takes the headings' ids and returns the one in view. Scroll the text and the marker moves. The link says current="location", not page: the reader is still on this page, at a place within it, which is what aria-current="location" means. The list is the same Nav; only the current word changes.
Overview
Loam is a soil of sand, silt and clay in roughly equal measure, which holds water without waterlogging and drains without drying out. It warms early in spring and is easy to work in most seasons. Loam is a soil of sand, silt and clay in roughly equal measure, which holds water without waterlogging and drains without drying out. It warms early in spring and is easy to work in most seasons. Loam is a soil of sand, silt and clay in roughly equal measure, which holds water without waterlogging and drains without drying out. It warms early in spring and is easy to work in most seasons.
Planting
Loam is a soil of sand, silt and clay in roughly equal measure, which holds water without waterlogging and drains without drying out. It warms early in spring and is easy to work in most seasons. Loam is a soil of sand, silt and clay in roughly equal measure, which holds water without waterlogging and drains without drying out. It warms early in spring and is easy to work in most seasons. Loam is a soil of sand, silt and clay in roughly equal measure, which holds water without waterlogging and drains without drying out. It warms early in spring and is easy to work in most seasons.
Irrigation
Loam is a soil of sand, silt and clay in roughly equal measure, which holds water without waterlogging and drains without drying out. It warms early in spring and is easy to work in most seasons. Loam is a soil of sand, silt and clay in roughly equal measure, which holds water without waterlogging and drains without drying out. It warms early in spring and is easy to work in most seasons. Loam is a soil of sand, silt and clay in roughly equal measure, which holds water without waterlogging and drains without drying out. It warms early in spring and is easy to work in most seasons.
Harvest
Loam is a soil of sand, silt and clay in roughly equal measure, which holds water without waterlogging and drains without drying out. It warms early in spring and is easy to work in most seasons. Loam is a soil of sand, silt and clay in roughly equal measure, which holds water without waterlogging and drains without drying out. It warms early in spring and is easy to work in most seasons. Loam is a soil of sand, silt and clay in roughly equal measure, which holds water without waterlogging and drains without drying out. It warms early in spring and is easy to work in most seasons.
Guidance
When to use it
- A side nav for an application or a documentation site: lists of links, the current page marked, related pages folded into groups.
- A table of contents beside a long page, with useScrollSpy marking the section in view.
- A column of links in a footer, or the list inside a mobile menu: the same parts, named by their Title, with the current page marked the same way everywhere.
When not to
- The path to the current page: that is Breadcrumbs, one link per ancestor.
- Pages of one result set: that is Pagination, whose links are Buttons and whose window has ellipses.
- Panels of content on one page switched in place: that is Tabs, which carries the tab and panel semantics.
- A list of actions opened from a button: that is Menu. A nav holds destinations; an action that changes something is a button, not a link. A dropdown of destinations is Nav.Dropdown, not a Menu.
Accessibility
- The Root is a <nav> landmark, named by its Title (aria-labelledby, from the first render) or by labels.navigation (aria-label); your own name wins. Name every nav on a page differently.
- Lists are real <ul> and <li> elements, nested where the navigation nests, so a screen reader reports levels and counts.
- The current destination carries aria-current ("page", "location" or another token), and the stylesheet draws its line, weight and background from that same attribute; under forced colours the marker is drawn in Highlight.
- A Group is a native <details>/<summary>: Enter and Space toggle it, and a closed group holding the current page shows the marker on its title.
- A Dropdown is a disclosure: a <button> with aria-expanded and aria-controls over a popover of ordinary links, never role="menu". Click, Enter or Space opens it; Tab walks the links; Escape and a click outside close it and focus returns to the button. A closed dropdown holding the current page shows the marker on its trigger.
- Icons are svg children, sized on the text; keep them aria-hidden so each link is named by its words alone.
How it works
Named from the first render
A page has several navs (a header, a sidebar, a table of contents, a footer column), and a screen reader lists them by name, so each needs one. One Root carries one name: a sidebar with several titled sections is one Root per section, stacked, and the stylesheet spaces them. The Root mints an id and points aria-labelledby at it in the server HTML; the Title renders that id, so the name is right before any JavaScript runs. If no Title registers, the reference is dropped after mount (a dangling aria-labelledby is an error) and labels.navigation names the nav instead. An aria-label or aria-labelledby you pass wins over both.
The current page is a line and a weight
current sets aria-current on the link, and the stylesheet draws the current line from that attribute: a bar on the start edge, a heavier weight and a subtle background. Three signals, so the state survives a flattened background, a colour-blind reader and forced colours, where the bar is drawn in the system highlight. The bar's space is reserved on every line by a transparent border, so marking a page never shifts its text.
The Link exists for its judgment
An <a> in an <li> needs no component. Nav.Link exists because it adds what a bare link leaves to memory: aria-current from a boolean, a class that styles whatever element render substitutes, an icon detected as a child, and the href forwarded either way. Pass a router's link through render and its own props win; the wiring merges on.
A group is a details element
Related links fold into a native details with a summary as its title, so the fold works before JavaScript, the keyboard already knows it, and the browser keeps one group open at a time when they share a name. open, defaultOpen and onOpenChange follow the library's controlled-or-not contract. A group holding the current page takes the weight on its title, and while closed the marker too, so where the reader is never disappears into a fold.
Vertical is the primitive; inline is a row
The List is a column. A header's horizontal nav is display: flex on the List in your own CSS, and nothing else changes: not the landmark, not the icons. The current marker is the one thing a row changes, since a bar on the start edge reads as a column's: --loam-nav-current-edge: block-end on the Root draws it under the link instead. A custom property, not a prop, because the edge is a fact of the layout around the nav, declared once where that layout is.
Click opens it; hover never does
A panel that opens on hover opens by accident: on the way to the link beside it, on the way across the header, and never for a keyboard, a touch screen or a switch. It closes the moment the pointer strays across the gap, and the code that mitigates that (delays, hit triangles) is guessing at intent. A click is a statement of it, from every input. The trigger opens the panel declaratively where the browser has commands (commandfor with command="toggle-popover"), through popovertarget where it has only that, and by its own click handler where it has neither; in all three, aria-expanded follows the panel's toggle event, so it says what the panel does. The panel is anchored under the trigger's start edge with CSS anchor positioning, flipped up or across when it would leave the viewport; without anchor positioning it is an absolutely positioned box under the Item, dismissed by the component. Focus stays on the trigger when the panel opens (Tab reaches the first link) and returns to it when the panel closes.
Page or location
current={true} says aria-current="page": the reader is on that page. A table of contents marks a place within the page, so it says current="location" instead; a wizard's steps say "step". The attribute is announced, and the stylesheet marks every value the same way, so choose the word that is true.
Parts
Custom properties
| Property | Syntax | Default | Description |
|---|---|---|---|
--loam-nav-indent | CSS length | var(--loam-space-xs) | How far a nested List indents, whether it sits in an Item or in a Group. Set it on the Root or any ancestor. |
--loam-nav-current-edge | inline-start | block-end | inline-start | Which edge the current marker's bar is drawn on: the start edge for a column, block-end (under the link) for a horizontal row. Read by a style query, so set it on the Root or an ancestor, never on the link. |
--loam-nav-dropdown-size | CSS length | 16rem | A DropdownPanel's width, capped to 90% of the viewport. Raise it on the panel (or an ancestor) for a wide panel with a grid of lists inside. |
--loam-nav-link-size | CSS length | 2.25rem | The minimum height of each line (a Link or a GroupTitle). Raise it to 2.75rem where a thumb is the pointer, as in a mobile menu. |
Hooks
useScrollSpy
Which of the elements with these ids is in view: the first, in the order given, that intersects the viewport, or the last one that did once none does (the reader is inside its section). null before anything has intersected, and on the server. One IntersectionObserver for the set, disconnected on unmount; a fresh array of the same ids does not re-observe.
const activeId = useScrollSpy(ids, options?);Options:
| Option | Type | Default | Description |
|---|---|---|---|
rootMargin | string | the whole viewport | The observer's rootMargin: shrink the band a heading must enter to count as in view, e.g. "0px 0px -70% 0px" for the top third. |
threshold | number | number[] | 0 | The observer's threshold: how much of the element must be visible. |