Slab

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.

Styled example As it ships
HTML
<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>
HTML
<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

Install
pnpm add slab-elements lit
ReactLitVueSvelteHTML
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>

Attributes
Name Type Default Description
label Required string Names the navigation.
version string
Properties
Name Type Description
items Read-only Element[]
Slots
Name Type Description
(default) Element The links and Navigation Menu Items, each in an \<li>\ of its own
CSS parts
Name Description
item
list The links and Items
nav The navigation

<slab-navigation-menu-item>

Attributes
Name Type Default Description
label Required string The button's text and so its accessible name.
open boolean false
version string
Properties
Name Type Description
items Read-only Element[]
Methods
Signature Description
click(): void
focus(options?: FocusOptions): void
hide(): void
show(): void
toggle(): void
Events
Name Type Description
toggle ToggleEvent
Slots
Name Type Description
(default) Element The links, each in an \<li>\ of its own
CSS parts
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.

ReactLitVueSvelte

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);
}