Navigation
Navigation Menu
A site's navigation: links, plus buttons that show and hide links of their own. It's the APG's disclosure navigation, not a menu.
- Element
<slab-navigation-menu>- Built on
<nav> + <button aria-expanded>- As Markdown
- navigation-menu.md
Example
Here's a styled version, <slab-styled-navigation-menu>. It's not
in the package — it's an example of what you can build with the
headless element. Copy it and make it yours at the
bottom of this page. Switch to "As it ships" to see the same markup
with the headless element and no CSS at all. That's your starting
point.
<slab-styled-navigation-menu label="Main">
<a href="#home" aria-current="page">Home</a>
<slab-styled-navigation-menu-item label="Services">
<a href="#pouring">Pouring</a>
<a href="#finishing">Finishing</a>
<a href="#repair">Repair</a>
</slab-styled-navigation-menu-item>
<slab-styled-navigation-menu-item label="Company">
<a href="#about">About</a>
<a href="#careers">Careers</a>
</slab-styled-navigation-menu-item>
<a href="#contact">Contact</a>
</slab-styled-navigation-menu>
<slab-navigation-menu label="Main">
<a href="#home" aria-current="page">Home</a>
<slab-navigation-menu-item label="Services">
<a href="#pouring">Pouring</a>
<a href="#finishing">Finishing</a>
<a href="#repair">Repair</a>
</slab-navigation-menu-item>
<slab-navigation-menu-item label="Company">
<a href="#about">About</a>
<a href="#careers">Careers</a>
</slab-navigation-menu-item>
<a href="#contact">Contact</a>
</slab-navigation-menu>
Usage
pnpm add slab-elements lit
import 'slab-elements/navigation-menu.js';
import 'slab-elements/navigation-menu.item.js';
export function MyNavigationMenu() {
return (
<slab-navigation-menu label="Main">
<a href="/" aria-current="page">Home</a>
<slab-navigation-menu-item label="Products">
<a href="/products/mixers/">Mixers</a>
<a href="/products/trowels/">Trowels</a>
</slab-navigation-menu-item>
<a href="/contact/">Contact</a>
</slab-navigation-menu>
);
}
MyNavigationMenu.jsx React 19 or later, which sets props on custom elements as properties.
import 'slab-elements/navigation-menu.js';
import 'slab-elements/navigation-menu.item.js';
import { html, LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';
@customElement('my-navigation-menu')
export class MyNavigationMenu extends LitElement {
render() {
return html`
<slab-navigation-menu label="Main">
<a href="/" aria-current="page">Home</a>
<slab-navigation-menu-item label="Products">
<a href="/products/mixers/">Mixers</a>
<a href="/products/trowels/">Trowels</a>
</slab-navigation-menu-item>
<a href="/contact/">Contact</a>
</slab-navigation-menu>
`;
}
}
my-navigation-menu.ts Slab is built on Lit, so its elements are at home in Lit's templates.
<script setup>
import 'slab-elements/navigation-menu.js';
import 'slab-elements/navigation-menu.item.js';
</script>
<template>
<slab-navigation-menu label="Main">
<a href="/" aria-current="page">Home</a>
<slab-navigation-menu-item label="Products">
<a href="/products/mixers/">Mixers</a>
<a href="/products/trowels/">Trowels</a>
</slab-navigation-menu-item>
<a href="/contact/">Contact</a>
</slab-navigation-menu>
</template>
MyNavigationMenu.vue Let Vue's compiler know slab- tags are custom elements with isCustomElement.
<script>
import 'slab-elements/navigation-menu.js';
import 'slab-elements/navigation-menu.item.js';
</script>
<slab-navigation-menu label="Main">
<a href="/" aria-current="page">Home</a>
<slab-navigation-menu-item label="Products">
<a href="/products/mixers/">Mixers</a>
<a href="/products/trowels/">Trowels</a>
</slab-navigation-menu-item>
<a href="/contact/">Contact</a>
</slab-navigation-menu>
MyNavigationMenu.svelte Svelte binds properties and events to custom elements like any other element.
<script type="module">
import 'slab-elements/navigation-menu.js';
import 'slab-elements/navigation-menu.item.js';
</script>
<slab-navigation-menu label="Main">
<a href="/" aria-current="page">Home</a>
<slab-navigation-menu-item label="Products">
<a href="/products/mixers/">Mixers</a>
<a href="/products/trowels/">Trowels</a>
</slab-navigation-menu-item>
<a href="/contact/">Contact</a>
</slab-navigation-menu>
index.html Any bundler (or an import map) takes care of the imports.
Each link and Navigation Menu Item gets an <li> of its own, and so does each link in an Item. An Item’s content is in the flow, right below its button, and nothing positions it, so a dropdown is a design’s: give ::part(content) a position: absolute and the Item a position: relative, or leave the content in place for a menu on a phone. Opening one Item closes the others, and a click outside an Item, or focus leaving it, closes it too.
It isn’t a Menubar. A [role=menu] is for commands, like a desktop app’s, and tells a screenreader user to expect only the arrow keys. Here the links are links, the buttons are buttons with aria-expanded, and Tab goes through all of them. Mark the current page’s link with aria-current="page".
Keyboard
| Key | What it does |
|---|---|
| Tab | Moves through the links and buttons in order, including an open Item’s links. Leaving an Item closes it. |
| Enter, Space | On an Item’s button, opens or closes it. |
| Down | On an Item’s button, opens it at its first link. |
| Up, Down | In an open Item, moves to the previous or next link. Up from the first goes to the button. |
| Home, End | In an open Item, moves to its first or last link. In the row, to the first or last link or button. |
| Left, Right | In the row, moves to the previous or next link or button, wrapping. |
| Escape | Closes the open Item and returns focus to its button. |
Behavior
<slab-navigation-menu>
A Navigation Menu with no visual styles: a site's navigation, as a <nav> around a <ul> of links and Navigation Menu Items, each Item a button that shows and hides links of its own.
It's the APG's disclosure navigation, not a menu. A [role=menu] is for commands, like a desktop app's, and tells a screenreader user to expect the arrow keys and nothing else. Here the links are links, the buttons are buttons with aria-expanded, and Tab goes through all of them in order.
Each element slotted into it gets an <li> of its own, as in Breadcrumb. Opening one Item closes the others, and the arrow keys are a shortcut on top of Tab: Left and Right move between the links and Items' buttons in the row, wrapping at each end, and Home and End go to the first and last.
Mark the link to the current page with aria-current="page".
<slab-navigation-menu-item>
A Navigation Menu Item with no visual styles: a button that shows and hides a list of links, the APG's disclosure navigation. It's not a menu. The button is a button with aria-expanded, the links are links in a list, and Tab goes through them in order.
Each element slotted into it gets an <li> of its own in a <ul>, as each in a Navigation Menu does. That's usually a link, but a design can slot in anything, such as a <div> with a heading and a short description.
The content is in the flow, below the button, so a design positions it, such as absolutely below the button for a dropdown, or left in place for a menu on a phone. It's display: none while closed.
Escape closes it and returns focus to the button. A click outside it, or focus leaving it by the keyboard, closes it too. In a Navigation Menu, opening one Item closes the others.
Down, on the button, opens it at the first link, and Up and Down move between the links. Up from the first goes back to the button.
API
<slab-navigation-menu>
| Name | Type | Default | Description |
|---|---|---|---|
label
Required
|
string |
Names the navigation. | |
version
|
string |
| Name | Type | Description |
|---|---|---|
items
Read-only
|
Element[] |
| Name | Type | Description |
|---|---|---|
(default)
|
Element |
The links and Navigation Menu Items, each in an \<li>\ of its own |
| Name | Description |
|---|---|
item
|
|
list
|
The links and Items |
nav
|
The navigation |
<slab-navigation-menu-item>
| Name | Type | Default | Description |
|---|---|---|---|
label
Required
|
string |
The button's text and so its accessible name. | |
open
|
boolean |
false |
|
version
|
string |
| Name | Type | Description |
|---|---|---|
items
Read-only
|
Element[] |
| Signature | Description |
|---|---|
click(): void
|
|
focus(options?: FocusOptions): void
|
|
hide(): void
|
|
show(): void
|
|
toggle(): void
|
| Name | Type | Description |
|---|---|---|
toggle
|
ToggleEvent |
| Name | Type | Description |
|---|---|---|
(default)
|
Element |
The links, each in an \<li>\ of its own |
| Name | Description |
|---|---|
content
|
The content shown when open |
item
|
|
list
|
The links |
trigger
|
The button that shows and hides the content |
Copy and own it
<slab-styled-navigation-menu> isn't in the package. It's an example —
copy it into your project and change whatever you want. Keyboard,
focus, ARIA, forms, and events all come from the headless element,
and the styles only reach in through ::part() and
:state(), same as yours would.
The same styles, written from outside the element, so
there's nothing to subclass. The CSS styles every
<slab-navigation-menu> on the page. Import
tokens.css, the colors and fonts every styled
example shares, once, wherever your global styles go.
Each component registers its headless element and imports its CSS. Use it like the element: its props are the element's attributes and properties.
NavigationMenu.jsx
import 'slab-elements/navigation-menu.js';
import './navigation-menu.css';
import 'slab-elements/navigation-menu.item.js';
import './navigation-menu.item.css';
export function NavigationMenu(props) {
return <slab-navigation-menu {...props} />;
}
export function NavigationMenuItem(props) {
return <slab-navigation-menu-item {...props} />;
}
navigation-menu.css
slab-navigation-menu::part(list) {
background-color: var(--paper);
border: var(--border);
box-shadow: var(--shadow);
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
inline-size: fit-content;
list-style: none;
margin: 0;
padding: 0.25rem;
}
slab-navigation-menu > a {
border: 2px solid transparent;
color: var(--ink);
display: block;
font-family: var(--font-display);
font-size: 0.9375rem;
font-weight: 600;
letter-spacing: 0.04em;
padding: 0.375rem 0.75rem;
text-decoration: none;
text-transform: uppercase;
}
slab-navigation-menu > a:hover {
background-color: var(--chalk);
}
slab-navigation-menu > a:focus-visible {
outline: 3px solid var(--ember);
outline-offset: 2px;
}
slab-navigation-menu > [aria-current='page'] {
text-decoration-color: var(--ember);
text-decoration-line: underline;
text-decoration-thickness: 2px;
text-underline-offset: 0.3em;
}
navigation-menu.item.css
slab-navigation-menu-item {
position: relative;
}
slab-navigation-menu-item::part(trigger) {
align-items: center;
background-color: transparent;
border: 2px solid transparent;
color: var(--ink);
cursor: pointer;
display: flex;
font-family: var(--font-display);
font-size: 0.9375rem;
font-weight: 600;
gap: 0.375rem;
letter-spacing: 0.04em;
padding: 0.375rem 0.75rem;
text-transform: uppercase;
}
/*
The marker. Its text is decorative, so it's given empty alternative
text: the button's expanded state already says whether it's open.
*/
slab-navigation-menu-item::part(trigger)::after {
color: var(--ember);
content: '▾' / '';
transition: rotate 150ms;
}
slab-navigation-menu-item::part(trigger):hover {
background-color: var(--chalk);
}
slab-navigation-menu-item::part(trigger):focus-visible {
outline: 3px solid var(--ember);
outline-offset: 2px;
}
slab-navigation-menu-item[open]::part(trigger) {
background-color: var(--ink);
color: var(--paper);
}
slab-navigation-menu-item[open]::part(trigger)::after {
rotate: 180deg;
}
/* Hidden while closed, so this runs each time it opens. */
slab-navigation-menu-item::part(content) {
animation: slab-navigation-menu-item-drop 150ms ease-out;
background-color: var(--paper);
border: var(--border);
box-shadow: var(--shadow);
color: var(--ink);
font-family: var(--font-body);
inset-block-start: calc(100% + 0.5rem);
inset-inline-start: -0.25rem;
min-inline-size: 12rem;
padding: 0.25rem 0;
position: absolute;
z-index: 1;
}
slab-navigation-menu-item::part(list) {
list-style: none;
margin: 0;
padding: 0;
}
slab-navigation-menu-item > a {
border-inline-start: 4px solid transparent;
color: var(--ink);
display: block;
padding: 0.5rem 0.875rem 0.5rem 0.75rem;
text-decoration: none;
transition:
background-color 100ms,
border-color 100ms,
color 100ms;
}
slab-navigation-menu-item > a:hover,
slab-navigation-menu-item > a:focus-visible {
background-color: var(--ink);
border-inline-start-color: var(--ember);
color: var(--paper);
outline: none;
}
slab-navigation-menu-item > [aria-current='page'] {
font-weight: 600;
}
@keyframes slab-navigation-menu-item-drop {
from {
opacity: 0;
translate: 0 -0.375rem;
}
}
@media (prefers-reduced-motion: reduce) {
slab-navigation-menu-item::part(trigger)::after,
slab-navigation-menu-item > a {
transition: none;
}
slab-navigation-menu-item::part(content) {
animation: none;
}
}
tokens.css
:root {
/* Secondary text. 6.8:1 and 6.9:1 on paper. */
--ash: var(--slab-styled-ash, light-dark(#5e5a55, #a8a19a));
--border: 2px solid var(--ink);
/* Behind disabled and read-only controls, and hovered rows. */
--chalk: var(--slab-styled-chalk, light-dark(#f4f1ec, #2b2926));
/*
The logo's orange, brighter in the dark. 4.25:1 and 5.2:1 on paper is
enough for borders, fills and large text, but not always for small text,
which takes "--ember-deep".
*/
--ember: var(--slab-styled-ember, light-dark(#d9492a, #ec5f3b));
/*
Orange for small text on paper, and for paper text on it. 5.7:1 and
7.4:1.
*/
--ember-deep: var(--slab-styled-ember-deep, light-dark(#b3381a, #ff8566));
--font-body: var(
--slab-styled-font-body,
'IBM Plex Sans',
system-ui,
sans-serif
);
--font-display: var(
--slab-styled-font-display,
'Zilla Slab',
rockwell,
'Roboto Slab',
serif
);
--font-mono: var(
--slab-styled-font-mono,
'IBM Plex Mono',
ui-monospace,
monospace
);
--ink: var(--slab-styled-ink, light-dark(#1c1c1c, #f4f1ec));
--paper: var(--slab-styled-paper, light-dark(#fff, #1a1918));
/* The hard, offset shadow a raised surface casts. */
--shadow: 4px 4px 0 var(--ink);
}
Under the hood, it's just the headless element, subclassed,
with styles set. That's it. To use it, copy
these files, swap the tag in @customElement()
for one of your own, and import it.
tokens.styles.ts holds the colors and fonts
every styled example shares, so you only need it once.
navigation-menu.ts
import { customElement } from 'lit/decorators.js';
import NavigationMenu from 'slab-elements/navigation-menu.js';
import styles from './navigation-menu.styles.js';
import tokens from './tokens.styles.js';
declare global {
interface HTMLElementTagNameMap {
'slab-styled-navigation-menu': StyledNavigationMenu;
}
}
/**
* Navigation Menu, styled: a raised bar of slab-serif links and buttons, with
* the current page underlined in ember.
*/
@customElement('slab-styled-navigation-menu')
export default class StyledNavigationMenu extends NavigationMenu {
static override styles = [tokens, styles];
}
navigation-menu.styles.ts
import { css } from 'lit';
/*
The links in the row are slotted in, so they're styled through
"::slotted()". Each Item styles its own button to match.
*/
export default css`
[part~='list'] {
background-color: var(--paper);
border: var(--border);
box-shadow: var(--shadow);
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
inline-size: fit-content;
list-style: none;
margin: 0;
padding: 0.25rem;
}
::slotted(a) {
border: 2px solid transparent;
color: var(--ink);
display: block;
font-family: var(--font-display);
font-size: 0.9375rem;
font-weight: 600;
letter-spacing: 0.04em;
padding: 0.375rem 0.75rem;
text-decoration: none;
text-transform: uppercase;
}
::slotted(a:hover) {
background-color: var(--chalk);
}
::slotted(a:focus-visible) {
outline: 3px solid var(--ember);
outline-offset: 2px;
}
::slotted([aria-current='page']) {
text-decoration-color: var(--ember);
text-decoration-line: underline;
text-decoration-thickness: 2px;
text-underline-offset: 0.3em;
}
`;
navigation-menu.item.ts
import { customElement } from 'lit/decorators.js';
import NavigationMenuItem from 'slab-elements/navigation-menu.item.js';
import styles from './navigation-menu.item.styles.js';
import tokens from './tokens.styles.js';
declare global {
interface HTMLElementTagNameMap {
'slab-styled-navigation-menu-item': StyledNavigationMenuItem;
}
}
/**
* Navigation Menu Item, styled: its button in ink while open, over a raised
* panel of links that drops down below it.
*/
@customElement('slab-styled-navigation-menu-item')
export default class StyledNavigationMenuItem extends NavigationMenuItem {
static override styles = [tokens, styles];
}
navigation-menu.item.styles.ts
import { css } from 'lit';
/*
The content drops down below the button, over what's below the menu,
rather than pushing it down.
*/
export default css`
:host {
position: relative;
}
[part~='trigger'] {
align-items: center;
background-color: transparent;
border: 2px solid transparent;
color: var(--ink);
cursor: pointer;
display: flex;
font-family: var(--font-display);
font-size: 0.9375rem;
font-weight: 600;
gap: 0.375rem;
letter-spacing: 0.04em;
padding: 0.375rem 0.75rem;
text-transform: uppercase;
/*
The marker. Its text is decorative, so it's given empty alternative
text: the button's expanded state already says whether it's open.
*/
&::after {
color: var(--ember);
content: '▾' / '';
transition: rotate 150ms;
}
&:hover {
background-color: var(--chalk);
}
&:focus-visible {
outline: 3px solid var(--ember);
outline-offset: 2px;
}
}
:host([open]) [part~='trigger'] {
background-color: var(--ink);
color: var(--paper);
&::after {
rotate: 180deg;
}
}
/* Hidden while closed, so this runs each time it opens. */
[part~='content'] {
animation: drop 150ms ease-out;
background-color: var(--paper);
border: var(--border);
box-shadow: var(--shadow);
color: var(--ink);
font-family: var(--font-body);
inset-block-start: calc(100% + 0.5rem);
inset-inline-start: -0.25rem;
min-inline-size: 12rem;
padding: 0.25rem 0;
position: absolute;
z-index: 1;
}
[part~='list'] {
list-style: none;
margin: 0;
padding: 0;
}
::slotted(a) {
border-inline-start: 4px solid transparent;
color: var(--ink);
display: block;
padding: 0.5rem 0.875rem 0.5rem 0.75rem;
text-decoration: none;
transition:
background-color 100ms,
border-color 100ms,
color 100ms;
}
::slotted(a:hover),
::slotted(a:focus-visible) {
background-color: var(--ink);
border-inline-start-color: var(--ember);
color: var(--paper);
outline: none;
}
::slotted([aria-current='page']) {
font-weight: 600;
}
@keyframes drop {
from {
opacity: 0;
translate: 0 -0.375rem;
}
}
@media (prefers-reduced-motion: reduce) {
[part~='trigger']::after,
::slotted(a) {
transition: none;
}
[part~='content'] {
animation: none;
}
}
`;
tokens.styles.ts
import { css } from 'lit';
/*
The styled components' palette and type, taken from the logo: ink for the
courses, ember for the base, paper behind them.
Each is a short alias for a "--slab-styled-*" property, with the default as
its fallback. Retheming is "--slab-styled-ember" and the rest on ":root",
which every shadow root inherits. Were the defaults declared under those
names here instead, each host's own declaration would shadow the page's.
Each default is "light-dark()", so the components follow the page's
"color-scheme": "dark" for dark, "light" for light, and "light dark" to
follow the operating system. Ink and paper trade places in the dark, so
ink is always the text and paper always what's behind it. Contrast is given
as light, then dark.
*/
export default css`
:host {
/* Secondary text. 6.8:1 and 6.9:1 on paper. */
--ash: var(--slab-styled-ash, light-dark(#5e5a55, #a8a19a));
--border: 2px solid var(--ink);
/* Behind disabled and read-only controls, and hovered rows. */
--chalk: var(--slab-styled-chalk, light-dark(#f4f1ec, #2b2926));
/*
The logo's orange, brighter in the dark. 4.25:1 and 5.2:1 on paper is
enough for borders, fills and large text, but not always for small text,
which takes "--ember-deep".
*/
--ember: var(--slab-styled-ember, light-dark(#d9492a, #ec5f3b));
/*
Orange for small text on paper, and for paper text on it. 5.7:1 and
7.4:1.
*/
--ember-deep: var(--slab-styled-ember-deep, light-dark(#b3381a, #ff8566));
--font-body: var(
--slab-styled-font-body,
'IBM Plex Sans',
system-ui,
sans-serif
);
--font-display: var(
--slab-styled-font-display,
'Zilla Slab',
rockwell,
'Roboto Slab',
serif
);
--font-mono: var(
--slab-styled-font-mono,
'IBM Plex Mono',
ui-monospace,
monospace
);
--ink: var(--slab-styled-ink, light-dark(#1c1c1c, #f4f1ec));
--paper: var(--slab-styled-paper, light-dark(#fff, #1a1918));
/* The hard, offset shadow a raised surface casts. */
--shadow: 4px 4px 0 var(--ink);
}
`;
The same styles, written from outside the element, so
there's nothing to subclass. The CSS styles every
<slab-navigation-menu> on the page. Import
tokens.css, the colors and fonts every styled
example shares, once, wherever your global styles go.
Each component registers its headless element and imports its CSS. Use it like the element: its props are the element's attributes and properties.
NavigationMenu.vue
<script setup>
import 'slab-elements/navigation-menu.js';
import './navigation-menu.css';
</script>
<template>
<slab-navigation-menu><slot /></slab-navigation-menu>
</template>
NavigationMenuItem.vue
<script setup>
import 'slab-elements/navigation-menu.item.js';
import './navigation-menu.item.css';
</script>
<template>
<slab-navigation-menu-item><slot /></slab-navigation-menu-item>
</template>
navigation-menu.css
slab-navigation-menu::part(list) {
background-color: var(--paper);
border: var(--border);
box-shadow: var(--shadow);
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
inline-size: fit-content;
list-style: none;
margin: 0;
padding: 0.25rem;
}
slab-navigation-menu > a {
border: 2px solid transparent;
color: var(--ink);
display: block;
font-family: var(--font-display);
font-size: 0.9375rem;
font-weight: 600;
letter-spacing: 0.04em;
padding: 0.375rem 0.75rem;
text-decoration: none;
text-transform: uppercase;
}
slab-navigation-menu > a:hover {
background-color: var(--chalk);
}
slab-navigation-menu > a:focus-visible {
outline: 3px solid var(--ember);
outline-offset: 2px;
}
slab-navigation-menu > [aria-current='page'] {
text-decoration-color: var(--ember);
text-decoration-line: underline;
text-decoration-thickness: 2px;
text-underline-offset: 0.3em;
}
navigation-menu.item.css
slab-navigation-menu-item {
position: relative;
}
slab-navigation-menu-item::part(trigger) {
align-items: center;
background-color: transparent;
border: 2px solid transparent;
color: var(--ink);
cursor: pointer;
display: flex;
font-family: var(--font-display);
font-size: 0.9375rem;
font-weight: 600;
gap: 0.375rem;
letter-spacing: 0.04em;
padding: 0.375rem 0.75rem;
text-transform: uppercase;
}
/*
The marker. Its text is decorative, so it's given empty alternative
text: the button's expanded state already says whether it's open.
*/
slab-navigation-menu-item::part(trigger)::after {
color: var(--ember);
content: '▾' / '';
transition: rotate 150ms;
}
slab-navigation-menu-item::part(trigger):hover {
background-color: var(--chalk);
}
slab-navigation-menu-item::part(trigger):focus-visible {
outline: 3px solid var(--ember);
outline-offset: 2px;
}
slab-navigation-menu-item[open]::part(trigger) {
background-color: var(--ink);
color: var(--paper);
}
slab-navigation-menu-item[open]::part(trigger)::after {
rotate: 180deg;
}
/* Hidden while closed, so this runs each time it opens. */
slab-navigation-menu-item::part(content) {
animation: slab-navigation-menu-item-drop 150ms ease-out;
background-color: var(--paper);
border: var(--border);
box-shadow: var(--shadow);
color: var(--ink);
font-family: var(--font-body);
inset-block-start: calc(100% + 0.5rem);
inset-inline-start: -0.25rem;
min-inline-size: 12rem;
padding: 0.25rem 0;
position: absolute;
z-index: 1;
}
slab-navigation-menu-item::part(list) {
list-style: none;
margin: 0;
padding: 0;
}
slab-navigation-menu-item > a {
border-inline-start: 4px solid transparent;
color: var(--ink);
display: block;
padding: 0.5rem 0.875rem 0.5rem 0.75rem;
text-decoration: none;
transition:
background-color 100ms,
border-color 100ms,
color 100ms;
}
slab-navigation-menu-item > a:hover,
slab-navigation-menu-item > a:focus-visible {
background-color: var(--ink);
border-inline-start-color: var(--ember);
color: var(--paper);
outline: none;
}
slab-navigation-menu-item > [aria-current='page'] {
font-weight: 600;
}
@keyframes slab-navigation-menu-item-drop {
from {
opacity: 0;
translate: 0 -0.375rem;
}
}
@media (prefers-reduced-motion: reduce) {
slab-navigation-menu-item::part(trigger)::after,
slab-navigation-menu-item > a {
transition: none;
}
slab-navigation-menu-item::part(content) {
animation: none;
}
}
tokens.css
:root {
/* Secondary text. 6.8:1 and 6.9:1 on paper. */
--ash: var(--slab-styled-ash, light-dark(#5e5a55, #a8a19a));
--border: 2px solid var(--ink);
/* Behind disabled and read-only controls, and hovered rows. */
--chalk: var(--slab-styled-chalk, light-dark(#f4f1ec, #2b2926));
/*
The logo's orange, brighter in the dark. 4.25:1 and 5.2:1 on paper is
enough for borders, fills and large text, but not always for small text,
which takes "--ember-deep".
*/
--ember: var(--slab-styled-ember, light-dark(#d9492a, #ec5f3b));
/*
Orange for small text on paper, and for paper text on it. 5.7:1 and
7.4:1.
*/
--ember-deep: var(--slab-styled-ember-deep, light-dark(#b3381a, #ff8566));
--font-body: var(
--slab-styled-font-body,
'IBM Plex Sans',
system-ui,
sans-serif
);
--font-display: var(
--slab-styled-font-display,
'Zilla Slab',
rockwell,
'Roboto Slab',
serif
);
--font-mono: var(
--slab-styled-font-mono,
'IBM Plex Mono',
ui-monospace,
monospace
);
--ink: var(--slab-styled-ink, light-dark(#1c1c1c, #f4f1ec));
--paper: var(--slab-styled-paper, light-dark(#fff, #1a1918));
/* The hard, offset shadow a raised surface casts. */
--shadow: 4px 4px 0 var(--ink);
}
The same styles, written from outside the element, so
there's nothing to subclass. The CSS styles every
<slab-navigation-menu> on the page. Import
tokens.css, the colors and fonts every styled
example shares, once, wherever your global styles go.
Import navigation-menu.js once, such
as in your root layout, and use
<slab-navigation-menu> as is. A Svelte component
around it would take slot="…" content as its
own slot and drop it.
navigation-menu.js
import 'slab-elements/navigation-menu.js';
import './navigation-menu.css';
import 'slab-elements/navigation-menu.item.js';
import './navigation-menu.item.css';
navigation-menu.css
slab-navigation-menu::part(list) {
background-color: var(--paper);
border: var(--border);
box-shadow: var(--shadow);
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
inline-size: fit-content;
list-style: none;
margin: 0;
padding: 0.25rem;
}
slab-navigation-menu > a {
border: 2px solid transparent;
color: var(--ink);
display: block;
font-family: var(--font-display);
font-size: 0.9375rem;
font-weight: 600;
letter-spacing: 0.04em;
padding: 0.375rem 0.75rem;
text-decoration: none;
text-transform: uppercase;
}
slab-navigation-menu > a:hover {
background-color: var(--chalk);
}
slab-navigation-menu > a:focus-visible {
outline: 3px solid var(--ember);
outline-offset: 2px;
}
slab-navigation-menu > [aria-current='page'] {
text-decoration-color: var(--ember);
text-decoration-line: underline;
text-decoration-thickness: 2px;
text-underline-offset: 0.3em;
}
navigation-menu.item.css
slab-navigation-menu-item {
position: relative;
}
slab-navigation-menu-item::part(trigger) {
align-items: center;
background-color: transparent;
border: 2px solid transparent;
color: var(--ink);
cursor: pointer;
display: flex;
font-family: var(--font-display);
font-size: 0.9375rem;
font-weight: 600;
gap: 0.375rem;
letter-spacing: 0.04em;
padding: 0.375rem 0.75rem;
text-transform: uppercase;
}
/*
The marker. Its text is decorative, so it's given empty alternative
text: the button's expanded state already says whether it's open.
*/
slab-navigation-menu-item::part(trigger)::after {
color: var(--ember);
content: '▾' / '';
transition: rotate 150ms;
}
slab-navigation-menu-item::part(trigger):hover {
background-color: var(--chalk);
}
slab-navigation-menu-item::part(trigger):focus-visible {
outline: 3px solid var(--ember);
outline-offset: 2px;
}
slab-navigation-menu-item[open]::part(trigger) {
background-color: var(--ink);
color: var(--paper);
}
slab-navigation-menu-item[open]::part(trigger)::after {
rotate: 180deg;
}
/* Hidden while closed, so this runs each time it opens. */
slab-navigation-menu-item::part(content) {
animation: slab-navigation-menu-item-drop 150ms ease-out;
background-color: var(--paper);
border: var(--border);
box-shadow: var(--shadow);
color: var(--ink);
font-family: var(--font-body);
inset-block-start: calc(100% + 0.5rem);
inset-inline-start: -0.25rem;
min-inline-size: 12rem;
padding: 0.25rem 0;
position: absolute;
z-index: 1;
}
slab-navigation-menu-item::part(list) {
list-style: none;
margin: 0;
padding: 0;
}
slab-navigation-menu-item > a {
border-inline-start: 4px solid transparent;
color: var(--ink);
display: block;
padding: 0.5rem 0.875rem 0.5rem 0.75rem;
text-decoration: none;
transition:
background-color 100ms,
border-color 100ms,
color 100ms;
}
slab-navigation-menu-item > a:hover,
slab-navigation-menu-item > a:focus-visible {
background-color: var(--ink);
border-inline-start-color: var(--ember);
color: var(--paper);
outline: none;
}
slab-navigation-menu-item > [aria-current='page'] {
font-weight: 600;
}
@keyframes slab-navigation-menu-item-drop {
from {
opacity: 0;
translate: 0 -0.375rem;
}
}
@media (prefers-reduced-motion: reduce) {
slab-navigation-menu-item::part(trigger)::after,
slab-navigation-menu-item > a {
transition: none;
}
slab-navigation-menu-item::part(content) {
animation: none;
}
}
tokens.css
:root {
/* Secondary text. 6.8:1 and 6.9:1 on paper. */
--ash: var(--slab-styled-ash, light-dark(#5e5a55, #a8a19a));
--border: 2px solid var(--ink);
/* Behind disabled and read-only controls, and hovered rows. */
--chalk: var(--slab-styled-chalk, light-dark(#f4f1ec, #2b2926));
/*
The logo's orange, brighter in the dark. 4.25:1 and 5.2:1 on paper is
enough for borders, fills and large text, but not always for small text,
which takes "--ember-deep".
*/
--ember: var(--slab-styled-ember, light-dark(#d9492a, #ec5f3b));
/*
Orange for small text on paper, and for paper text on it. 5.7:1 and
7.4:1.
*/
--ember-deep: var(--slab-styled-ember-deep, light-dark(#b3381a, #ff8566));
--font-body: var(
--slab-styled-font-body,
'IBM Plex Sans',
system-ui,
sans-serif
);
--font-display: var(
--slab-styled-font-display,
'Zilla Slab',
rockwell,
'Roboto Slab',
serif
);
--font-mono: var(
--slab-styled-font-mono,
'IBM Plex Mono',
ui-monospace,
monospace
);
--ink: var(--slab-styled-ink, light-dark(#1c1c1c, #f4f1ec));
--paper: var(--slab-styled-paper, light-dark(#fff, #1a1918));
/* The hard, offset shadow a raised surface casts. */
--shadow: 4px 4px 0 var(--ink);
}