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.

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.

AttributeBehavior
data-nav-fillNav starts transparent; gains .filled (opaque background + border) once the page scrolls past 10 px. Use over hero sections.
data-nav-autohideNav 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

AttributeWhereWhy
aria-label="Main navigation".navNames the landmark for screen readers.
aria-expanded.nav-toggleUpdated by the script to reflect open/closed state.
aria-controls="nav-drawer".nav-toggleAssociates the toggle with the drawer element.
role="dialog" aria-modal="true".nav-drawerIdentifies 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-drawerNames the dialog.

Token reference

TokenDefaultControls
--nav-height64pxBar height — also set on <body> as padding-top: var(--nav-height) to clear the fixed bar
--nav-link-font-sizevar(--text-sm)Primary link text size
--nav-cta-height36pxCTA button height
--nav-overlay-bgcolor-mix(in srgb, black 50%, transparent)Drawer backdrop color

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.

SituationGuidance
Current page linkKeep as an <a> with aria-current="page". Avoid plain <span> — it breaks keyboard navigation consistency.
DepthShow the full path from root. Truncating intermediate crumbs hides context without saving meaningful space.
PlacementRender above the page <h1>, below the primary navigation bar.
Mobile.breadcrumb uses flex-wrap: wrap so long paths reflow naturally.

Accessibility

AttributeWhereWhy
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::afterGenerated 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

SituationGuidance
FAQs and help contentIdeal fit. Users scan summaries to find the answer they need, then expand only the relevant panel.
Settings panelsGroup related settings under a collapsible heading to reduce visual noise without burying options.
Progressive disclosureUse when secondary details are genuinely optional — not to shorten a page that is already the right length.
Multiple open at onceNative <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 contentDo 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

TokenDefaultControls
--accordion-bordervar(--color-border)Outer border and item dividers
--accordion-radiusvar(--radius-md)Outer corner radius
--accordion-summary-bgtransparentSummary row background (idle)
--accordion-summary-hover-bgvar(--color-bg-interactive-hover)Summary row background (hover)
--accordion-panel-bgtransparentPanel content background
--accordion-font-size0.9375remSummary 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.

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

KeyAction
TabMoves focus into the tab list (to the active tab), then out to the panel.
/ Moves focus and activates the next/previous enabled tab.
Home / EndMoves focus and activates the first/last enabled tab.

Usage guidance

SituationGuidance
Content typeUse tabs only when panel content is parallel and mutually exclusive — overviews, specs, reviews. Avoid for sequential steps (use a stepper instead).
Number of tabs2–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 loadSet aria-selected="true" and tabindex="0" on the first tab in the HTML; all others get tabindex="-1". The script maintains this.
Disabled tabsUse the HTML disabled attribute on the <button>. The tab renders at reduced opacity and is skipped by arrow-key navigation.
Tab labelsKeep labels short (1–3 words). Never use icons alone without a visible label — add aria-label if an icon is truly needed.
Panel content heightPanels 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

AttributeWhereWhy
role="tablist".tab-listIdentifies the group of tabs as a tab widget to assistive technology.
aria-label.tab-listNames the tab group — required when multiple tab lists appear on the same page.
role="tab".tabMarks each button as a tab control. Screen readers announce "Tab 1 of 3, selected" etc.
aria-selected="true/false".tabCommunicates active state to assistive technology. CSS reads this attribute for all visual styling.
aria-controls.tabAssociates the tab with its panel via the panel's id.
tabindex="0/-1".tabRoving tabindex — only the active tab is in the tab order; arrow keys move within the list.
role="tabpanel".tab-panelIdentifies the panel container for assistive technology.
aria-labelledby.tab-panelLinks the panel back to its controlling tab label.
hiddenInactive .tab-panelHTML attribute removes the panel from the accessibility tree and from layout. Toggled by the script.
tabindex="0".tab-panel with no focusable childrenIf 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

TokenDefaultControls
--tab-idle-textvar(--color-text-secondary)Inactive tab label colour
--tab-hover-bgvar(--color-bg-interactive-hover)Tab background on hover
--tab-active-textvar(--color-text)Active (selected) tab label colour
--tab-indicatorvar(--color-accent)Underline indicator colour on the active tab
--tab-bordervar(--color-border)Full-width separator between the tab list and panel
--tab-panel-bgtransparentPanel content area background
--tab-gap4pxGap between tab buttons
--tab-font-size0.875remTab label font size
--tab-disabled-opacity0.4Opacity 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

SituationGuidance
Active pageAdd aria-current="page" to the link for the current page. CSS styles it automatically; screen readers announce it as the current page.
Disabled prev/nextAdd aria-disabled="true" to the link. Do not remove href — keep the element an <a> so DOM order stays consistent.
Icon-only controlsProvide aria-label="Previous page" / aria-label="Next page" on icon-only links. Text labels are preferred for clarity.
EllipsisUse aria-hidden="true" on the .page-item wrapping the ellipsis span. Screen readers skip it; the surrounding numbered links provide sufficient context.
Very long rangesShow 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

AttributeWhereWhy
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-linkAnnounces the current page to screen readers and drives the active style — no class needed.
aria-disabled="true"Disabled .page-linkCommunicates disabled state to assistive technology; pointer-events: none prevents click.
aria-hidden="true".page-item with ellipsisHides the decorative separator from the accessibility tree.

Token reference

TokenDefaultControls
--pagination-active-bgvar(--color-accent)Active page item background
--pagination-active-textvar(--color-accent-text)Active page item text color
--pagination-hover-bgvar(--color-bg-interactive-hover)Hovered item background
--pagination-hover-textvar(--color-text)Hovered item text color
--pagination-radiusvar(--radius-md)Corner radius on each page link
--pagination-gapvar(--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;
}