Navigation Menu
Navigation Menu Primitive
Terminal window
~/components/ui/navigation-menu.tsx
Demo
Shows a collection of navigation links.
Installation
npx @react-native-reusables/cli@latest add navigation-menu
Copy/paste the following code to ~/components/ui/navigation-menu.tsx
:
import * as NavigationMenuPrimitive from '@rn-primitives/navigation-menu';import { cva } from 'class-variance-authority';import * as React from 'react';import { Platform, View } from 'react-native';import Animated, { Extrapolation, FadeInLeft, FadeOutLeft, interpolate, useAnimatedStyle, useDerivedValue, withTiming,} from 'react-native-reanimated';import { ChevronDown } from '~/lib/icons/ChevronDown';import { cn } from '~/lib/utils';
const NavigationMenu = React.forwardRef< NavigationMenuPrimitive.RootRef, NavigationMenuPrimitive.RootProps>(({ className, children, ...props }, ref) => ( <NavigationMenuPrimitive.Root ref={ref} className={cn('relative z-10 flex flex-row max-w-max items-center justify-center', className)} {...props} > {children} {Platform.OS === 'web' && <NavigationMenuViewport />} </NavigationMenuPrimitive.Root>));NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName;
const NavigationMenuList = React.forwardRef< NavigationMenuPrimitive.ListRef, NavigationMenuPrimitive.ListProps>(({ className, ...props }, ref) => ( <NavigationMenuPrimitive.List ref={ref} className={cn( 'web:group flex flex-1 flex-row web:list-none items-center justify-center gap-1', className )} {...props} />));NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
const NavigationMenuItem = NavigationMenuPrimitive.Item;
const navigationMenuTriggerStyle = cva( 'web:group web:inline-flex flex-row h-10 native:h-12 native:px-3 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium web:transition-colors web:hover:bg-accent active:bg-accent web:hover:text-accent-foreground web:focus:bg-accent web:focus:text-accent-foreground web:focus:outline-none web:disabled:pointer-events-none disabled:opacity-50 web:data-[active]:bg-accent/50 web:data-[state=open]:bg-accent/50');
const NavigationMenuTrigger = React.forwardRef< NavigationMenuPrimitive.TriggerRef, NavigationMenuPrimitive.TriggerProps>(({ className, children, ...props }, ref) => { const { value } = NavigationMenuPrimitive.useRootContext(); const { value: itemValue } = NavigationMenuPrimitive.useItemContext();
const progress = useDerivedValue(() => value === itemValue ? withTiming(1, { duration: 250 }) : withTiming(0, { duration: 200 }) ); const chevronStyle = useAnimatedStyle(() => ({ transform: [{ rotate: `${progress.value * 180}deg` }], opacity: interpolate(progress.value, [0, 1], [1, 0.8], Extrapolation.CLAMP), }));
return ( <NavigationMenuPrimitive.Trigger ref={ref} className={cn( navigationMenuTriggerStyle(), 'web:group gap-1.5', value === itemValue && 'bg-accent', className )} {...props} > <>{children}</> <Animated.View style={chevronStyle}> <ChevronDown size={12} className={cn('relative text-foreground h-3 w-3 web:transition web:duration-200')} aria-hidden={true} /> </Animated.View> </NavigationMenuPrimitive.Trigger> );});NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
const NavigationMenuContent = React.forwardRef< NavigationMenuPrimitive.ContentRef, NavigationMenuPrimitive.ContentProps & { portalHost?: string; }>(({ className, children, portalHost, ...props }, ref) => { const { value } = NavigationMenuPrimitive.useRootContext(); const { value: itemValue } = NavigationMenuPrimitive.useItemContext(); return ( <NavigationMenuPrimitive.Portal hostName={portalHost}> <NavigationMenuPrimitive.Content ref={ref} className={cn( 'w-full native:border native:border-border native:rounded-lg native:shadow-lg native:bg-popover native:text-popover-foreground native:overflow-hidden', value === itemValue ? 'web:animate-in web:fade-in web:slide-in-from-right-20' : 'web:animate-out web:fade-out web:slide-out-to-left-20', className )} {...props} > <Animated.View entering={Platform.OS !== 'web' ? FadeInLeft : undefined} exiting={Platform.OS !== 'web' ? FadeOutLeft : undefined} > {children} </Animated.View> </NavigationMenuPrimitive.Content> </NavigationMenuPrimitive.Portal> );});NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
const NavigationMenuLink = NavigationMenuPrimitive.Link;
const NavigationMenuViewport = React.forwardRef< NavigationMenuPrimitive.ViewportRef, NavigationMenuPrimitive.ViewportProps>(({ className, ...props }, ref) => { return ( <View className={cn('absolute left-0 top-full flex justify-center')}> <View className={cn( 'web:origin-top-center relative mt-1.5 web:h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-lg web:animate-in web:zoom-in-90', className )} ref={ref} {...props} > <NavigationMenuPrimitive.Viewport /> </View> </View> );});NavigationMenuViewport.displayName = NavigationMenuPrimitive.Viewport.displayName;
const NavigationMenuIndicator = React.forwardRef< NavigationMenuPrimitive.IndicatorRef, NavigationMenuPrimitive.IndicatorProps>(({ className, ...props }, ref) => { const { value } = NavigationMenuPrimitive.useRootContext(); const { value: itemValue } = NavigationMenuPrimitive.useItemContext();
return ( <NavigationMenuPrimitive.Indicator ref={ref} className={cn( 'top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden', value === itemValue ? 'web:animate-in web:fade-in' : 'web:animate-out web:fade-out', className )} {...props} > <View className='relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md shadow-foreground/5' /> </NavigationMenuPrimitive.Indicator> );});NavigationMenuIndicator.displayName = NavigationMenuPrimitive.Indicator.displayName;
export { NavigationMenu, NavigationMenuContent, NavigationMenuIndicator, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, NavigationMenuTrigger, navigationMenuTriggerStyle, NavigationMenuViewport,};
Usage
import type { TextRef } from '@rn-primitives/types';import { useNavigation } from 'expo-router';import * as React from 'react';import { Platform, Pressable, StyleSheet, View } from 'react-native';import { useSafeAreaInsets } from 'react-native-safe-area-context';import { NavigationMenu, NavigationMenuContent, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, NavigationMenuTrigger, navigationMenuTriggerStyle,} from '~/components/ui/navigation-menu';import { Text } from '~/components/ui/text'; import { Sparkles } from '~/lib/icons/Sparkles';import { cn } from '~/lib/utils';
const components: { title: string; href: string; description: string }[] = [ { title: 'Alert Dialog', href: '/alert-dialog/alert-dialog-universal', description: 'A modal dialog that interrupts the user with important content and expects a response.', }, ...];
function Example() { const insets = useSafeAreaInsets(); const contentInsets = { top: insets.top, bottom: insets.bottom, left: 12, right: 12, }; const [value, setValue] = React.useState<string>(); const navigation = useNavigation();
function closeAll() { setValue(''); }
React.useEffect(() => { const sub = navigation.addListener('blur', () => { closeAll(); });
return sub; }, []);
return ( <> {Platform.OS !== 'web' && !!value && ( <Pressable onPress={() => { setValue(''); }} style={StyleSheet.absoluteFill} /> )} <NavigationMenu value={value} onValueChange={setValue}> <NavigationMenuList> <NavigationMenuItem value='getting-started'> <NavigationMenuTrigger> <Text>Getting started</Text> </NavigationMenuTrigger> <NavigationMenuContent insets={contentInsets}> <View role='list' className='web:grid gap-3 p-6 md:w-[400px] lg:w-[500px] web:lg:grid-cols-[.75fr_1fr]' > <View role='listitem' className='web:row-span-3'> <NavigationMenuLink asChild> <View className='flex web:select-none flex-col justify-end rounded-md web:bg-gradient-to-b web:from-muted/50 web:to-muted native:border native:border-border p-6 web:no-underline web:outline-none web:focus:shadow-md web:focus:shadow-foreground/5'> <Sparkles size={16} className='text-foreground' /> <Text className='mb-2 mt-4 text-lg native:text-2xl font-medium'> react-native-reusables </Text> <Text className='text-sm native:text-base leading-tight text-muted-foreground'> Universal components that you can copy and paste into your apps. Accessible. Customizable. Open Source. </Text> </View> </NavigationMenuLink> </View> <ListItem href='/docs' title='Introduction'> <Text> Re-usable components built using Radix UI on the web and Tailwind CSS. </Text> </ListItem> <ListItem href='/docs/installation' title='Installation'> <Text>How to install dependencies and structure your app.</Text> </ListItem> <ListItem href='/docshttps://rn-primitives.vercel.app/typography' title='Typography'> <Text>Styles for headings, paragraphs, lists...etc</Text> </ListItem> </View> </NavigationMenuContent> </NavigationMenuItem> <NavigationMenuItem value='components'> <NavigationMenuTrigger> <Text className='text-foreground'>Components</Text> </NavigationMenuTrigger> <NavigationMenuContent insets={contentInsets}> <View role='list' className='web:grid w-[400px] gap-3 p-4 md:w-[500px] web:md:grid-cols-2 lg:w-[600px] ' > {components.map((component) => ( <ListItem key={component.title} title={component.title} href={component.href}> {component.description} </ListItem> ))} </View> </NavigationMenuContent> </NavigationMenuItem> <NavigationMenuItem value='documentation'> <NavigationMenuLink onPress={closeAll} className={navigationMenuTriggerStyle()}> <Text>Documentation</Text> </NavigationMenuLink> </NavigationMenuItem> </NavigationMenuList> </NavigationMenu> </> );}
const ListItem = React.forwardRef< TextRef, React.ComponentPropsWithoutRef<typeof Text> & { title: string; href: string }>(({ className, title, children, ...props }, ref) => { // TODO: add navigationn to `href` on `NavigationMenuLink` onPress return ( <View role='listitem'> <NavigationMenuLink ref={ref} className={cn( 'web:block web:select-none gap-1 rounded-md p-3 leading-none no-underline text-foreground web:outline-none web:transition-colors web:hover:bg-accent active:bg-accent web:hover:text-accent-foreground web:focus:bg-accent web:focus:text-accent-foreground', className )} {...props} > <Text className='text-sm native:text-base font-medium text-foreground leading-none'> {title} </Text> <Text className='line-clamp-2 text-sm native:text-base leading-snug text-muted-foreground'> {children} </Text> </NavigationMenuLink> </View> );});ListItem.displayName = 'ListItem';
Props
NavigationMenuRoot
Extends View
props
Prop | Type | Note |
---|---|---|
value* | boolean | |
onValueChange* | (value: boolean) => void | |
asChild | boolean | (optional) |
delayDuration | number | Web only (optional) |
skipDelayDuration | number | Web only (optional) |
dir | ’ltr’ | ‘rtl’ | Web only (optional) |
orientation | ’horizontal’ | ‘vertical’ | Web only (optional) |
NavigationMenuList
Extends View
props
Prop | Type | Note |
---|---|---|
asChild | boolean | (optional) |
NavigationMenuItem
Extends Pressable
props
Prop | Type | Note |
---|---|---|
value* | string | |
asChild | boolean | (optional) |
NavigationMenuTrigger
Extends Pressable
props
Prop | Type | Note |
---|---|---|
asChild | boolean | (optional) |
NavigationMenuContent
Extends View
props
Prop | Type | Note |
---|---|---|
asChild | boolean | (optional) |
forceMount | true | undefined | (optional) |
alignOffset | number | Native Only (optional) |
insets | Insets | Native Only (optional) |
avoidCollisions | boolean | Native Only (optional) |
align | ’start’ | ‘center’ | ‘end’ | Native Only (optional) |
side | ’top’ | ‘bottom’ | Native Only (optional) |
sideOffset | number | Native Only (optional) |
disablePositioningStyle | boolean | Native Only (optional) |
loop | boolean | Web Only (optional) |
onEscapeKeyDown | (event: KeyboardEvent) => void | Web Only (optional) |
onPointerDownOutside | (event: PointerDownOutsideEvent) => void | Web Only (optional) |
onFocusOutside | (event: FocusOutsideEvent) => void | Web Only (optional) |
onInteractOutside | PointerDownOutsideEvent | FocusOutsideEvent | Web Only (optional) |
NavigationMenuLink
Extends Pressable
props
Prop | Type | Note |
---|---|---|
active | boolean | (optional) |
asChild | boolean | (optional) |
NavigationMenuViewport
Should only be used for web
Extends View
props except children
NavigationMenuIndicator
Extends View
props