A single navigation link with an optional leading icon and active state. The `active` modifier is applied on the component's own root element (per BEM), so it can be dropped into any nav/sidebar layout.

pnpm add @behivetech/atoms.nav-item

You are viewing Nav Item v0.5.1, snapshotted 2026-11-10. The latest is v0.5.2. Go to the latest →

registry/behivetech/atoms/nav-item at v0.5.1, snapshotted 2026-11-10

nav-item.tsx
1import { type AnchorHTMLAttributes, type ReactNode } from "react";
2import { Slot } from "@radix-ui/react-slot";
3import { getClassName } from "@bht-component-registry/class-names";
4import styles from "./nav-item.module.scss";
5
6export interface NavItemProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
7 /** Additional class names to merge with the component root element */
8 className?: string;
9 /** Leading icon rendered before the content */
10 icon?: ReactNode;
11 /**
12 * Whether this item represents the current route: adds the `--active`
13 * modifier and sets `aria-current="page"` unless you pass your own
14 * `aria-current`.
15 */
16 active?: boolean;
17 /**
18 * Renders the item's props onto its single child element (via Radix `Slot`)
19 * instead of an `<a>`, so a router link such as Next's `Link` can be the
20 * element. The child supplies the content, so `icon` is ignored.
21 */
22 asChild?: boolean;
23 /** Content to render inside the component */
24 children?: ReactNode;
25}
26
27/**
28 * Single navigation link with an optional leading icon and active state.
29 */
30export const NavItem = ({
31 className,
32 icon,
33 active = false,
34 asChild = false,
35 children,
36 "aria-current": ariaCurrent,
37 ...rest
38}: NavItemProps) => {
39 // One value drives both the attribute and the modifier, so they never
40 // disagree: `active` means "page" unless the caller passes their own
41 // `aria-current`, and any value other than false/"false" turns the pill on.
42 const current = ariaCurrent ?? (active ? "page" : undefined);
43 const [rootClass, getChildClass] = getClassName({
44 className,
45 rootClass: "BHT__NavItem",
46 modifiers: {
47 active: current !== undefined && current !== false && current !== "false",
48 },
49 styles,
50 });
51
52 const Comp = asChild ? Slot : "a";
53
54 return (
55 <Comp {...rest} aria-current={current} className={rootClass}>
56 {asChild ? (
57 children
58 ) : (
59 <>
60 {icon ? <span className={getChildClass("icon")}>{icon}</span> : null}
61 {children}
62 </>
63 )}
64 </Comp>
65 );
66};
nav-item.module.scss
1// Material Design 3 navigation drawer destination.
2//
3// The active item carries the MD3 pill-shaped active indicator
4// (`secondary-container`); inactive items sit directly on the drawer in
5// `on-surface-variant`. Colors previously came from `--admin-sidebar-*`
6// variables that only the CMS admin app defined, so the atom rendered
7// unstyled anywhere else.
8
9.BHT__NavItem {
10 // `isolation` keeps the state layer's stacking context local to the item.
11 position: relative;
12 isolation: isolate;
13 display: flex;
14 align-items: center;
15 gap: var(--spacing-2, 0.5rem);
16 padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);
17 border-radius: var(--md-sys-shape-corner-full, 9999px);
18 color: var(--md-sys-color-on-surface-variant, var(--color-text-muted));
19 text-decoration: none;
20 font-family: var(--md-sys-typescale-label-large-font, var(--font-body));
21 font-size: var(--md-sys-typescale-label-large-size, 0.875rem);
22 font-weight: var(--md-sys-typescale-label-large-weight, 500);
23 line-height: var(--md-sys-typescale-label-large-line-height, 1.25rem);
24 letter-spacing: var(--md-sys-typescale-label-large-tracking, 0.0071em);
25 transition:
26 background var(--md-sys-motion-duration-short-3, 150ms)
27 var(--md-sys-motion-easing-standard, ease),
28 color var(--md-sys-motion-duration-short-3, 150ms)
29 var(--md-sys-motion-easing-standard, ease);
30
31 &__icon {
32 font-size: var(--md-sys-typescale-body-large-size, 1rem);
33 width: 1.125rem;
34 text-align: center;
35 flex-shrink: 0;
36 }
37
38 // State layer: a veil of the content color, so hover follows the active
39 // indicator's `on-secondary-container` as well as the inactive text color.
40 &::after {
41 content: "";
42 position: absolute;
43 inset: 0;
44 border-radius: inherit;
45 background: currentColor;
46 opacity: 0;
47 pointer-events: none;
48 transition: opacity var(--md-sys-motion-duration-short-2, 100ms)
49 var(--md-sys-motion-easing-standard, ease);
50 }
51
52 &:hover::after {
53 opacity: var(--md-sys-state-hover-opacity, 0.08);
54 }
55
56 &:focus-visible::after {
57 opacity: var(--md-sys-state-focus-opacity, 0.1);
58 }
59
60 &:active::after {
61 opacity: var(--md-sys-state-pressed-opacity, 0.1);
62 }
63
64 // Touch target: the drawn item is 2.25rem tall, under the 48px floor. This
65 // extends the hit area without reflowing the drawer. px, not rem, because a
66 // target is sized to the finger, not the type.
67 &::before {
68 content: "";
69 position: absolute;
70 left: 0;
71 right: 0;
72 top: 50%;
73 translate: 0 -50%;
74 height: 100%;
75 min-height: 48px;
76 }
77
78 &:focus-visible {
79 outline: 3px solid var(--md-sys-color-secondary, var(--color-border-focus));
80 outline-offset: 2px;
81 }
82
83 // This compound previously read `&.NavItem`, which the markup never
84 // carries, so the active state never rendered.
85 // `active` also sets `aria-current="page"`, so the pill comes from the
86 // modifier alone.
87 &--active {
88 &.BHT__NavItem {
89 background: var(
90 --md-sys-color-secondary-container,
91 var(--color-surface-light)
92 );
93 color: var(
94 --md-sys-color-on-secondary-container,
95 var(--color-on-surface)
96 );
97 }
98 }
99}
index.ts
1export { NavItem } from "./nav-item.js";
2export type { NavItemProps } from "./nav-item.js";
nav-item.example.tsx
1import { NavItem } from "./nav-item.js";
2
3export const BasicNavItem = () => (
4 <NavItem href="/pages" icon="☰">
5 Pages
6 </NavItem>
7);
8
9export const ActiveNavItem = () => (
10 <NavItem href="/" icon="⊞" active>
11 Dashboard
12 </NavItem>
13);
14
15/** With `asChild`, a router link renders as the item; `active` marks it as the current route and sets `aria-current="page"`. */
16export const AsChildNavItem = () => (
17 <NavItem asChild active>
18 <a href="/guide">Guide</a>
19 </NavItem>
20);