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.
Live preview
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-navUsage
The demo above, as code.
'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).
| Prop | Type | Default | Description |
|---|---|---|---|
| items | NavItem[] | - | Items in order: { id, label, href?, content? }. An item with content opens the panel; an item with only href is a plain link. Required. |
| collisionPadding | number | 16 | Space in px the panel keeps from the left and right screen edges. |
| Event | Description |
|---|---|
| openChange | Fires with the id of the open item, or null when the panel closes. |
| Slot | Description |
|---|---|
| content | Per 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.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| MN1 | pointer or focus moves to an item | hover highlight | transform translateX + width | last item position and width → new item position and width | duration-lg (320)ease-out | Moves at once |
| MN2 | pointer enters or leaves the item list | hover highlight | opacity | 0 → 1 | duration-sm (200)ease-exit | Shows at once |
| MN3 | hover or open an item | item trigger label | color | text-tertiary → text-primary | duration-sm (200)ease-out | Swaps at once |
| MN4 | item opens or closes | chevron | transform rotate | 0deg → 180deg | duration-lg (320)ease-out | Swaps at once |
| MN5 | first open (Closed -> Open) | panel | opacity + transform translateY + scale (from the top center) | 0, -distance-rise, scale-press → 1, 0, 1 | duration-md (260)ease-out | Shows at once |
| MN6 | pointer moves to another item with content (Open -> Moving) | panel | left + width + height | old item content box → new item content box, centered under the item, kept inside the screen | duration-xl (380)ease-out | Resizes at once |
| MN7 | pointer moves to another item with content (Open -> Moving) | content layer (current) | opacity + transform translateX | 0, distance-slide x direction → 1, 0 | duration-xl (380)ease-out | Shows at once |
| MN8 | pointer moves to another item with content (Open -> Moving) | content layer (leaving) | opacity + transform translateX | 1, 0 → 0, -distance-slide x direction | duration-md (260)ease-out | Hides at once |
| MN9 | pointer leaves the nav (after 140ms), Escape, click outside, or focus leaves | panel | opacity | 1 → 0 | duration-sm (200)ease-exit | Hides at once |
| MN10 | pointer leaves the nav (after 140ms), Escape, click outside, or focus leaves | panel | transform translateY + scale (from the top center) | 0, 1 → -distance-rise, scale-press | duration-md (260)ease-out | Hides 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.