Animated Multi-Product Header Switcher

Frontend Engineering intermediate Building with AI ~20 min read

Overview

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.

Core Concepts

Data-Driven Brand Configuration

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 Layout Animations

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 for Enter/Exit

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.

Internal vs. External Link Routing

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.

Accessible Navigation Landmarks

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.

How It Works

Brand Configuration Schema

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];
}

Animated Header Component

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">&#8599;</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>
  );
}

Spring-Based Indicator Animation

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
  }}
/>

Practical Applications

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.

Implementation Patterns

Longest-Prefix Matching for Brand Detection

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.

CSS Custom Property Overrides for Brand Colors

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.

AnimatePresence mode=“wait”

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.

Common Pitfalls

Further Reading