diff --git a/packages/propel/src/components/navigation-menu/index.tsx b/packages/propel/src/components/navigation-menu/index.tsx
index 2360a7e0..a64e7554 100644
--- a/packages/propel/src/components/navigation-menu/index.tsx
+++ b/packages/propel/src/components/navigation-menu/index.tsx
@@ -7,16 +7,24 @@ export {
type NavigationMenuArrowProps,
NavigationMenuContent,
type NavigationMenuContentProps,
+ NavigationMenuContentList,
+ type NavigationMenuContentListProps,
NavigationMenuIcon,
type NavigationMenuIconProps,
NavigationMenuItem,
type NavigationMenuItemProps,
NavigationMenuLink,
type NavigationMenuLinkProps,
+ NavigationMenuLinkDescription,
+ type NavigationMenuLinkDescriptionProps,
+ NavigationMenuLinkTitle,
+ type NavigationMenuLinkTitleProps,
NavigationMenuList,
type NavigationMenuListProps,
NavigationMenuTrigger,
type NavigationMenuTriggerProps,
+ NavigationMenuTriggerLabel,
+ type NavigationMenuTriggerLabelProps,
NavigationMenuViewport,
type NavigationMenuViewportProps,
} from "../../ui/navigation-menu";
diff --git a/packages/propel/src/components/navigation-menu/navigation-menu.stories.tsx b/packages/propel/src/components/navigation-menu/navigation-menu.stories.tsx
index 8e2ceb22..e1c0955b 100644
--- a/packages/propel/src/components/navigation-menu/navigation-menu.stories.tsx
+++ b/packages/propel/src/components/navigation-menu/navigation-menu.stories.tsx
@@ -6,12 +6,16 @@ import { expect, waitFor } from "storybook/test";
import {
NavigationMenu,
NavigationMenuContent,
+ NavigationMenuContentList,
NavigationMenuIcon,
NavigationMenuItem,
NavigationMenuLink,
+ NavigationMenuLinkDescription,
+ NavigationMenuLinkTitle,
NavigationMenuList,
NavigationMenuPanel,
NavigationMenuTrigger,
+ NavigationMenuTriggerLabel,
NavigationMenuViewport,
} from "./index";
@@ -24,8 +28,14 @@ const meta = {
subcomponents: {
NavigationMenuList,
NavigationMenuItem,
+ NavigationMenuTrigger,
+ NavigationMenuTriggerLabel,
+ NavigationMenuIcon,
NavigationMenuContent,
+ NavigationMenuContentList,
NavigationMenuLink,
+ NavigationMenuLinkTitle,
+ NavigationMenuLinkDescription,
NavigationMenuPanel,
},
parameters: {
@@ -80,7 +90,31 @@ const RESOURCE_LINKS = [
// navigates the page, tears down the iframe, and fails unrelated stories.
const cancelNavigation = (event: React.MouseEvent) => event.preventDefault();
-/** Two dropdown items plus a bare top-level link, opening into the shared `NavigationMenuPanel`. */
+/** A rich content link pairing a title with a description, wrapped in its list item. */
+function ContentLink({ href, title, description }: (typeof PRODUCT_LINKS)[number]) {
+ return (
+
+ }>
+ {title}
+ {description}
+
+
+ );
+}
+
+/** A trigger row that pairs the label with the rotating disclosure caret. */
+function TriggerRow({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+
+
+
+ );
+}
+
+/** Two menu items plus a bare top-level link, opening into the shared `NavigationMenuPanel`. */
export const Default: Story = {
render: () => (
@@ -90,12 +124,7 @@ export const Default: Story = {
{PRODUCT_LINKS.map((item) => (
- -
- }>
- {item.title}
- {item.description}
-
-
+
))}
@@ -104,21 +133,19 @@ export const Default: Story = {
Resources
-
+
{RESOURCE_LINKS.map((item) => (
- -
- }>
- {item.title}
- {item.description}
-
-
+
))}
-
+
- }>
+ }
+ >
Pricing
@@ -160,15 +187,18 @@ export const OpenContent: Story = {
Product
-
+
{PRODUCT_LINKS.map((item) => (
-
- }>
- {item.title}
+ }
+ >
+ {item.title}
))}
-
+
@@ -190,15 +220,3 @@ export const OpenContent: Story = {
});
},
};
-
-/** A trigger row that pairs the label with the rotating disclosure caret. */
-function TriggerRow({ children }: { children: React.ReactNode }) {
- return (
-
- {children}
-
-
-
-
- );
-}
diff --git a/packages/propel/src/ui/navigation-menu/index.tsx b/packages/propel/src/ui/navigation-menu/index.tsx
index 3662af1e..91ce357d 100644
--- a/packages/propel/src/ui/navigation-menu/index.tsx
+++ b/packages/propel/src/ui/navigation-menu/index.tsx
@@ -5,9 +5,21 @@ export {
type NavigationMenuBackdropProps,
} from "./navigation-menu-backdrop";
export { NavigationMenuContent, type NavigationMenuContentProps } from "./navigation-menu-content";
+export {
+ NavigationMenuContentList,
+ type NavigationMenuContentListProps,
+} from "./navigation-menu-content-list";
export { NavigationMenuIcon, type NavigationMenuIconProps } from "./navigation-menu-icon";
export { NavigationMenuItem, type NavigationMenuItemProps } from "./navigation-menu-item";
export { NavigationMenuLink, type NavigationMenuLinkProps } from "./navigation-menu-link";
+export {
+ NavigationMenuLinkDescription,
+ type NavigationMenuLinkDescriptionProps,
+} from "./navigation-menu-link-description";
+export {
+ NavigationMenuLinkTitle,
+ type NavigationMenuLinkTitleProps,
+} from "./navigation-menu-link-title";
export { NavigationMenuList, type NavigationMenuListProps } from "./navigation-menu-list";
export { NavigationMenuPopup, type NavigationMenuPopupProps } from "./navigation-menu-popup";
export { NavigationMenuPortal, type NavigationMenuPortalProps } from "./navigation-menu-portal";
@@ -16,6 +28,10 @@ export {
type NavigationMenuPositionerProps,
} from "./navigation-menu-positioner";
export { NavigationMenuTrigger, type NavigationMenuTriggerProps } from "./navigation-menu-trigger";
+export {
+ NavigationMenuTriggerLabel,
+ type NavigationMenuTriggerLabelProps,
+} from "./navigation-menu-trigger-label";
export {
NavigationMenuViewport,
type NavigationMenuViewportProps,
diff --git a/packages/propel/src/ui/navigation-menu/navigation-menu-content-list.tsx b/packages/propel/src/ui/navigation-menu/navigation-menu-content-list.tsx
new file mode 100644
index 00000000..c3472f6c
--- /dev/null
+++ b/packages/propel/src/ui/navigation-menu/navigation-menu-content-list.tsx
@@ -0,0 +1,17 @@
+import type * as React from "react";
+
+import { navigationMenuContentListVariants } from "./variants";
+
+export type NavigationMenuContentListProps = Omit<
+ React.ComponentPropsWithoutRef<"ul">,
+ "className" | "style"
+>;
+
+/**
+ * The vertical list of links inside a `NavigationMenuContent` panel. Stacks its items with a
+ * consistent gap; the surrounding `NavigationMenuPopup` owns the padding. Renders a ``, so each
+ * child link belongs in its own `- `.
+ */
+export function NavigationMenuContentList(props: NavigationMenuContentListProps) {
+ return ;
+}
diff --git a/packages/propel/src/ui/navigation-menu/navigation-menu-link-description.tsx b/packages/propel/src/ui/navigation-menu/navigation-menu-link-description.tsx
new file mode 100644
index 00000000..16f39b41
--- /dev/null
+++ b/packages/propel/src/ui/navigation-menu/navigation-menu-link-description.tsx
@@ -0,0 +1,16 @@
+import type * as React from "react";
+
+import { navigationMenuLinkDescriptionVariants } from "./variants";
+
+export type NavigationMenuLinkDescriptionProps = Omit<
+ React.ComponentPropsWithoutRef<"span">,
+ "className" | "style"
+>;
+
+/**
+ * The optional secondary line of a `variant="card"` `NavigationMenuLink`: a muted description shown
+ * below the `NavigationMenuLinkTitle`.
+ */
+export function NavigationMenuLinkDescription(props: NavigationMenuLinkDescriptionProps) {
+ return ;
+}
diff --git a/packages/propel/src/ui/navigation-menu/navigation-menu-link-title.tsx b/packages/propel/src/ui/navigation-menu/navigation-menu-link-title.tsx
new file mode 100644
index 00000000..a4e13808
--- /dev/null
+++ b/packages/propel/src/ui/navigation-menu/navigation-menu-link-title.tsx
@@ -0,0 +1,16 @@
+import type * as React from "react";
+
+import { navigationMenuLinkTitleVariants } from "./variants";
+
+export type NavigationMenuLinkTitleProps = Omit<
+ React.ComponentPropsWithoutRef<"span">,
+ "className" | "style"
+>;
+
+/**
+ * The primary line of a `variant="card"` `NavigationMenuLink`: the navigable label. Pairs with an
+ * optional `NavigationMenuLinkDescription` below it.
+ */
+export function NavigationMenuLinkTitle(props: NavigationMenuLinkTitleProps) {
+ return ;
+}
diff --git a/packages/propel/src/ui/navigation-menu/navigation-menu-link.tsx b/packages/propel/src/ui/navigation-menu/navigation-menu-link.tsx
index c28f9777..84f965de 100644
--- a/packages/propel/src/ui/navigation-menu/navigation-menu-link.tsx
+++ b/packages/propel/src/ui/navigation-menu/navigation-menu-link.tsx
@@ -1,13 +1,17 @@
import { NavigationMenu as BaseNavigationMenu } from "@base-ui/react/navigation-menu";
+import type { VariantProps } from "class-variance-authority";
import { navigationMenuLinkVariants } from "./variants";
-export type NavigationMenuLinkProps = Omit;
+export type NavigationMenuLinkProps = Omit &
+ Required>;
/**
- * A navigable link, either as a top-level item or inside `Content`. Shares the nav-item chrome with
- * `Trigger`. Maps 1:1 to `NavigationMenu.Link`.
+ * A navigable link. `variant="item"` is a top-level pill that shares the nav-item chrome with
+ * `Trigger`; `variant="card"` is a stacked entry inside `Content`, pairing a
+ * `NavigationMenuLinkTitle` with an optional `NavigationMenuLinkDescription`. Maps 1:1 to
+ * `NavigationMenu.Link`.
*/
-export function NavigationMenuLink(props: NavigationMenuLinkProps) {
- return ;
+export function NavigationMenuLink({ variant, ...props }: NavigationMenuLinkProps) {
+ return ;
}
diff --git a/packages/propel/src/ui/navigation-menu/navigation-menu-trigger-label.tsx b/packages/propel/src/ui/navigation-menu/navigation-menu-trigger-label.tsx
new file mode 100644
index 00000000..36ba8de3
--- /dev/null
+++ b/packages/propel/src/ui/navigation-menu/navigation-menu-trigger-label.tsx
@@ -0,0 +1,17 @@
+import type * as React from "react";
+
+import { navigationMenuTriggerLabelVariants } from "./variants";
+
+export type NavigationMenuTriggerLabelProps = Omit<
+ React.ComponentPropsWithoutRef<"span">,
+ "className" | "style"
+>;
+
+/**
+ * The trigger's text label, sitting beside the disclosure `NavigationMenuIcon`. Splitting the label
+ * into its own part keeps `NavigationMenuTrigger` a single styled element that composes parts
+ * rather than baking in raw text.
+ */
+export function NavigationMenuTriggerLabel(props: NavigationMenuTriggerLabelProps) {
+ return ;
+}
diff --git a/packages/propel/src/ui/navigation-menu/navigation-menu.stories.tsx b/packages/propel/src/ui/navigation-menu/navigation-menu.stories.tsx
index 74063533..39752fbd 100644
--- a/packages/propel/src/ui/navigation-menu/navigation-menu.stories.tsx
+++ b/packages/propel/src/ui/navigation-menu/navigation-menu.stories.tsx
@@ -6,14 +6,18 @@ import { expect, waitFor } from "storybook/test";
import {
NavigationMenu,
NavigationMenuContent,
+ NavigationMenuContentList,
NavigationMenuIcon,
NavigationMenuItem,
NavigationMenuLink,
+ NavigationMenuLinkDescription,
+ NavigationMenuLinkTitle,
NavigationMenuList,
NavigationMenuPopup,
NavigationMenuPortal,
NavigationMenuPositioner,
NavigationMenuTrigger,
+ NavigationMenuTriggerLabel,
NavigationMenuViewport,
} from "./index";
@@ -26,8 +30,14 @@ const meta = {
subcomponents: {
NavigationMenuList,
NavigationMenuItem,
+ NavigationMenuTrigger,
+ NavigationMenuTriggerLabel,
+ NavigationMenuIcon,
NavigationMenuContent,
+ NavigationMenuContentList,
NavigationMenuLink,
+ NavigationMenuLinkTitle,
+ NavigationMenuLinkDescription,
NavigationMenuViewport,
},
} satisfies Meta;
@@ -76,7 +86,31 @@ const RESOURCE_LINKS = [
// navigates the page, tears down the iframe, and fails unrelated stories.
const cancelNavigation = (event: React.MouseEvent) => event.preventDefault();
-/** A menu with two dropdown items and a bare top-level link. */
+/** A rich content link pairing a title with a description, wrapped in its list item. */
+function ContentLink({ href, title, description }: (typeof PRODUCT_LINKS)[number]) {
+ return (
+
-
+ }>
+ {title}
+ {description}
+
+
+ );
+}
+
+/** A trigger row that pairs the label with the rotating disclosure caret. */
+function NavigationMenuTriggerRow({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+
+
+
+ );
+}
+
+/** A menu with two menu items and a bare top-level link. */
export const Default: Story = {
render: () => (
@@ -86,12 +120,7 @@ export const Default: Story = {
{PRODUCT_LINKS.map((item) => (
- -
- }>
- {item.title}
- {item.description}
-
-
+
))}
@@ -100,21 +129,19 @@ export const Default: Story = {
Resources
-
+
{RESOURCE_LINKS.map((item) => (
- -
- }>
- {item.title}
- {item.description}
-
-
+
))}
-
+
- }>
+ }
+ >
Pricing
@@ -160,15 +187,18 @@ export const OpenContent: Story = {
Product
-
+
{PRODUCT_LINKS.map((item) => (
-
- }>
- {item.title}
+ }
+ >
+ {item.title}
))}
-
+
@@ -194,15 +224,3 @@ export const OpenContent: Story = {
});
},
};
-
-/** A trigger row that pairs the label with the rotating disclosure caret. */
-function NavigationMenuTriggerRow({ children }: { children: React.ReactNode }) {
- return (
-
- {children}
-
-
-
-
- );
-}
diff --git a/packages/propel/src/ui/navigation-menu/navigation-menu.tsx b/packages/propel/src/ui/navigation-menu/navigation-menu.tsx
index 66fed03e..42e52424 100644
--- a/packages/propel/src/ui/navigation-menu/navigation-menu.tsx
+++ b/packages/propel/src/ui/navigation-menu/navigation-menu.tsx
@@ -3,8 +3,8 @@ import { NavigationMenu as BaseNavigationMenu } from "@base-ui/react/navigation-
export type NavigationMenuProps = Omit;
/**
- * The root of a navigation menu — a collection of links and dropdown menus for site navigation.
- * Maps 1:1 to Base UI's `NavigationMenu.Root`.
+ * The root of a navigation menu — a collection of links and menus for site navigation. Maps 1:1 to
+ * Base UI's `NavigationMenu.Root`.
*/
export function NavigationMenu(props: NavigationMenuProps) {
return ;
diff --git a/packages/propel/src/ui/navigation-menu/variants.ts b/packages/propel/src/ui/navigation-menu/variants.ts
index bb3530ae..45fec652 100644
--- a/packages/propel/src/ui/navigation-menu/variants.ts
+++ b/packages/propel/src/ui/navigation-menu/variants.ts
@@ -1,9 +1,11 @@
import { cva, cx } from "class-variance-authority";
-// Navigation Menu is a verbatim wrapping of Base UI's parts. Base UI drives all
-// open/active state through `data-*` attributes, so there are no styling axes
-// (variant/tone/magnitude) to expose. The cva pairings below hold the static
-// chrome for every styled part in one place, with no `className` at the boundary.
+// Navigation Menu wraps Base UI's parts and extends the anatomy with the styled regions
+// inside a menu item (TriggerLabel, ContentList, LinkTitle, LinkDescription). Base UI drives
+// open/active state through `data-*` attributes, so the only authored styling axis is the
+// `Link`'s `variant` (an inline `item` pill vs a stacked content `card`). The cva pairings
+// below hold the static chrome for every styled part in one place, with no `className` at the
+// boundary.
export const navigationMenuListVariants = cva("flex items-center gap-1");
@@ -17,18 +19,51 @@ export const navigationMenuTriggerVariants = cva(
),
);
+// The trigger's text label, sitting beside the disclosure `Icon`. Mirrors the
+// `Title`/`Icon` split used by other triggers so the trigger renders parts, not raw text.
+export const navigationMenuTriggerLabelVariants = cva("min-w-0 truncate");
+
+// A `Link` is used two ways, so its arrangement is a required `variant`: an `item`
+// pill (a top-level nav entry beside the triggers) or a stacked `card` (a rich entry
+// inside a `Content` panel, pairing a `Title` with an optional `Description`). The
+// shared chrome — radius, highlight, focus ring — is baked in for both.
export const navigationMenuLinkVariants = cva(
cx(
- "inline-flex h-8 items-center gap-1 rounded-md px-3 text-13 font-medium text-secondary outline-none",
+ "rounded-md text-13 font-medium text-secondary outline-none",
"hover:bg-layer-transparent-hover data-popup-open:bg-layer-transparent-selected",
"focus-visible:ring-2 focus-visible:ring-accent-strong",
),
+ {
+ variants: {
+ variant: {
+ item: "inline-flex h-8 items-center gap-1 px-3",
+ card: "flex flex-col gap-0.5 px-3 py-2 text-start",
+ },
+ },
+ },
+);
+
+// The link's primary line: the navigable label. Bold, primary-tinted, single line.
+export const navigationMenuLinkTitleVariants = cva(
+ "block truncate text-14 font-medium text-primary",
);
-// Rotates the caret while the popup is open; reads the parent Trigger's
-// `group-data-popup-open` state.
+// The link's optional secondary line: a muted one- or two-line description below the title.
+export const navigationMenuLinkDescriptionVariants = cva("font-normal block text-12 text-tertiary");
+
+// The vertical stack of links inside a `Content` panel. Per the spec, nav items stack
+// vertically with a consistent gap; the surrounding `Popup` owns the padding.
+export const navigationMenuContentListVariants = cva("flex w-72 flex-col gap-1");
+
+// The disclosure caret slot inside a Trigger. Sizes its child SVG to `--node-size`
+// (default 1rem, matching the trigger's line-height) and rotates while the popup is
+// open; reads the parent Trigger's `group-data-popup-open` state.
export const navigationMenuIconVariants = cva(
- "flex size-4 items-center justify-center text-icon-secondary transition-transform group-data-popup-open:rotate-180",
+ cx(
+ "inline-flex shrink-0 items-center justify-center [--node-size:1rem]",
+ "[&>svg]:size-(--node-size)",
+ "text-icon-secondary transition-transform group-data-popup-open:rotate-180",
+ ),
);
export const navigationMenuPositionerVariants = cva("z-50 outline-none");