Skip to content
softwarityPublic

Repository files navigation

Softwarity

@softwarity/rail-nav

npm version license

Material Design 3 Navigation Rail component for Angular. Extends Angular Material mat-sidenav to provide a collapsible rail navigation pattern.

Live Demo

Rail Nav Collapsed Rail Nav Expanded Rail Nav Contextual Drawer

Features

  • Material Design 3 - Implements the Navigation Rail pattern from MD3
  • Extends Material - Built on top of MatSidenav for reliability
  • Navigation Items - rail-nav-item component with MD3 pill animation, badges, and router support
  • Contextual Drawer - [for] on an item opens a side drawer from an <ng-template>, with hover-intent, content cross-fade, outside-tap dismiss and mobile support
  • Separator & Spacer - rail-nav-separator to group items, rail-nav-spacer to anchor items at the bottom
  • Expand/Collapse - Smooth transition between rail and drawer with adaptive width
  • Backdrop - Optional backdrop overlay when expanded
  • Position - Support for left (start) or right (end) positioning
  • Dark Mode - Automatically adapts to light/dark color scheme
  • SCSS Theming - Customizable via SCSS mixin following Angular Material pattern

Installation

npm install @softwarity/rail-nav

Peer Dependencies

Package Version
@angular/common >= 21.0.0
@angular/core >= 21.0.0
@angular/cdk >= 21.0.0
@angular/material >= 21.0.0

Usage

Import the components in your Angular component:

import {
  RailnavComponent,
  RailnavContainerComponent,
  RailnavContentComponent,
  RailnavItemComponent,
  RailnavSeparatorComponent, // Optional: separator between item groups
  RailnavSpacerComponent,    // Optional: push following items to the bottom
  RailnavBrandingDirective   // Optional: for custom branding
} from '@softwarity/rail-nav';

@Component({
  imports: [
    RailnavComponent,
    RailnavContainerComponent,
    RailnavContentComponent,
    RailnavItemComponent,
    RailnavSeparatorComponent,
    RailnavSpacerComponent,
    RailnavBrandingDirective
  ],
  template: `
    <rail-nav-container>
      <rail-nav title="My App" subtitle="v1.0">
        <rail-nav-item label="Home" routerLink="/home">
          <mat-icon>home</mat-icon>
        </rail-nav-item>
        <rail-nav-item label="Settings" routerLink="/settings">
          <mat-icon>settings</mat-icon>
        </rail-nav-item>
      </rail-nav>
      <rail-nav-content>
        <!-- Page content -->
      </rail-nav-content>
    </rail-nav-container>
  `
})
export class AppComponent {}

API

RailnavComponent

The main navigation rail component. Extends MatSidenav.

Input Type Default Description
position 'start' | 'end' 'start' Position of the rail (left or right)
title string - Title displayed next to burger when expanded
subtitle string - Subtitle displayed below title when expanded
hideDefaultHeader boolean false Hide the default header to provide custom content
autoCollapse boolean true Auto-collapse the rail when an item is clicked
Property/Method Type Description
expanded Signal<boolean> Read the current expanded state
toggleExpanded() void Toggle between collapsed and expanded
expand() void Expand the rail
collapse() void Collapse the rail

RailnavContainerComponent

Container component. Extends MatSidenavContainer.

Input Type Default Description
showBackdrop boolean true Show backdrop overlay when expanded

RailnavContentComponent

Content area component. Extends MatSidenavContent.

Input Type Default Description
position 'start' | 'end' from the sibling rail Rail side, when it cannot be read from a sibling rail-nav
scrollOffset number 0 Distance (px) from the top at which a section counts as reached, and the room kept above a section scrolled to, by the rail or by the browser for a URL fragment: the height of a sticky header, if any. See Scroll-spy
anchorFragment boolean false Mirror the section in view in the URL fragment (#id, replacing the history entry; none above the first section) and scroll to the fragment's section on load
Property/Method Type Description
activeAnchor Signal<string | null> Id of the section in view, among the anchors of the rail's items
scrollToAnchor(id, behavior?) void Scroll to the section #id — 'smooth' (default) or 'instant' — and make its item active

Note: When placed inside a rail-nav-container alongside a rail-nav, the component automatically detects the rail position and applies the correct margin using the --rail-nav-collapsed-width CSS variable.

RailnavItemComponent

Navigation item with MD3 pill animation.

Input Type Default Description
routerLink string | any[] - Router link for navigation
label string - Label text (below icon when collapsed, beside when expanded)
badge string | number | boolean - Badge value. Use true for a small dot badge
active boolean - Whether this item is active. On a routerLink item, left unset the router decides (the link matches the URL, as a prefix); set, it decides alone: false keeps off an item whose link is a prefix of the URL, such as the root. An anchor item is also active while its section is in view
anchor string - Id of an element inside rail-nav-content: a click scrolls to it, and the item is active while that section is in view. See Scroll-spy. Ignored with routerLink
for TemplateRef | null null Template projected as a contextual drawer when the item is hovered or clicked. Aliased to for for mat-datepicker-toggle-style ergonomics. See Contextual drawer.
Output Type Description
itemClick void Emitted when clicked (automatically collapses rail)

RailnavSeparatorComponent

Visual separator between groups of <rail-nav-item>. Hairline 1px rule, vertically centered in a fixed-height host so the spacing stays consistent across collapsed / expanded modes.

<rail-nav>
  <rail-nav-item label="Home">...</rail-nav-item>
  <rail-nav-separator />
  <rail-nav-item label="Trash">...</rail-nav-item>
</rail-nav>

Color overridable via --rail-nav-separator-color.

RailnavSpacerComponent

Flexible spacer (flex: 1 1 auto). Place between two groups of items to push everything after it to the bottom of the rail — the classic pattern for primary nav on top and Settings / Profile anchored at the bottom.

<rail-nav>
  <rail-nav-item label="Home">...</rail-nav-item>
  <rail-nav-item label="Inbox">...</rail-nav-item>
  <rail-nav-spacer />
  <rail-nav-item label="Settings">...</rail-nav-item>
</rail-nav>

Scroll-spy

For a single page split into sections, point each item at the id of its section instead of a route. A click scrolls smoothly to the section, and the item of the section in view turns active as the page scrolls — no active bookkeeping in the host.

<rail-nav-container>
  <rail-nav>
    <rail-nav-item label="Palette" anchor="palette"><mat-icon>palette</mat-icon></rail-nav-item>
    <rail-nav-item label="Forms" anchor="forms"><mat-icon>edit_note</mat-icon></rail-nav-item>
  </rail-nav>
  <rail-nav-content scrollOffset="64" anchorFragment>
    <mat-toolbar class="sticky-header">…</mat-toolbar>
    <section id="palette">…</section>
    <section id="forms">…</section>
  </rail-nav-content>
</rail-nav-container>
  • The section in view is the last one scrolled up to scrollOffset px below the top of the content. Once the content is scrolled to the very bottom, the last section in view wins, so a short final section still gets its turn.
  • The browser's own scroll to a URL fragment — a shared link while the page loads, an in-page <a href="#…"> — leaves the same room above the section. Avoid scroll-padding-top on rail-nav-content for that: the focus entering a sticky header would scroll the content back up.
  • Only the sections some item points at are watched. They must sit inside rail-nav-content, the element that scrolls.
  • The scroll is animated by the component — MatSidenavContent ignores scrollTo({ behavior: 'smooth' }) — skipped under prefers-reduced-motion, and it stops as soon as the user scrolls.
  • anchorFragment keeps #section in the address bar without adding history entries, removes it above the first section, and opens a shared link on its section. Leave it off in an app routed with withHashLocation(): the route lives in the fragment, which anchorFragment would overwrite.
  • The active item carries aria-current="location".

Contextual drawer

Any rail item can declare a contextual side drawer with [for]="someTemplate". The library renders the template in a CDK overlay positioned right next to the rail, handles hover-intent (200ms), close debounce (300ms for cursor transit), outside-tap dismiss, auto-close when the rail is expanded, and re-targets the same overlay when the cursor moves between trigger items.

import {
  RailnavComponent,
  RailnavContainerComponent,
  RailnavContentComponent,
  RailnavItemComponent,
} from '@softwarity/rail-nav';

@Component({
  imports: [
    RailnavComponent,
    RailnavContainerComponent,
    RailnavContentComponent,
    RailnavItemComponent,
  ],
  template: `
    <rail-nav-container>
      <rail-nav>
        <rail-nav-item label="Workspaces" [for]="wsTpl">
          <mat-icon>workspaces</mat-icon>
        </rail-nav-item>
      </rail-nav>
      <rail-nav-content>...</rail-nav-content>
    </rail-nav-container>

    <ng-template #wsTpl>
      <a routerLink="/ws/1">Workspace 1</a>
      <a routerLink="/ws/2">Workspace 2</a>
    </ng-template>
  `,
})
export class AppComponent {}

The drawer panel applies its own chrome (background, shadow, rounded right corner) and a default nav-list layout (flex column, padding, gap, fixed width) — your template only needs the children. Both desktop hover and mobile tap open the drawer; the lib swallows the synthetic mouse events touch devices fire after a tap so the drawer doesn't close instantly.

Drawer CSS custom properties

Override per rail (or globally) — all read with sane Material defaults as fallback.

Variable Default Description
--rail-nav-drawer-width 240px Fixed drawer width
--rail-nav-drawer-padding 12px Inner padding
--rail-nav-drawer-gap 4px Vertical gap between children
--rail-nav-drawer-surface var(--mat-sys-surface) Background color
--rail-nav-drawer-on-surface var(--mat-sys-on-surface) Text color
--rail-nav-drawer-shadow var(--mat-sys-level2) Elevation
--rail-nav-drawer-radius 0 12px 12px 0 Outer border-radius

SCSS Theming

Use the SCSS mixin to customize colors following the Angular Material pattern:

@use '@softwarity/rail-nav/rail-nav-theme' as rail-nav;

:root {
  @include rail-nav.overrides((
    backdrop-color: rgba(0, 0, 0, 0.5),
    primary: #6200ea,
    surface-color: #f5f5f5,
  ));
}

Available Tokens

Token Default Description
collapsed-width 72px Width of the rail when collapsed
expanded-width fit-content Width of the rail when expanded
header-height 56px Height of the header area
backdrop-color - Background color for the backdrop overlay
surface-color - Background color for the rail
surface-container-high - Background color for hover states
on-surface - Text color for primary content
on-surface-variant - Text color for secondary content
secondary-container - Background color for active items
on-secondary-container - Text color for active items
primary - Focus ring color
error - Badge background color
on-error - Badge text color

CSS Custom Properties

You can also use CSS custom properties directly.

Size Properties

:root {
  --rail-nav-collapsed-width: 72px;
  --rail-nav-expanded-width: fit-content;
  --rail-nav-header-height: 56px;
}

Color Properties

Color properties support the CSS light-dark() function for automatic light/dark theme adaptation:

:root {
  /* Static colors (single theme) */
  --rail-nav-surface-color: #ffffff;
  --rail-nav-primary: #6750a4;

  /* Or use light-dark() for automatic theme adaptation */
  --rail-nav-surface-color: light-dark(#ffffff, #1e1e1e);
  --rail-nav-primary: light-dark(#6750a4, #d0bcff);
}

The component uses Material Design 3 system tokens (--mat-sys-*) as fallbacks.

Light/Dark Theme Support

Use the CSS light-dark() function to define colors that automatically adapt to the user's color scheme. The first value is for light mode, the second for dark mode.

With SCSS mixin

@use '@softwarity/rail-nav/rail-nav-theme' as rail-nav;

:root {
  @include rail-nav.overrides((
    backdrop-color: light-dark(rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.6)),
    surface-color: light-dark(#ffffff, #1e1e1e),
    on-surface: light-dark(#1c1b1f, #e6e1e5),
    primary: light-dark(#6750a4, #d0bcff),
  ));
}

With CSS custom properties

:root {
  --rail-nav-backdrop-color: light-dark(rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.6));
  --rail-nav-surface-color: light-dark(#ffffff, #1e1e1e);
  --rail-nav-on-surface: light-dark(#1c1b1f, #e6e1e5);
  --rail-nav-on-surface-variant: light-dark(#49454f, #cac4d0);
  --rail-nav-secondary-container: light-dark(#e8def8, #4a4458);
  --rail-nav-on-secondary-container: light-dark(#1d192b, #e8def8);
  --rail-nav-primary: light-dark(#6750a4, #d0bcff);
  --rail-nav-error: light-dark(#b3261e, #f2b8b5);
  --rail-nav-on-error: light-dark(#ffffff, #601410);
}

Setting up color-scheme

For light-dark() to work, set the color-scheme property on your document:

html {
  color-scheme: light;
  &.dark-mode { color-scheme: dark; }
}

/* Or use system preference */
html {
  color-scheme: light dark;
}

Example

@Component({
  imports: [
    RailnavComponent,
    RailnavContainerComponent,
    RailnavContentComponent,
    RailnavItemComponent,
    MatIconModule
  ],
  template: `
    <rail-nav-container [showBackdrop]="true">
      <rail-nav title="My App" subtitle="v1.0">
        <rail-nav-item label="Home" [badge]="3" routerLink="/home">
          <mat-icon>home</mat-icon>
        </rail-nav-item>
        <rail-nav-item label="Favorites" [badge]="true" routerLink="/favorites">
          <mat-icon>favorite</mat-icon>
        </rail-nav-item>
        <rail-nav-item label="Settings" routerLink="/settings">
          <mat-icon>settings</mat-icon>
        </rail-nav-item>
      </rail-nav>
      <rail-nav-content>
        <main>Your content here</main>
      </rail-nav-content>
    </rail-nav-container>
  `
})
export class MyComponent {}

License

Apache-2.0