Navigation
Navigation rail
Collapsed or expanded navigation rail for larger screens.
Inbox
The navigation rail is the side navigation for medium and large screens. Collapsed it shows icons with labels underneath; expanded it widens and lays items out in a row, with room for a menu button and FAB in its header.
Installation
npx @grenishrai/expressive add navigation-railThis copies navigation-rail.tsx into your components folder, along with what it needs from Expressive (utils, navigation-bar, styles), and installs @base-ui/react.
Usage
import { NavigationRail, NavigationRailItem } from "@/components/expressive/navigation-rail";
<NavigationRail
expanded={expanded}
header={<IconButton aria-label="Menu" onClick={toggle}><MenuIcon /></IconButton>}
>
<NavigationRailItem href="/inbox" icon={<InboxIcon />} label="Inbox" active />
<NavigationRailItem href="/files" icon={<FolderIcon />} label="Files" />
</NavigationRail>Props
NavigationRail
| Prop | Type | Default | Description |
|---|---|---|---|
| expanded | boolean | false | Wide layout with horizontal items. |
| header | ReactNode | — | Top slot for a menu button and/or FAB. |
| aria-label | string | "Main" | Name of the navigation landmark. |
NavigationRailItem
| Prop | Type | Default | Description |
|---|---|---|---|
| … | — | Same props as NavigationBarItem. |
Accessibility
- Same link and
aria-currentsemantics as the navigation bar. - If a button toggles
expanded, give itaria-expanded.
Source
This is the file the CLI writes into your project, shown with the default import paths. It's yours to change.
"use client";
import * as React from "react";
import { useRender } from "@base-ui/react/use-render";
import { mergeProps } from "@base-ui/react/merge-props";
import { cn } from "@/lib/expressive/utils";
import { Badge, type NavigationItemProps } from "@/components/expressive/navigation-bar";
import { useFluidPop } from "@/lib/expressive/use-fluid";
/*
* Navigation rail for medium and larger screens. Collapsed (96px) it shows
* icons with labels below; `expanded` widens it and lays items out
* horizontally, like the M3 Expressive expanded rail.
*/
export interface NavigationRailProps extends React.ComponentProps<"nav"> {
expanded?: boolean;
/** Top slot for a menu button and/or FAB. */
header?: React.ReactNode;
"aria-label"?: string;
}
export function NavigationRail({ className, expanded = false, header, children, "aria-label": ariaLabel = "Main", ...props }: NavigationRailProps) {
return (
<nav
data-slot="navigation-rail"
data-expanded={expanded ? "" : undefined}
aria-label={ariaLabel}
className={cn(
"group/rail flex h-full w-24 shrink-0 flex-col gap-1 py-6 text-on-surface-variant",
"transition-[width,padding] spring-spatial-default data-expanded:w-60 data-expanded:px-5",
className,
)}
{...props}
>
{header && <div className="mb-6 flex flex-col items-center gap-4 group-data-expanded/rail:items-start">{header}</div>}
<div className="flex flex-col items-stretch gap-1">{children}</div>
</nav>
);
}
export function NavigationRailItem({ className, icon, activeIcon, label, active = false, badge, render, ref, ...props }: NavigationItemProps) {
const root = React.useRef<HTMLAnchorElement>(null);
const wide = React.useRef<HTMLSpanElement>(null);
const compact = React.useRef<HTMLSpanElement>(null);
// Fluid: whichever indicator is showing swells open with a jelly wobble.
useFluidPop(root, { attributes: ["data-active"], target: wide, from: 0.5 });
useFluidPop(root, { attributes: ["data-active"], target: compact, from: 0.35 });
return useRender({
defaultTagName: "a",
render,
ref: [root, ref].filter(Boolean) as React.Ref<HTMLAnchorElement>[],
props: mergeProps<"a">(
{
"data-slot": "navigation-rail-item",
"data-active": active ? "" : undefined,
"aria-current": active ? "page" : undefined,
className: cn(
"group focus-ring relative flex cursor-pointer flex-col items-center gap-1 rounded-shape-medium py-1 text-label-medium",
"group-data-expanded/rail:h-14 group-data-expanded/rail:flex-row group-data-expanded/rail:gap-3 group-data-expanded/rail:rounded-shape-full group-data-expanded/rail:px-4 group-data-expanded/rail:py-0 group-data-expanded/rail:text-label-large",
"data-active:text-on-surface",
className,
),
children: (
<>
{/* In the expanded rail the indicator spans the whole item. */}
<span
ref={wide}
aria-hidden="true"
className="absolute inset-0 hidden scale-x-[0.6] rounded-shape-full bg-secondary-container opacity-0 transition-[transform,opacity] spring-spatial-fast group-data-active:scale-x-100 group-data-active:opacity-100 group-data-expanded/rail:block fluid:transition-opacity"
/>
<span
aria-hidden="true"
className="state-layer absolute inset-0 hidden rounded-shape-full group-hover:before:opacity-(--ex-state-hover) group-active:before:opacity-(--ex-state-pressed) group-data-expanded/rail:block"
/>
<span className="relative flex h-8 w-14 shrink-0 items-center justify-center group-data-expanded/rail:w-6 [&_svg]:size-6">
<span
ref={compact}
aria-hidden="true"
className="absolute inset-0 scale-x-[0.4] rounded-shape-full bg-secondary-container opacity-0 transition-[transform,opacity] spring-spatial-fast group-data-active:scale-x-100 group-data-active:opacity-100 group-data-expanded/rail:hidden fluid:transition-opacity"
/>
<span
aria-hidden="true"
className="state-layer absolute inset-0 rounded-shape-full group-hover:before:opacity-(--ex-state-hover) group-active:before:opacity-(--ex-state-pressed) group-data-expanded/rail:hidden"
/>
<span className="relative flex group-data-active:text-on-secondary-container">{active && activeIcon ? activeIcon : icon}</span>
{badge !== undefined && <Badge value={badge} />}
</span>
<span className="relative truncate group-data-active:font-bold group-data-active:group-data-expanded/rail:text-on-secondary-container">
{label}
</span>
</>
),
} as React.ComponentProps<"a">,
props,
),
});
}