Lunia DS

Navigation and workspace

Morphing nav

A top navigation bar with one shared dropdown panel that glides and resizes from item to item, while the new content slides in from the side the pointer moved toward.

Loading preview

A bar with Product, Solutions and Resources (each opens a panel of a different width and height: a grid of four feature links with one-line descriptions, a list of three team types, and two columns of guides) and a plain Pricing link. Replay scripts the pointer from Product to Solutions to Resources and back to Product, then leaves the bar so the panel fades away.

Installation

Run this in your app after the one-time setup on the Getting started page.

$ npx @ganisigit/lunia-ds add morphing-nav

Usage

The demo above, as code.

demo.tsx
'use client';

import * as React from 'react';
import {
  MorphingNav,
  MorphingNavFeature,
  MorphingNavHeading,
  type MorphingNavItem,
  MorphingNavLink,
} from './morphing-nav';

/** Own 16px marks for the demo links. */
const MARKS = {
  chart: 'M2 14h12M4.667 11.333V8M8 11.333V4.667M11.333 11.333V6.667',
  clock: 'M8 14.667A6.667 6.667 0 1 0 8 1.333a6.667 6.667 0 0 0 0 13.334zM8 4v4l2.667 1.333',
  plug: 'M6 1.333v3.334M10 1.333v3.334M4 4.667h8v2.666a4 4 0 0 1-8 0zM8 11.333v3.334',
  lock: 'M3.333 8.667c0-.737.597-1.334 1.334-1.334h6.666c.737 0 1.334.597 1.334 1.334v4c0 .736-.597 1.333-1.334 1.333H4.667a1.333 1.333 0 0 1-1.334-1.333zM5.333 7.333V5.333a2.667 2.667 0 0 1 5.334 0v2',
  rocket:
    'M9.333 10.667 5.333 6.667M4 12l-2 2M11.333 2c-2.666 0-5.333 2-6.666 4.667l4.666 4.666C12 10 14 7.333 14 4.667V2z',
  layers: 'M8 1.333 1.333 4.667 8 8l6.667-3.333zM1.333 11.333 8 14.667l6.667-3.334M1.333 8 8 11.333 14.667 8',
  building:
    'M2.667 14.667V2.667c0-.737.597-1.334 1.333-1.334h5.333c.737 0 1.334.597 1.334 1.334v12M10.667 6h2.666c.737 0 1.334.597 1.334 1.333v7.334M1.333 14.667h13.334M5.333 4.667h2M5.333 7.333h2M5.333 10h2',
};

function Mark({ d }: { d: string }) {
  return (
    <svg
      aria-hidden="true"
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.5"
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d={d} />
    </svg>
  );
}

const ITEMS: MorphingNavItem[] = [
  {
    id: 'product',
    label: 'Product',
    content: (
      <div className="grid grid-cols-2 gap-1">
        <MorphingNavFeature
          href="#analytics"
          icon={<Mark d={MARKS.chart} />}
          title="Analytics"
          description="See usage by team and day"
        />
        <MorphingNavFeature
          href="#automations"
          icon={<Mark d={MARKS.clock} />}
          title="Automations"
          description="Run routine work on a timer"
        />
        <MorphingNavFeature
          href="#integrations"
          icon={<Mark d={MARKS.plug} />}
          title="Integrations"
          description="Connect the tools you use"
        />
        <MorphingNavFeature
          href="#security"
          icon={<Mark d={MARKS.lock} />}
          title="Security"
          description="Control who sees what"
        />
      </div>
    ),
  },
  {
    id: 'solutions',
    label: 'Solutions',
    content: (
      <div className="flex flex-col gap-1">
        <MorphingNavFeature
          href="#startups"
          icon={<Mark d={MARKS.rocket} />}
          title="For startups"
          description="Move fast with a small team"
        />
        <MorphingNavFeature
          href="#agencies"
          icon={<Mark d={MARKS.layers} />}
          title="For agencies"
          description="Run work for many clients"
        />
        <MorphingNavFeature
          href="#enterprise"
          icon={<Mark d={MARKS.building} />}
          title="For enterprise"
          description="Scale with more control"
        />
      </div>
    ),
  },
  {
    id: 'resources',
    label: 'Resources',
    content: (
      <div className="grid w-[356px] grid-cols-2 gap-1">
        <div>
          <MorphingNavHeading>Learn</MorphingNavHeading>
          <MorphingNavLink href="#start">Getting started</MorphingNavLink>
          <MorphingNavLink href="#guides">Guides</MorphingNavLink>
          <MorphingNavLink href="#templates">Templates</MorphingNavLink>
        </div>
        <div>
          <MorphingNavHeading>Support</MorphingNavHeading>
          <MorphingNavLink href="#help">Help center</MorphingNavLink>
          <MorphingNavLink href="#community">Community</MorphingNavLink>
          <MorphingNavLink href="#contact">Contact us</MorphingNavLink>
        </div>
      </div>
    ),
  },
  { id: 'pricing', label: 'Pricing', href: '#pricing' },
];

/** Moves a fake pointer onto an element: React listens to pointerover, Vue to pointerenter. */
function pointAt(el: Element | null | undefined) {
  if (!el) return;
  const init = { bubbles: true, pointerType: 'mouse' } as PointerEventInit;
  el.dispatchEvent(new PointerEvent('pointerover', init));
  el.dispatchEvent(new PointerEvent('pointerenter', { ...init, bubbles: false }));
}

export default function MorphingNavDemo({ replay = 0 }: { replay?: number }) {
  const rootRef = React.useRef<HTMLDivElement>(null);

  // Replay: point at Product, Solutions, Resources and back to Product, then leave the bar.
  React.useEffect(() => {
    if (replay === 0) return;
    const root = rootRef.current;
    const nav = () => root?.querySelector('nav');
    const item = (label: string) =>
      [...(root?.querySelectorAll('nav > ul button, nav > ul a') ?? [])].find(
        (el) => el.textContent?.trim() === label,
      );
    const steps: [number, () => void][] = [
      [0, () => pointAt(nav())],
      [0, () => pointAt(item('Product'))],
      [900, () => pointAt(item('Solutions'))],
      [1800, () => pointAt(item('Resources'))],
      [2700, () => pointAt(item('Product'))],
      [3700, () => nav()?.dispatchEvent(new PointerEvent('pointerleave', { pointerType: 'mouse' }))],
    ];
    const timers = steps.map(([at, run]) => window.setTimeout(run, at));
    return () => {
      for (const timer of timers) window.clearTimeout(timer);
    };
  }, [replay]);

  return (
    <div ref={rootRef} className="flex min-h-[300px] w-full items-start justify-center pt-2">
      <MorphingNav items={ITEMS} />
    </div>
  );
}

API

Parts: nav root, item list, hover highlight (one pill under the pointed item), item trigger (button with chevron, or plain link), chevron, panel (one shared surface), content layer (current), content layer (leaving).

PropTypeDefaultDescription
itemsNavItem[]-Items in order: { id, label, href?, content? }. An item with content opens the panel; an item with only href is a plain link. Required.
collisionPaddingnumber16Space in px the panel keeps from the left and right screen edges.
EventDescription
openChangeFires with the id of the open item, or null when the panel closes.
SlotDescription
contentPer item: what the panel shows. Each content block sets its own width; the panel takes its size.

Motion

Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.

IDTriggerElementPropertyFrom → toTimingReduced motion
MN1pointer or focus moves to an itemhover highlighttransform translateX + widthlast item position and width → new item position and widthduration-lg (320)ease-outMoves at once
MN2pointer enters or leaves the item listhover highlightopacity0 → 1duration-sm (200)ease-exitShows at once
MN3hover or open an itemitem trigger labelcolortext-tertiary → text-primaryduration-sm (200)ease-outSwaps at once
MN4item opens or closeschevrontransform rotate0deg → 180degduration-lg (320)ease-outSwaps at once
MN5first open (Closed -> Open)panelopacity + transform translateY + scale (from the top center)0, -distance-rise, scale-press → 1, 0, 1duration-md (260)ease-outShows at once
MN6pointer moves to another item with content (Open -> Moving)panelleft + width + heightold item content box → new item content box, centered under the item, kept inside the screenduration-xl (380)ease-outResizes at once
MN7pointer moves to another item with content (Open -> Moving)content layer (current)opacity + transform translateX0, distance-slide x direction → 1, 0duration-xl (380)ease-outShows at once
MN8pointer moves to another item with content (Open -> Moving)content layer (leaving)opacity + transform translateX1, 0 → 0, -distance-slide x directionduration-md (260)ease-outHides at once
MN9pointer leaves the nav (after 140ms), Escape, click outside, or focus leavespanelopacity1 → 0duration-sm (200)ease-exitHides at once
MN10pointer leaves the nav (after 140ms), Escape, click outside, or focus leavespaneltransform translateY + scale (from the top center)0, 1 → -distance-rise, scale-pressduration-md (260)ease-outHides at once

Accessibility

  • A nav landmark with an accessible name (default "Main"); items are a list.
  • Items with content are buttons with aria-expanded and aria-controls pointing at the panel; items with only href are plain links.
  • Left and Right arrows move focus between items and wrap; while the panel is open, the panel follows the focused item.
  • Enter, Space or Down opens an item and moves focus to the first link or button in the panel; pressing it again on an open item moves focus into the panel.
  • Escape closes the panel and returns focus to its item; a click outside or focus leaving the nav closes it.
  • The leaving content layer is aria-hidden and inert, so screen readers and Tab only reach the current content.
  • The pointer gets 140ms of grace when it leaves the nav, so a diagonal move to the panel does not close it; touch does not open on hover.
  • Items are 36px tall, above the 24px minimum target; visible focus ring on every item.
  • Reduced motion: the highlight, panel size and content change at once; nothing slides or scales.