Menu Morph
The three menu bars become the menu: the button flows into the card and each bar into a row.
Preview
"use client";
import { useState } from "react";
import { useAutoplay } from "../demo-context";
import { MenuMorph } from "./MenuMorph";
const line = (width: string) => (
<span style={{ display: "block", width, height: 10, borderRadius: 999, background: "color-mix(in srgb, var(--fluid-text) 12%, transparent)" }} />
);
export default function Demo() {
const [open, setOpen] = useState(false);
useAutoplay(() => setOpen((o) => !o), 1900);
return (
<div
style={{
position: "relative",
boxSizing: "border-box",
width: "min(100%, 360px)",
height: 300,
padding: 14,
borderRadius: 26,
background: "color-mix(in srgb, var(--fluid-text) 5%, transparent)",
boxShadow: "inset 0 0 0 1px color-mix(in srgb, var(--fluid-text) 10%, transparent)",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", height: 48 }}>
<strong style={{ paddingLeft: 6, color: "var(--fluid-text)", font: "600 16px/1 var(--fluid-font)" }}>Orbe</strong>
<MenuMorph
open={open}
onOpenChange={setOpen}
title="Orbe"
width={268}
items={[{ label: "Home", current: true }, { label: "Orders" }, { label: "My account" }]}
/>
</div>
<div style={{ display: "grid", gap: 10, padding: "22px 6px 0" }}>
{line("70%")}
{line("90%")}
{line("55%")}
</div>
</div>
);
}Installation
npx @usefluid/cli add menu-morphpnpm dlx @usefluid/cli add menu-morphyarn dlx @usefluid/cli add menu-morphbunx @usefluid/cli add menu-morphInstall the engine.
Terminal npm i @usefluid/motionCopy these files into your project.
components/fluid/menu-morph/MenuMorph.tsx "use client"; import { MorphPath } from "@usefluid/motion/react"; import { useEffect, useId, useRef } from "react"; import styles from "./MenuMorph.module.css"; const W = 320; const BUTTON = 48; const PAD = 16; const TOP = 64; const ROW_H = 44; const GAP = 8; const rect = (x: number, y: number, w: number, h: number, r: number) => { const k = Math.min(r, w / 2, h / 2); return `M${x + k} ${y}H${x + w - k}A${k} ${k} 0 0 1 ${x + w} ${y + k}V${y + h - k}A${k} ${k} 0 0 1 ${x + w - k} ${y + h}H${x + k}A${k} ${k} 0 0 1 ${x} ${y + h - k}V${y + k}A${k} ${k} 0 0 1 ${x + k} ${y}Z`; }; const BODY_SPRING = { w: 13, z: 0.8, points: 140, spread: 0.18 }; const BAR_SPRING = { w: 15, z: 0.78, points: 80, spread: 0.12 }; export type MenuItem = { label: string; href?: string; current?: boolean; onSelect?(): void }; type Props = { open: boolean; onOpenChange(open: boolean): void; items: MenuItem[]; title?: string; label?: string; side?: "left" | "right"; width?: number; }; export function MenuMorph({ open, onOpenChange, items, title, label = "Menu", side = "right", width = 320 }: Props) { const id = useId(); const root = useRef<HTMLDivElement>(null); const toggle = useRef<HTMLButtonElement>(null); const height = TOP + items.length * (ROW_H + GAP) + PAD - GAP; const x = (left: number, width: number) => (side === "left" ? left : W - left - width); const bodyClosed = rect(x(0, BUTTON), 0, BUTTON, BUTTON, BUTTON / 2); const bodyOpen = rect(0, 0, W, height, 26); const barsClosed = [15, 22, 29].map((y) => rect(x(13, 22), y, 22, 4, 2)).join(""); const barsOpen = items.map((_, i) => rect(PAD, TOP + i * (ROW_H + GAP), W - PAD * 2, ROW_H, 22)).join(""); useEffect(() => { if (open) return; if (root.current?.contains(document.activeElement) && document.activeElement !== toggle.current) toggle.current?.focus(); }, [open]); useEffect(() => { if (!open) return; const outside = (e: PointerEvent) => { if (!root.current?.contains(e.target as Node)) onOpenChange(false); }; addEventListener("pointerdown", outside); return () => removeEventListener("pointerdown", outside); }, [open, onOpenChange]); return ( <div ref={root} className={styles.root} data-open={open} data-side={side} onBlur={(e) => { if (open && !e.currentTarget.contains(e.relatedTarget as Node | null)) onOpenChange(false); }} onKeyDown={(e) => { if (e.key === "Escape" && open) { e.stopPropagation(); onOpenChange(false); toggle.current?.focus(); } }} > <button ref={toggle} type="button" className={styles.toggle} aria-expanded={open} aria-controls={id} aria-label={open ? `Close ${label.toLowerCase()}` : label} onClick={() => onOpenChange(!open)} /> <div className={styles.card} style={{ aspectRatio: `${W} / ${height}`, width: `min(${width}px, calc(100vw - 32px))` }}> <svg viewBox={`0 0 ${W} ${height}`} className={styles.svg} aria-hidden> <MorphPath d={open ? bodyOpen : bodyClosed} config={BODY_SPRING} className={styles.body} /> <MorphPath d={open ? barsOpen : barsClosed} config={BAR_SPRING} className={styles.bars} /> </svg> {title && <p className={styles.title} aria-hidden>{title}</p>} <ul id={id} className={styles.items} inert={!open}> {items.map((item, i) => ( <li key={item.label} style={{ top: `${((TOP + i * (ROW_H + GAP)) / height) * 100}%`, height: `${(ROW_H / height) * 100}%`, transitionDelay: open ? `${180 + i * 50}ms` : "0ms", }} > {item.href ? ( <a href={item.href} aria-current={item.current ? "page" : undefined} onClick={(e) => { if (item.onSelect && !(e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)) { e.preventDefault(); item.onSelect(); } onOpenChange(false); }} > {item.label} </a> ) : ( <button type="button" aria-current={item.current ? "page" : undefined} onClick={() => { item.onSelect?.(); onOpenChange(false); }}>{item.label}</button> )} </li> ))} </ul> </div> </div> ); }components/fluid/menu-morph/MenuMorph.module.css .root { position: relative; z-index: 20; width: 48px; height: 48px; color: var(--fluid-on-accent); font-family: var(--fluid-font); } .card { position: absolute; top: 0; pointer-events: none; } .root[data-side="right"] .card { right: 0; } .root[data-side="left"] .card { left: 0; } .svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; } .body { fill: var(--fluid-accent); } .bars { fill: var(--fluid-on-accent); fill-opacity: 1; transition: fill-opacity 320ms ease; } .root[data-open="true"] .bars { fill-opacity: 0.14; } .root[data-open="true"] .card { pointer-events: auto; } .toggle { position: absolute; z-index: 2; inset: 0; border: 0; border-radius: 999px; background: none; cursor: pointer; } .toggle::before, .toggle::after { content: ""; position: absolute; top: calc(50% - 1px); left: calc(50% - 9px); width: 18px; height: 2px; border-radius: 2px; background: var(--fluid-on-accent); opacity: 0; transition: opacity 200ms ease, transform 300ms cubic-bezier(0.22, 1, 0.36, 1); } .root[data-open="true"] .toggle::before { opacity: 1; transform: rotate(45deg); transition-delay: 160ms; } .root[data-open="true"] .toggle::after { opacity: 1; transform: rotate(-45deg); transition-delay: 160ms; } .toggle:focus-visible { outline: 2px solid var(--fluid-focus); outline-offset: 2px; } .title { position: absolute; top: 16px; margin: 0; font-size: 15px; font-weight: 600; line-height: 16px; opacity: 0; transform: translateY(-4px); transition: opacity 200ms ease, transform 300ms cubic-bezier(0.22, 1, 0.36, 1); } .root[data-side="right"] .title { left: 20px; } .root[data-side="left"] .title { right: 20px; } .root[data-open="true"] .title { opacity: 1; transform: none; transition-delay: 160ms; } .items { margin: 0; padding: 0; list-style: none; } .items li { position: absolute; left: 5%; right: 5%; opacity: 0; filter: blur(6px); transform: translateY(8px); transition: opacity 240ms ease, filter 240ms ease, transform 360ms cubic-bezier(0.22, 1, 0.36, 1); } .root[data-open="true"] .items li { opacity: 1; filter: blur(0); transform: none; } .items a, .items button { display: flex; align-items: center; box-sizing: border-box; width: 100%; height: 100%; padding: 0 20px; border: 0; border-radius: 999px; background: none; color: inherit; font: 500 16px/1 var(--fluid-font); text-align: left; text-decoration: none; cursor: pointer; } .items [aria-current="page"] { background: color-mix(in srgb, var(--fluid-on-accent) 16%, transparent); } @media (hover: hover) { .items a:hover, .items button:hover { background: color-mix(in srgb, var(--fluid-on-accent) 10%, transparent); } } .items a:focus-visible, .items button:focus-visible { outline: 2px solid var(--fluid-focus); outline-offset: -2px; } @media (prefers-reduced-motion: reduce) { .items li, .title, .bars, .toggle::before, .toggle::after { transition: none; } }Import the theme once, then point its variables at your own tokens.
layout.tsx import "@usefluid/motion/theme.css";
Usage
import { MenuMorph } from "./MenuMorph";
const [open, setOpen] = useState(false);
<MenuMorph
open={open}
onOpenChange={setOpen}
title="Acme"
items={[
{ label: "Home", href: "/" },
{ label: "Orders", href: "/orders" },
{ label: "Help", onSelect: openHelp },
]}
/>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Whether the card is open. |
onOpenChange | (open) => void | — | Called on the button, Esc, a tap outside or choosing an item. |
items | { label, href?, onSelect? }[] | — | Any number of rows. With href a row is a link. |
title | string | — | Heading shown on the open card. |
label | string | "Menu" | Accessible name of the button. |
side | "left" | "right" | "right" | The side the card opens towards. Put the menu at that edge of your header. |
width | number | 320 | Width of the open card in px; it never exceeds the screen. |
Keyboard and screen readers
The button has aria-expanded. Esc and a tap outside close it; focus goes back to the button, unless an item's onSelect moved it somewhere on purpose.
Notes
Closed, it takes 48 x 48. Open, the card floats over the page (z-index 20), so place the menu in your header and nothing moves.
Every colour comes from the --fluid-* variables, and the piece is yours to change.
How it is made
Two contours of the engine. The round button flows to the size of the card, and the three bars each stretch into a row of the menu, all on springs with slightly different frequencies. Rows beyond three are born from a point. Then the words come out of a blur, one at a time. Esc, a tap outside or choosing an item closes it, and focus goes back to the button.
For your agent
For your agent
Add Menu Morph to my project with the Fluid library. Use MenuMorph as the mobile menu of my product, with its real sections. Read my project first. Use my logo, my colours and my font through the --fluid-* variables. Keep 44px touch targets, visible focus and reduced motion. If I have no SVG logo, ask before making one up.