Navigation
Components that help users orient themselves and move through a site or application. Nav and Tabs add a small progressive-enhancement script; all other components ship as pure CSS.
Nav
A fixed top navigation bar with a logo zone, primary links, an actions zone (icon links, theme toggle, CTAs), and a mobile drawer. The mobile drawer and theme toggle are wired by dist/nav.js. Scroll-fill and auto-hide behaviors are opt-in via data attributes.
↑ Live demo — The navigation bar fixed to the top of this page is the shipped component. Resize below 640 px to see the mobile drawer open.
Full reference — Nav
Anatomy
The .nav wrapper is position: fixed. Addpadding-top: var(--nav-height) to <body> so page content clears it. The drawer and overlay are siblings of.nav — not nested inside it.
<nav class="nav" aria-label="Main navigation">
<div class="nav-inner">
<a href="/" class="nav-logo">Brand</a>
<ul class="nav-links">
<li><a href="/about">About</a></li>
<li><a href="/work" class="active">Work</a></li>
</ul>
<div class="nav-right">
<a href="https://github.com/…" class="nav-icon-link" aria-label="GitHub">
<!-- icon -->
</a>
<button class="nav-theme-toggle" aria-label="Switch to dark mode">
<!-- icon -->
</button>
<a href="/get-started" class="nav-cta">Get started</a>
<button class="nav-toggle" aria-expanded="false" aria-controls="nav-drawer">
<!-- hamburger icon -->
</button>
</div>
</div>
</nav>
<div class="nav-drawer" id="nav-drawer" role="dialog" aria-modal="true" aria-label="Navigation menu">
<div class="nav-drawer-header">
<span class="nav-drawer-logo">Brand</span>
<button class="nav-drawer-close" aria-label="Close menu"><!-- icon --></button>
</div>
<nav class="nav-drawer-nav">
<span class="nav-drawer-group-label">Docs</span>
<a href="/about" class="nav-drawer-link">About</a>
<a href="/work" class="nav-drawer-link active">Work</a>
</nav>
<div class="nav-drawer-actions">
<a href="/get-started" class="nav-drawer-cta">Get started</a>
</div>
</div>
<div class="nav-overlay"></div>Scroll behaviors (opt-in)
Add data attributes to .nav to enable scroll-driven behaviors. Both are handled by nav.js — no extra configuration needed.
| Attribute | Behavior |
|---|---|
data-nav-fill | Nav starts transparent; gains .filled (opaque background + border) once the page scrolls past 10 px. Use over hero sections. |
data-nav-autohide | Nav slides up (.hidden) when scrolling down past 80 px; slides back on scroll-up. Recovers screen space on long pages. |
<!-- Always filled (doc pages, app shells) -->
<nav class="nav filled" …>…</nav>
<!-- Transparent-to-filled + auto-hide (landing pages) -->
<nav class="nav" data-nav-fill data-nav-autohide …>…</nav>Script
dist/nav.js is a self-initializing script — include it once and it finds .nav automatically. Ships as a named package export:
<!-- Via CDN — defer is required so the script runs after the DOM is ready -->
<script defer src="https://cdn.jsdelivr.net/npm/farn-theme@0.7.0/dist/nav.js"></script>
<!-- Via npm (Astro, Vite, etc.) — deferred automatically -->
import 'farn-theme/scripts/nav';The script handles: theme toggle (.nav-theme-toggle), mobile drawer open/close (.nav-toggle, .nav-drawer-close,.nav-overlay), Escape key close, and opt-in scroll behaviors. Without the script, the nav renders as a static bar — no content is hidden.
Theme toggle
Add .nav-theme-toggle to any button inside .nav-right. The script reads and writes data-theme on <html>and persists the choice to localStorage under the keyfarn-theme. Include the FOWT-prevention inline script in<head> to avoid a flash on load:
<script>
(function () {
const t = localStorage.getItem('farn-theme');
const s = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', t ?? s);
})();
</script>Accessibility
| Attribute | Where | Why |
|---|---|---|
aria-label="Main navigation" | .nav | Names the landmark for screen readers. |
aria-expanded | .nav-toggle | Updated by the script to reflect open/closed state. |
aria-controls="nav-drawer" | .nav-toggle | Associates the toggle with the drawer element. |
role="dialog" aria-modal="true" | .nav-drawer | Identifies the drawer as a modal for assistive technology. The script traps focus inside the drawer while it is open and returns focus to .nav-toggle on close. |
aria-label="Navigation menu" | .nav-drawer | Names the dialog. |
Token reference
| Token | Default | Controls |
|---|---|---|
--nav-height | 64px | Bar height — also set on <body> as padding-top: var(--nav-height) to clear the fixed bar |
--nav-link-font-size | var(--text-sm) | Primary link text size |
--nav-cta-height | 36px | CTA button height |
--nav-overlay-bg | color-mix(in srgb, black 50%, transparent) | Drawer backdrop color |
Breadcrumbs
Shows users where they are within a site hierarchy and lets them navigate back to parent sections. Colors adapt automatically to any surface or theme context.
Full reference — Breadcrumbs
Anatomy
Wrap an <ol> in a <nav> witharia-label="Breadcrumb". Each list item gets .breadcrumb-item. The current page's link carries aria-current="page". The ›separator is generated by CSS — never in the DOM.
<nav aria-label="Breadcrumb">
<ol class="breadcrumb" role="list">
<li class="breadcrumb-item"><a href="/">Home</a></li>
<li class="breadcrumb-item"><a href="/docs">Docs</a></li>
<li class="breadcrumb-item">
<a href="/docs/start" aria-current="page">Getting started</a>
</li>
</ol>
</nav>Usage guidance
Use breadcrumbs when the site has two or more levels of hierarchy and users are likely to have arrived deep in the tree from search, a direct link, or a previous session. Skip them on flat sites, landing pages, or anywhere the hierarchy has fewer than two meaningful levels.
| Situation | Guidance |
|---|---|
| Current page link | Keep as an <a> with aria-current="page". Avoid plain <span> — it breaks keyboard navigation consistency. |
| Depth | Show the full path from root. Truncating intermediate crumbs hides context without saving meaningful space. |
| Placement | Render above the page <h1>, below the primary navigation bar. |
| Mobile | .breadcrumb uses flex-wrap: wrap so long paths reflow naturally. |
Accessibility
| Attribute | Where | Why |
|---|---|---|
aria-label="Breadcrumb" | <nav> | Distinguishes this landmark from the primary navigation when both appear on the page. |
role="list" | <ol> | Restores list semantics in Safari/VoiceOver, which removes them from any <ol> with list-style: none. |
aria-current="page" | Last <a> | Tells screen readers which link represents the current page. |
| CSS separator | ::after | Generated via content: '›' / '' — the empty alt value suppresses screen-reader announcement. |
CSS reference
Shipped in dist/farn-components.css. Colors are semantic — adapt automatically under data-theme and data-surface.
.breadcrumb {
display: flex;
flex-wrap: wrap;
align-items: center;
list-style: none;
padding: 0;
margin: 0;
font-size: 0.875rem;
}
.breadcrumb-item {
display: flex;
align-items: center;
color: var(--color-text-secondary);
}
.breadcrumb-item:not(:last-child)::after {
content: '›'; /* fallback */
content: '›' / ''; /* modern: suppresses screen-reader announcement */
margin: 0 var(--space-xs);
}
.breadcrumb-item a {
color: inherit;
text-decoration: none;
transition: color var(--duration-fast) var(--ease-out);
}
.breadcrumb-item a:hover,
.breadcrumb-item a[aria-current="page"] {
color: var(--color-text);
}Accordion
Expandable content panels built on native <details> /<summary> — no JavaScript required. Animated height in Chrome 131+ via interpolate-size: allow-keywords; instant reveal in all other browsers.
What is Farn?
Farn is a token-first design system built around Iron Night and Birch Mist palettes. It ships three CSS artifacts: a full bundle, a tokens-only build for teams with their own reset, and an opt-in component classes file.
How do I install it?
Install via npm: npm install farn-theme. Then import the CSS bundle that matches your setup — farn-theme for the full bundle or farn-theme/tokens if you manage your own reset.
Does it support dark mode?
Yes. Set data-theme="dark" or data-theme="light" on any element. All semantic tokens update automatically — no additional class toggling required.
Full reference — Accordion
Anatomy
Wrap one or more <details> elements in a .accordioncontainer. Each <details> must contain exactly one<summary> and a .accordion-panel div.
<div class="accordion">
<details>
<summary>Question one</summary>
<div class="accordion-panel">
<p>Answer text.</p>
</div>
</details>
<details>
<summary>Question two</summary>
<div class="accordion-panel">
<p>Answer text.</p>
</div>
</details>
</div>Open by default
Add the open attribute to any <details> to render it expanded on load — the standard HTML boolean attribute, no extra classes needed.
<details open>
<summary>Expanded on load</summary>
<div class="accordion-panel">…</div>
</details>Usage guidance
| Situation | Guidance |
|---|---|
| FAQs and help content | Ideal fit. Users scan summaries to find the answer they need, then expand only the relevant panel. |
| Settings panels | Group related settings under a collapsible heading to reduce visual noise without burying options. |
| Progressive disclosure | Use when secondary details are genuinely optional — not to shorten a page that is already the right length. |
| Multiple open at once | Native <details> allows any number of panels open simultaneously. To enforce exclusive-open, close sibling <details> on the toggle event with a small JS listener. |
| Critical content | Do not hide content that every user needs in a closed accordion. Use open for important panels, or a different layout. |
Animation
In Chrome 131+ the panel height animates using interpolate-size: allow-keywords combined with the ::details-content pseudo-element. Older browsers get an instant reveal. The + icon rotates 45° on open. Both transitions are disabled under prefers-reduced-motion: reduce.
Token reference
| Token | Default | Controls |
|---|---|---|
--accordion-border | var(--color-border) | Outer border and item dividers |
--accordion-radius | var(--radius-md) | Outer corner radius |
--accordion-summary-bg | transparent | Summary row background (idle) |
--accordion-summary-hover-bg | var(--color-bg-interactive-hover) | Summary row background (hover) |
--accordion-panel-bg | transparent | Panel content background |
--accordion-font-size | 0.9375rem | Summary text size |
CSS reference
Shipped in dist/farn-components.css. All colors are semantic — adapt to data-theme and data-surface automatically.
.accordion {
border: 1px solid var(--accordion-border);
border-radius: var(--accordion-radius);
overflow: hidden;
}
.accordion-panel {
padding: 0 var(--space-md) var(--space-md);
background: var(--accordion-panel-bg);
}
/* Animated height — Chrome 131+ */
@supports (interpolate-size: allow-keywords) {
.accordion { interpolate-size: allow-keywords; }
.accordion details::details-content {
overflow: hidden;
transition: block-size var(--duration-base) var(--ease-out);
}
.accordion details:not([open])::details-content { block-size: 0; }
}Tabs
Horizontal tabs for switching between related views on the same screen. CSS drives all visual states via [aria-selected]; a small script handles panel switching and arrow-key navigation.
A brief summary of the product, its key benefits, and who it's for. This panel is active by default.
Technical specifications: dimensions, weight, materials, and compatibility details.
Customer reviews and ratings appear here.
This panel is not reachable — its tab is disabled.
Full reference — Tabs
Anatomy
A .tab-list wrapper carries role="tablist" and anaria-label. Each .tab button has role="tab",aria-selected, aria-controls pointing to its panel, and an id. Each .tab-panel has role="tabpanel"and aria-labelledby pointing back to its tab. Non-active panels carry the HTML hidden attribute — the script toggles it on click; CSS reveals all panels when scripting is unavailable.
<div class="tabs">
<div class="tab-list" role="tablist" aria-label="Section label">
<button class="tab" role="tab"
id="tab-a" aria-selected="true"
aria-controls="panel-a" tabindex="0">First</button>
<button class="tab" role="tab"
id="tab-b" aria-selected="false"
aria-controls="panel-b" tabindex="-1">Second</button>
<button class="tab" role="tab"
id="tab-c" aria-selected="false"
aria-controls="panel-c" tabindex="-1" disabled>Off</button>
</div>
<div class="tab-panel" role="tabpanel"
id="panel-a" aria-labelledby="tab-a" tabindex="0">
Panel A content.
</div>
<div class="tab-panel" role="tabpanel"
id="panel-b" aria-labelledby="tab-b" tabindex="0" hidden>
Panel B content.
</div>
<div class="tab-panel" role="tabpanel"
id="panel-c" aria-labelledby="tab-c" tabindex="0" hidden>
Panel C content.
</div>
</div>Script
dist/tabs.js exports initTabs() and ships as a named package export. Call it once after the DOM is ready:
import { initTabs } from 'farn-theme/scripts/tabs';
document.addEventListener('DOMContentLoaded', initTabs);The Astro docs site calls it automatically via DocLayout.astro. Without the script, panels with hidden are shown stacked (via @media (scripting: none)) so no content is lost.
Keyboard navigation
| Key | Action |
|---|---|
| Tab | Moves focus into the tab list (to the active tab), then out to the panel. |
| → / ← | Moves focus and activates the next/previous enabled tab. |
| Home / End | Moves focus and activates the first/last enabled tab. |
Usage guidance
| Situation | Guidance |
|---|---|
| Content type | Use tabs only when panel content is parallel and mutually exclusive — overviews, specs, reviews. Avoid for sequential steps (use a stepper instead). |
| Number of tabs | 2–6 tabs is the sweet spot. Fewer than two is not a tab pattern; more than six overwhelms the list even on desktop. Use overflow-scroll sparingly — prefer restructuring. |
| Active tab on load | Set aria-selected="true" and tabindex="0" on the first tab in the HTML; all others get tabindex="-1". The script maintains this. |
| Disabled tabs | Use the HTML disabled attribute on the <button>. The tab renders at reduced opacity and is skipped by arrow-key navigation. |
| Tab labels | Keep labels short (1–3 words). Never use icons alone without a visible label — add aria-label if an icon is truly needed. |
| Panel content height | Panels can vary in height — the container grows naturally. Avoid fixed-height panels with scrollable content inside; users will struggle to reach content with keyboard. |
Accessibility
| Attribute | Where | Why |
|---|---|---|
role="tablist" | .tab-list | Identifies the group of tabs as a tab widget to assistive technology. |
aria-label | .tab-list | Names the tab group — required when multiple tab lists appear on the same page. |
role="tab" | .tab | Marks each button as a tab control. Screen readers announce "Tab 1 of 3, selected" etc. |
aria-selected="true/false" | .tab | Communicates active state to assistive technology. CSS reads this attribute for all visual styling. |
aria-controls | .tab | Associates the tab with its panel via the panel's id. |
tabindex="0/-1" | .tab | Roving tabindex — only the active tab is in the tab order; arrow keys move within the list. |
role="tabpanel" | .tab-panel | Identifies the panel container for assistive technology. |
aria-labelledby | .tab-panel | Links the panel back to its controlling tab label. |
hidden | Inactive .tab-panel | HTML attribute removes the panel from the accessibility tree and from layout. Toggled by the script. |
tabindex="0" | .tab-panel with no focusable children | If the panel contains no links, buttons, or inputs, add tabindex="0" so keyboard users can Tab into it after selecting a tab. Omit when the panel has focusable content — Tab will find it naturally. |
Token reference
| Token | Default | Controls |
|---|---|---|
--tab-idle-text | var(--color-text-secondary) | Inactive tab label colour |
--tab-hover-bg | var(--color-bg-interactive-hover) | Tab background on hover |
--tab-active-text | var(--color-text) | Active (selected) tab label colour |
--tab-indicator | var(--color-accent) | Underline indicator colour on the active tab |
--tab-border | var(--color-border) | Full-width separator between the tab list and panel |
--tab-panel-bg | transparent | Panel content area background |
--tab-gap | 4px | Gap between tab buttons |
--tab-font-size | 0.875rem | Tab label font size |
--tab-disabled-opacity | 0.4 | Opacity of disabled tabs |
CSS reference
Shipped in dist/farn-components.css. All colours adapt to data-theme and data-surface automatically.
.tab-list {
display: flex;
gap: var(--tab-gap);
border-bottom: 1px solid var(--tab-border);
overflow-x: auto;
scrollbar-width: none;
}
.tab {
position: relative;
flex-shrink: 0;
padding: var(--space-sm) var(--space-md);
background: transparent;
border: none;
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
color: var(--tab-idle-text);
font-size: var(--tab-font-size);
font-family: inherit;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: color var(--duration-fast) var(--ease-out),
background var(--duration-fast) var(--ease-out);
}
.tab::after {
content: '';
position: absolute;
bottom: -1px;
left: 0; right: 0;
height: 2px;
background: transparent;
transition: background var(--duration-fast) var(--ease-out);
}
.tab:hover { background: var(--tab-hover-bg); color: var(--tab-active-text); }
.tab[aria-selected="true"] { color: var(--tab-active-text); font-weight: 600; }
.tab[aria-selected="true"]::after { background: var(--tab-indicator); }
.tab:focus-visible { outline: 2px solid var(--color-accent); outline-offset: -2px; }
.tab:disabled,
.tab[aria-disabled="true"] {
opacity: var(--tab-disabled-opacity);
cursor: not-allowed;
pointer-events: none;
}
.tab-panel {
padding-top: var(--space-md);
background: var(--tab-panel-bg);
}
@media (scripting: none) {
.tab-panel[hidden] { display: block; }
}Pagination
Numbered and prev/next page controls for navigating multi-page content. CSS-only — no JavaScript required. Active state driven by aria-current="page"; disabled state by aria-disabled="true".
Numbered
With ellipsis
Previous / next only
Full reference — Pagination
Anatomy
Wrap an <ol> in a <nav> witharia-label="Pagination". Each <li> gets.page-item; each link gets .page-link. The active page carries aria-current="page" on its <a> — no extra class needed. Disabled controls carry aria-disabled="true".
<nav aria-label="Pagination">
<ol class="pagination" role="list">
<li class="page-item">
<a class="page-link" href="#" aria-disabled="true" aria-label="Previous page">← Prev</a>
</li>
<li class="page-item"><a class="page-link" href="/page/1">1</a></li>
<li class="page-item">
<a class="page-link" href="/page/2" aria-current="page">2</a>
</li>
<li class="page-item"><a class="page-link" href="/page/3">3</a></li>
<li class="page-item">
<a class="page-link" href="/page/3" aria-label="Next page">Next →</a>
</li>
</ol>
</nav>Ellipsis
For truncated ranges, use a .page-ellipsis <span>inside a .page-item marked aria-hidden="true". It is decorative — screen readers skip it entirely.
<li class="page-item" aria-hidden="true">
<span class="page-ellipsis">…</span>
</li>Usage guidance
| Situation | Guidance |
|---|---|
| Active page | Add aria-current="page" to the link for the current page. CSS styles it automatically; screen readers announce it as the current page. |
| Disabled prev/next | Add aria-disabled="true" to the link. Do not remove href — keep the element an <a> so DOM order stays consistent. |
| Icon-only controls | Provide aria-label="Previous page" / aria-label="Next page" on icon-only links. Text labels are preferred for clarity. |
| Ellipsis | Use aria-hidden="true" on the .page-item wrapping the ellipsis span. Screen readers skip it; the surrounding numbered links provide sufficient context. |
| Very long ranges | Show the first page, last page, active page, and ±1–2 neighbours. Everything else becomes ellipsis. This pattern is purely HTML — no extra CSS needed. |
Accessibility
| Attribute | Where | Why |
|---|---|---|
aria-label="Pagination" | <nav> | Distinguishes this landmark from site navigation when both appear on the page. |
role="list" | <ol> | Restores list semantics in Safari/VoiceOver when list-style: none is applied. |
aria-current="page" | Active .page-link | Announces the current page to screen readers and drives the active style — no class needed. |
aria-disabled="true" | Disabled .page-link | Communicates disabled state to assistive technology; pointer-events: none prevents click. |
aria-hidden="true" | .page-item with ellipsis | Hides the decorative separator from the accessibility tree. |
Token reference
| Token | Default | Controls |
|---|---|---|
--pagination-active-bg | var(--color-accent) | Active page item background |
--pagination-active-text | var(--color-accent-text) | Active page item text color |
--pagination-hover-bg | var(--color-bg-interactive-hover) | Hovered item background |
--pagination-hover-text | var(--color-text) | Hovered item text color |
--pagination-radius | var(--radius-md) | Corner radius on each page link |
--pagination-gap | var(--space-xs) | Gap between page items |
CSS reference
Shipped in dist/farn-components.css. All colors adapt to data-theme and data-surface automatically.
.pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--pagination-gap);
list-style: none;
padding: 0;
margin: 0;
}
.page-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 36px;
height: 36px;
padding: 0 var(--space-xs);
border: 1px solid transparent;
border-radius: var(--pagination-radius);
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-secondary);
text-decoration: none;
}
.page-link[aria-current="page"] {
background: var(--pagination-active-bg);
color: var(--pagination-active-text);
font-weight: 600;
cursor: default;
pointer-events: none;
}
.page-link[aria-disabled="true"] {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}