Navigation
Sidebar, NavigationMenu, Menubar, DropdownMenu, ContextMenu, Breadcrumb, NavList, Dock, DisplayMenu, Direction.
Sidebar
The most feature-rich navigation component. Collapse modes, mobile sheet via Sheet, cookie-persisted state, and Cmd/Ctrl+B keyboard toggle. Exports 24 parts — SidebarProvider, Sidebar, SidebarTrigger, SidebarRail, SidebarInset, SidebarInput, SidebarHeader, SidebarFooter, SidebarSeparator, SidebarContent, SidebarGroup, SidebarGroupLabel, SidebarGroupAction, SidebarGroupContent, SidebarMenu, SidebarMenuItem, SidebarMenuButton, SidebarMenuAction, SidebarMenuBadge, SidebarMenuSkeleton, SidebarMenuSub, SidebarMenuSubItem, SidebarMenuSubButton, useSidebar.
Usage:
import {
SidebarProvider, Sidebar, SidebarHeader, SidebarContent,
SidebarMenu, SidebarMenuItem, SidebarMenuButton, SidebarTrigger, SidebarInset,
} from "@flow-industries/ui/components/sidebar";
<SidebarProvider>
<Sidebar>
<SidebarHeader><Logomark /></SidebarHeader>
<SidebarContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton>Home</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton>Projects</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarContent>
</Sidebar>
<SidebarInset>
<SidebarTrigger />
{/* content */}
</SidebarInset>
</SidebarProvider>Knobs:
collapsibleon<Sidebar>—offcanvas(default),icon,none.variant—sidebar(default),floating,inset.side—left(default) orright.- Cookie —
sidebar_statepersists open/closed for one week. - Keyboard — Cmd+B (macOS) / Ctrl+B (other).
Source:
"use client";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react";
import * as React from "react";
import { useIsMobile } from "../../hooks/use-mobile";
import { cn } from "../../utils/cn";
import { cssVars } from "../../utils/css-vars";
import { Button } from "./button";
import { Input } from "./input";
import { Separator } from "./separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "./sheet";
import { Skeleton } from "./skeleton";
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(openState: boolean) => {
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((prev) => !prev) : setOpen(!open);
}, [isMobile, setOpen, open]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<div
data-slot="sidebar-wrapper"
style={cssVars({
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
})}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-secondary",
className,
)}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
);
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
dir,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-secondary text-foreground",
className,
)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
dir={dir}
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-secondary p-0 text-foreground [&>button]:hidden"
style={cssVars({ "--sidebar-width": SIDEBAR_WIDTH_MOBILE })}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className="group peer hidden text-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-(--ease-out)",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
/>
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-(--ease-out) data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r-[length:var(--border-width)] group-data-[side=right]:border-l-[length:var(--border-width)]",
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex size-full flex-col bg-secondary group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-secondary"
>
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon-sm"
className={cn(className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-[background-color] group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-secondary sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-secondary",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className,
)}
{...props}
/>
);
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("h-8 w-full bg-background shadow-none", className)}
{...props}
/>
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("mx-2 w-auto bg-secondary", className)}
{...props}
/>
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
}
function SidebarGroupLabel({
className,
render,
...props
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-muted-foreground outline-hidden transition-[margin,opacity] duration-200 ease-(--ease-out) group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:border-[length:var(--border-width-focus)] focus-visible:border-focus [&>svg]:size-4 [&>svg]:shrink-0",
className,
),
},
props,
),
render,
state: {
slot: "sidebar-group-label",
sidebar: "group-label",
},
});
}
function SidebarGroupAction({
className,
render,
...props
}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-foreground outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-secondary-accent hover:text-foreground focus-visible:border-[length:var(--border-width-focus)] focus-visible:border-focus md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className,
),
},
props,
),
render,
state: {
slot: "sidebar-group-action",
sidebar: "group-action",
},
});
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
{...props}
/>
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
);
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-secondary-accent hover:text-foreground focus-visible:border-[length:var(--border-width-focus)] focus-visible:border-focus active:bg-secondary-accent active:text-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-secondary-accent data-open:hover:text-foreground data-active:bg-secondary-accent data-active:font-medium data-active:text-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
default: "hover:bg-secondary-accent hover:text-foreground",
outline:
"bg-background ring-1 ring-secondary hover:bg-muted hover:text-foreground hover:ring-muted",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function SidebarMenuButton({
render,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const { isMobile, state } = useSidebar();
const comp = useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
},
props,
),
render: !tooltip ? render : <TooltipTrigger render={render} />,
state: {
slot: "sidebar-menu-button",
sidebar: "menu-button",
size,
active: isActive,
},
});
if (!tooltip) {
return comp;
}
// The prop is a convenience union: a bare string is shorthand for `{ children }`.
// A string primitive is not an Object, so this discriminates the two forms.
const tooltipProps =
tooltip instanceof Object ? tooltip : { children: tooltip };
return (
<Tooltip>
{comp}
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltipProps}
/>
</Tooltip>
);
}
function SidebarMenuAction({
className,
render,
showOnHover = false,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
showOnHover?: boolean;
}) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-foreground outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-secondary-accent hover:text-foreground focus-visible:border-[length:var(--border-width-focus)] focus-visible:border-focus md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-foreground aria-expanded:opacity-100 md:opacity-0",
className,
),
},
props,
),
render,
state: {
slot: "sidebar-menu-action",
sidebar: "menu-action",
},
});
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-foreground",
className,
)}
{...props}
/>
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
});
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={cssVars({ "--skeleton-width": width })}
/>
</div>
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l-[length:var(--border-width)] border-secondary px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
);
}
function SidebarMenuSubButton({
render,
size = "md",
isActive = false,
className,
...props
}: useRender.ComponentProps<"a"> &
React.ComponentProps<"a"> & {
size?: "sm" | "md";
isActive?: boolean;
}) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-foreground outline-hidden group-data-[collapsible=icon]:hidden hover:bg-secondary-accent hover:text-foreground focus-visible:border-[length:var(--border-width-focus)] focus-visible:border-focus active:bg-secondary-accent active:text-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-secondary-accent data-active:text-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-foreground",
className,
),
},
props,
),
render,
state: {
slot: "sidebar-menu-sub-button",
sidebar: "menu-sub-button",
size,
active: isActive,
},
});
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};The sidebar is the single largest component in the package (~700 lines of JSX). The full source is embedded above — copy it verbatim if you want to vendor the component.
NavigationMenu
Horizontal top-nav with animated dropdown groups. Exports: NavigationMenu, NavigationMenuList, NavigationMenuItem, NavigationMenuTrigger, NavigationMenuContent, NavigationMenuLink, NavigationMenuIndicator, NavigationMenuPositioner, navigationMenuTriggerStyle.
Usage:
import {
NavigationMenu, NavigationMenuList, NavigationMenuItem,
NavigationMenuTrigger, NavigationMenuContent, NavigationMenuLink,
} from "@flow-industries/ui/components/navigation-menu";
<NavigationMenu>
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuTrigger>Products</NavigationMenuTrigger>
<NavigationMenuContent>
<NavigationMenuLink href="/game">Flow Game</NavigationMenuLink>
<NavigationMenuLink href="/talk">Flow Talk</NavigationMenuLink>
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>Source:
import { NavigationMenu as NavigationMenuPrimitive } from "@base-ui/react/navigation-menu";
import { cva } from "class-variance-authority";
import { ChevronDownIcon } from "lucide-react";
import { cn } from "../../utils/cn";
function NavigationMenu({
align = "start",
className,
children,
...props
}: NavigationMenuPrimitive.Root.Props &
Pick<NavigationMenuPrimitive.Positioner.Props, "align">) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className,
)}
{...props}
>
{children}
<NavigationMenuPositioner align={align} />
</NavigationMenuPrimitive.Root>
);
}
function NavigationMenuList({
className,
...props
}: React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
aria-orientation={undefined}
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-0",
className,
)}
{...props}
/>
);
}
function NavigationMenuItem({
className,
...props
}: React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
);
}
const navigationMenuTriggerStyle = cva(
"group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center rounded-lg px-2.5 py-1.5 text-sm font-medium text-secondary-foreground transition-[color,background-color,border-color,box-shadow] outline-none hover:bg-muted hover:text-foreground focus:bg-muted focus:text-foreground focus-visible:border-[length:var(--border-width-focus)] focus-visible:border-focus disabled:pointer-events-none disabled:opacity-50 data-popup-open:bg-muted/50 data-popup-open:text-foreground data-popup-open:hover:bg-muted data-open:bg-muted/50 data-open:text-foreground data-open:hover:bg-muted data-open:focus:bg-muted",
);
function NavigationMenuTrigger({
className,
children,
...props
}: NavigationMenuPrimitive.Trigger.Props) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-px ml-1 size-3 transition duration-200 group-data-popup-open/navigation-menu-trigger:rotate-180 group-data-open/navigation-menu-trigger:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
);
}
function NavigationMenuContent({
className,
...props
}: NavigationMenuPrimitive.Content.Props) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-ending-style:data-activation-direction=left:translate-x-[50%] data-ending-style:data-activation-direction=right:translate-x-[-50%] data-starting-style:data-activation-direction=left:translate-x-[-50%] data-starting-style:data-activation-direction=right:translate-x-[50%] h-full w-auto p-1 transition-[opacity,transform,translate] duration-[0.35s] ease-(--ease-out-expo) group-data-[viewport=false]/navigation-menu:rounded-lg group-data-[viewport=false]/navigation-menu:bg-background group-data-[viewport=false]/navigation-menu:text-foreground group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:ring-1 group-data-[viewport=false]/navigation-menu:ring-primary/10 group-data-[viewport=false]/navigation-menu:duration-300 data-ending-style:opacity-0 data-starting-style:opacity-0 data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 data-[motion^=from-]:animate-in data-[motion^=from-]:fade-in data-[motion^=to-]:animate-out data-[motion^=to-]:fade-out **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none group-data-[viewport=false]/navigation-menu:data-open:animate-in group-data-[viewport=false]/navigation-menu:data-open:fade-in-0 group-data-[viewport=false]/navigation-menu:data-open:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-closed:animate-out group-data-[viewport=false]/navigation-menu:data-closed:fade-out-0 group-data-[viewport=false]/navigation-menu:data-closed:zoom-out-95",
className,
)}
{...props}
/>
);
}
function NavigationMenuPositioner({
className,
side = "bottom",
sideOffset = 8,
align = "start",
alignOffset = 0,
...props
}: NavigationMenuPrimitive.Positioner.Props) {
return (
<NavigationMenuPrimitive.Portal>
<NavigationMenuPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
className={cn(
"isolate z-50 h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-[top,left,right,bottom] duration-[0.35s] ease-(--ease-out-expo) data-instant:transition-none data-[side=bottom]:before:top-[-10px] data-[side=bottom]:before:right-0 data-[side=bottom]:before:left-0",
className,
)}
{...props}
>
<NavigationMenuPrimitive.Popup className="data-[ending-style]:easing-[ease] xs:w-(--popup-width) relative h-(--popup-height) w-(--popup-width) origin-(--transform-origin) rounded-lg bg-background text-foreground shadow ring-1 ring-primary/10 transition-[opacity,transform,width,height,scale,translate] duration-[0.35s] ease-(--ease-out-expo) outline-none data-ending-style:scale-90 data-ending-style:opacity-0 data-ending-style:duration-150 data-starting-style:scale-90 data-starting-style:opacity-0">
<NavigationMenuPrimitive.Viewport className="relative size-full overflow-hidden" />
</NavigationMenuPrimitive.Popup>
</NavigationMenuPrimitive.Positioner>
</NavigationMenuPrimitive.Portal>
);
}
function NavigationMenuLink({
className,
...props
}: NavigationMenuPrimitive.Link.Props) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"flex items-center gap-2 rounded-lg p-2 text-sm text-secondary-foreground transition-[color,background-color,border-color,box-shadow] outline-none hover:bg-muted hover:text-foreground focus:bg-muted focus:text-foreground focus-visible:border-[length:var(--border-width-focus)] focus-visible:border-focus in-data-[slot=navigation-menu-content]:rounded-md data-active:bg-muted/50 data-active:text-foreground data-active:hover:bg-muted data-active:focus:bg-muted [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.Icon>) {
return (
<NavigationMenuPrimitive.Icon
data-slot="navigation-menu-indicator"
className={cn(
"top-full z-1 flex h-1.5 items-end justify-center overflow-hidden data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:animate-in data-[state=visible]:fade-in",
className,
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-background shadow-md" />
</NavigationMenuPrimitive.Icon>
);
}
export {
NavigationMenu,
NavigationMenuContent,
NavigationMenuIndicator,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuPositioner,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
};DropdownMenu
Contextual menu with sub-menus, check items, and radio groups. Exports: DropdownMenu, DropdownMenuPortal, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent.
Usage:
import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuItem, DropdownMenuSeparator,
} from "@flow-industries/ui/components/dropdown-menu";
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost">Options</Button>} />
<DropdownMenuContent>
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Duplicate</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>Source:
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
import { CheckIcon, ChevronRightIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "../../utils/cn";
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn(
"z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-background p-1 text-foreground shadow-md ring-1 ring-primary/10 duration-150 ease-(--ease-out) outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
);
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className,
)}
{...props}
/>
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-muted focus:text-foreground not-data-[variant=destructive]:focus:**:text-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-muted focus:text-foreground not-data-[variant=destructive]:focus:**:text-foreground data-inset:pl-7 data-popup-open:bg-muted data-popup-open:text-foreground data-open:bg-muted data-open:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
);
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn(
"w-auto min-w-[96px] rounded-lg bg-background p-1 text-foreground shadow-lg ring-1 ring-primary/10 duration-150 ease-(--ease-out) data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-muted focus:text-foreground focus:**:text-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon />
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-muted focus:text-foreground focus:**:text-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon />
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
);
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-secondary", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-foreground",
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
};ContextMenu
Same shape as DropdownMenu but opens on right-click. Exports: ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem, ContextMenuCheckboxItem, ContextMenuRadioItem, ContextMenuLabel, ContextMenuSeparator, ContextMenuShortcut, ContextMenuGroup, ContextMenuPortal, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuRadioGroup.
Usage:
import {
ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem,
} from "@flow-industries/ui/components/context-menu";
<ContextMenu>
<ContextMenuTrigger>Right-click me</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>Copy</ContextMenuItem>
<ContextMenuItem>Paste</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>Source uses Base UI's @base-ui/react/context-menu with the same pattern as DropdownMenu — see the GitHub source for the full 271-line file.
Menubar
Desktop-app-style menu bar. Built on Base UI's Menubar plus the DropdownMenu wrappers. Exports: Menubar, MenubarPortal, MenubarMenu, MenubarTrigger, MenubarContent, MenubarGroup, MenubarSeparator, MenubarLabel, MenubarItem, MenubarShortcut, MenubarCheckboxItem, MenubarRadioGroup, MenubarRadioItem, MenubarSub, MenubarSubTrigger, MenubarSubContent.
Usage:
import {
Menubar, MenubarMenu, MenubarTrigger, MenubarContent,
MenubarItem, MenubarSeparator, MenubarShortcut,
} from "@flow-industries/ui/components/menubar";
<Menubar>
<MenubarMenu>
<MenubarTrigger>File</MenubarTrigger>
<MenubarContent>
<MenubarItem>New <MenubarShortcut>⌘N</MenubarShortcut></MenubarItem>
<MenubarItem>Open <MenubarShortcut>⌘O</MenubarShortcut></MenubarItem>
<MenubarSeparator />
<MenubarItem>Save <MenubarShortcut>⌘S</MenubarShortcut></MenubarItem>
</MenubarContent>
</MenubarMenu>
</Menubar>Source is a thin wrapper over DropdownMenu sub-components — see the GitHub source for the full 283-line file.
Breadcrumb
Exports: Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator, BreadcrumbEllipsis.
Usage:
import {
Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink,
BreadcrumbSeparator, BreadcrumbPage,
} from "@flow-industries/ui/components/breadcrumb";
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem><BreadcrumbLink render={<a href="/" />}>Home</BreadcrumbLink></BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem><BreadcrumbLink render={<a href="/docs" />}>Docs</BreadcrumbLink></BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem><BreadcrumbPage>UI</BreadcrumbPage></BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>Source:
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "../../utils/cn";
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
className={cn(className)}
{...props}
/>
);
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground",
className,
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
);
}
function BreadcrumbLink({
className,
render,
...props
}: useRender.ComponentProps<"a">) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn("transition-colors hover:text-foreground", className),
},
props,
),
render,
state: {
slot: "breadcrumb-link",
},
});
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
// biome-ignore lint/a11y/useFocusableInteractive: current page is intentionally non-interactive
// biome-ignore lint/a11y/useSemanticElements: current page is announced as a disabled link, not rendered as <a>
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
);
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRightIcon />}
</li>
);
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn(
"flex size-5 items-center justify-center [&>svg]:size-4",
className,
)}
{...props}
>
<MoreHorizontalIcon />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbEllipsis,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
};NavList
Simple stacked nav. Uses Overline from ./typography for group headers. Exports: NavList, NavListGroup, NavListHeader, NavListItem.
Usage:
import {
NavList, NavListGroup, NavListHeader, NavListItem,
} from "@flow-industries/ui/components/nav-list";
<NavList>
<NavListGroup>
<NavListHeader>Docs</NavListHeader>
<NavListItem href="/overview">Overview</NavListItem>
<NavListItem href="/usage">Usage</NavListItem>
<NavListItem href="/reference">Reference</NavListItem>
</NavListGroup>
</NavList>Source:
import type * as React from "react";
import { cn } from "../../utils/cn";
import { Overline } from "./typography";
function NavList({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
data-slot="nav-list"
className={cn("flex flex-wrap gap-12", className)}
{...props}
/>
);
}
function NavListGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="nav-list-group"
className={cn("flex flex-col gap-4", className)}
{...props}
/>
);
}
function NavListHeader({ className, ...props }: React.ComponentProps<"span">) {
return (
<Overline
data-slot="nav-list-header"
variant="brand"
className={className}
{...props}
/>
);
}
function NavListItem({ className, ...props }: React.ComponentProps<"a">) {
return (
<a
data-slot="nav-list-item"
className={cn(
"inline-flex items-center gap-2 text-xs font-[450] tracking-wide text-foreground [transition:color_80ms_ease] hover:text-brand-text pointer-coarse:min-h-11 -m-0.5 rounded-sm border-[length:var(--border-width)] border-transparent outline-none focus-visible:border-focus",
className,
)}
{...props}
/>
);
}
export { NavList, NavListGroup, NavListHeader, NavListItem };Dock
A floating icon-bar — vertical on desktop, horizontal on mobile — with a hover popover that grows from the hovered item. Built for primary in-app navigation: notification trays, profile menus, account switchers. Three sizes (sm, md, lg) and three per-item variants (default, secondary, primary). Each item can supply a Lucide icon, a customIcon, or a fully custom customComponent; an optional extra prop renders inside the hover popover so the same item can be both a button and a menu trigger.
Usage:
import { Dock } from "@flow-industries/ui/components/dock";
import { Bell, Home, Settings, User } from "lucide-react";
<Dock
size="md"
items={[
{ icon: Home, label: "Home", onClick: () => navigate("/") },
{ icon: Bell, label: "Inbox", isActive: route === "/inbox", extra: <InboxPreview /> },
{ icon: Settings, label: "Settings", variant: "secondary" },
{ icon: User, label: "Account", variant: "primary" },
]}
/>The popover uses motion/react shared layoutId so it morphs between items as the user moves between buttons. Honors prefers-reduced-motion.
type DockSize = "sm" | "md" | "lg";
type DockItem = {
icon?: LucideIcon;
customIcon?: React.ReactNode;
label: string;
onClick?: () => void;
customComponent?: React.ReactNode; // bypass the icon-button shell entirely
extra?: React.ReactNode; // rendered inside the hover popover
variant?: "default" | "secondary" | "primary";
isActive?: boolean;
};
type DockProps = Omit<React.ComponentProps<"div">, "children"> & {
items: DockItem[];
size?: DockSize;
};DisplayMenu
An oversized, typographic navigation list — the big stacked menu behind Flow's game and landing screens, not a dropdown. DisplayMenu is the <nav> wrapper; each DisplayMenuItem is a bold, uppercase entry that underlines in the focus color on keyboard focus and dims to muted on hover.
Usage:
import { DisplayMenu, DisplayMenuItem } from "@flow-industries/ui/components/display-menu";
<DisplayMenu>
<DisplayMenuItem size="xl">Play</DisplayMenuItem>
<DisplayMenuItem trailing="12">Rooms</DisplayMenuItem>
<DisplayMenuItem render={<a href="/customize" />}>Customize</DisplayMenuItem>
<DisplayMenuItem disabled>Store</DisplayMenuItem>
</DisplayMenu>Knobs on DisplayMenuItem:
size—lg(default,text-2xl→text-4xl) orxl(text-6xl→text-8xl).trailing— an optional node rendered smaller and muted beside the label (a count, a keybind, a badge).disabled— drops the hover/pointer affordance and blocks clicks viaaria-disabled.render— Base UI render prop; pass an<a>to make an item a link instead of the default<button>.
Source:
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { cn } from "../../utils/cn";
function DisplayMenu({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
data-slot="display-menu"
className={cn("flex flex-col items-start gap-1.5", className)}
{...props}
/>
);
}
const displayMenuItemVariants = cva(
"flex w-fit items-baseline gap-3 text-left font-bold uppercase tracking-tight text-foreground outline-none transition-[color] duration-200 ease-(--ease-out) focus-visible:underline focus-visible:decoration-focus focus-visible:underline-offset-8",
{
variants: {
size: {
lg: "text-2xl md:text-4xl",
xl: "text-6xl leading-none md:text-8xl",
},
disabled: {
true: "cursor-default",
false: "cursor-pointer hover:text-muted-foreground",
},
},
defaultVariants: {
size: "lg",
disabled: false,
},
},
);
const displayMenuItemTrailingVariants = cva(
"font-semibold text-muted-foreground",
{
variants: {
size: {
lg: "text-lg md:text-xl",
xl: "text-3xl md:text-4xl",
},
},
defaultVariants: {
size: "lg",
},
},
);
function DisplayMenuItem({
className,
size = "lg",
disabled = false,
trailing,
children,
render,
...props
}: useRender.ComponentProps<"button"> &
VariantProps<typeof displayMenuItemVariants> & {
trailing?: React.ReactNode;
}) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
type: render ? undefined : "button",
"aria-disabled": disabled || undefined,
className: cn(displayMenuItemVariants({ size, disabled }), className),
children: (
<>
<span data-slot="display-menu-item-label">{children}</span>
{trailing != null && (
<span
data-slot="display-menu-item-trailing"
className={displayMenuItemTrailingVariants({ size })}
>
{trailing}
</span>
)}
</>
),
},
props,
disabled
? {
onClick(event) {
event.preventDefault();
// mergeProps only decorates the event with preventBaseUIHandler
// when another onClick is merged in
event.preventBaseUIHandler?.();
},
}
: undefined,
),
render,
state: {
slot: "display-menu-item",
size,
disabled: Boolean(disabled),
},
});
}
export { DisplayMenu, DisplayMenuItem, displayMenuItemVariants };Direction
Re-exports Base UI's DirectionProvider and useDirection. Wrap a subtree to set its reading direction (ltr or rtl) — every Base UI primitive below it (menus, navigation, sliders, popovers) flips its layout accordingly. Set it once near the root for right-to-left locales like Arabic.
import { DirectionProvider, useDirection } from "@flow-industries/ui/components/direction";
<DirectionProvider direction="rtl">
<App />
</DirectionProvider>;Read the active direction from any descendant with the hook:
const direction = useDirection(); // "ltr" | "rtl"