Navigation
Installation
Add the following Soul components
The navigation component uses the streamable, button, logo, price-label, product-card, toaster and form-status components. Make sure you have added them to your project.
Install the following dependencies
npm install @conform-to/react @radix-ui/react-dropdown-menu @radix-ui/react-navigation-menu @radix-ui/react-popover clsx lodash.debounce lucide-react @conform-to/zod
Copy and paste the following code into your project
primitives/navigation/index.tsx
'use client';import { SubmissionResult, useForm } from '@conform-to/react';import * as DropdownMenu from '@radix-ui/react-dropdown-menu';import * as NavigationMenu from '@radix-ui/react-navigation-menu';import * as Popover from '@radix-ui/react-popover';import { clsx } from 'clsx';import debounce from 'lodash.debounce';import { ArrowRight, ChevronDown, Search, SearchIcon, ShoppingBag, User } from 'lucide-react';import Link from 'next/link';import { usePathname } from 'next/navigation';import React, { forwardRef, Ref, startTransition, useActionState, useCallback, useEffect, useMemo, useState, useTransition,} from 'react';import { useFormStatus } from 'react-dom';import { FormStatus } from '@/vibes/soul/form/form-status';import { Stream, Streamable } from '@/vibes/soul/lib/streamable';import { Button } from '@/vibes/soul/primitives/button';import { Logo } from '@/vibes/soul/primitives/logo';import { Price } from '@/vibes/soul/primitives/price-label';import { ProductCard } from '@/vibes/soul/primitives/product-card';import { toast } from '@/vibes/soul/primitives/toaster';interface Link { label: string; href: string; groups?: Array<{ label?: string; href?: string; links: Array<{ label: string; href: string; }>; }>;}interface Locale { id: string; label: string;}interface Currency { id: string; label: string;}type Action<State, Payload> = ( state: Awaited<State>, payload: Awaited<Payload>,) => State | Promise<State>;export type SearchResult = | { type: 'products'; title: string; products: Array<{ id: string; title: string; href: string; price?: Price; image?: { src: string; alt: string }; }>; } | { type: 'links'; title: string; links: Array<{ label: string; href: string }>; };type LocaleAction = Action<SubmissionResult | null, FormData>;type CurrencyAction = Action<SubmissionResult | null, FormData>;type SearchAction<S extends SearchResult> = Action< { searchResults: S[] | null; lastResult: SubmissionResult | null; emptyStateTitle?: string; emptyStateSubtitle?: string; }, FormData>;interface Props<S extends SearchResult> { className?: string; isFloating?: boolean; accountHref: string; cartCount?: Streamable<number | null>; cartHref: string; links: Streamable<Link[]>; linksPosition?: 'center' | 'left' | 'right'; locales?: Locale[]; activeLocaleId?: string; localeAction?: LocaleAction; currencies?: Currency[]; activeCurrencyId?: Streamable<string | undefined>; currencyAction?: CurrencyAction; logo: Streamable<string | { src: string; alt: string }>; logoWidth?: number; logoHeight?: number; logoHref?: string; logoLabel?: string; mobileLogo?: Streamable<string | { src: string; alt: string }>; mobileLogoWidth?: number; mobileLogoHeight?: number; searchHref: string; searchParamName?: string; searchAction?: SearchAction<S>; searchCtaLabel?: string; searchInputPlaceholder?: string; cartLabel?: string; accountLabel?: string; openSearchPopupLabel?: string; searchLabel?: string; mobileMenuTriggerLabel?: string;}const MobileMenuButton = forwardRef< React.ComponentRef<'button'>, { open: boolean } & React.ComponentPropsWithoutRef<'button'>>(({ open, className, ...rest }, ref) => { return ( <button {...rest} className={clsx( 'group relative rounded-lg p-2 outline-0 ring-[var(--nav-focus,hsl(var(--primary)))] transition-colors focus-visible:ring-2', className, )} ref={ref} > <div className="flex h-4 w-4 origin-center transform flex-col justify-between overflow-hidden transition-all duration-300"> <div className={clsx( 'h-px origin-left transform bg-[var(--nav-mobile-button-icon,hsl(var(--foreground)))] transition-all duration-300', open ? 'translate-x-10' : 'w-7', )} /> <div className={clsx( 'h-px transform rounded bg-[var(--nav-mobile-button-icon,hsl(var(--foreground)))] transition-all delay-75 duration-300', open ? 'translate-x-10' : 'w-7', )} /> <div className={clsx( 'h-px origin-left transform bg-[var(--nav-mobile-button-icon,hsl(var(--foreground)))] transition-all delay-150 duration-300', open ? 'translate-x-10' : 'w-7', )} /> <div className={clsx( 'absolute top-2 flex transform items-center justify-between bg-[var(--nav-mobile-button-icon,hsl(var(--foreground)))] transition-all duration-500', open ? 'w-12 translate-x-0' : 'w-0 -translate-x-10', )} > <div className={clsx( 'absolute h-px w-4 transform bg-[var(--nav-mobile-button-icon,hsl(var(--foreground)))] transition-all delay-300 duration-500', open ? 'rotate-45' : 'rotate-0', )} /> <div className={clsx( 'absolute h-px w-4 transform bg-[var(--nav-mobile-button-icon,hsl(var(--foreground)))] transition-all delay-300 duration-500', open ? '-rotate-45' : 'rotate-0', )} /> </div> </div> </button> );});MobileMenuButton.displayName = 'MobileMenuButton';const navGroupClassName = 'block rounded-lg bg-[var(--nav-group-background,transparent)] px-3 py-2 font-[family-name:var(--nav-group-font-family,var(--font-family-body))] font-medium text-[var(--nav-group-text,hsl(var(--foreground)))] ring-[var(--nav-focus,hsl(var(--primary)))] transition-colors hover:bg-[var(--nav-group-background-hover,hsl(var(--contrast-100)))] hover:text-[var(--nav-group-text-hover,hsl(var(--foreground)))] focus-visible:outline-0 focus-visible:ring-2';const navButtonClassName = 'relative rounded-lg bg-[var(--nav-button-background,transparent)] p-1.5 text-[var(--nav-button-icon,hsl(var(--foreground)))] ring-[var(--nav-focus,hsl(var(--primary)))] transition-colors focus-visible:outline-0 focus-visible:ring-2 @4xl:hover:bg-[var(--nav-button-background-hover,hsl(var(--contrast-100)))] @4xl:hover:text-[var(--nav-button-icon-hover,hsl(var(--foreground)))]';/** * This component supports various CSS variables for theming. Here's a comprehensive list, along * with their default values: * * ```css * :root { * --nav-focus: hsl(var(--primary)); * --nav-background: hsl(var(--background)); * --nav-floating-border: hsl(var(--foreground) / 10%); * --nav-link-text: hsl(var(--foreground)); * --nav-link-text-hover: hsl(var(--foreground)); * --nav-link-background: transparent; * --nav-link-background-hover: hsl(var(--contrast-100)); * --nav-link-font-family: var(--font-family-body); * --nav-group-text: hsl(var(--foreground)); * --nav-group-text-hover: hsl(var(--foreground)); * --nav-group-background: transparent; * --nav-group-background-hover: hsl(var(--contrast-100)); * --nav-group-font-family: var(--font-family-body); * --nav-sub-link-text: hsl(var(--contrast-500)); * --nav-sub-link-text-hover: hsl(var(--foreground)); * --nav-sub-link-background: transparent; * --nav-sub-link-background-hover: hsl(var(--contrast-100)); * --nav-sub-link-font-family: var(--font-family-body); * --nav-button-icon: hsl(var(--foreground)); * --nav-button-icon-hover: hsl(var(--foreground)); * --nav-button-background: hsl(var(--background)); * --nav-button-background-hover: hsl(var(--contrast-100)); * --nav-menu-background: hsl(var(--background)); * --nav-menu-border: hsl(var(--foreground) / 5%); * --nav-mobile-background: hsl(var(--background)); * --nav-mobile-divider: hsl(var(--contrast-100)); * --nav-mobile-button-icon: hsl(var(--foreground)); * --nav-mobile-link-text: hsl(var(--foreground)); * --nav-mobile-link-text-hover: hsl(var(--foreground)); * --nav-mobile-link-background: transparent; * --nav-mobile-link-background-hover: hsl(var(--contrast-100)); * --nav-mobile-link-font-family: var(--font-family-body); * --nav-mobile-sub-link-text: hsl(var(--contrast-500)); * --nav-mobile-sub-link-text-hover: hsl(var(--foreground)); * --nav-mobile-sub-link-background: transparent; * --nav-mobile-sub-link-background-hover: hsl(var(--contrast-100)); * --nav-mobile-sub-link-font-family: var(--font-family-body); * --nav-search-background: hsl(var(--background)); * --nav-search-border: hsl(var(--foreground) / 5%); * --nav-search-divider: hsl(var(--foreground) / 5%); * --nav-search-icon: hsl(var(--contrast-500)); * --nav-search-empty-title: hsl(var(--foreground)); * --nav-search-empty-subtitle: hsl(var(--contrast-500)); * --nav-search-result-title: hsl(var(--foreground)); * --nav-search-result-title-font-family: var(--font-family-mono); * --nav-search-result-link-text: hsl(var(--foreground)); * --nav-search-result-link-text-hover: hsl(var(--foreground)); * --nav-search-result-link-background: hsl(var(--background)); * --nav-search-result-link-background-hover: hsl(var(--contrast-100)); * --nav-search-result-link-font-family: var(--font-family-body); * --nav-cart-count-text: hsl(var(--background)); * --nav-cart-count-background: hsl(var(--foreground)); * --nav-locale-background: hsl(var(--background)); * --nav-locale-link-text: hsl(var(--contrast-400)); * --nav-locale-link-text-hover: hsl(var(--foreground)); * --nav-locale-link-text-selected: hsl(var(--foreground)); * --nav-locale-link-background: transparent; * --nav-locale-link-background-hover: hsl(var(--contrast-100)); * --nav-locale-link-font-family: var(--font-family-body); * } * ``` */export const Navigation = forwardRef(function Navigation<S extends SearchResult>( { className, isFloating = false, cartHref, cartCount: streamableCartCount, accountHref, links: streamableLinks, logo: streamableLogo, logoHref = '/', logoLabel = 'Home', logoWidth = 200, logoHeight = 40, mobileLogo: streamableMobileLogo, mobileLogoWidth = 100, mobileLogoHeight = 40, linksPosition = 'center', activeLocaleId, localeAction, locales, currencies, activeCurrencyId: streamableActiveCurrencyId, currencyAction, searchHref, searchParamName = 'query', searchAction, searchCtaLabel, searchInputPlaceholder, cartLabel = 'Cart', accountLabel = 'Profile', openSearchPopupLabel = 'Open search popup', searchLabel = 'Search', mobileMenuTriggerLabel = 'Toggle navigation', }: Props<S>, ref: Ref<HTMLDivElement>,) { const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isSearchOpen, setIsSearchOpen] = useState(false); const pathname = usePathname(); useEffect(() => { setIsMobileMenuOpen(false); setIsSearchOpen(false); }, [pathname]); useEffect(() => { function handleScroll() { setIsSearchOpen(false); setIsMobileMenuOpen(false); } window.addEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll); }, []); return ( <NavigationMenu.Root className={clsx('relative mx-auto w-full max-w-screen-2xl @container', className)} delayDuration={0} onValueChange={() => setIsSearchOpen(false)} ref={ref} > <div className={clsx( 'flex min-h-16 items-center justify-between gap-1 bg-[var(--nav-background,hsl(var(--background)))] py-2 pl-3 pr-2 transition-shadow @4xl:rounded-2xl @4xl:px-2 @4xl:pl-6 @4xl:pr-2.5', isFloating ? 'shadow-xl ring-1 ring-[var(--nav-floating-border,hsl(var(--foreground)/10%))]' : 'shadow-none ring-0', )} > {/* Mobile Menu */} <Popover.Root onOpenChange={setIsMobileMenuOpen} open={isMobileMenuOpen}> <Popover.Anchor className="absolute left-0 right-0 top-full" /> <Popover.Trigger asChild> <MobileMenuButton aria-label={mobileMenuTriggerLabel} className="mr-1 @4xl:hidden" onClick={() => setIsMobileMenuOpen((prev) => !prev)} open={isMobileMenuOpen} /> </Popover.Trigger> <Popover.Portal> <Popover.Content className="max-h-[calc(var(--radix-popover-content-available-height)-8px)] w-[var(--radix-popper-anchor-width)] @container data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"> <div className="max-h-[inherit] divide-y divide-[var(--nav-mobile-divider,hsl(var(--contrast-100)))] overflow-y-auto bg-[var(--nav-mobile-background,hsl(var(--background)))]"> <Stream fallback={ <ul className="flex animate-pulse flex-col gap-4 p-5 @4xl:gap-2 @4xl:p-5"> <li> <span className="block h-4 w-10 rounded-md bg-contrast-100" /> </li> <li> <span className="block h-4 w-14 rounded-md bg-contrast-100" /> </li> <li> <span className="block h-4 w-24 rounded-md bg-contrast-100" /> </li> <li> <span className="block h-4 w-16 rounded-md bg-contrast-100" /> </li> </ul> } value={streamableLinks} > {(links) => links.map((item, i) => ( <ul className="flex flex-col p-2 @4xl:gap-2 @4xl:p-5" key={i}> {item.label !== '' && ( <li> <Link className="block rounded-lg bg-[var(--nav-mobile-link-background,transparent)] px-3 py-2 font-[family-name:var(--nav-mobile-link-font-family,var(--font-family-body))] font-semibold text-[var(--nav-mobile-link-text,hsl(var(--foreground)))] ring-[var(--nav-focus,hsl(var(--primary)))] transition-colors hover:bg-[var(--nav-mobile-link-background-hover,hsl(var(--contrast-100)))] hover:text-[var(--nav-mobile-link-text-hover,hsl(var(--foreground)))] focus-visible:outline-0 focus-visible:ring-2 @4xl:py-4" href={item.href} > {item.label} </Link> </li> )} {item.groups ?.flatMap((group) => group.links) .map((link, j) => ( <li key={j}> <Link className="block rounded-lg bg-[var(--nav-mobile-sub-link-background,transparent)] px-3 py-2 font-[family-name:var(--nav-mobile-sub-link-font-family,var(--font-family-body))] text-sm font-medium text-[var(--nav-mobile-sub-link-text,hsl(var(--contrast-500)))] ring-[var(--nav-focus,hsl(var(--primary)))] transition-colors hover:bg-[var(--nav-mobile-sub-link-background-hover,hsl(var(--contrast-100)))] hover:text-[var(--nav-mobile-sub-link-text-hover,hsl(var(--foreground)))] focus-visible:outline-0 focus-visible:ring-2 @4xl:py-4" href={link.href} > {link.label} </Link> </li> ))} </ul> )) } </Stream> </div> </Popover.Content> </Popover.Portal> </Popover.Root> {/* Logo */} <div className={clsx( 'flex items-center justify-start self-stretch', linksPosition === 'center' ? 'flex-1' : 'flex-1 @4xl:flex-none', )} > <Logo className={clsx(streamableMobileLogo != null ? 'hidden @4xl:flex' : 'flex')} height={logoHeight} href={logoHref} label={logoLabel} logo={streamableLogo} width={logoWidth} /> {streamableMobileLogo != null && ( <Logo className="flex @4xl:hidden" height={mobileLogoHeight} href={logoHref} label={logoLabel} logo={streamableMobileLogo} width={mobileLogoWidth} /> )} </div> {/* Top Level Nav Links */} <ul className={clsx( 'hidden gap-1 @4xl:flex @4xl:flex-1', { left: '@4xl:justify-start', center: '@4xl:justify-center', right: '@4xl:justify-end', }[linksPosition], )} > <Stream fallback={ <ul className="flex animate-pulse flex-row gap-6"> <li> <span className="block h-4 w-16 rounded-md bg-contrast-100" /> </li> <li> <span className="block h-4 w-12 rounded-md bg-contrast-100" /> </li> <li> <span className="block h-4 w-24 rounded-md bg-contrast-100" /> </li> <li> <span className="block h-4 w-16 rounded-md bg-contrast-100" /> </li> </ul> } value={streamableLinks} > {(links) => links.map((item, i) => ( <NavigationMenu.Item key={i} value={i.toString()}> <NavigationMenu.Trigger asChild> <Link className="hidden items-center whitespace-nowrap rounded-xl bg-[var(--nav-link-background,transparent)] p-2.5 font-[family-name:var(--nav-link-font-family,var(--font-family-body))] text-sm font-medium text-[var(--nav-link-text,hsl(var(--foreground)))] ring-[var(--nav-focus,hsl(var(--primary)))] transition-colors duration-200 hover:bg-[var(--nav-link-background-hover,hsl(var(--contrast-100)))] hover:text-[var(--nav-link-text-hover,hsl(var(--foreground)))] focus-visible:outline-0 focus-visible:ring-2 @4xl:inline-flex" href={item.href} > {item.label} </Link> </NavigationMenu.Trigger> {item.groups != null && item.groups.length > 0 && ( <NavigationMenu.Content className="rounded-2xl bg-[var(--nav-menu-background,hsl(var(--background)))] shadow-xl ring-1 ring-[var(--nav-menu-border,hsl(var(--foreground)/5%))]"> <div className="m-auto grid w-full max-w-screen-lg grid-cols-5 justify-center gap-5 px-5 pb-8 pt-5"> {item.groups.map((group, columnIndex) => ( <ul className="flex flex-col" key={columnIndex}> {/* Second Level Links */} {group.label != null && group.label !== '' && ( <li> {group.href != null && group.href !== '' ? ( <Link className={navGroupClassName} href={group.href}> {group.label} </Link> ) : ( <span className={navGroupClassName}>{group.label}</span> )} </li> )} {group.links.map((link, idx) => ( // Third Level Links <li key={idx}> <Link className="block rounded-lg bg-[var(--nav-sub-link-background,transparent)] px-3 py-1.5 font-[family-name:var(--nav-sub-link-font-family,var(--font-family-body))] text-sm font-medium text-[var(--nav-sub-link-text,hsl(var(--contrast-500)))] ring-[var(--nav-focus,hsl(var(--primary)))] transition-colors hover:bg-[var(--nav-sub-link-background-hover,hsl(var(--contrast-100)))] hover:text-[var(--nav-sub-link-text-hover,hsl(var(--foreground)))] focus-visible:outline-0 focus-visible:ring-2" href={link.href} > {link.label} </Link> </li> ))} </ul> ))} </div> </NavigationMenu.Content> )} </NavigationMenu.Item> )) } </Stream> </ul> {/* Icon Buttons */} <div className={clsx( 'flex items-center justify-end gap-0.5 transition-colors duration-300', linksPosition === 'center' ? 'flex-1' : 'flex-1 @4xl:flex-none', )} > {searchAction ? ( <Popover.Root onOpenChange={setIsSearchOpen} open={isSearchOpen}> <Popover.Anchor className="absolute left-0 right-0 top-full" /> <Popover.Trigger asChild> <button aria-label={openSearchPopupLabel} className={navButtonClassName} onPointerEnter={(e) => e.preventDefault()} onPointerLeave={(e) => e.preventDefault()} onPointerMove={(e) => e.preventDefault()} > <Search size={20} strokeWidth={1} /> </button> </Popover.Trigger> <Popover.Portal> <Popover.Content className="max-h-[calc(var(--radix-popover-content-available-height)-16px)] w-[var(--radix-popper-anchor-width)] py-2 @container data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"> <div className="flex max-h-[inherit] flex-col rounded-2xl bg-[var(--nav-search-background,hsl(var(--background)))] shadow-xl ring-1 ring-[var(--nav-search-border,hsl(var(--foreground)/5%))] transition-all duration-200 ease-in-out @4xl:inset-x-0"> <SearchForm searchAction={searchAction} searchCtaLabel={searchCtaLabel} searchHref={searchHref} searchInputPlaceholder={searchInputPlaceholder} searchParamName={searchParamName} /> </div> </Popover.Content> </Popover.Portal> </Popover.Root> ) : ( <Link aria-label={searchLabel} className={navButtonClassName} href={searchHref}> <Search size={20} strokeWidth={1} /> </Link> )} <Link aria-label={accountLabel} className={navButtonClassName} href={accountHref}> <User size={20} strokeWidth={1} /> </Link> <Link aria-label={cartLabel} className={navButtonClassName} href={cartHref}> <ShoppingBag size={20} strokeWidth={1} /> <Stream fallback={ <span className="absolute -right-0.5 -top-0.5 flex h-4 w-4 animate-pulse items-center justify-center rounded-full bg-contrast-100 text-xs text-background" /> } value={streamableCartCount} > {(cartCount) => cartCount != null && cartCount > 0 && ( <span className="absolute -right-0.5 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-[var(--nav-cart-count-background,hsl(var(--foreground)))] font-[family-name:var(--nav-cart-count-font-family,var(--font-family-body))] text-xs text-[var(--nav-cart-count-text,hsl(var(--background)))]"> {cartCount} </span> ) } </Stream> </Link> {/* Locale / Language Dropdown */} {locales && locales.length > 1 && localeAction ? ( <LocaleForm action={localeAction} activeLocaleId={activeLocaleId} // eslint-disable-next-line @typescript-eslint/consistent-type-assertions locales={locales as [Locale, Locale, ...Locale[]]} /> ) : null} {/* Currency Dropdown */} {currencies && currencies.length > 1 && currencyAction ? ( <Stream fallback={ <CurrencyForm action={currencyAction} // eslint-disable-next-line @typescript-eslint/consistent-type-assertions currencies={currencies as [Currency, ...Currency[]]} /> } value={streamableActiveCurrencyId} > {(activeCurrencyId) => ( <CurrencyForm action={currencyAction} activeCurrencyId={activeCurrencyId} // eslint-disable-next-line @typescript-eslint/consistent-type-assertions currencies={currencies as [Currency, ...Currency[]]} /> )} </Stream> ) : null} </div> </div> <div className="perspective-[2000px] absolute left-0 right-0 top-full z-50 flex w-full justify-center"> <NavigationMenu.Viewport className="relative mt-2 w-full data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95" /> </div> </NavigationMenu.Root> );});Navigation.displayName = 'Navigation';function SearchForm<S extends SearchResult>({ searchAction, searchParamName = 'query', searchHref = '/search', searchInputPlaceholder = 'Search Products', searchCtaLabel = 'View more', submitLabel = 'Submit',}: { searchAction: SearchAction<S>; searchParamName?: string; searchHref?: string; searchCtaLabel?: string; searchInputPlaceholder?: string; submitLabel?: string;}) { const [query, setQuery] = useState(''); const [isSearching, startSearching] = useTransition(); const [{ searchResults, lastResult, emptyStateTitle, emptyStateSubtitle }, formAction] = useActionState(searchAction, { searchResults: null, lastResult: null, }); const [isDebouncing, setIsDebouncing] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const isPending = isSearching || isDebouncing || isSubmitting; const debouncedOnChange = useMemo(() => { const debounced = debounce((q: string) => { setIsDebouncing(false); const formData = new FormData(); formData.append(searchParamName, q); startSearching(() => { formAction(formData); }); }, 300); return (q: string) => { setIsDebouncing(true); debounced(q); }; }, [formAction, searchParamName]); const [form] = useForm({ lastResult }); const handleSubmit = useCallback(() => { setIsSubmitting(true); }, []); return ( <> <form action={searchHref} className="flex items-center gap-3 px-3 py-3 @4xl:px-5 @4xl:py-4" onSubmit={handleSubmit} > <SearchIcon className="hidden shrink-0 text-[var(--nav-search-icon,hsl(var(--contrast-500)))] @xl:block" size={20} strokeWidth={1} /> <input className="flex-grow bg-transparent pl-2 text-lg font-medium outline-0 focus-visible:outline-none @xl:pl-0" name={searchParamName} onChange={(e) => { setQuery(e.currentTarget.value); debouncedOnChange(e.currentTarget.value); }} placeholder={searchInputPlaceholder} type="text" value={query} /> <SubmitButton loading={isPending} submitLabel={submitLabel} /> </form> <SearchResults emptySearchSubtitle={emptyStateSubtitle} emptySearchTitle={emptyStateTitle} errors={form.errors} query={query} searchCtaLabel={searchCtaLabel} searchParamName={searchParamName} searchResults={searchResults} stale={isPending} /> </> );}function SubmitButton({ loading, submitLabel }: { loading: boolean; submitLabel: string }) { const { pending } = useFormStatus(); return ( <Button loading={pending || loading} shape="circle" size="small" type="submit" variant="secondary" > <ArrowRight aria-label={submitLabel} size={20} strokeWidth={1.5} /> </Button> );}function SearchResults({ query, searchResults, stale, emptySearchTitle = `No results were found for '${query}'`, emptySearchSubtitle = 'Please try another search.', errors,}: { query: string; searchParamName: string; searchCtaLabel?: string; emptySearchTitle?: string; emptySearchSubtitle?: string; searchResults: SearchResult[] | null; stale: boolean; errors?: string[];}) { if (query === '') return null; if (errors != null && errors.length > 0) { if (stale) return null; return ( <div className="flex flex-col border-t border-[var(--nav-search-divider,hsl(var(--contrast-100)))] p-6"> {errors.map((error) => ( <FormStatus key={error} type="error"> {error} </FormStatus> ))} </div> ); } if (searchResults == null || searchResults.length === 0) { if (stale) return null; return ( <div className="flex flex-col border-t border-[var(--nav-search-divider,hsl(var(--contrast-100)))] p-6"> <p className="text-2xl font-medium text-[var(--nav-search-empty-title,hsl(var(--foreground)))]"> {emptySearchTitle} </p> <p className="text-[var(--nav-search-empty-subtitle,hsl(var(--contrast-500)))]"> {emptySearchSubtitle} </p> </div> ); } return ( <div className={clsx( 'flex flex-1 flex-col overflow-y-auto border-t border-[var(--nav-search-divider,hsl(var(--contrast-100)))] @2xl:flex-row', stale && 'opacity-50', )} > {searchResults.map((result, index) => { switch (result.type) { case 'links': { return ( <section aria-label={result.title} className="flex w-full flex-col gap-1 border-b border-[var(--nav-search-divider,hsl(var(--contrast-100)))] p-5 @2xl:max-w-80 @2xl:border-b-0 @2xl:border-r" key={`result-${index}`} > <h3 className="mb-4 font-[family-name:var(--nav-search-result-title-font-family,var(--font-family-mono))] text-sm uppercase text-[var(--nav-search-result-title,hsl(var(--foreground)))]"> {result.title} </h3> <ul role="listbox"> {result.links.map((link, i) => ( <li key={i}> <Link className="block rounded-lg bg-[var(--nav-search-result-link-background,transparent)] px-3 py-4 font-[family-name:var(--nav-search-result-link-font-family,var(--font-family-body))] font-semibold text-[var(--nav-search-result-link-text,hsl(var(--contrast-500)))] ring-[var(--nav-focus,hsl(var(--primary)))] transition-colors hover:bg-[var(--nav-search-result-link-background-hover,hsl(var(--contrast-100)))] hover:text-[var(--nav-search-result-link-text-hover,hsl(var(--foreground)))] focus-visible:outline-0 focus-visible:ring-2" href={link.href} > {link.label} </Link> </li> ))} </ul> </section> ); } case 'products': { return ( <section aria-label={result.title} className="flex w-full flex-col gap-5 p-5 @container" key={`result-${index}`} > <h3 className="font-[family-name:var(--nav-search-result-title-font-family,var(--font-family-mono))] text-sm uppercase text-[var(--nav-search-result-title,hsl(var(--foreground)))]"> {result.title} </h3> <ul className="grid w-full grid-cols-1 gap-5 @xs:grid-cols-2 @md:grid-cols-3 @lg:grid-cols-4" role="listbox" > {result.products.map((product) => ( <li key={product.id}> <ProductCard imageSizes="(min-width: 42rem) 25vw, 50vw" product={{ id: product.id, title: product.title, href: product.href, price: product.price, image: product.image, }} /> </li> ))} </ul> </section> ); } default: return null; } })} </div> );}function LocaleForm({ action, locales, activeLocaleId,}: { activeLocaleId?: string; action: LocaleAction; locales: [Locale, ...Locale[]];}) { const [lastResult, formAction] = useActionState(action, null); const activeLocale = locales.find((locale) => locale.id === activeLocaleId); const [form] = useForm({ lastResult, }); useEffect(() => { if (form.errors) { form.errors.forEach((error) => { toast.error(error); }); } }, [form.errors]); return ( <DropdownMenu.Root> <DropdownMenu.Trigger className={clsx('flex items-center gap-1 text-xs uppercase', navButtonClassName)} > {activeLocale?.id ?? locales[0].id} <ChevronDown size={16} strokeWidth={1.5} /> </DropdownMenu.Trigger> <DropdownMenu.Portal> <DropdownMenu.Content align="end" className="z-50 max-h-80 overflow-y-scroll rounded-xl bg-[var(--nav-locale-background,hsl(var(--background)))] p-2 shadow-xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 @4xl:w-32 @4xl:rounded-2xl @4xl:p-2" sideOffset={16} > {locales.map(({ id, label }) => ( <DropdownMenu.Item className={clsx( 'cursor-default rounded-lg bg-[var(--nav-locale-link-background,transparent)] px-2.5 py-2 font-[family-name:var(--nav-locale-link-font-family,var(--font-family-body))] text-sm font-medium text-[var(--nav-locale-link-text,hsl(var(--contrast-400)))] outline-none ring-[var(--nav-focus,hsl(var(--primary)))] transition-colors hover:bg-[var(--nav-locale-link-background-hover,hsl(var(--contrast-100)))] hover:text-[var(--nav-locale-link-text-hover,hsl(var(--foreground)))]', { 'text-[var(--nav-locale-link-text-selected,hsl(var(--foreground)))]': id === activeLocaleId, }, )} key={id} onSelect={() => { // eslint-disable-next-line @typescript-eslint/require-await startTransition(async () => { const formData = new FormData(); formData.append('id', id); formAction(formData); }); }} > {label} </DropdownMenu.Item> ))} </DropdownMenu.Content> </DropdownMenu.Portal> </DropdownMenu.Root> );}function CurrencyForm({ action, currencies, activeCurrencyId,}: { activeCurrencyId?: string; action: CurrencyAction; currencies: [Currency, ...Currency[]];}) { const [lastResult, formAction] = useActionState(action, null); const activeCurrency = currencies.find((currency) => currency.id === activeCurrencyId); const [form] = useForm({ lastResult, }); useEffect(() => { if (form.errors) { form.errors.forEach((error) => { toast.error(error); }); } }, [form.errors]); return ( <DropdownMenu.Root> <DropdownMenu.Trigger className={clsx('flex items-center gap-1 text-xs uppercase', navButtonClassName)} > {activeCurrency?.label ?? currencies[0].label} <ChevronDown size={16} strokeWidth={1.5} /> </DropdownMenu.Trigger> <DropdownMenu.Portal> <DropdownMenu.Content align="end" className="z-50 max-h-80 overflow-y-scroll rounded-xl bg-[var(--nav-locale-background,hsl(var(--background)))] p-2 shadow-xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 @4xl:w-32 @4xl:rounded-2xl @4xl:p-2" sideOffset={16} > {currencies.map((currency) => ( <DropdownMenu.Item className={clsx( 'cursor-default rounded-lg bg-[var(--nav-locale-link-background,transparent)] px-2.5 py-2 font-[family-name:var(--nav-locale-link-font-family,var(--font-family-body))] text-sm font-medium text-[var(--nav-locale-link-text,hsl(var(--contrast-400)))] outline-none ring-[var(--nav-focus,hsl(var(--primary)))] transition-colors hover:bg-[var(--nav-locale-link-background-hover,hsl(var(--contrast-100)))] hover:text-[var(--nav-locale-link-text-hover,hsl(var(--foreground)))]', { 'text-[var(--nav-locale-link-text-selected,hsl(var(--foreground)))]': currency.id === activeCurrencyId, }, )} key={currency.id} onSelect={() => { // eslint-disable-next-line @typescript-eslint/require-await startTransition(async () => { const formData = new FormData(); formData.append('id', currency.id); formAction(formData); }); }} > {currency.label} </DropdownMenu.Item> ))} </DropdownMenu.Content> </DropdownMenu.Portal> </DropdownMenu.Root> );}
Usage
import { Navigation } from '@/vibes/soul/primitives/navigation';const navigationLinks = [ { label: 'Shop All', href: '#', }]function Usage() { return ( <Navigation accountHref="#" cartHref="#" links={navigationLinks} logo="SOUL" searchHref="#" /> );}
API Reference
NavigationProps
Prop | Type | Default |
---|---|---|
className | string | |
isFloating | boolean | false |
accountHref* | string | |
cartCount | Streamable <number | null> | |
cartHref* | string | |
links* | ||
linksPosition | 'center' | 'left' | 'right' | 'center' |
locales | Locale[] | |
activeLocaleId | string | |
localeAction | LocaleAction | |
currencies | Currency[] | |
activeCurrencyId | Streamable<string | undefined> | |
currencyAction | CurrencyAction | |
logo* | Streamable <string | { src: string; alt: string } | null> | |
logoWidth | number | 200 |
logoHeight | number | 40 |
logoHref | string | '/' |
logoLabel | string | 'Home' |
mobileLogo | Streamable <string | { src: string; alt: string } | null> | |
mobileLogoWidth | number | 100 |
mobileLogoHeight | number | `40 |
searchHref* | string | |
searchParamName | string | 'query' |
searchAction | SearchAction<S> | |
searchCtaLabel | string | |
searchInputPlaceholder | string | |
cartLabel | string | 'Cart' |
accountLabel | string | 'Profile' |
openSearchPopupLabel | string | 'Open search popup' |
searchLabel | string | 'Search' |
mobileMenuTriggerLabel | string | 'Toggle navigation' |
Actions
You can find the type defintions for the actions below.
type Action<State, Payload> = ( state: Awaited<State>, payload: Awaited<Payload>,) => State | Promise<State>;type LocaleAction = Action<SubmissionResult | null, FormData>;type CurrencyAction = Action<SubmissionResult | null, FormData>;type SearchAction<S extends SearchResult> = Action< { searchResults: S[] | null; lastResult: SubmissionResult | null; emptyStateTitle?: string; emptyStateSubtitle?: string; }, FormData>;
This component uses Confom to handle form submissions. Refer to the Conform docs for more details.
Locale
Prop | Type | Default |
---|---|---|
id* | string | |
label* | string |
Currency
Prop | Type | Default |
---|---|---|
id* | string | |
label* | string |
CSS Variables
This component supports various CSS variables for theming. Here's a comprehensive list.
:root { --nav-focus: hsl(var(--primary)); --nav-background: hsl(var(--background)); --nav-floating-border: hsl(var(--foreground) / 10%); --nav-link-text: hsl(var(--foreground)); --nav-link-text-hover: hsl(var(--foreground)); --nav-link-background: transparent; --nav-link-background-hover: hsl(var(--contrast-100)); --nav-link-font-family: var(--font-family-body); --nav-group-text: hsl(var(--foreground)); --nav-group-text-hover: hsl(var(--foreground)); --nav-group-background: transparent; --nav-group-background-hover: hsl(var(--contrast-100)); --nav-group-font-family: var(--font-family-body); --nav-sub-link-text: hsl(var(--contrast-500)); --nav-sub-link-text-hover: hsl(var(--foreground)); --nav-sub-link-background: transparent; --nav-sub-link-background-hover: hsl(var(--contrast-100)); --nav-sub-link-font-family: var(--font-family-body); --nav-button-icon: hsl(var(--foreground)); --nav-button-icon-hover: hsl(var(--foreground)); --nav-button-background: hsl(var(--background)); --nav-button-background-hover: hsl(var(--contrast-100)); --nav-menu-background: hsl(var(--background)); --nav-menu-border: hsl(var(--foreground) / 5%); --nav-mobile-background: hsl(var(--background)); --nav-mobile-divider: hsl(var(--contrast-100)); --nav-mobile-button-icon: hsl(var(--foreground)); --nav-mobile-link-text: hsl(var(--foreground)); --nav-mobile-link-text-hover: hsl(var(--foreground)); --nav-mobile-link-background: transparent; --nav-mobile-link-background-hover: hsl(var(--contrast-100)); --nav-mobile-link-font-family: var(--font-family-body); --nav-mobile-sub-link-text: hsl(var(--contrast-500)); --nav-mobile-sub-link-text-hover: hsl(var(--foreground)); --nav-mobile-sub-link-background: transparent; --nav-mobile-sub-link-background-hover: hsl(var(--contrast-100)); --nav-mobile-sub-link-font-family: var(--font-family-body); --nav-search-background: hsl(var(--background)); --nav-search-border: hsl(var(--foreground) / 5%); --nav-search-divider: hsl(var(--foreground) / 5%); --nav-search-icon: hsl(var(--contrast-500)); --nav-search-empty-title: hsl(var(--foreground)); --nav-search-empty-subtitle: hsl(var(--contrast-500)); --nav-search-result-title: hsl(var(--foreground)); --nav-search-result-title-font-family: var(--font-family-mono); --nav-search-result-link-text: hsl(var(--foreground)); --nav-search-result-link-text-hover: hsl(var(--foreground)); --nav-search-result-link-background: hsl(var(--background)); --nav-search-result-link-background-hover: hsl(var(--contrast-100)); --nav-search-result-link-font-family: var(--font-family-body); --nav-cart-count-text: hsl(var(--background)); --nav-cart-count-background: hsl(var(--foreground)); --nav-locale-background: hsl(var(--background)); --nav-locale-link-text: hsl(var(--contrast-400)); --nav-locale-link-text-hover: hsl(var(--foreground)); --nav-locale-link-text-selected: hsl(var(--foreground)); --nav-locale-link-background: transparent; --nav-locale-link-background-hover: hsl(var(--contrast-100)); --nav-locale-link-font-family: var(--font-family-body);}