Multi-product companies face a recurring UX challenge: the global navigation header must adapt to whichever product the user is currently viewing while maintaining a consistent brand identity. A naive approach — hardcoding separate headers per product — leads to duplicated code, inconsistent behavior, and fragile layout coupling. An animated header switcher solves this by treating each product’s branding (logo, color accent, navigation links) as a data-driven configuration and using Framer Motion’s layout animations to smoothly transition between them as the user navigates across products.
As a concrete example, consider a multi-product marketing site that pivots between three brands: CoreDB (the primary database product), Indexer (the distributed indexing engine), and SearchAPI (the managed search API). Each brand has distinct navigation links — some pointing to internal pages under the same Next.js app, others pointing to external product sites. The header detects the current product context from the URL pathname, loads the appropriate brand configuration, and animates the transition using Framer Motion’s AnimatePresence and layoutId directives. Accessibility semantics are preserved throughout — the header uses proper <nav> landmarks, ARIA labels, and keyboard-navigable menus regardless of which brand is active.
This pattern matters beyond aesthetics. A well-executed product switcher reduces cognitive load for users who work across multiple tools in the same ecosystem. The animation provides continuity — the user understands they’ve moved to a different product context without losing their sense of place in the overall navigation hierarchy.
Rather than building separate header components per product, a single header component consumes a brand configuration object that defines the logo, accent color, navigation links, and external URLs. Switching products means swapping the config — the component tree remains identical. This pattern ensures structural consistency (same DOM, same accessibility attributes) while allowing complete visual differentiation.
Framer Motion’s layoutId prop enables shared-layout animations: two elements with the same layoutId in different render states will animate position and size between them automatically. For the header switcher, the active product indicator (an underline or highlight) carries a layoutId so it smoothly slides from one nav item to another when the product changes, rather than disappearing and reappearing.
AnimatePresence wraps components that mount and unmount, enabling exit animations. Without it, React immediately removes DOM nodes on unmount — there’s no opportunity to animate out. The header uses AnimatePresence around the brand-specific content (logo, product name) so the outgoing brand fades/slides out while the incoming brand fades/slides in, creating a crossfade effect.
In a multi-product header, some links are internal Next.js routes (handled by next/link for client-side navigation) and others are external URLs pointing to separate product domains. The brand config distinguishes these with an external: boolean flag, and the header renders <Link> for internal routes and <a target="_blank" rel="noopener"> for external ones. This prevents full-page reloads for same-app navigation while correctly opening cross-domain links.
The header wraps all navigation in a <nav aria-label="Main navigation"> landmark. When the product switches, the ARIA label updates to reflect the active product (e.g., “CoreDB main navigation”). Screen readers announce the context change, and keyboard focus management ensures that switching products doesn’t trap or lose focus.
Each product brand is defined as a typed configuration object. The header component selects the active config based on the current URL pathname.
// lib/brands.ts
interface NavLink {
label: string;
href: string;
external?: boolean;
}
interface BrandConfig {
id: string;
name: string;
logo: string; // path to SVG logo
accentColor: string; // CSS custom property override
navLinks: NavLink[];
pathPrefix: string; // URL prefix that activates this brand
}
export const brands: BrandConfig[] = [
{
id: 'coredb',
name: 'CoreDB',
logo: '/logos/coredb.svg',
accentColor: 'oklch(0.55 0.25 264)',
pathPrefix: '/',
navLinks: [
{ label: 'Documentation', href: '/docs' },
{ label: 'Pricing', href: '/pricing' },
{ label: 'Dashboard', href: 'https://app.example.io', external: true },
],
},
{
id: 'indexer',
name: 'Indexer',
logo: '/logos/indexer.svg',
accentColor: 'oklch(0.62 0.19 145)',
pathPrefix: '/indexer',
navLinks: [
{ label: 'Overview', href: '/indexer' },
{ label: 'API Reference', href: '/indexer/api' },
{ label: 'GitHub', href: 'https://github.com/example-org/indexer', external: true },
],
},
{
id: 'searchapi',
name: 'SearchAPI',
logo: '/logos/searchapi.svg',
accentColor: 'oklch(0.65 0.16 175)',
pathPrefix: '/searchapi',
navLinks: [
{ label: 'Features', href: '/searchapi' },
{ label: 'Integrations', href: '/searchapi/integrations' },
{ label: 'Console', href: 'https://console.example.com', external: true },
],
},
];
export function getActiveBrand(pathname: string): BrandConfig {
// Match longest prefix first to avoid '/' matching everything
const sorted = [...brands].sort(
(a, b) => b.pathPrefix.length - a.pathPrefix.length
);
return sorted.find(b => pathname.startsWith(b.pathPrefix)) ?? brands[0];
}
The header uses AnimatePresence for crossfading the brand logo and name, and motion.div with layoutId for the active product indicator.
// components/Header.tsx
'use client';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
import { motion, AnimatePresence } from 'framer-motion';
import { brands, getActiveBrand, type BrandConfig } from '@/lib/brands';
function NavItem({
link,
isActive,
}: {
link: BrandConfig['navLinks'][number];
isActive: boolean;
}) {
const className = `nav-link ${isActive ? 'active' : ''}`;
if (link.external) {
return (
<a href={link.href} target="_blank" rel="noopener noreferrer"
className={className}>
{link.label}
<span className="external-icon" aria-hidden="true">↗</span>
</a>
);
}
return <Link href={link.href} className={className}>{link.label}</Link>;
}
export function Header() {
const pathname = usePathname();
const brand = getActiveBrand(pathname);
return (
<header
className="site-header"
style={{ '--brand-accent': brand.accentColor } as React.CSSProperties}
>
<div className="header-inner">
{/* Brand logo with crossfade animation */}
<AnimatePresence mode="wait">
<motion.div
key={brand.id}
className="brand-block"
initial={{ opacity: 0, x: -12 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 12 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
>
<img src={brand.logo} alt={`${brand.name} logo`} width={32} height={32} />
<span className="brand-name">{brand.name}</span>
</motion.div>
</AnimatePresence>
{/* Product switcher tabs */}
<nav className="product-tabs" role="tablist" aria-label="Product">
{brands.map(b => (
<Link key={b.id} href={b.pathPrefix} role="tab"
aria-selected={b.id === brand.id}
className={`tab ${b.id === brand.id ? 'active' : ''}`}>
{b.name}
{b.id === brand.id && (
<motion.div className="tab-indicator" layoutId="tab-indicator"
transition={{ type: 'spring', stiffness: 380, damping: 30 }} />
)}
</Link>
))}
</nav>
{/* Navigation links for active brand */}
<nav aria-label={`${brand.name} main navigation`}>
<AnimatePresence mode="wait">
<motion.ul key={brand.id} className="nav-links"
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
{brand.navLinks.map(link => (
<li key={link.href}>
<NavItem link={link} isActive={pathname === link.href} />
</li>
))}
</motion.ul>
</AnimatePresence>
</nav>
</div>
</header>
);
}
The product tab indicator uses a spring physics animation rather than a duration-based tween. Spring animations feel more natural because they overshoot slightly and settle, mimicking physical motion. The stiffness and damping parameters control how snappy the animation feels — higher stiffness means faster movement, higher damping reduces oscillation.
// The tab indicator configuration in detail
<motion.div
className="tab-indicator"
layoutId="tab-indicator"
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 2,
background: 'var(--brand-accent)',
borderRadius: 1,
}}
transition={{
type: 'spring',
stiffness: 380, // High stiffness = fast snap
damping: 30, // Moderate damping = slight overshoot
mass: 0.8, // Lighter mass = quicker response
}}
/>
In a multi-product marketing site using this pattern, the header sits at the top of every page. When a visitor navigates from the CoreDB homepage to the Indexer documentation section, the header smoothly transitions: the logo crossfades from the CoreDB icon to the Indexer icon, the product tab indicator slides from “CoreDB” to “Indexer,” the navigation links fade and swap, and the accent color shifts from indigo to green. The entire transition takes under 300ms and maintains full keyboard navigability throughout.
The external link distinction is critical for the dashboard and console links. Clicking “Dashboard” in the CoreDB header opens app.example.io in a new tab (it’s a separate React app), while clicking “Documentation” does a client-side Next.js navigation within the same app. Without the external flag routing, Next.js would try to resolve external URLs as internal routes and show a 404.
The brand configuration also drives the footer, mobile menu, and meta tags — a single source of truth for all product-aware UI elements. When SearchAPI was added as a third product, the change was entirely additive: a new entry in the brands array, a new logo SVG, and a new set of nav links. No header component code was modified.
The brand detection function sorts by prefix length descending before matching. This ensures /searchapi/integrations matches the SearchAPI brand (prefix /searchapi) rather than the CoreDB brand (prefix /). Without this sorting, the root prefix would match every URL.
Rather than maintaining separate CSS files per brand, the header sets --brand-accent as an inline style on the header element. All descendant styles reference var(--brand-accent) for hover states, focus rings, and the tab indicator. This keeps the color switching in a single place and avoids class-name gymnastics.
The mode="wait" prop ensures the exit animation completes before the enter animation begins. Without it, both the outgoing and incoming elements render simultaneously during the transition, causing layout shifts and overlapping content. For the brand logo crossfade, “wait” mode creates a clean sequential transition.
layoutId simultaneously (e.g., due to a rendering bug where two tabs both think they’re active), Framer Motion renders both and the animation breaks. Always ensure exactly one element has the layoutId at any time — conditionally render it only for the active tab.target="_blank" without rel="noopener noreferrer" gives the opened page access to window.opener, which is a security risk (the external page could redirect the opening tab). Always include both attributes on external links.AnimatePresence unmounts the old nav links and mounts new ones, keyboard focus can jump to the document body. Manage focus explicitly — after the brand transition completes, move focus to the first nav link in the new brand’s menu or back to the product tab that triggered the switch.prefers-reduced-motion: reduce set at the OS level should not see spring animations or crossfades. Framer Motion respects this preference when you set reducedMotion="user" on the MotionConfig provider, but you must explicitly wrap the header in this provider or it defaults to playing all animations.usePathname() is only available on the client. If the header is server-rendered, the server doesn’t know the current path and may render the wrong brand. Mark the header with 'use client' and handle the initial render gracefully to avoid hydration mismatches.