diff --git a/apps/docs/content/2.block/navbar-gooey-bloom.mdx b/apps/docs/content/2.block/navbar-gooey-bloom.mdx new file mode 100644 index 0000000..a70282c --- /dev/null +++ b/apps/docs/content/2.block/navbar-gooey-bloom.mdx @@ -0,0 +1,169 @@ +--- +title: Navbar Gooey Bloom +description: A navbar whose hover highlight is a pool of lime that stretches between links, with menus that swell out of a drop. +icon: blend +--- + +A glass navbar with **liquid for a highlight**. Point at a link and a lime pool +**stretches across the bar**: its front edge runs ahead, its back edge drags a +droplet behind it, and the label **inverts to ink** as the pool arrives. Every +menu is a **gooey panel** that swells out of a small drop under the trigger, +keeps a soft bulge pointed at it, and **leans and stretches** when you move to +the next menu. Inside, a lime blob **flows from icon to icon** as you move down +the list. The bar wears the Tween UI mark and uses the Dot Bloom Button for its action. GSAP, a +small spring loop and three SVG filters. + +::component-preview{name="navbar-gooey-bloom"} +:: + +## Installation + +::tabs +:::tabs-item{label="CLI" icon="i-lucide-terminal"} + +::install-tabs{item="navbar-gooey-bloom"} +:: + +::: +:::tabs-item{label="Manual" icon="i-lucide-clipboard"} + +::component-source{name="navbar-gooey-bloom"} +:: + +::: +:: + +## Usage + +::tabs +:::tabs-item{label="React" icon="i-lucide-atom"} + +```tsx [app/page.tsx] +import NavbarGooeyBloom from '@/components/tweenui/navbar-gooey-bloom'; + +export default function Page() { + return ; +} +``` + +::: +:: + +It arrives dressed as a Tween UI demo, with three mega menus, one plain link +and a single hero as the page. Put your own page inside it as `children`: + +```tsx [app/layout.tsx] +'use client'; + +import { useRouter } from 'next/navigation'; +import NavbarGooeyBloom from '@/components/tweenui/navbar-gooey-bloom'; + +export default function Layout({ children }: { children: React.ReactNode }) { + const router = useRouter(); + + return ( + router.push('/signup')} + menus={[ + { + label: 'Product', + columns: [ + [ + { + label: 'Overview', + href: '/product', + description: 'What it does, in one screen.', + icon: 'bloom', + }, + { + label: 'Pricing', + href: '/pricing', + description: 'Three plans, no surprises.', + icon: 'layers', + }, + ], + ], + feature: { + title: 'What is new', + description: 'The changelog, kept short.', + href: '/changelog', + cta: 'Read it', + }, + }, + { label: 'Docs', href: '/docs' }, + ]} + > + {children} + + ); +} +``` + +A menu is `{ label, columns?, feature?, href? }`. Give it `columns` (an array +of columns, each an array of links) to make it a panel; give it only `href` to +make it a plain link. A link is `{ label, href, description?, icon? }`, with +`icon` one of `'bloom'`, `'drop'`, `'orbit'`, `'spark'`, `'layers'`, `'wave'`, +`'bolt'` or `'shield'`. A feature is `{ title, description, href, cta?, image? }`; +without an image it draws its own gooey bloom mark. + +## Props + +| Prop | Type | Default | Description | +| --------------- | -------------------------------------- | ----------------- | ---------------------------------------------- | +| `brand` | `string` | `'Tween UI'` | Name beside the logo, and the section's label. | +| `logo` | `ReactNode` | The Tween UI mark | Shown before the brand name. | +| `homeHref` | `string` | `'/'` | Where the logo links. | +| `menus` | `NavbarGooeyBloomMenu[]` | Three + Journal | Bar links, in order, with their panels. | +| `currentHref` | `string` | `'/'` | The page you are on; nested paths count too. | +| `actionLabel` | `string` | `'Get started'` | Label of the Dot Bloom button in the bar. | +| `onActionClick` | `MouseEventHandler` | — | Runs when the action button is pressed. | +| `children` | `ReactNode` | A sample hero | The page under the bar. | + +## Behavior + +- **The pool runs on springs, not tweens.** Each edge of the highlight is a + critically damped spring. The edge facing the direction of travel is pulled + harder than the one behind it, so the pool stretches and thins while it moves + and gathers back under the label when it stops. Retargeting keeps the + current velocity, so sweeping across the bar never jerks. +- **One droplet, never loose.** A single drop sits on the pool's centre line + inside a `feGaussianBlur` + `feColorMatrix` layer. It lags the pool on a + softer spring but is clamped to its body, so it shows as a tail on the + trailing end and is absorbed when the pool settles. +- **Menus swell out of a drop.** The panel starts as a small blob under the + trigger and springs out to its full width and height. A goo filter merges the + panel with a bulge on its top edge that follows the pool, so the panel always + leans toward its trigger. Its edges also lead and trail when you switch + menus, so the panel stretches across before it settles. +- **Contents are cut to the liquid.** The rows are clipped to the panel's + current shape every frame, so they are revealed as it grows. Items rise + 10px in, 20ms apart. +- **Rows share one lime blob.** A single blob behind the icons follows your + pointer from row to row with the same leading and trailing springs, thinning + as it stretches and dragging a small drop. A soft surface tracks the row, the + icon inverts to ink and a lime arrow slides in beside the label. +- **The sample fits the block.** The hero uses `container-type: size` and + `cqh` type steps, so it fills the stage at any height without an inner + scrollbar. On pages taller than the block, the bar hides after 220px of + downward travel and returns on the way up, and never hides while a panel is + open. Scroll position is read from the block's own scroller. +- **Below 1024px** the links fold into a full-screen sheet with accordion + menus, the same Dot Bloom action and the same icons as the panels. + +## Accessibility + +::note +Triggers are real buttons with `aria-expanded`, `aria-controls` and +`aria-haspopup`, and the panel is `inert` while closed, so the 20-odd links +inside it never enter the tab order unseen. Focus opens a menu and moves the +row blob, the header's `focusout` closes it when focus leaves, Escape closes it +and returns focus to the trigger. The mobile sheet is `inert` when closed, +traps its own scroll, closes on Escape and hands focus back to the toggle. +Labels keep contrast because the pool inverts text to ink (`#03110f` on +`#c6f56f`). Under `prefers-reduced-motion: reduce` the springs settle at once, +the intro and sheet animations are skipped, and every menu still opens and +closes. +:: diff --git a/apps/docs/public/r/navbar-gooey-bloom-demo.json b/apps/docs/public/r/navbar-gooey-bloom-demo.json new file mode 100644 index 0000000..61a3294 --- /dev/null +++ b/apps/docs/public/r/navbar-gooey-bloom-demo.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "navbar-gooey-bloom-demo", + "type": "registry:example", + "title": "Navbar Gooey Bloom Demo", + "description": "Usage examples for Navbar Gooey Bloom.", + "registryDependencies": [ + "https://tween-ui.vercel.app/r/navbar-gooey-bloom.json" + ], + "files": [ + { + "path": "examples/navbar-gooey-bloom-demo.tsx", + "type": "registry:example", + "content": "import NavbarGooeyBloom from '@/components/tweenui/navbar-gooey-bloom';\n\nexport default function Page() {\n return ;\n}" + }, + { + "path": "examples/navbar-gooey-bloom-demo-2.tsx", + "type": "registry:example", + "content": "'use client';\n\nimport { useRouter } from 'next/navigation';\nimport NavbarGooeyBloom from '@/components/tweenui/navbar-gooey-bloom';\n\nexport default function Layout({ children }: { children: React.ReactNode }) {\n const router = useRouter();\n\n return (\n router.push('/signup')}\n menus={[\n {\n label: 'Product',\n columns: [\n [\n {\n label: 'Overview',\n href: '/product',\n description: 'What it does, in one screen.',\n icon: 'bloom',\n },\n {\n label: 'Pricing',\n href: '/pricing',\n description: 'Three plans, no surprises.',\n icon: 'layers',\n },\n ],\n ],\n feature: {\n title: 'What is new',\n description: 'The changelog, kept short.',\n href: '/changelog',\n cta: 'Read it',\n },\n },\n { label: 'Docs', href: '/docs' },\n ]}\n >\n {children}\n \n );\n}" + } + ] +} \ No newline at end of file diff --git a/apps/docs/public/r/navbar-gooey-bloom.json b/apps/docs/public/r/navbar-gooey-bloom.json new file mode 100644 index 0000000..8582e55 --- /dev/null +++ b/apps/docs/public/r/navbar-gooey-bloom.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "navbar-gooey-bloom", + "type": "registry:block", + "title": "Navbar Gooey Bloom", + "description": "A glass navbar whose hover highlight is liquid: a lime pool stretches from link to link with a droplet dragging behind, while the label inverts to ink. Menus swell out of a drop into one gooey panel that leans toward its trigger and morphs between layouts, and inside it a lime blob flows from icon to icon.", + "dependencies": [ + "gsap", + "@gsap/react" + ], + "registryDependencies": [ + "https://tween-ui.vercel.app/r/dot-bloom-button.json" + ], + "cssVars": { + "theme": {} + }, + "files": [ + { + "path": "components/tweenui/navbar-gooey-bloom.tsx", + "type": "registry:component", + "target": "components/tweenui/navbar-gooey-bloom.tsx", + "content": "'use client';\n\nimport {\n useCallback,\n useEffect,\n useId,\n useRef,\n useState,\n type ComponentPropsWithoutRef,\n type MouseEventHandler,\n type ReactNode,\n} from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { cn } from '@/lib/utils';\nimport DotBloomButton from './dot-bloom-button';\n\ngsap.registerPlugin(useGSAP);\n\nconst prefersReducedMotion = () =>\n typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nexport type NavbarGooeyBloomIcon =\n 'bloom' | 'drop' | 'orbit' | 'spark' | 'layers' | 'wave' | 'bolt' | 'shield';\n\nexport interface NavbarGooeyBloomLink {\n label: string;\n href: string;\n description?: string;\n icon?: NavbarGooeyBloomIcon;\n}\n\nexport interface NavbarGooeyBloomFeature {\n title: string;\n description: string;\n href: string;\n cta?: string;\n image?: string;\n}\n\nexport interface NavbarGooeyBloomMenu {\n label: string;\n href?: string;\n columns?: NavbarGooeyBloomLink[][];\n feature?: NavbarGooeyBloomFeature;\n}\n\nexport interface NavbarGooeyBloomProps extends Omit<\n ComponentPropsWithoutRef<'section'>,\n 'children'\n> {\n brand?: string;\n logo?: ReactNode;\n homeHref?: string;\n menus?: NavbarGooeyBloomMenu[];\n currentHref?: string;\n actionLabel?: string;\n onActionClick?: MouseEventHandler;\n children?: ReactNode;\n}\n\nconst CLOSE_DELAY = 160;\nconst HOVER_INTENT = 70;\nconst HIDE_AFTER = 220;\nconst PANEL_GAP = 12;\nconst PANEL_RADIUS = 26;\nconst DROP_SIZE = 22;\nconst EDGE_LEAD = 520;\nconst EDGE_TRAIL = 170;\nconst TAIL_PULL = 110;\nconst PANEL_LEAD = 300;\nconst PANEL_TRAIL = 140;\nconst PANEL_PULL = 260;\nconst BUMP_RADIUS = 14;\nconst BUMP_RISE = 6;\nconst BUMP_PULL = 150;\nconst ICON_SIZE = 32;\nconst ICON_DROP = 14;\nconst SCROLL_TOLERANCE = 6;\nconst DESKTOP_QUERY = '(min-width: 1024px)';\n\nconst CONTAINER = 'mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8';\nconst EASE_OUT_EXPO = 'ease-[cubic-bezier(0.16,1,0.3,1)]';\nconst focusRing =\n 'focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#c6f56f]';\n\nconst TWEEN_MARK = (\n \n \n \n \n \n);\n\nconst ICON_PATHS: Record = {\n bloom: (\n <>\n \n \n \n \n ),\n drop: ,\n orbit: (\n <>\n \n \n \n ),\n spark: ,\n layers: (\n <>\n \n \n \n ),\n wave: ,\n bolt: ,\n shield: ,\n};\n\nconst DEFAULT_MENUS: NavbarGooeyBloomMenu[] = [\n {\n label: 'Work',\n columns: [\n [\n {\n label: 'Case studies',\n href: '#',\n description: 'Shipped products, start to finish.',\n icon: 'bloom',\n },\n {\n label: 'Experiments',\n href: '#',\n description: 'Small ideas, pushed too far.',\n icon: 'spark',\n },\n {\n label: 'Playground',\n href: '#',\n description: 'Live code you can poke at.',\n icon: 'orbit',\n },\n {\n label: 'Archive',\n href: '#',\n description: 'Everything since 2019.',\n icon: 'layers',\n },\n ],\n [\n {\n label: 'Motion kit',\n href: '#',\n description: 'Easing, springs and timing.',\n icon: 'wave',\n },\n {\n label: 'Interface kit',\n href: '#',\n description: 'Bars, panels and overlays.',\n icon: 'drop',\n },\n {\n label: 'Brand systems',\n href: '#',\n description: 'Marks that know how to move.',\n icon: 'shield',\n },\n {\n label: 'Performance',\n href: '#',\n description: 'Sixty frames, no excuses.',\n icon: 'bolt',\n },\n ],\n ],\n feature: {\n title: 'Bloom 2.0',\n description: 'The metaball engine under this bar, documented end to end.',\n href: '#',\n cta: 'Read the notes',\n },\n },\n {\n label: 'Components',\n columns: [\n [\n {\n label: 'Navbars',\n href: '/components',\n description: 'Eleven ways to hold a menu.',\n icon: 'layers',\n },\n {\n label: 'Buttons',\n href: '/components',\n description: 'Press, roll, bloom.',\n icon: 'drop',\n },\n {\n label: 'Carousels',\n href: '/components',\n description: 'Arcs, shutters and reels.',\n icon: 'orbit',\n },\n {\n label: 'Loaders',\n href: '/components',\n description: 'Waiting, but on purpose.',\n icon: 'spark',\n },\n {\n label: 'Type',\n href: '/components',\n description: 'Letters that misbehave.',\n icon: 'wave',\n },\n ],\n ],\n },\n {\n label: 'Studio',\n columns: [\n [\n { label: 'About', href: '#', description: 'Four people, one room.', icon: 'bloom' },\n { label: 'Team', href: '#', description: 'Who makes what.', icon: 'shield' },\n { label: 'Careers', href: '#', description: 'Two seats open.', icon: 'bolt' },\n { label: 'Press', href: '#', description: 'Logos and words.', icon: 'layers' },\n ],\n [\n { label: 'Contact', href: '#', description: 'Say hello properly.', icon: 'drop' },\n {\n label: 'Changelog',\n href: '#',\n description: 'What moved this week.',\n icon: 'wave',\n },\n { label: 'License', href: '#', description: 'What you may ship.', icon: 'orbit' },\n {\n label: 'Support',\n href: '#',\n description: 'We answer on weekdays.',\n icon: 'spark',\n },\n ],\n ],\n },\n { label: 'Journal', href: '#' },\n];\n\nconst cleanHref = (href: string) => href.split(/[?#]/)[0]?.replace(/\\/+$/, '') || '/';\n\nconst isExternal = (href: string) => /^https?:/.test(href);\n\nconst externalProps = (href: string) =>\n isExternal(href) ? { target: '_blank', rel: 'noopener noreferrer' } : {};\n\nconst clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);\n\nconst panelWidth = (menu: NavbarGooeyBloomMenu) => {\n const columns = menu.columns?.length ?? 0;\n if (menu.feature) return columns > 1 ? 880 : 720;\n return columns > 1 ? 720 : 360;\n};\n\nconst panelGrid = (menu: NavbarGooeyBloomMenu) => {\n const columns = menu.columns?.length ?? 0;\n if (menu.feature) return columns > 1 ? 'grid-cols-[1fr_1fr_15rem]' : 'grid-cols-[1fr_15rem]';\n return columns > 1 ? 'grid-cols-2' : 'grid-cols-1';\n};\n\ninterface Spring {\n x: number;\n v: number;\n t: number;\n}\n\ninterface GooMotion {\n left: Spring;\n right: Spring;\n tail: Spring;\n panelLeft: Spring;\n panelRight: Spring;\n panelHeight: Spring;\n bump: Spring;\n rowTop: Spring;\n rowBottom: Spring;\n rowLeft: Spring;\n rowWidth: Spring;\n iconTop: Spring;\n iconBottom: Spring;\n iconTail: Spring;\n iconOffset: number;\n look: { scale: number; alpha: number };\n hover: { alpha: number };\n pillShown: boolean;\n panelShown: boolean;\n hoverShown: boolean;\n barBottom: number;\n awake: boolean;\n}\n\nconst spring = (): Spring => ({ x: 0, v: 0, t: 0 });\n\nconst place = (target: Spring, value: number) => {\n target.x = value;\n target.v = 0;\n target.t = value;\n};\n\nconst snap = (target: Spring) => {\n target.x = target.t;\n target.v = 0;\n};\n\nconst stepSpring = (target: Spring, stiffness: number, dt: number) => {\n const force = stiffness * (target.t - target.x) - 2 * Math.sqrt(stiffness) * target.v;\n target.v += force * dt;\n target.x += target.v * dt;\n};\n\nconst atRest = (target: Spring, epsilon: number) =>\n Math.abs(target.t - target.x) < epsilon && Math.abs(target.v) < epsilon;\n\nconst createMotion = (): GooMotion => ({\n left: spring(),\n right: spring(),\n tail: spring(),\n panelLeft: spring(),\n panelRight: spring(),\n panelHeight: spring(),\n bump: spring(),\n rowTop: spring(),\n rowBottom: spring(),\n rowLeft: spring(),\n rowWidth: spring(),\n iconTop: spring(),\n iconBottom: spring(),\n iconTail: spring(),\n iconOffset: 0,\n look: { scale: 0.4, alpha: 0 },\n hover: { alpha: 0 },\n pillShown: false,\n panelShown: false,\n hoverShown: false,\n barBottom: 60,\n awake: false,\n});\n\nconst springsOf = (motion: GooMotion) => [\n motion.left,\n motion.right,\n motion.tail,\n motion.panelLeft,\n motion.panelRight,\n motion.panelHeight,\n motion.bump,\n motion.rowTop,\n motion.rowBottom,\n motion.rowLeft,\n motion.rowWidth,\n motion.iconTop,\n motion.iconBottom,\n motion.iconTail,\n];\n\nconst settleMotion = (motion: GooMotion) => {\n const center = (motion.left.t + motion.right.t) / 2;\n motion.tail.t = center;\n motion.bump.t = center;\n motion.iconTail.t = (motion.iconTop.t + motion.iconBottom.t) / 2;\n springsOf(motion).forEach(snap);\n};\n\nconst motionAtRest = (motion: GooMotion) =>\n springsOf(motion).every((item) => atRest(item, 0.05)) &&\n !gsap.isTweening(motion.look) &&\n !gsap.isTweening(motion.hover);\n\nconst steer = (start: Spring, end: Spring) => {\n const heading = start.t + end.t - start.x - end.x;\n return {\n start: heading > 1 ? EDGE_TRAIL : EDGE_LEAD,\n end: heading < -1 ? EDGE_TRAIL : EDGE_LEAD,\n };\n};\n\nconst px = (value: number) => Math.round(value * 100) / 100;\n\nconst roundedRect = (left: number, right: number, top: number, height: number) => {\n const radius = px(Math.max(0, Math.min(PANEL_RADIUS, (right - left) / 2, height / 2)));\n const bottom = px(top + height);\n const l = px(left);\n const r = px(right);\n return `M${px(l + radius)},${top}H${px(r - radius)}A${radius},${radius} 0 0 1 ${r},${px(top + radius)}V${px(bottom - radius)}A${radius},${radius} 0 0 1 ${px(r - radius)},${bottom}H${px(l + radius)}A${radius},${radius} 0 0 1 ${l},${px(bottom - radius)}V${px(top + radius)}A${radius},${radius} 0 0 1 ${px(l + radius)},${top}Z`;\n};\n\nconst ArrowIcon = ({ className }: { className?: string }) => {\n return (\n \n \n \n );\n};\n\nconst ChevronIcon = ({ className }: { className?: string }) => {\n return (\n \n \n \n );\n};\n\nconst MenuGlyph = ({ icon, className }: { icon: NavbarGooeyBloomIcon; className?: string }) => {\n return (\n \n {ICON_PATHS[icon]}\n \n );\n};\n\nconst FeatureArt = () => {\n const rawId = useId().replace(/[^a-zA-Z0-9_-]/g, '');\n const gooId = `feature-goo-${rawId}`;\n const limeId = `feature-lime-${rawId}`;\n\n return (\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n );\n};\n\nconst SamplePage = () => {\n return (\n
\n \n \n\n
\n

\n \n Metaball navigation\n

\n

\n The menu that melts between links.\n

\n

\n Point at a link. A pool of lime stretches across the bar, sheds a few droplets on the way,\n and settles under the label. Open a menu and the card below re-forms around it.\n

\n
\n \n See it again\n \n \n \n Read the props\n \n
\n
\n
\n );\n};\n\nconst NavbarGooeyBloom = ({\n brand = 'Tween UI',\n logo = TWEEN_MARK,\n homeHref = '/',\n menus = DEFAULT_MENUS,\n currentHref = '/',\n actionLabel = 'Get started',\n onActionClick,\n children,\n className,\n ...props\n}: NavbarGooeyBloomProps) => {\n const [openKey, setOpenKey] = useState(null);\n const [mobileOpen, setMobileOpen] = useState(false);\n const [accordion, setAccordion] = useState(null);\n\n const rawId = useId().replace(/[^a-zA-Z0-9_-]/g, '');\n const gooId = `goo-${rawId}`;\n const linkGooId = `link-goo-${rawId}`;\n const panelGooId = `panel-goo-${rawId}`;\n const panelId = `panel-${rawId}`;\n const mobileId = `mobile-${rawId}`;\n\n const rootRef = useRef(null);\n const barRef = useRef(null);\n const shellRef = useRef(null);\n const scrollerRef = useRef(null);\n const panelRef = useRef(null);\n const bodyRef = useRef(null);\n const shapeRef = useRef(null);\n const bumpRef = useRef(null);\n const hoverRef = useRef<{ row: HTMLElement; blob: HTMLElement; drop: HTMLElement } | null>(null);\n const pillRef = useRef(null);\n const coreRef = useRef(null);\n const dropRef = useRef(null);\n const inkRefs = useRef>([]);\n const inkBoxes = useRef>([]);\n const motion = useRef(createMotion());\n const activeRef = useRef(null);\n const sectionRefs = useRef>({});\n const triggerRefs = useRef>({});\n const closeTimer = useRef(null);\n const hoverTimer = useRef(null);\n const pendingHover = useRef<{ menu: NavbarGooeyBloomMenu; el: HTMLElement } | null>(null);\n const pointerPos = useRef({ x: 0, y: 0 });\n const mobileRef = useRef(null);\n const toggleRef = useRef(null);\n const mobileCloseRef = useRef(null);\n const openRef = useRef(null);\n const skipFocusOpen = useRef(false);\n\n const current = (href: string) => {\n if (href.startsWith('#')) return false;\n const link = cleanHref(href);\n const page = cleanHref(currentHref);\n return link === page || (link !== '/' && page.startsWith(`${link}/`));\n };\n\n const { contextSafe } = useGSAP(\n () => {\n if (prefersReducedMotion()) return;\n const bar = barRef.current;\n if (!bar) return;\n const intro = bar.querySelectorAll('[data-intro]');\n gsap.fromTo(\n bar,\n { y: -28, autoAlpha: 0 },\n {\n y: 0,\n autoAlpha: 1,\n duration: 1,\n ease: 'expo.out',\n clearProps: 'transform,opacity,visibility',\n }\n );\n gsap.fromTo(\n intro,\n { y: -8, autoAlpha: 0 },\n {\n y: 0,\n autoAlpha: 1,\n duration: 0.7,\n ease: 'power3.out',\n stagger: 0.06,\n delay: 0.12,\n clearProps: 'transform,opacity,visibility',\n }\n );\n },\n { scope: rootRef }\n );\n\n const measureInk = useCallback(() => {\n const bar = barRef.current;\n if (!bar) return;\n const barRect = bar.getBoundingClientRect();\n inkRefs.current.forEach((ink, index) => {\n const host = ink?.parentElement;\n if (!host) return;\n const box = host.getBoundingClientRect();\n inkBoxes.current[index] = { left: box.left - barRect.left, width: box.width };\n });\n }, []);\n\n const draw = useCallback(() => {\n const m = motion.current;\n const left = m.left.x;\n const right = m.right.x;\n const width = Math.max(right - left, 0);\n const stretch = width - (m.right.t - m.left.t);\n const thin = 1 - clamp(stretch / 320, 0, 0.18);\n const { scale, alpha } = m.look;\n const pill = pillRef.current;\n const core = coreRef.current;\n const drop = dropRef.current;\n\n if (pill && core && drop) {\n pill.style.width = `${width}px`;\n pill.style.transform = `translate3d(${left}px,0,0)`;\n pill.style.opacity = `${alpha}`;\n core.style.transform = `scale(${scale},${scale * thin})`;\n drop.style.transform = `translate3d(${m.tail.x - DROP_SIZE / 2}px,0,0) scale(${scale})`;\n drop.style.opacity = `${alpha}`;\n }\n\n inkRefs.current.forEach((ink, index) => {\n const box = inkBoxes.current[index];\n if (!ink || !box) return;\n const start = clamp(left - box.left, 0, box.width);\n const end = clamp(right - box.left, 0, box.width);\n ink.style.clipPath = `inset(-4px ${box.width - end}px -4px ${start}px)`;\n ink.style.opacity = `${alpha}`;\n });\n\n const hover = hoverRef.current;\n if (hover) {\n const alpha = m.hover.alpha;\n const iconHeight = Math.max(m.iconBottom.x - m.iconTop.x, 0);\n const iconStretch = iconHeight - (m.iconBottom.t - m.iconTop.t);\n const iconWidth = ICON_SIZE * (1 - clamp(iconStretch / 160, 0, 0.3));\n const iconX = m.rowLeft.x + m.iconOffset;\n hover.row.style.transform = `translate3d(${m.rowLeft.x}px,${m.rowTop.x}px,0)`;\n hover.row.style.width = `${Math.max(m.rowWidth.x, 0)}px`;\n hover.row.style.height = `${Math.max(m.rowBottom.x - m.rowTop.x, 0)}px`;\n hover.row.style.opacity = `${alpha}`;\n hover.blob.style.transform = `translate3d(${iconX + (ICON_SIZE - iconWidth) / 2}px,${m.iconTop.x}px,0)`;\n hover.blob.style.width = `${iconWidth}px`;\n hover.blob.style.height = `${iconHeight}px`;\n hover.blob.style.opacity = `${alpha}`;\n hover.drop.style.transform = `translate3d(${iconX + (ICON_SIZE - ICON_DROP) / 2}px,${m.iconTail.x - ICON_DROP / 2}px,0)`;\n hover.drop.style.opacity = `${alpha}`;\n }\n\n const shape = shapeRef.current;\n const bump = bumpRef.current;\n const body = bodyRef.current;\n if (!m.panelShown || !shape || !bump || !body) return;\n const top = m.barBottom + PANEL_GAP;\n const height = Math.max(m.panelHeight.x, 0);\n const panelWidth = Math.max(m.panelRight.x - m.panelLeft.x, 0);\n const bumpRadius = Math.max(0, Math.min(BUMP_RADIUS, panelWidth / 4, height / 3));\n shape.setAttribute('d', roundedRect(m.panelLeft.x, m.panelRight.x, top, height));\n const edge = Math.min(PANEL_RADIUS, panelWidth / 2) + bumpRadius;\n const bumpX =\n panelWidth > edge * 2\n ? clamp(m.bump.x, m.panelLeft.x + edge, m.panelRight.x - edge)\n : m.panelLeft.x + panelWidth / 2;\n bump.setAttribute('cx', `${px(bumpX)}`);\n bump.setAttribute('cy', `${px(top + bumpRadius - BUMP_RISE * (bumpRadius / BUMP_RADIUS))}`);\n bump.setAttribute('r', `${px(bumpRadius)}`);\n body.style.top = `${top}px`;\n body.style.clipPath = `path('${roundedRect(m.panelLeft.x, m.panelRight.x, 0, height)}')`;\n }, []);\n\n useEffect(() => {\n const tick = (_time: number, deltaTime: number) => {\n const m = motion.current;\n if (!m.awake) return;\n const elapsed = Math.min(deltaTime / 1000, 0.05);\n const steps = Math.max(1, Math.ceil(elapsed * 120));\n const dt = elapsed / steps;\n const pill = steer(m.left, m.right);\n const row = steer(m.rowTop, m.rowBottom);\n const icon = steer(m.iconTop, m.iconBottom);\n const panelHeading = m.panelLeft.t + m.panelRight.t - m.panelLeft.x - m.panelRight.x;\n const panelLeftPull = panelHeading > 1 ? PANEL_TRAIL : PANEL_LEAD;\n const panelRightPull = panelHeading < -1 ? PANEL_TRAIL : PANEL_LEAD;\n\n for (let step = 0; step < steps; step += 1) {\n stepSpring(m.left, pill.start, dt);\n stepSpring(m.right, pill.end, dt);\n const center = (m.left.x + m.right.x) / 2;\n m.tail.t = center;\n stepSpring(m.tail, TAIL_PULL, dt);\n if (m.panelShown) {\n m.bump.t = center;\n stepSpring(m.panelLeft, panelLeftPull, dt);\n stepSpring(m.panelRight, panelRightPull, dt);\n stepSpring(m.panelHeight, PANEL_PULL, dt);\n stepSpring(m.bump, BUMP_PULL, dt);\n }\n stepSpring(m.rowTop, row.start, dt);\n stepSpring(m.rowBottom, row.end, dt);\n stepSpring(m.rowLeft, EDGE_LEAD, dt);\n stepSpring(m.rowWidth, EDGE_LEAD, dt);\n stepSpring(m.iconTop, icon.start, dt);\n stepSpring(m.iconBottom, icon.end, dt);\n m.iconTail.t = (m.iconTop.x + m.iconBottom.x) / 2;\n stepSpring(m.iconTail, TAIL_PULL, dt);\n }\n\n const center = (m.left.x + m.right.x) / 2;\n m.tail.x = m.right.x - m.left.x > 4 ? clamp(m.tail.x, m.left.x + 2, m.right.x - 2) : center;\n m.iconTail.x = clamp(m.iconTail.x, m.iconTop.x - 2, m.iconBottom.x + 2);\n\n if (motionAtRest(m)) {\n settleMotion(m);\n m.awake = false;\n }\n draw();\n };\n\n gsap.ticker.add(tick);\n return () => gsap.ticker.remove(tick);\n }, [draw]);\n\n const hideBlob = contextSafe(() => {\n const m = motion.current;\n if (!m.pillShown) return;\n m.pillShown = false;\n const center = (m.left.t + m.right.t) / 2;\n m.left.t = center - 20;\n m.right.t = center + 20;\n gsap.killTweensOf(m.look);\n if (prefersReducedMotion()) {\n m.look.scale = 0.4;\n m.look.alpha = 0;\n settleMotion(m);\n draw();\n return;\n }\n gsap.to(m.look, { scale: 0.4, alpha: 0, duration: 0.32, ease: 'power2.inOut' });\n m.awake = true;\n });\n\n const moveBlob = contextSafe((el: HTMLElement) => {\n const bar = barRef.current;\n if (!bar) return;\n const m = motion.current;\n const base = bar.getBoundingClientRect();\n const box = el.getBoundingClientRect();\n const left = box.left - base.left;\n const center = left + box.width / 2;\n activeRef.current = el;\n measureInk();\n gsap.killTweensOf(m.look);\n\n if (!m.pillShown && m.look.alpha < 0.05) {\n place(m.left, center - 20);\n place(m.right, center + 20);\n place(m.tail, center);\n m.look.scale = 0.4;\n }\n m.pillShown = true;\n m.left.t = left;\n m.right.t = left + box.width;\n\n if (prefersReducedMotion()) {\n m.look.scale = 1;\n m.look.alpha = 1;\n settleMotion(m);\n draw();\n return;\n }\n gsap.to(m.look, { scale: 1, alpha: 1, duration: 0.45, ease: 'power3.out' });\n m.awake = true;\n });\n\n const resetHover = () => {\n const m = motion.current;\n const hover = hoverRef.current;\n gsap.killTweensOf(m.hover);\n m.hover.alpha = 0;\n m.hoverShown = false;\n hoverRef.current = null;\n if (hover) gsap.set([hover.row, hover.blob, hover.drop], { opacity: 0 });\n };\n\n const moveHover = contextSafe((link: HTMLElement) => {\n const section = link.closest('[data-section]');\n const icon = link.querySelector('[data-icon]');\n const row = section?.querySelector('[data-hover-row]');\n const blob = section?.querySelector('[data-hover-blob]');\n const drop = section?.querySelector('[data-hover-drop]');\n if (!section || !icon || !row || !blob || !drop) return;\n\n const m = motion.current;\n if (hoverRef.current && hoverRef.current.row !== row) resetHover();\n const fresh = !hoverRef.current || (!m.hoverShown && m.hover.alpha < 0.05);\n hoverRef.current = { row, blob, drop };\n\n const base = section.getBoundingClientRect();\n const box = link.getBoundingClientRect();\n const tile = icon.getBoundingClientRect();\n const top = box.top - base.top;\n const left = box.left - base.left;\n const iconTop = tile.top - base.top;\n const iconCenter = iconTop + tile.height / 2;\n m.iconOffset = tile.left - box.left;\n gsap.killTweensOf(m.hover);\n\n if (fresh) {\n place(m.rowTop, top);\n place(m.rowBottom, top + box.height);\n place(m.rowLeft, left);\n place(m.rowWidth, box.width);\n place(m.iconTop, iconCenter - 4);\n place(m.iconBottom, iconCenter + 4);\n place(m.iconTail, iconCenter);\n }\n m.hoverShown = true;\n m.rowTop.t = top;\n m.rowBottom.t = top + box.height;\n m.rowLeft.t = left;\n m.rowWidth.t = box.width;\n m.iconTop.t = iconTop;\n m.iconBottom.t = iconTop + tile.height;\n\n if (prefersReducedMotion()) {\n m.hover.alpha = 1;\n settleMotion(m);\n draw();\n return;\n }\n gsap.to(m.hover, { alpha: 1, duration: 0.3, ease: 'power2.out' });\n m.awake = true;\n });\n\n const hideHover = contextSafe(() => {\n const m = motion.current;\n if (!m.hoverShown) return;\n m.hoverShown = false;\n const center = (m.iconTop.t + m.iconBottom.t) / 2;\n m.iconTop.t = center - 4;\n m.iconBottom.t = center + 4;\n gsap.killTweensOf(m.hover);\n if (prefersReducedMotion()) {\n m.hover.alpha = 0;\n settleMotion(m);\n draw();\n return;\n }\n gsap.to(m.hover, { alpha: 0, duration: 0.28, ease: 'power2.out' });\n m.awake = true;\n });\n\n const getLayout = useCallback((trigger: HTMLElement, section: HTMLElement) => {\n const bar = barRef.current;\n const width = section.offsetWidth;\n const height = section.offsetHeight;\n if (!bar) return { left: 0, width, height, center: width / 2 };\n const barRect = bar.getBoundingClientRect();\n const triggerRect = trigger.getBoundingClientRect();\n const center = triggerRect.left - barRect.left + triggerRect.width / 2;\n const min = 8 - barRect.left;\n const max = window.innerWidth - 8 - width - barRect.left;\n return { left: clamp(center - width / 2, Math.min(min, max), max), width, height, center };\n }, []);\n\n const sectionNodes = () =>\n Object.values(sectionRefs.current).filter((node): node is HTMLDivElement => Boolean(node));\n\n const openMenu = contextSafe((menu: NavbarGooeyBloomMenu, trigger: HTMLElement) => {\n const section = sectionRefs.current[menu.label];\n const panel = panelRef.current;\n const shell = shellRef.current;\n if (!section || !panel || !shell) return;\n if (openRef.current === menu.label) return;\n\n const previous = openRef.current;\n const previousSection = previous ? sectionRefs.current[previous] : null;\n openRef.current = menu.label;\n setOpenKey(menu.label);\n\n const m = motion.current;\n const layout = getLayout(trigger, section);\n const reduced = prefersReducedMotion();\n const items = section.querySelectorAll('[data-item]');\n m.barBottom = shell.offsetHeight;\n resetHover();\n\n gsap.killTweensOf([panel, ...sectionNodes()]);\n sectionNodes().forEach((node) => {\n if (node !== section && node !== previousSection) gsap.set(node, { opacity: 0 });\n });\n gsap.set(section, { left: layout.left, opacity: 1 });\n gsap.set(panel, { opacity: 1 });\n\n if (!m.panelShown) {\n place(m.panelLeft, layout.center - 12);\n place(m.panelRight, layout.center + 12);\n place(m.panelHeight, 24);\n place(m.bump, layout.center);\n m.panelShown = true;\n }\n m.panelLeft.t = layout.left;\n m.panelRight.t = layout.left + layout.width;\n m.panelHeight.t = layout.height;\n\n if (reduced) {\n if (previousSection && previousSection !== section) {\n gsap.set(previousSection, { opacity: 0 });\n }\n gsap.set(items, { y: 0, opacity: 1 });\n settleMotion(m);\n draw();\n return;\n }\n\n m.awake = true;\n if (previousSection && previousSection !== section) {\n gsap.to(previousSection, { opacity: 0, duration: 0.14, ease: 'power2.out' });\n }\n if (items.length) {\n gsap.fromTo(\n items,\n { y: 10, opacity: 0 },\n {\n y: 0,\n opacity: 1,\n duration: 0.42,\n ease: 'power3.out',\n stagger: 0.02,\n delay: previous ? 0.05 : 0.12,\n overwrite: 'auto',\n }\n );\n }\n });\n\n const closeMenu = contextSafe((options?: { clearHighlight?: boolean }) => {\n cancelHover();\n if (options?.clearHighlight) {\n hideBlob();\n }\n if (!openRef.current) return;\n openRef.current = null;\n setOpenKey(null);\n setAccordion(null);\n\n const panel = panelRef.current;\n if (!panel) return;\n const m = motion.current;\n const sections = sectionNodes();\n const center = (m.left.t + m.right.t) / 2;\n gsap.killTweensOf([panel, ...sections]);\n m.panelLeft.t = center - 12;\n m.panelRight.t = center + 12;\n m.panelHeight.t = 24;\n\n const finish = () => {\n m.panelShown = false;\n resetHover();\n gsap.set(sections, { opacity: 0 });\n gsap.set(panel, { opacity: 0 });\n };\n\n if (prefersReducedMotion()) {\n finish();\n return;\n }\n m.awake = true;\n gsap.to(sections, { opacity: 0, duration: 0.12, ease: 'power2.out' });\n gsap.to(panel, {\n opacity: 0,\n duration: 0.24,\n delay: 0.08,\n ease: 'power2.in',\n onComplete: finish,\n });\n });\n\n const cancelClose = () => {\n if (closeTimer.current !== null) {\n window.clearTimeout(closeTimer.current);\n closeTimer.current = null;\n }\n };\n\n const scheduleClose = contextSafe(() => {\n cancelHover();\n cancelClose();\n closeTimer.current = window.setTimeout(() => {\n const bar = barRef.current;\n if (bar) {\n const rect = bar.getBoundingClientRect();\n const { x, y } = pointerPos.current;\n if (\n x >= rect.left - 2 &&\n x <= rect.right + 2 &&\n y >= rect.top - 2 &&\n y <= rect.bottom + 2\n ) {\n return;\n }\n }\n closeMenu({ clearHighlight: true });\n }, CLOSE_DELAY);\n });\n\n const cancelHover = () => {\n if (hoverTimer.current !== null) {\n window.clearTimeout(hoverTimer.current);\n hoverTimer.current = null;\n }\n pendingHover.current = null;\n };\n\n const handleTriggerEnter = (menu: NavbarGooeyBloomMenu, el: HTMLElement) => {\n cancelClose();\n moveBlob(el);\n pendingHover.current = { menu, el };\n if (hoverTimer.current !== null) window.clearTimeout(hoverTimer.current);\n hoverTimer.current = window.setTimeout(() => {\n hoverTimer.current = null;\n const next = pendingHover.current;\n pendingHover.current = null;\n if (!next) return;\n if (next.menu.columns?.length) openMenu(next.menu, next.el);\n else closeMenu();\n }, HOVER_INTENT);\n };\n\n useEffect(() => {\n const scroller = scrollerRef.current;\n const bar = barRef.current;\n if (!scroller || !bar) return;\n let last = scroller.scrollTop;\n let hidden = false;\n\n const onScroll = () => {\n const y = scroller.scrollTop;\n const delta = y - last;\n if (Math.abs(delta) < SCROLL_TOLERANCE) return;\n last = y;\n const next = delta > 0 && y > HIDE_AFTER && !openRef.current;\n if (next === hidden) return;\n hidden = next;\n gsap.to(bar, {\n yPercent: next ? -170 : 0,\n duration: prefersReducedMotion() ? 0 : next ? 0.5 : 0.7,\n ease: next ? 'power3.in' : 'expo.out',\n overwrite: 'auto',\n });\n };\n\n scroller.addEventListener('scroll', onScroll, { passive: true });\n return () => scroller.removeEventListener('scroll', onScroll);\n }, []);\n\n useEffect(() => {\n const onResize = () => {\n const bar = barRef.current;\n const shell = shellRef.current;\n if (!bar || !shell) return;\n const m = motion.current;\n measureInk();\n const active = activeRef.current;\n if (active && m.pillShown) {\n const base = bar.getBoundingClientRect();\n const box = active.getBoundingClientRect();\n m.left.t = box.left - base.left;\n m.right.t = m.left.t + box.width;\n }\n const key = openRef.current;\n const section = key ? sectionRefs.current[key] : null;\n const trigger = key ? triggerRefs.current[key] : null;\n if (section && trigger) {\n const layout = getLayout(trigger, section);\n m.barBottom = shell.offsetHeight;\n m.panelLeft.t = layout.left;\n m.panelRight.t = layout.left + layout.width;\n m.panelHeight.t = layout.height;\n gsap.set(section, { left: layout.left });\n }\n settleMotion(m);\n draw();\n };\n\n window.addEventListener('resize', onResize);\n return () => window.removeEventListener('resize', onResize);\n }, [draw, getLayout, measureInk]);\n\n useEffect(() => {\n if (!openKey) return;\n const desktop = window.matchMedia(DESKTOP_QUERY);\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key !== 'Escape') return;\n const key = openRef.current;\n closeMenu({ clearHighlight: true });\n if (key) {\n skipFocusOpen.current = true;\n triggerRefs.current[key]?.focus();\n skipFocusOpen.current = false;\n }\n };\n\n const onPointerDown = (event: PointerEvent) => {\n if (!barRef.current?.contains(event.target as Node)) {\n closeMenu({ clearHighlight: true });\n }\n };\n\n const onDesktopChange = (event: MediaQueryListEvent) => {\n if (!event.matches) closeMenu({ clearHighlight: true });\n };\n\n document.addEventListener('keydown', onKeyDown);\n document.addEventListener('pointerdown', onPointerDown);\n desktop.addEventListener('change', onDesktopChange);\n return () => {\n document.removeEventListener('keydown', onKeyDown);\n document.removeEventListener('pointerdown', onPointerDown);\n desktop.removeEventListener('change', onDesktopChange);\n };\n }, [openKey, closeMenu]);\n\n useEffect(() => {\n const menu = mobileRef.current;\n if (!menu) return;\n const reduced = prefersReducedMotion();\n const items = menu.querySelectorAll('[data-mobile-item]');\n\n if (mobileOpen) {\n gsap.set(menu, { visibility: 'visible' });\n gsap.fromTo(\n menu,\n { opacity: 0 },\n { opacity: 1, duration: reduced ? 0 : 0.28, ease: 'power2.out' }\n );\n gsap.fromTo(\n items,\n { y: 22, opacity: 0 },\n {\n y: 0,\n opacity: 1,\n duration: reduced ? 0 : 0.45,\n ease: 'power3.out',\n stagger: reduced ? 0 : 0.035,\n delay: reduced ? 0 : 0.08,\n }\n );\n mobileCloseRef.current?.focus();\n } else {\n gsap.to(menu, {\n opacity: 0,\n duration: reduced ? 0 : 0.22,\n ease: 'power2.out',\n onComplete: () => gsap.set(menu, { visibility: 'hidden' }),\n });\n }\n }, [mobileOpen]);\n\n useEffect(() => {\n if (!mobileOpen) return;\n const desktop = window.matchMedia(DESKTOP_QUERY);\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key !== 'Escape') return;\n setMobileOpen(false);\n toggleRef.current?.focus();\n };\n const onChange = (event: MediaQueryListEvent) => {\n if (event.matches) setMobileOpen(false);\n };\n document.addEventListener('keydown', onKeyDown);\n desktop.addEventListener('change', onChange);\n return () => {\n document.removeEventListener('keydown', onKeyDown);\n desktop.removeEventListener('change', onChange);\n };\n }, [mobileOpen]);\n\n useEffect(() => {\n return () => {\n cancelClose();\n if (hoverTimer.current !== null) window.clearTimeout(hoverTimer.current);\n pendingHover.current = null;\n };\n }, []);\n\n return (\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n {\n if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {\n closeMenu({ clearHighlight: true });\n }\n }}\n >\n \n {\n pointerPos.current = { x: event.clientX, y: event.clientY };\n scheduleClose();\n if (!openRef.current) hideBlob();\n }}\n className=\"relative flex h-15 items-center justify-between gap-3 rounded-full border border-[#9fd4d6]/12 bg-[#071d1b]/70 px-3 shadow-[0_24px_60px_-32px_rgb(0_0_0/0.95)] backdrop-blur-xl\"\n >\n \n \n \n \n
\n \n
\n
\n\n \n {logo}\n \n {brand}\n \n \n\n \n\n
\n \n {actionLabel}\n \n\n setMobileOpen((value) => !value)}\n className={cn(\n 'grid size-10 cursor-pointer place-items-center rounded-full border border-[#9fd4d6]/15 bg-white/[0.03] lg:hidden',\n focusRing\n )}\n >\n \n \n \n \n \n
\n \n\n \n \n \n \n \n \n \n \n {menus.map((menu) => {\n if (!menu.columns?.length) return null;\n const width = panelWidth(menu);\n\n return (\n {\n sectionRefs.current[menu.label] = node;\n }}\n data-section=\"\"\n inert={openKey !== menu.label}\n onPointerLeave={hideHover}\n style={{ width }}\n className={cn(\n 'absolute top-0 left-0 p-2.5 opacity-0',\n openKey === menu.label ? 'pointer-events-auto' : 'pointer-events-none'\n )}\n >\n \n \n \n \n \n \n \n
\n {menu.columns.map((column, columnIndex) => (\n
    \n {column.map((link) => (\n
  • \n closeMenu()}\n onPointerEnter={(event) => moveHover(event.currentTarget)}\n onFocus={(event) => moveHover(event.currentTarget)}\n className={cn(\n 'group/link relative flex items-start gap-3 rounded-2xl p-2.5',\n focusRing\n )}\n >\n \n \n \n \n \n {link.label}\n \n \n {link.description ? (\n \n {link.description}\n \n ) : null}\n \n \n
  • \n ))}\n
\n ))}\n\n {menu.feature ? (\n \n
\n {menu.feature.image ? (\n \n ) : (\n \n )}\n
\n
\n

{menu.feature.title}

\n

\n {menu.feature.description}\n

\n closeMenu()}\n className={cn(\n 'group/feature mt-auto inline-flex items-center gap-1.5 pt-3 text-[12px] font-medium text-[#c6f56f]',\n focusRing\n )}\n >\n {menu.feature.cta ?? 'Explore'}\n \n \n
\n
\n ) : null}\n \n \n );\n })}\n \n \n \n \n\n \n {children ?? }\n \n\n \n
\n \n {logo}\n {brand}\n \n {\n setMobileOpen(false);\n toggleRef.current?.focus();\n }}\n className={cn(\n 'grid size-10 cursor-pointer place-items-center rounded-full border border-[#9fd4d6]/15 bg-white/[0.04]',\n focusRing\n )}\n >\n \n \n \n \n
\n\n
\n
    \n {menus.map((menu) => {\n const panelMenu = Boolean(menu.columns?.length);\n const isOpen = accordion === menu.label;\n const links = menu.columns?.flat() ?? [];\n const contentId = `${mobileId}-${menu.label.replace(/[^a-zA-Z0-9]/g, '')}`;\n\n return (\n \n {panelMenu ? (\n <>\n setAccordion(isOpen ? null : menu.label)}\n className={cn(\n 'flex w-full cursor-pointer items-center justify-between py-4 text-left',\n focusRing\n )}\n >\n \n {menu.label}\n \n \n \n \n
    \n
      \n {links.map((link) => (\n
    • \n setMobileOpen(false)}\n className={cn(\n 'flex items-center gap-3 rounded-xl px-2 py-2.5 text-[15px] text-[#e8f3f2]/75',\n focusRing\n )}\n >\n \n {link.label}\n \n
    • \n ))}\n
    \n
    \n
\n \n ) : (\n setMobileOpen(false)}\n className={cn(\n 'block py-4 text-[26px] leading-none font-medium tracking-[-0.03em]',\n focusRing\n )}\n >\n {menu.label}\n \n )}\n \n );\n })}\n \n\n
\n {\n setMobileOpen(false);\n onActionClick?.(event);\n }}\n className=\"h-12 w-full rounded-full focus-visible:ring-[#c6f56f] focus-visible:ring-offset-[#04140f]\"\n >\n {actionLabel}\n \n
\n \n \n \n );\n};\n\nexport default NavbarGooeyBloom;\n" + } + ], + "docs": "Docs, props and examples: https://tween-ui.vercel.app/block/navbar-gooey-bloom\nPass `menus` (a `label` plus `columns` and an optional `feature`, or just an `href` for a plain link) and put your page inside as `children`. The goo filter is generated per instance, so several bars can share a page." +} \ No newline at end of file diff --git a/apps/docs/public/r/registry.json b/apps/docs/public/r/registry.json index e494ddf..9d25057 100644 --- a/apps/docs/public/r/registry.json +++ b/apps/docs/public/r/registry.json @@ -1554,6 +1554,34 @@ "type": "registry:example" } ] + }, + { + "name": "navbar-gooey-bloom", + "type": "registry:block", + "title": "Navbar Gooey Bloom", + "description": "A glass navbar whose hover highlight is liquid: a lime pool stretches from link to link with a droplet dragging behind, while the label inverts to ink. Menus swell out of a drop into one gooey panel that leans toward its trigger and morphs between layouts, and inside it a lime blob flows from icon to icon.", + "files": [ + { + "path": "components/tweenui/navbar-gooey-bloom.tsx", + "type": "registry:component" + } + ] + }, + { + "name": "navbar-gooey-bloom-demo", + "type": "registry:example", + "title": "Navbar Gooey Bloom Demo", + "description": "Usage examples for Navbar Gooey Bloom.", + "files": [ + { + "path": "examples/navbar-gooey-bloom-demo.tsx", + "type": "registry:example" + }, + { + "path": "examples/navbar-gooey-bloom-demo-2.tsx", + "type": "registry:example" + } + ] } ] } \ No newline at end of file diff --git a/apps/docs/registry/__index__.generated.tsx b/apps/docs/registry/__index__.generated.tsx index fcc279d..94b8406 100644 --- a/apps/docs/registry/__index__.generated.tsx +++ b/apps/docs/registry/__index__.generated.tsx @@ -69,6 +69,7 @@ export const demos: Record = { 'navbar-corner-panel:default': dynamic(() => import('./demos/navbar-corner-panel')), 'footer-pulse-links:default': dynamic(() => import('./demos/footer-pulse-links')), 'arc-carousel:default': dynamic(() => import('./demos/arc-carousel')), + 'navbar-gooey-bloom:default': dynamic(() => import('./demos/navbar-gooey-bloom')), }; export function getDemo(component: string, variant: string): ComponentType | undefined { diff --git a/apps/docs/registry/__sources__.generated.ts b/apps/docs/registry/__sources__.generated.ts index e0a2a81..67e6841 100644 --- a/apps/docs/registry/__sources__.generated.ts +++ b/apps/docs/registry/__sources__.generated.ts @@ -354,6 +354,12 @@ export const usage: Record = { "code": "import ArcCarousel from '@/components/tweenui/arc-carousel';\n\nexport default function Example() {\n return ;\n}", "codeHtml": "
import ArcCarousel from '@/components/tweenui/arc-carousel';\n\nexport default function Example() {\n  return <ArcCarousel />;\n}
" } + }, + "navbar-gooey-bloom": { + "react": { + "code": "import NavbarGooeyBloom from '@/components/tweenui/navbar-gooey-bloom';\n\nexport default function Example() {\n return ;\n}", + "codeHtml": "
import NavbarGooeyBloom from '@/components/tweenui/navbar-gooey-bloom';\n\nexport default function Example() {\n  return <NavbarGooeyBloom />;\n}
" + } } }; @@ -705,5 +711,11 @@ export const manual: Record = { "code": "'use client';\n\nimport {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ComponentPropsWithoutRef,\n type KeyboardEvent,\n type MouseEvent,\n} from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { CustomEase } from 'gsap/CustomEase';\nimport { ScrollTrigger } from 'gsap/ScrollTrigger';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP, CustomEase, ScrollTrigger);\n\nconst prefersReducedMotion = () =>\n typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nexport interface ArcCarouselItem {\n title: string;\n image: string;\n /** Chip on the top left of the card. */\n tag?: string;\n /** Line under the title, e.g. a client and year or a price. */\n meta?: string;\n href?: string;\n}\n\nexport interface ArcCarouselProps extends ComponentPropsWithoutRef<'section'> {\n /** Eight or more keep the wrap seamless. */\n items?: ArcCarouselItem[];\n /** Accessible name for the carousel. */\n label?: string;\n /** Step on its own; holds on hover, keyboard focus and while off-screen. */\n autoplay?: boolean;\n /** Time each card holds the centre while autoplaying, in ms. */\n interval?: number;\n}\n\n// Card position by distance from the centre: px across, px down, degrees of turn\nconst SLOTS = [\n { x: 0, y: 0, r: 0 },\n { x: 359, y: 26, r: 8.77 },\n { x: 711, y: 113, r: 18.83 },\n { x: 1033, y: 243, r: 25.05 },\n];\nconst ACTIVE_SCALE = 1.05;\nconst MOVE_DURATION = 0.8;\nconst MOVE_EASE = CustomEase.create('arc-move', 'M0,0 C0.98,0.3 0.3,1 1,1');\nconst SWIPE = 50;\n\n/** Where a card sits at a (fractional) distance from the centre. Past the last slot it fades out. */\nconst slot = (offset: number) => {\n const t = Math.abs(offset);\n const sign = Math.sign(offset);\n const i = Math.min(Math.floor(t), SLOTS.length - 2);\n const a = SLOTS[i];\n const b = SLOTS[i + 1];\n const f = t - i;\n return {\n x: (a.x + (b.x - a.x) * f) * sign,\n y: a.y + (b.y - a.y) * f,\n rotation: (a.r + (b.r - a.r) * f) * sign,\n scale: 1 + (ACTIVE_SCALE - 1) * gsap.utils.clamp(0, 1, 1 - t),\n opacity: gsap.utils.clamp(0, 1, SLOTS.length - t),\n zIndex: 100 - Math.round(t * 10),\n };\n};\n\nconst pad2 = (n: number) => String(n).padStart(2, '0');\n\nconst photo = (id: string, width = 640) =>\n `https://images.unsplash.com/photo-${id}?w=${width}&h=${Math.round(width * (430 / 312))}&fit=crop&q=80&auto=format`;\nconst srcSetOf = (src: string) =>\n src.includes('images.unsplash.com')\n ? `${src.replace(/([?&])w=\\d+&h=\\d+/, '$1w=320&h=441')} 1x, ${src} 2x`\n : undefined;\n\nconst DEFAULT_ITEMS: ArcCarouselItem[] = [\n {\n title: 'Nightfall',\n tag: 'Astronomy app',\n meta: 'Stellar Co. · 2026',\n image: photo('1519681393784-d120267933ba'),\n },\n {\n title: 'Chroma Identity',\n tag: 'Brand identity',\n meta: 'Chroma Studio · 2026',\n image: photo('1541701494587-cb58502866ab'),\n },\n {\n title: 'Alpine Lodge',\n tag: 'Hospitality',\n meta: 'Moraine Hotels · 2026',\n image: photo('1493246507139-91e8fad9978e'),\n },\n {\n title: 'Metro Pulse',\n tag: 'Data visualisation',\n meta: 'City of Lakes · 2025',\n image: photo('1477959858617-67f85cf4f1df'),\n },\n {\n title: 'Orbit Motion',\n tag: '3D & motion',\n meta: 'Orbit Labs · 2025',\n image: photo('1618005198919-d3d4b5a92ead'),\n },\n {\n title: 'Open Road',\n tag: 'Campaign film',\n meta: 'Westward · 2025',\n image: photo('1500530855697-b586d89ba3ee'),\n },\n {\n title: 'Still Water',\n tag: 'Wellness app',\n meta: 'Calm Harbor · 2024',\n image: photo('1534447677768-be436bb09401'),\n },\n {\n title: 'Lago Retreats',\n tag: 'Travel platform',\n meta: 'Lago Travel · 2024',\n image: photo('1501785888041-af3ef285b470'),\n },\n];\n\nconst STEP = cn(\n 'grid size-11 place-items-center rounded-full border border-[#9fd4d6]/25 text-white',\n 'transition-[background-color,border-color,color] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] hover:border-[#c6f56f] hover:bg-[#c6f56f] hover:text-[#03110f]',\n 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#c6f56f] motion-reduce:transition-none'\n);\nconst FADE =\n 'pointer-events-none absolute inset-y-0 z-2 w-[clamp(2.5rem,14cqw,12rem)] from-[#03110f] via-[#03110f]/70 to-transparent';\n\nconst Caret = ({ flip }: { flip?: boolean }) => (\n \n \n \n);\n\nconst PauseIcon = () => (\n \n \n \n \n);\n\nconst PlayIcon = () => (\n \n \n \n);\n\nconst ArrowUpRight = ({ className }: { className?: string }) => (\n \n \n \n);\n\nconst ArcCarousel = ({\n items = DEFAULT_ITEMS,\n label = 'Featured projects',\n autoplay = true,\n interval = 5000,\n className,\n ...props\n}: ArcCarouselProps) => {\n const count = items.length;\n const [position, setPosition] = useState(0);\n const [playing, setPlaying] = useState(autoplay);\n const [held, setHeld] = useState(false);\n const rootRef = useRef(null);\n const ringRef = useRef(null);\n const cardRefs = useRef<(HTMLDivElement | null)[]>([]);\n const track = useRef({ pos: 0 });\n const clockRef = useRef(null);\n const swipe = useRef({ tracking: false, swiped: false, x: 0, y: 0 });\n const holds = useRef({ hover: false, focus: false, offscreen: false });\n\n const current = count ? gsap.utils.wrap(0, count, position) : 0;\n const active = items[current];\n const offsetOf = (index: number, pos: number) =>\n gsap.utils.wrap(-count / 2, count / 2, index - pos);\n\n const step = useCallback((direction: number) => setPosition((p) => p + direction), []);\n\n // `playing` is the viewer's choice; a hold is a pause they did not ask for\n const hold = useCallback((key: keyof typeof holds.current, on: boolean) => {\n holds.current[key] = on;\n setHeld(Object.values(holds.current).some(Boolean));\n }, []);\n\n // Every card is placed from one number, so a step is a single tween and the wrap needs no copies\n useGSAP(\n () => {\n const cards = cardRefs.current.filter((card): card is HTMLDivElement => card !== null);\n const render = () =>\n cards.forEach((card, i) => {\n const s = slot(offsetOf(i, track.current.pos));\n gsap.set(card, { ...s, pointerEvents: s.opacity > 0.5 ? 'auto' : 'none' });\n });\n\n gsap.to(track.current, {\n pos: position,\n duration: prefersReducedMotion() ? 0 : MOVE_DURATION,\n ease: MOVE_EASE,\n overwrite: true,\n onUpdate: render,\n });\n },\n { scope: rootRef, dependencies: [position, count] }\n );\n\n // The media query is client-only, so reduced motion applies after hydration\n useEffect(() => {\n setPlaying(autoplay && !prefersReducedMotion());\n }, [autoplay]);\n\n // The clock is the progress ring: it fills over `interval`, then steps. Any step restarts it\n useGSAP(\n () => {\n const ring = ringRef.current;\n if (!ring || !playing || count < 2) return;\n clockRef.current = gsap.fromTo(\n ring,\n // The attribute, not the CSS property, so the dash stays in `pathLength` units\n { attr: { 'stroke-dashoffset': 1 } },\n {\n attr: { 'stroke-dashoffset': 0 },\n duration: interval / 1000,\n ease: 'none',\n onComplete: () => step(1),\n }\n );\n },\n { scope: rootRef, dependencies: [playing, position, interval, count], revertOnUpdate: true }\n );\n\n useEffect(() => {\n if (held) clockRef.current?.pause();\n else clockRef.current?.resume();\n }, [held, position, playing]);\n\n useGSAP(\n () => {\n if (!autoplay) return;\n ScrollTrigger.create({\n trigger: rootRef.current,\n onToggle: (self) => hold('offscreen', !self.isActive),\n });\n },\n { scope: rootRef, dependencies: [autoplay, hold] }\n );\n\n // A swipe can end outside the list\n useEffect(() => {\n const onUp = (event: PointerEvent) => {\n const s = swipe.current;\n if (!s.tracking) return;\n s.tracking = false;\n const dx = event.clientX - s.x;\n if (Math.abs(dx) > SWIPE && Math.abs(dx) > Math.abs(event.clientY - s.y)) {\n s.swiped = true;\n step(dx < 0 ? 1 : -1);\n }\n };\n const onCancel = () => {\n swipe.current.tracking = false;\n };\n window.addEventListener('pointerup', onUp);\n window.addEventListener('pointercancel', onCancel);\n return () => {\n window.removeEventListener('pointerup', onUp);\n window.removeEventListener('pointercancel', onCancel);\n };\n }, [step]);\n\n // After a swipe, or on a side card, a click steps the arc instead of following the card's link\n const onClickCapture = (event: MouseEvent) => {\n if (swipe.current.swiped) {\n swipe.current.swiped = false;\n event.preventDefault();\n event.stopPropagation();\n return;\n }\n if (event.detail === 0) return;\n const index = cardRefs.current.findIndex((card) => card?.contains(event.target as Node));\n const offset = index < 0 ? 0 : Math.round(offsetOf(index, position));\n if (offset !== 0) {\n event.preventDefault();\n event.stopPropagation();\n step(Math.sign(offset));\n }\n };\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;\n event.preventDefault();\n step(event.key === 'ArrowRight' ? 1 : -1);\n };\n\n return (\n \n \n\n {/* Isolated, so the cards' z-indexes stay under the edge fades */}\n event.pointerType !== 'touch' && hold('hover', true)}\n onPointerLeave={() => hold('hover', false)}\n >\n
\n {\n swipe.current = { tracking: true, swiped: false, x: event.clientX, y: event.clientY };\n }}\n onDragStart={(event) => event.preventDefault()}\n onClickCapture={onClickCapture}\n onKeyDown={onKeyDown}\n onFocus={(event) => {\n let visible = true;\n try {\n visible = event.target.matches(':focus-visible');\n } catch {\n // No :focus-visible support: treat every focus as visible\n }\n if (visible) hold('focus', true);\n }}\n onBlur={(event) => {\n if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {\n hold('focus', false);\n }\n }}\n >\n {items.map((item, i) => {\n const isActive = i === current;\n const Card = item.href ? 'a' : 'div';\n // Also set in markup, so the arc is in place before hydration\n const first = slot(offsetOf(i, 0));\n return (\n {\n cardRefs.current[i] = el;\n }}\n role=\"group\"\n aria-roledescription=\"slide\"\n aria-label={`${i + 1} of ${count}: ${item.title}`}\n aria-hidden={isActive ? undefined : true}\n data-active={isActive ? '' : undefined}\n className=\"group/card absolute top-0 left-0 h-107.5 w-78 will-change-transform\"\n style={{\n transform: `translate(${first.x}px, ${first.y}px) rotate(${first.rotation}deg) scale(${first.scale})`,\n opacity: first.opacity,\n zIndex: first.zIndex,\n }}\n >\n \n \n \n \n\n \n {item.tag ? (\n \n {item.tag}\n \n ) : (\n \n )}\n \n {pad2(i + 1)}\n / {pad2(count)}\n \n \n\n \n \n \n {item.title}\n \n {item.meta && (\n {item.meta}\n )}\n \n {item.href && (\n \n \n \n )}\n \n\n \n \n
\n );\n })}\n \n \n \n\n
\n step(-1)}\n >\n \n \n \n {autoplay && (\n setPlaying((on) => !on)}\n >\n {playing ? : }\n \n \n \n \n )}\n
\n\n {/* Quiet while autoplaying, so a screen reader is not interrupted every few seconds */}\n

\n {active ? `${current + 1} of ${count}: ${active.title}` : ''}\n

\n\n
\n
\n \n );\n};\n\nexport default ArcCarousel;\n", "codeHtml": "
'use client';\n\nimport {\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n  type ComponentPropsWithoutRef,\n  type KeyboardEvent,\n  type MouseEvent,\n} from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { CustomEase } from 'gsap/CustomEase';\nimport { ScrollTrigger } from 'gsap/ScrollTrigger';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP, CustomEase, ScrollTrigger);\n\nconst prefersReducedMotion = () =>\n  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nexport interface ArcCarouselItem {\n  title: string;\n  image: string;\n  /** Chip on the top left of the card. */\n  tag?: string;\n  /** Line under the title, e.g. a client and year or a price. */\n  meta?: string;\n  href?: string;\n}\n\nexport interface ArcCarouselProps extends ComponentPropsWithoutRef<'section'> {\n  /** Eight or more keep the wrap seamless. */\n  items?: ArcCarouselItem[];\n  /** Accessible name for the carousel. */\n  label?: string;\n  /** Step on its own; holds on hover, keyboard focus and while off-screen. */\n  autoplay?: boolean;\n  /** Time each card holds the centre while autoplaying, in ms. */\n  interval?: number;\n}\n\n// Card position by distance from the centre: px across, px down, degrees of turn\nconst SLOTS = [\n  { x: 0, y: 0, r: 0 },\n  { x: 359, y: 26, r: 8.77 },\n  { x: 711, y: 113, r: 18.83 },\n  { x: 1033, y: 243, r: 25.05 },\n];\nconst ACTIVE_SCALE = 1.05;\nconst MOVE_DURATION = 0.8;\nconst MOVE_EASE = CustomEase.create('arc-move', 'M0,0 C0.98,0.3 0.3,1 1,1');\nconst SWIPE = 50;\n\n/** Where a card sits at a (fractional) distance from the centre. Past the last slot it fades out. */\nconst slot = (offset: number) => {\n  const t = Math.abs(offset);\n  const sign = Math.sign(offset);\n  const i = Math.min(Math.floor(t), SLOTS.length - 2);\n  const a = SLOTS[i];\n  const b = SLOTS[i + 1];\n  const f = t - i;\n  return {\n    x: (a.x + (b.x - a.x) * f) * sign,\n    y: a.y + (b.y - a.y) * f,\n    rotation: (a.r + (b.r - a.r) * f) * sign,\n    scale: 1 + (ACTIVE_SCALE - 1) * gsap.utils.clamp(0, 1, 1 - t),\n    opacity: gsap.utils.clamp(0, 1, SLOTS.length - t),\n    zIndex: 100 - Math.round(t * 10),\n  };\n};\n\nconst pad2 = (n: number) => String(n).padStart(2, '0');\n\nconst photo = (id: string, width = 640) =>\n  `https://images.unsplash.com/photo-${id}?w=${width}&h=${Math.round(width * (430 / 312))}&fit=crop&q=80&auto=format`;\nconst srcSetOf = (src: string) =>\n  src.includes('images.unsplash.com')\n    ? `${src.replace(/([?&])w=\\d+&h=\\d+/, '$1w=320&h=441')} 1x, ${src} 2x`\n    : undefined;\n\nconst DEFAULT_ITEMS: ArcCarouselItem[] = [\n  {\n    title: 'Nightfall',\n    tag: 'Astronomy app',\n    meta: 'Stellar Co. · 2026',\n    image: photo('1519681393784-d120267933ba'),\n  },\n  {\n    title: 'Chroma Identity',\n    tag: 'Brand identity',\n    meta: 'Chroma Studio · 2026',\n    image: photo('1541701494587-cb58502866ab'),\n  },\n  {\n    title: 'Alpine Lodge',\n    tag: 'Hospitality',\n    meta: 'Moraine Hotels · 2026',\n    image: photo('1493246507139-91e8fad9978e'),\n  },\n  {\n    title: 'Metro Pulse',\n    tag: 'Data visualisation',\n    meta: 'City of Lakes · 2025',\n    image: photo('1477959858617-67f85cf4f1df'),\n  },\n  {\n    title: 'Orbit Motion',\n    tag: '3D & motion',\n    meta: 'Orbit Labs · 2025',\n    image: photo('1618005198919-d3d4b5a92ead'),\n  },\n  {\n    title: 'Open Road',\n    tag: 'Campaign film',\n    meta: 'Westward · 2025',\n    image: photo('1500530855697-b586d89ba3ee'),\n  },\n  {\n    title: 'Still Water',\n    tag: 'Wellness app',\n    meta: 'Calm Harbor · 2024',\n    image: photo('1534447677768-be436bb09401'),\n  },\n  {\n    title: 'Lago Retreats',\n    tag: 'Travel platform',\n    meta: 'Lago Travel · 2024',\n    image: photo('1501785888041-af3ef285b470'),\n  },\n];\n\nconst STEP = cn(\n  'grid size-11 place-items-center rounded-full border border-[#9fd4d6]/25 text-white',\n  'transition-[background-color,border-color,color] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] hover:border-[#c6f56f] hover:bg-[#c6f56f] hover:text-[#03110f]',\n  'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#c6f56f] motion-reduce:transition-none'\n);\nconst FADE =\n  'pointer-events-none absolute inset-y-0 z-2 w-[clamp(2.5rem,14cqw,12rem)] from-[#03110f] via-[#03110f]/70 to-transparent';\n\nconst Caret = ({ flip }: { flip?: boolean }) => (\n  <svg\n    viewBox=\"0 0 9 16\"\n    aria-hidden=\"true\"\n    className={cn('h-4 w-[8.5px]', flip && 'rotate-180')}\n    fill=\"none\"\n    stroke=\"currentColor\"\n    strokeWidth=\"1.6\"\n    strokeLinecap=\"round\"\n    strokeLinejoin=\"round\"\n  >\n    <path d=\"M7.5 1.5 1.5 8l6 6.5\" />\n  </svg>\n);\n\nconst PauseIcon = () => (\n  <svg viewBox=\"0 0 16 16\" aria-hidden=\"true\" className=\"size-3.5\" fill=\"currentColor\">\n    <rect x=\"3.5\" y=\"2.5\" width=\"3\" height=\"11\" rx=\"1\" />\n    <rect x=\"9.5\" y=\"2.5\" width=\"3\" height=\"11\" rx=\"1\" />\n  </svg>\n);\n\nconst PlayIcon = () => (\n  <svg viewBox=\"0 0 16 16\" aria-hidden=\"true\" className=\"size-3.5\" fill=\"currentColor\">\n    <path d=\"M4.5 2.9v10.2a1 1 0 0 0 1.53.85l8.1-5.1a1 1 0 0 0 0-1.7l-8.1-5.1A1 1 0 0 0 4.5 2.9Z\" />\n  </svg>\n);\n\nconst ArrowUpRight = ({ className }: { className?: string }) => (\n  <svg\n    viewBox=\"0 0 16 16\"\n    aria-hidden=\"true\"\n    className={className}\n    fill=\"none\"\n    stroke=\"currentColor\"\n    strokeWidth=\"1.6\"\n    strokeLinecap=\"round\"\n    strokeLinejoin=\"round\"\n  >\n    <path d=\"M4.5 11.5 11.5 4.5M5.5 4.5h6v6\" />\n  </svg>\n);\n\nconst ArcCarousel = ({\n  items = DEFAULT_ITEMS,\n  label = 'Featured projects',\n  autoplay = true,\n  interval = 5000,\n  className,\n  ...props\n}: ArcCarouselProps) => {\n  const count = items.length;\n  const [position, setPosition] = useState(0);\n  const [playing, setPlaying] = useState(autoplay);\n  const [held, setHeld] = useState(false);\n  const rootRef = useRef<HTMLElement>(null);\n  const ringRef = useRef<SVGCircleElement>(null);\n  const cardRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const track = useRef({ pos: 0 });\n  const clockRef = useRef<gsap.core.Tween | null>(null);\n  const swipe = useRef({ tracking: false, swiped: false, x: 0, y: 0 });\n  const holds = useRef({ hover: false, focus: false, offscreen: false });\n\n  const current = count ? gsap.utils.wrap(0, count, position) : 0;\n  const active = items[current];\n  const offsetOf = (index: number, pos: number) =>\n    gsap.utils.wrap(-count / 2, count / 2, index - pos);\n\n  const step = useCallback((direction: number) => setPosition((p) => p + direction), []);\n\n  // `playing` is the viewer's choice; a hold is a pause they did not ask for\n  const hold = useCallback((key: keyof typeof holds.current, on: boolean) => {\n    holds.current[key] = on;\n    setHeld(Object.values(holds.current).some(Boolean));\n  }, []);\n\n  // Every card is placed from one number, so a step is a single tween and the wrap needs no copies\n  useGSAP(\n    () => {\n      const cards = cardRefs.current.filter((card): card is HTMLDivElement => card !== null);\n      const render = () =>\n        cards.forEach((card, i) => {\n          const s = slot(offsetOf(i, track.current.pos));\n          gsap.set(card, { ...s, pointerEvents: s.opacity > 0.5 ? 'auto' : 'none' });\n        });\n\n      gsap.to(track.current, {\n        pos: position,\n        duration: prefersReducedMotion() ? 0 : MOVE_DURATION,\n        ease: MOVE_EASE,\n        overwrite: true,\n        onUpdate: render,\n      });\n    },\n    { scope: rootRef, dependencies: [position, count] }\n  );\n\n  // The media query is client-only, so reduced motion applies after hydration\n  useEffect(() => {\n    setPlaying(autoplay && !prefersReducedMotion());\n  }, [autoplay]);\n\n  // The clock is the progress ring: it fills over `interval`, then steps. Any step restarts it\n  useGSAP(\n    () => {\n      const ring = ringRef.current;\n      if (!ring || !playing || count < 2) return;\n      clockRef.current = gsap.fromTo(\n        ring,\n        // The attribute, not the CSS property, so the dash stays in `pathLength` units\n        { attr: { 'stroke-dashoffset': 1 } },\n        {\n          attr: { 'stroke-dashoffset': 0 },\n          duration: interval / 1000,\n          ease: 'none',\n          onComplete: () => step(1),\n        }\n      );\n    },\n    { scope: rootRef, dependencies: [playing, position, interval, count], revertOnUpdate: true }\n  );\n\n  useEffect(() => {\n    if (held) clockRef.current?.pause();\n    else clockRef.current?.resume();\n  }, [held, position, playing]);\n\n  useGSAP(\n    () => {\n      if (!autoplay) return;\n      ScrollTrigger.create({\n        trigger: rootRef.current,\n        onToggle: (self) => hold('offscreen', !self.isActive),\n      });\n    },\n    { scope: rootRef, dependencies: [autoplay, hold] }\n  );\n\n  // A swipe can end outside the list\n  useEffect(() => {\n    const onUp = (event: PointerEvent) => {\n      const s = swipe.current;\n      if (!s.tracking) return;\n      s.tracking = false;\n      const dx = event.clientX - s.x;\n      if (Math.abs(dx) > SWIPE && Math.abs(dx) > Math.abs(event.clientY - s.y)) {\n        s.swiped = true;\n        step(dx < 0 ? 1 : -1);\n      }\n    };\n    const onCancel = () => {\n      swipe.current.tracking = false;\n    };\n    window.addEventListener('pointerup', onUp);\n    window.addEventListener('pointercancel', onCancel);\n    return () => {\n      window.removeEventListener('pointerup', onUp);\n      window.removeEventListener('pointercancel', onCancel);\n    };\n  }, [step]);\n\n  // After a swipe, or on a side card, a click steps the arc instead of following the card's link\n  const onClickCapture = (event: MouseEvent<HTMLDivElement>) => {\n    if (swipe.current.swiped) {\n      swipe.current.swiped = false;\n      event.preventDefault();\n      event.stopPropagation();\n      return;\n    }\n    if (event.detail === 0) return;\n    const index = cardRefs.current.findIndex((card) => card?.contains(event.target as Node));\n    const offset = index < 0 ? 0 : Math.round(offsetOf(index, position));\n    if (offset !== 0) {\n      event.preventDefault();\n      event.stopPropagation();\n      step(Math.sign(offset));\n    }\n  };\n\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;\n    event.preventDefault();\n    step(event.key === 'ArrowRight' ? 1 : -1);\n  };\n\n  return (\n    <section\n      ref={rootRef}\n      aria-label={label}\n      className={cn(\n        '@container relative isolate flex w-full flex-col items-center gap-4 overflow-hidden bg-[#03110f] py-16 text-white',\n        '@max-[48rem]:gap-8 @max-[48rem]:py-12',\n        className\n      )}\n      {...props}\n    >\n      <div\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute top-[62%] left-1/2 -z-10 size-[min(120cqw,70rem)] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[radial-gradient(closest-side,color-mix(in_oklch,#045f64_45%,transparent),color-mix(in_oklch,#045f64_10%,transparent)_55%,transparent)]\"\n      />\n\n      {/* Isolated, so the cards' z-indexes stay under the edge fades */}\n      <div\n        className=\"relative isolate z-1 h-150 w-78 @max-[64rem]:h-134 @max-[48rem]:h-72.5\"\n        onPointerEnter={(event) => event.pointerType !== 'touch' && hold('hover', true)}\n        onPointerLeave={() => hold('hover', false)}\n      >\n        <div className=\"absolute inset-0 origin-[50%_0] @max-[64rem]:scale-80 @max-[48rem]:scale-62\">\n          <div\n            role=\"group\"\n            tabIndex={0}\n            aria-roledescription=\"carousel\"\n            aria-label={label}\n            className=\"absolute top-16 left-0 h-107.5 w-78 touch-pan-y rounded-[8px] outline-none select-none focus-visible:ring-2 focus-visible:ring-[#c6f56f] focus-visible:ring-offset-8 focus-visible:ring-offset-[#03110f] @max-[48rem]:top-2\"\n            onPointerDown={(event) => {\n              swipe.current = { tracking: true, swiped: false, x: event.clientX, y: event.clientY };\n            }}\n            onDragStart={(event) => event.preventDefault()}\n            onClickCapture={onClickCapture}\n            onKeyDown={onKeyDown}\n            onFocus={(event) => {\n              let visible = true;\n              try {\n                visible = event.target.matches(':focus-visible');\n              } catch {\n                // No :focus-visible support: treat every focus as visible\n              }\n              if (visible) hold('focus', true);\n            }}\n            onBlur={(event) => {\n              if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {\n                hold('focus', false);\n              }\n            }}\n          >\n            {items.map((item, i) => {\n              const isActive = i === current;\n              const Card = item.href ? 'a' : 'div';\n              // Also set in markup, so the arc is in place before hydration\n              const first = slot(offsetOf(i, 0));\n              return (\n                <div\n                  key={`${item.title}-${i}`}\n                  ref={(el) => {\n                    cardRefs.current[i] = el;\n                  }}\n                  role=\"group\"\n                  aria-roledescription=\"slide\"\n                  aria-label={`${i + 1} of ${count}: ${item.title}`}\n                  aria-hidden={isActive ? undefined : true}\n                  data-active={isActive ? '' : undefined}\n                  className=\"group/card absolute top-0 left-0 h-107.5 w-78 will-change-transform\"\n                  style={{\n                    transform: `translate(${first.x}px, ${first.y}px) rotate(${first.rotation}deg) scale(${first.scale})`,\n                    opacity: first.opacity,\n                    zIndex: first.zIndex,\n                  }}\n                >\n                  <Card\n                    {...(item.href ? { href: item.href } : {})}\n                    tabIndex={item.href && isActive ? 0 : -1}\n                    draggable={false}\n                    className={cn(\n                      'relative block size-full overflow-hidden rounded-[8px] bg-[#071d1b]',\n                      'shadow-[0_30px_60px_-24px_rgb(0_0_0/0.75)]',\n                      'focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#c6f56f]'\n                    )}\n                  >\n                    <img\n                      src={item.image}\n                      srcSet={srcSetOf(item.image)}\n                      alt=\"\"\n                      width={312}\n                      height={430}\n                      draggable={false}\n                      decoding=\"async\"\n                      loading={Math.abs(offsetOf(i, 0)) <= 2 ? 'eager' : 'lazy'}\n                      className=\"absolute inset-0 size-full object-cover\"\n                    />\n                    <span\n                      aria-hidden=\"true\"\n                      className=\"absolute inset-0 bg-[#03110f]/40 transition-opacity duration-500 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-active/card:opacity-0 motion-reduce:transition-none\"\n                    />\n                    <span\n                      aria-hidden=\"true\"\n                      className=\"absolute inset-x-0 bottom-0 h-3/5 bg-linear-to-t from-[#03110f] via-[#03110f]/60 to-transparent\"\n                    />\n\n                    <span className=\"absolute inset-x-4 top-4 flex items-center justify-between gap-3\">\n                      {item.tag ? (\n                        <span className=\"truncate rounded-full bg-[#03110f]/70 px-3 py-1.5 text-[11px] leading-none font-medium tracking-[0.12em] text-white uppercase ring-1 ring-white/10\">\n                          {item.tag}\n                        </span>\n                      ) : (\n                        <span />\n                      )}\n                      <span className=\"shrink-0 text-xs font-medium text-white/70 tabular-nums\">\n                        {pad2(i + 1)}\n                        <span className=\"text-white/35\"> / {pad2(count)}</span>\n                      </span>\n                    </span>\n\n                    <span className=\"absolute inset-x-0 bottom-0 flex items-end justify-between gap-4 p-5\">\n                      <span className=\"grid min-w-0 gap-1.5\">\n                        <span className=\"truncate text-[22px] leading-tight font-semibold tracking-[-0.025em] text-white\">\n                          {item.title}\n                        </span>\n                        {item.meta && (\n                          <span className=\"truncate text-sm text-[#9fd4d6]/80\">{item.meta}</span>\n                        )}\n                      </span>\n                      {item.href && (\n                        <span\n                          aria-hidden=\"true\"\n                          className={cn(\n                            'grid size-11 shrink-0 place-items-center rounded-full bg-white/10 text-white ring-1 ring-white/20',\n                            'transition-[background-color,color,box-shadow] duration-500 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none',\n                            'group-data-active/card:bg-[#c6f56f] group-data-active/card:text-[#03110f] group-data-active/card:ring-transparent'\n                          )}\n                        >\n                          <ArrowUpRight className=\"size-4 transition-transform duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] group-hover/card:rotate-45 motion-reduce:transition-none\" />\n                        </span>\n                      )}\n                    </span>\n\n                    <span\n                      aria-hidden=\"true\"\n                      className=\"pointer-events-none absolute inset-0 rounded-[8px] ring-1 ring-white/10 transition-shadow duration-500 ease-[cubic-bezier(0.32,0.72,0,1)] ring-inset group-data-active/card:ring-2 group-data-active/card:ring-[#c6f56f]/80 motion-reduce:transition-none\"\n                    />\n                  </Card>\n                </div>\n              );\n            })}\n          </div>\n        </div>\n      </div>\n\n      <div className=\"relative z-3 flex items-center gap-2\">\n        <button\n          className={STEP}\n          type=\"button\"\n          aria-label=\"Previous project\"\n          onClick={() => step(-1)}\n        >\n          <Caret />\n        </button>\n        <button className={STEP} type=\"button\" aria-label=\"Next project\" onClick={() => step(1)}>\n          <Caret flip />\n        </button>\n        {autoplay && (\n          <button\n            className={cn(STEP, 'relative ml-2')}\n            type=\"button\"\n            aria-label={playing ? 'Pause autoplay' : 'Start autoplay'}\n            onClick={() => setPlaying((on) => !on)}\n          >\n            {playing ? <PauseIcon /> : <PlayIcon />}\n            <svg\n              aria-hidden=\"true\"\n              viewBox=\"0 0 44 44\"\n              className={cn(\n                'pointer-events-none absolute -inset-px -rotate-90 transition-opacity duration-300',\n                playing ? 'opacity-100' : 'opacity-0'\n              )}\n            >\n              <circle\n                ref={ringRef}\n                cx=\"22\"\n                cy=\"22\"\n                r=\"21\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                strokeWidth=\"1.5\"\n                pathLength={1}\n                strokeDasharray=\"1\"\n                strokeDashoffset=\"1\"\n                className=\"text-[#c6f56f]\"\n              />\n            </svg>\n          </button>\n        )}\n      </div>\n\n      {/* Quiet while autoplaying, so a screen reader is not interrupted every few seconds */}\n      <p aria-live={playing && !held ? 'off' : 'polite'} className=\"sr-only\">\n        {active ? `${current + 1} of ${count}: ${active.title}` : ''}\n      </p>\n\n      <div aria-hidden=\"true\" className={cn(FADE, 'left-0 bg-linear-to-r')} />\n      <div aria-hidden=\"true\" className={cn(FADE, 'right-0 bg-linear-to-l')} />\n    </section>\n  );\n};\n\nexport default ArcCarousel;\n
" } + }, + "navbar-gooey-bloom": { + "react": { + "code": "'use client';\n\nimport {\n useCallback,\n useEffect,\n useId,\n useRef,\n useState,\n type ComponentPropsWithoutRef,\n type MouseEventHandler,\n type ReactNode,\n} from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { cn } from '@/lib/utils';\nimport DotBloomButton from './dot-bloom-button';\n\ngsap.registerPlugin(useGSAP);\n\nconst prefersReducedMotion = () =>\n typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nexport type NavbarGooeyBloomIcon =\n 'bloom' | 'drop' | 'orbit' | 'spark' | 'layers' | 'wave' | 'bolt' | 'shield';\n\nexport interface NavbarGooeyBloomLink {\n label: string;\n href: string;\n description?: string;\n icon?: NavbarGooeyBloomIcon;\n}\n\nexport interface NavbarGooeyBloomFeature {\n title: string;\n description: string;\n href: string;\n cta?: string;\n image?: string;\n}\n\nexport interface NavbarGooeyBloomMenu {\n label: string;\n href?: string;\n columns?: NavbarGooeyBloomLink[][];\n feature?: NavbarGooeyBloomFeature;\n}\n\nexport interface NavbarGooeyBloomProps extends Omit<\n ComponentPropsWithoutRef<'section'>,\n 'children'\n> {\n brand?: string;\n logo?: ReactNode;\n homeHref?: string;\n menus?: NavbarGooeyBloomMenu[];\n currentHref?: string;\n actionLabel?: string;\n onActionClick?: MouseEventHandler;\n children?: ReactNode;\n}\n\nconst CLOSE_DELAY = 160;\nconst HOVER_INTENT = 70;\nconst HIDE_AFTER = 220;\nconst PANEL_GAP = 12;\nconst PANEL_RADIUS = 26;\nconst DROP_SIZE = 22;\nconst EDGE_LEAD = 520;\nconst EDGE_TRAIL = 170;\nconst TAIL_PULL = 110;\nconst PANEL_LEAD = 300;\nconst PANEL_TRAIL = 140;\nconst PANEL_PULL = 260;\nconst BUMP_RADIUS = 14;\nconst BUMP_RISE = 6;\nconst BUMP_PULL = 150;\nconst ICON_SIZE = 32;\nconst ICON_DROP = 14;\nconst SCROLL_TOLERANCE = 6;\nconst DESKTOP_QUERY = '(min-width: 1024px)';\n\nconst CONTAINER = 'mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8';\nconst EASE_OUT_EXPO = 'ease-[cubic-bezier(0.16,1,0.3,1)]';\nconst focusRing =\n 'focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#c6f56f]';\n\nconst TWEEN_MARK = (\n \n \n \n \n \n);\n\nconst ICON_PATHS: Record = {\n bloom: (\n <>\n \n \n \n \n ),\n drop: ,\n orbit: (\n <>\n \n \n \n ),\n spark: ,\n layers: (\n <>\n \n \n \n ),\n wave: ,\n bolt: ,\n shield: ,\n};\n\nconst DEFAULT_MENUS: NavbarGooeyBloomMenu[] = [\n {\n label: 'Work',\n columns: [\n [\n {\n label: 'Case studies',\n href: '#',\n description: 'Shipped products, start to finish.',\n icon: 'bloom',\n },\n {\n label: 'Experiments',\n href: '#',\n description: 'Small ideas, pushed too far.',\n icon: 'spark',\n },\n {\n label: 'Playground',\n href: '#',\n description: 'Live code you can poke at.',\n icon: 'orbit',\n },\n {\n label: 'Archive',\n href: '#',\n description: 'Everything since 2019.',\n icon: 'layers',\n },\n ],\n [\n {\n label: 'Motion kit',\n href: '#',\n description: 'Easing, springs and timing.',\n icon: 'wave',\n },\n {\n label: 'Interface kit',\n href: '#',\n description: 'Bars, panels and overlays.',\n icon: 'drop',\n },\n {\n label: 'Brand systems',\n href: '#',\n description: 'Marks that know how to move.',\n icon: 'shield',\n },\n {\n label: 'Performance',\n href: '#',\n description: 'Sixty frames, no excuses.',\n icon: 'bolt',\n },\n ],\n ],\n feature: {\n title: 'Bloom 2.0',\n description: 'The metaball engine under this bar, documented end to end.',\n href: '#',\n cta: 'Read the notes',\n },\n },\n {\n label: 'Components',\n columns: [\n [\n {\n label: 'Navbars',\n href: '/components',\n description: 'Eleven ways to hold a menu.',\n icon: 'layers',\n },\n {\n label: 'Buttons',\n href: '/components',\n description: 'Press, roll, bloom.',\n icon: 'drop',\n },\n {\n label: 'Carousels',\n href: '/components',\n description: 'Arcs, shutters and reels.',\n icon: 'orbit',\n },\n {\n label: 'Loaders',\n href: '/components',\n description: 'Waiting, but on purpose.',\n icon: 'spark',\n },\n {\n label: 'Type',\n href: '/components',\n description: 'Letters that misbehave.',\n icon: 'wave',\n },\n ],\n ],\n },\n {\n label: 'Studio',\n columns: [\n [\n { label: 'About', href: '#', description: 'Four people, one room.', icon: 'bloom' },\n { label: 'Team', href: '#', description: 'Who makes what.', icon: 'shield' },\n { label: 'Careers', href: '#', description: 'Two seats open.', icon: 'bolt' },\n { label: 'Press', href: '#', description: 'Logos and words.', icon: 'layers' },\n ],\n [\n { label: 'Contact', href: '#', description: 'Say hello properly.', icon: 'drop' },\n {\n label: 'Changelog',\n href: '#',\n description: 'What moved this week.',\n icon: 'wave',\n },\n { label: 'License', href: '#', description: 'What you may ship.', icon: 'orbit' },\n {\n label: 'Support',\n href: '#',\n description: 'We answer on weekdays.',\n icon: 'spark',\n },\n ],\n ],\n },\n { label: 'Journal', href: '#' },\n];\n\nconst cleanHref = (href: string) => href.split(/[?#]/)[0]?.replace(/\\/+$/, '') || '/';\n\nconst isExternal = (href: string) => /^https?:/.test(href);\n\nconst externalProps = (href: string) =>\n isExternal(href) ? { target: '_blank', rel: 'noopener noreferrer' } : {};\n\nconst clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);\n\nconst panelWidth = (menu: NavbarGooeyBloomMenu) => {\n const columns = menu.columns?.length ?? 0;\n if (menu.feature) return columns > 1 ? 880 : 720;\n return columns > 1 ? 720 : 360;\n};\n\nconst panelGrid = (menu: NavbarGooeyBloomMenu) => {\n const columns = menu.columns?.length ?? 0;\n if (menu.feature) return columns > 1 ? 'grid-cols-[1fr_1fr_15rem]' : 'grid-cols-[1fr_15rem]';\n return columns > 1 ? 'grid-cols-2' : 'grid-cols-1';\n};\n\ninterface Spring {\n x: number;\n v: number;\n t: number;\n}\n\ninterface GooMotion {\n left: Spring;\n right: Spring;\n tail: Spring;\n panelLeft: Spring;\n panelRight: Spring;\n panelHeight: Spring;\n bump: Spring;\n rowTop: Spring;\n rowBottom: Spring;\n rowLeft: Spring;\n rowWidth: Spring;\n iconTop: Spring;\n iconBottom: Spring;\n iconTail: Spring;\n iconOffset: number;\n look: { scale: number; alpha: number };\n hover: { alpha: number };\n pillShown: boolean;\n panelShown: boolean;\n hoverShown: boolean;\n barBottom: number;\n awake: boolean;\n}\n\nconst spring = (): Spring => ({ x: 0, v: 0, t: 0 });\n\nconst place = (target: Spring, value: number) => {\n target.x = value;\n target.v = 0;\n target.t = value;\n};\n\nconst snap = (target: Spring) => {\n target.x = target.t;\n target.v = 0;\n};\n\nconst stepSpring = (target: Spring, stiffness: number, dt: number) => {\n const force = stiffness * (target.t - target.x) - 2 * Math.sqrt(stiffness) * target.v;\n target.v += force * dt;\n target.x += target.v * dt;\n};\n\nconst atRest = (target: Spring, epsilon: number) =>\n Math.abs(target.t - target.x) < epsilon && Math.abs(target.v) < epsilon;\n\nconst createMotion = (): GooMotion => ({\n left: spring(),\n right: spring(),\n tail: spring(),\n panelLeft: spring(),\n panelRight: spring(),\n panelHeight: spring(),\n bump: spring(),\n rowTop: spring(),\n rowBottom: spring(),\n rowLeft: spring(),\n rowWidth: spring(),\n iconTop: spring(),\n iconBottom: spring(),\n iconTail: spring(),\n iconOffset: 0,\n look: { scale: 0.4, alpha: 0 },\n hover: { alpha: 0 },\n pillShown: false,\n panelShown: false,\n hoverShown: false,\n barBottom: 60,\n awake: false,\n});\n\nconst springsOf = (motion: GooMotion) => [\n motion.left,\n motion.right,\n motion.tail,\n motion.panelLeft,\n motion.panelRight,\n motion.panelHeight,\n motion.bump,\n motion.rowTop,\n motion.rowBottom,\n motion.rowLeft,\n motion.rowWidth,\n motion.iconTop,\n motion.iconBottom,\n motion.iconTail,\n];\n\nconst settleMotion = (motion: GooMotion) => {\n const center = (motion.left.t + motion.right.t) / 2;\n motion.tail.t = center;\n motion.bump.t = center;\n motion.iconTail.t = (motion.iconTop.t + motion.iconBottom.t) / 2;\n springsOf(motion).forEach(snap);\n};\n\nconst motionAtRest = (motion: GooMotion) =>\n springsOf(motion).every((item) => atRest(item, 0.05)) &&\n !gsap.isTweening(motion.look) &&\n !gsap.isTweening(motion.hover);\n\nconst steer = (start: Spring, end: Spring) => {\n const heading = start.t + end.t - start.x - end.x;\n return {\n start: heading > 1 ? EDGE_TRAIL : EDGE_LEAD,\n end: heading < -1 ? EDGE_TRAIL : EDGE_LEAD,\n };\n};\n\nconst px = (value: number) => Math.round(value * 100) / 100;\n\nconst roundedRect = (left: number, right: number, top: number, height: number) => {\n const radius = px(Math.max(0, Math.min(PANEL_RADIUS, (right - left) / 2, height / 2)));\n const bottom = px(top + height);\n const l = px(left);\n const r = px(right);\n return `M${px(l + radius)},${top}H${px(r - radius)}A${radius},${radius} 0 0 1 ${r},${px(top + radius)}V${px(bottom - radius)}A${radius},${radius} 0 0 1 ${px(r - radius)},${bottom}H${px(l + radius)}A${radius},${radius} 0 0 1 ${l},${px(bottom - radius)}V${px(top + radius)}A${radius},${radius} 0 0 1 ${px(l + radius)},${top}Z`;\n};\n\nconst ArrowIcon = ({ className }: { className?: string }) => {\n return (\n \n \n \n );\n};\n\nconst ChevronIcon = ({ className }: { className?: string }) => {\n return (\n \n \n \n );\n};\n\nconst MenuGlyph = ({ icon, className }: { icon: NavbarGooeyBloomIcon; className?: string }) => {\n return (\n \n {ICON_PATHS[icon]}\n \n );\n};\n\nconst FeatureArt = () => {\n const rawId = useId().replace(/[^a-zA-Z0-9_-]/g, '');\n const gooId = `feature-goo-${rawId}`;\n const limeId = `feature-lime-${rawId}`;\n\n return (\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n );\n};\n\nconst SamplePage = () => {\n return (\n
\n \n \n\n
\n

\n \n Metaball navigation\n

\n

\n The menu that melts between links.\n

\n

\n Point at a link. A pool of lime stretches across the bar, sheds a few droplets on the way,\n and settles under the label. Open a menu and the card below re-forms around it.\n

\n
\n \n See it again\n \n \n \n Read the props\n \n
\n
\n
\n );\n};\n\nconst NavbarGooeyBloom = ({\n brand = 'Tween UI',\n logo = TWEEN_MARK,\n homeHref = '/',\n menus = DEFAULT_MENUS,\n currentHref = '/',\n actionLabel = 'Get started',\n onActionClick,\n children,\n className,\n ...props\n}: NavbarGooeyBloomProps) => {\n const [openKey, setOpenKey] = useState(null);\n const [mobileOpen, setMobileOpen] = useState(false);\n const [accordion, setAccordion] = useState(null);\n\n const rawId = useId().replace(/[^a-zA-Z0-9_-]/g, '');\n const gooId = `goo-${rawId}`;\n const linkGooId = `link-goo-${rawId}`;\n const panelGooId = `panel-goo-${rawId}`;\n const panelId = `panel-${rawId}`;\n const mobileId = `mobile-${rawId}`;\n\n const rootRef = useRef(null);\n const barRef = useRef(null);\n const shellRef = useRef(null);\n const scrollerRef = useRef(null);\n const panelRef = useRef(null);\n const bodyRef = useRef(null);\n const shapeRef = useRef(null);\n const bumpRef = useRef(null);\n const hoverRef = useRef<{ row: HTMLElement; blob: HTMLElement; drop: HTMLElement } | null>(null);\n const pillRef = useRef(null);\n const coreRef = useRef(null);\n const dropRef = useRef(null);\n const inkRefs = useRef>([]);\n const inkBoxes = useRef>([]);\n const motion = useRef(createMotion());\n const activeRef = useRef(null);\n const sectionRefs = useRef>({});\n const triggerRefs = useRef>({});\n const closeTimer = useRef(null);\n const hoverTimer = useRef(null);\n const pendingHover = useRef<{ menu: NavbarGooeyBloomMenu; el: HTMLElement } | null>(null);\n const pointerPos = useRef({ x: 0, y: 0 });\n const mobileRef = useRef(null);\n const toggleRef = useRef(null);\n const mobileCloseRef = useRef(null);\n const openRef = useRef(null);\n const skipFocusOpen = useRef(false);\n\n const current = (href: string) => {\n if (href.startsWith('#')) return false;\n const link = cleanHref(href);\n const page = cleanHref(currentHref);\n return link === page || (link !== '/' && page.startsWith(`${link}/`));\n };\n\n const { contextSafe } = useGSAP(\n () => {\n if (prefersReducedMotion()) return;\n const bar = barRef.current;\n if (!bar) return;\n const intro = bar.querySelectorAll('[data-intro]');\n gsap.fromTo(\n bar,\n { y: -28, autoAlpha: 0 },\n {\n y: 0,\n autoAlpha: 1,\n duration: 1,\n ease: 'expo.out',\n clearProps: 'transform,opacity,visibility',\n }\n );\n gsap.fromTo(\n intro,\n { y: -8, autoAlpha: 0 },\n {\n y: 0,\n autoAlpha: 1,\n duration: 0.7,\n ease: 'power3.out',\n stagger: 0.06,\n delay: 0.12,\n clearProps: 'transform,opacity,visibility',\n }\n );\n },\n { scope: rootRef }\n );\n\n const measureInk = useCallback(() => {\n const bar = barRef.current;\n if (!bar) return;\n const barRect = bar.getBoundingClientRect();\n inkRefs.current.forEach((ink, index) => {\n const host = ink?.parentElement;\n if (!host) return;\n const box = host.getBoundingClientRect();\n inkBoxes.current[index] = { left: box.left - barRect.left, width: box.width };\n });\n }, []);\n\n const draw = useCallback(() => {\n const m = motion.current;\n const left = m.left.x;\n const right = m.right.x;\n const width = Math.max(right - left, 0);\n const stretch = width - (m.right.t - m.left.t);\n const thin = 1 - clamp(stretch / 320, 0, 0.18);\n const { scale, alpha } = m.look;\n const pill = pillRef.current;\n const core = coreRef.current;\n const drop = dropRef.current;\n\n if (pill && core && drop) {\n pill.style.width = `${width}px`;\n pill.style.transform = `translate3d(${left}px,0,0)`;\n pill.style.opacity = `${alpha}`;\n core.style.transform = `scale(${scale},${scale * thin})`;\n drop.style.transform = `translate3d(${m.tail.x - DROP_SIZE / 2}px,0,0) scale(${scale})`;\n drop.style.opacity = `${alpha}`;\n }\n\n inkRefs.current.forEach((ink, index) => {\n const box = inkBoxes.current[index];\n if (!ink || !box) return;\n const start = clamp(left - box.left, 0, box.width);\n const end = clamp(right - box.left, 0, box.width);\n ink.style.clipPath = `inset(-4px ${box.width - end}px -4px ${start}px)`;\n ink.style.opacity = `${alpha}`;\n });\n\n const hover = hoverRef.current;\n if (hover) {\n const alpha = m.hover.alpha;\n const iconHeight = Math.max(m.iconBottom.x - m.iconTop.x, 0);\n const iconStretch = iconHeight - (m.iconBottom.t - m.iconTop.t);\n const iconWidth = ICON_SIZE * (1 - clamp(iconStretch / 160, 0, 0.3));\n const iconX = m.rowLeft.x + m.iconOffset;\n hover.row.style.transform = `translate3d(${m.rowLeft.x}px,${m.rowTop.x}px,0)`;\n hover.row.style.width = `${Math.max(m.rowWidth.x, 0)}px`;\n hover.row.style.height = `${Math.max(m.rowBottom.x - m.rowTop.x, 0)}px`;\n hover.row.style.opacity = `${alpha}`;\n hover.blob.style.transform = `translate3d(${iconX + (ICON_SIZE - iconWidth) / 2}px,${m.iconTop.x}px,0)`;\n hover.blob.style.width = `${iconWidth}px`;\n hover.blob.style.height = `${iconHeight}px`;\n hover.blob.style.opacity = `${alpha}`;\n hover.drop.style.transform = `translate3d(${iconX + (ICON_SIZE - ICON_DROP) / 2}px,${m.iconTail.x - ICON_DROP / 2}px,0)`;\n hover.drop.style.opacity = `${alpha}`;\n }\n\n const shape = shapeRef.current;\n const bump = bumpRef.current;\n const body = bodyRef.current;\n if (!m.panelShown || !shape || !bump || !body) return;\n const top = m.barBottom + PANEL_GAP;\n const height = Math.max(m.panelHeight.x, 0);\n const panelWidth = Math.max(m.panelRight.x - m.panelLeft.x, 0);\n const bumpRadius = Math.max(0, Math.min(BUMP_RADIUS, panelWidth / 4, height / 3));\n shape.setAttribute('d', roundedRect(m.panelLeft.x, m.panelRight.x, top, height));\n const edge = Math.min(PANEL_RADIUS, panelWidth / 2) + bumpRadius;\n const bumpX =\n panelWidth > edge * 2\n ? clamp(m.bump.x, m.panelLeft.x + edge, m.panelRight.x - edge)\n : m.panelLeft.x + panelWidth / 2;\n bump.setAttribute('cx', `${px(bumpX)}`);\n bump.setAttribute('cy', `${px(top + bumpRadius - BUMP_RISE * (bumpRadius / BUMP_RADIUS))}`);\n bump.setAttribute('r', `${px(bumpRadius)}`);\n body.style.top = `${top}px`;\n body.style.clipPath = `path('${roundedRect(m.panelLeft.x, m.panelRight.x, 0, height)}')`;\n }, []);\n\n useEffect(() => {\n const tick = (_time: number, deltaTime: number) => {\n const m = motion.current;\n if (!m.awake) return;\n const elapsed = Math.min(deltaTime / 1000, 0.05);\n const steps = Math.max(1, Math.ceil(elapsed * 120));\n const dt = elapsed / steps;\n const pill = steer(m.left, m.right);\n const row = steer(m.rowTop, m.rowBottom);\n const icon = steer(m.iconTop, m.iconBottom);\n const panelHeading = m.panelLeft.t + m.panelRight.t - m.panelLeft.x - m.panelRight.x;\n const panelLeftPull = panelHeading > 1 ? PANEL_TRAIL : PANEL_LEAD;\n const panelRightPull = panelHeading < -1 ? PANEL_TRAIL : PANEL_LEAD;\n\n for (let step = 0; step < steps; step += 1) {\n stepSpring(m.left, pill.start, dt);\n stepSpring(m.right, pill.end, dt);\n const center = (m.left.x + m.right.x) / 2;\n m.tail.t = center;\n stepSpring(m.tail, TAIL_PULL, dt);\n if (m.panelShown) {\n m.bump.t = center;\n stepSpring(m.panelLeft, panelLeftPull, dt);\n stepSpring(m.panelRight, panelRightPull, dt);\n stepSpring(m.panelHeight, PANEL_PULL, dt);\n stepSpring(m.bump, BUMP_PULL, dt);\n }\n stepSpring(m.rowTop, row.start, dt);\n stepSpring(m.rowBottom, row.end, dt);\n stepSpring(m.rowLeft, EDGE_LEAD, dt);\n stepSpring(m.rowWidth, EDGE_LEAD, dt);\n stepSpring(m.iconTop, icon.start, dt);\n stepSpring(m.iconBottom, icon.end, dt);\n m.iconTail.t = (m.iconTop.x + m.iconBottom.x) / 2;\n stepSpring(m.iconTail, TAIL_PULL, dt);\n }\n\n const center = (m.left.x + m.right.x) / 2;\n m.tail.x = m.right.x - m.left.x > 4 ? clamp(m.tail.x, m.left.x + 2, m.right.x - 2) : center;\n m.iconTail.x = clamp(m.iconTail.x, m.iconTop.x - 2, m.iconBottom.x + 2);\n\n if (motionAtRest(m)) {\n settleMotion(m);\n m.awake = false;\n }\n draw();\n };\n\n gsap.ticker.add(tick);\n return () => gsap.ticker.remove(tick);\n }, [draw]);\n\n const hideBlob = contextSafe(() => {\n const m = motion.current;\n if (!m.pillShown) return;\n m.pillShown = false;\n const center = (m.left.t + m.right.t) / 2;\n m.left.t = center - 20;\n m.right.t = center + 20;\n gsap.killTweensOf(m.look);\n if (prefersReducedMotion()) {\n m.look.scale = 0.4;\n m.look.alpha = 0;\n settleMotion(m);\n draw();\n return;\n }\n gsap.to(m.look, { scale: 0.4, alpha: 0, duration: 0.32, ease: 'power2.inOut' });\n m.awake = true;\n });\n\n const moveBlob = contextSafe((el: HTMLElement) => {\n const bar = barRef.current;\n if (!bar) return;\n const m = motion.current;\n const base = bar.getBoundingClientRect();\n const box = el.getBoundingClientRect();\n const left = box.left - base.left;\n const center = left + box.width / 2;\n activeRef.current = el;\n measureInk();\n gsap.killTweensOf(m.look);\n\n if (!m.pillShown && m.look.alpha < 0.05) {\n place(m.left, center - 20);\n place(m.right, center + 20);\n place(m.tail, center);\n m.look.scale = 0.4;\n }\n m.pillShown = true;\n m.left.t = left;\n m.right.t = left + box.width;\n\n if (prefersReducedMotion()) {\n m.look.scale = 1;\n m.look.alpha = 1;\n settleMotion(m);\n draw();\n return;\n }\n gsap.to(m.look, { scale: 1, alpha: 1, duration: 0.45, ease: 'power3.out' });\n m.awake = true;\n });\n\n const resetHover = () => {\n const m = motion.current;\n const hover = hoverRef.current;\n gsap.killTweensOf(m.hover);\n m.hover.alpha = 0;\n m.hoverShown = false;\n hoverRef.current = null;\n if (hover) gsap.set([hover.row, hover.blob, hover.drop], { opacity: 0 });\n };\n\n const moveHover = contextSafe((link: HTMLElement) => {\n const section = link.closest('[data-section]');\n const icon = link.querySelector('[data-icon]');\n const row = section?.querySelector('[data-hover-row]');\n const blob = section?.querySelector('[data-hover-blob]');\n const drop = section?.querySelector('[data-hover-drop]');\n if (!section || !icon || !row || !blob || !drop) return;\n\n const m = motion.current;\n if (hoverRef.current && hoverRef.current.row !== row) resetHover();\n const fresh = !hoverRef.current || (!m.hoverShown && m.hover.alpha < 0.05);\n hoverRef.current = { row, blob, drop };\n\n const base = section.getBoundingClientRect();\n const box = link.getBoundingClientRect();\n const tile = icon.getBoundingClientRect();\n const top = box.top - base.top;\n const left = box.left - base.left;\n const iconTop = tile.top - base.top;\n const iconCenter = iconTop + tile.height / 2;\n m.iconOffset = tile.left - box.left;\n gsap.killTweensOf(m.hover);\n\n if (fresh) {\n place(m.rowTop, top);\n place(m.rowBottom, top + box.height);\n place(m.rowLeft, left);\n place(m.rowWidth, box.width);\n place(m.iconTop, iconCenter - 4);\n place(m.iconBottom, iconCenter + 4);\n place(m.iconTail, iconCenter);\n }\n m.hoverShown = true;\n m.rowTop.t = top;\n m.rowBottom.t = top + box.height;\n m.rowLeft.t = left;\n m.rowWidth.t = box.width;\n m.iconTop.t = iconTop;\n m.iconBottom.t = iconTop + tile.height;\n\n if (prefersReducedMotion()) {\n m.hover.alpha = 1;\n settleMotion(m);\n draw();\n return;\n }\n gsap.to(m.hover, { alpha: 1, duration: 0.3, ease: 'power2.out' });\n m.awake = true;\n });\n\n const hideHover = contextSafe(() => {\n const m = motion.current;\n if (!m.hoverShown) return;\n m.hoverShown = false;\n const center = (m.iconTop.t + m.iconBottom.t) / 2;\n m.iconTop.t = center - 4;\n m.iconBottom.t = center + 4;\n gsap.killTweensOf(m.hover);\n if (prefersReducedMotion()) {\n m.hover.alpha = 0;\n settleMotion(m);\n draw();\n return;\n }\n gsap.to(m.hover, { alpha: 0, duration: 0.28, ease: 'power2.out' });\n m.awake = true;\n });\n\n const getLayout = useCallback((trigger: HTMLElement, section: HTMLElement) => {\n const bar = barRef.current;\n const width = section.offsetWidth;\n const height = section.offsetHeight;\n if (!bar) return { left: 0, width, height, center: width / 2 };\n const barRect = bar.getBoundingClientRect();\n const triggerRect = trigger.getBoundingClientRect();\n const center = triggerRect.left - barRect.left + triggerRect.width / 2;\n const min = 8 - barRect.left;\n const max = window.innerWidth - 8 - width - barRect.left;\n return { left: clamp(center - width / 2, Math.min(min, max), max), width, height, center };\n }, []);\n\n const sectionNodes = () =>\n Object.values(sectionRefs.current).filter((node): node is HTMLDivElement => Boolean(node));\n\n const openMenu = contextSafe((menu: NavbarGooeyBloomMenu, trigger: HTMLElement) => {\n const section = sectionRefs.current[menu.label];\n const panel = panelRef.current;\n const shell = shellRef.current;\n if (!section || !panel || !shell) return;\n if (openRef.current === menu.label) return;\n\n const previous = openRef.current;\n const previousSection = previous ? sectionRefs.current[previous] : null;\n openRef.current = menu.label;\n setOpenKey(menu.label);\n\n const m = motion.current;\n const layout = getLayout(trigger, section);\n const reduced = prefersReducedMotion();\n const items = section.querySelectorAll('[data-item]');\n m.barBottom = shell.offsetHeight;\n resetHover();\n\n gsap.killTweensOf([panel, ...sectionNodes()]);\n sectionNodes().forEach((node) => {\n if (node !== section && node !== previousSection) gsap.set(node, { opacity: 0 });\n });\n gsap.set(section, { left: layout.left, opacity: 1 });\n gsap.set(panel, { opacity: 1 });\n\n if (!m.panelShown) {\n place(m.panelLeft, layout.center - 12);\n place(m.panelRight, layout.center + 12);\n place(m.panelHeight, 24);\n place(m.bump, layout.center);\n m.panelShown = true;\n }\n m.panelLeft.t = layout.left;\n m.panelRight.t = layout.left + layout.width;\n m.panelHeight.t = layout.height;\n\n if (reduced) {\n if (previousSection && previousSection !== section) {\n gsap.set(previousSection, { opacity: 0 });\n }\n gsap.set(items, { y: 0, opacity: 1 });\n settleMotion(m);\n draw();\n return;\n }\n\n m.awake = true;\n if (previousSection && previousSection !== section) {\n gsap.to(previousSection, { opacity: 0, duration: 0.14, ease: 'power2.out' });\n }\n if (items.length) {\n gsap.fromTo(\n items,\n { y: 10, opacity: 0 },\n {\n y: 0,\n opacity: 1,\n duration: 0.42,\n ease: 'power3.out',\n stagger: 0.02,\n delay: previous ? 0.05 : 0.12,\n overwrite: 'auto',\n }\n );\n }\n });\n\n const closeMenu = contextSafe((options?: { clearHighlight?: boolean }) => {\n cancelHover();\n if (options?.clearHighlight) {\n hideBlob();\n }\n if (!openRef.current) return;\n openRef.current = null;\n setOpenKey(null);\n setAccordion(null);\n\n const panel = panelRef.current;\n if (!panel) return;\n const m = motion.current;\n const sections = sectionNodes();\n const center = (m.left.t + m.right.t) / 2;\n gsap.killTweensOf([panel, ...sections]);\n m.panelLeft.t = center - 12;\n m.panelRight.t = center + 12;\n m.panelHeight.t = 24;\n\n const finish = () => {\n m.panelShown = false;\n resetHover();\n gsap.set(sections, { opacity: 0 });\n gsap.set(panel, { opacity: 0 });\n };\n\n if (prefersReducedMotion()) {\n finish();\n return;\n }\n m.awake = true;\n gsap.to(sections, { opacity: 0, duration: 0.12, ease: 'power2.out' });\n gsap.to(panel, {\n opacity: 0,\n duration: 0.24,\n delay: 0.08,\n ease: 'power2.in',\n onComplete: finish,\n });\n });\n\n const cancelClose = () => {\n if (closeTimer.current !== null) {\n window.clearTimeout(closeTimer.current);\n closeTimer.current = null;\n }\n };\n\n const scheduleClose = contextSafe(() => {\n cancelHover();\n cancelClose();\n closeTimer.current = window.setTimeout(() => {\n const bar = barRef.current;\n if (bar) {\n const rect = bar.getBoundingClientRect();\n const { x, y } = pointerPos.current;\n if (\n x >= rect.left - 2 &&\n x <= rect.right + 2 &&\n y >= rect.top - 2 &&\n y <= rect.bottom + 2\n ) {\n return;\n }\n }\n closeMenu({ clearHighlight: true });\n }, CLOSE_DELAY);\n });\n\n const cancelHover = () => {\n if (hoverTimer.current !== null) {\n window.clearTimeout(hoverTimer.current);\n hoverTimer.current = null;\n }\n pendingHover.current = null;\n };\n\n const handleTriggerEnter = (menu: NavbarGooeyBloomMenu, el: HTMLElement) => {\n cancelClose();\n moveBlob(el);\n pendingHover.current = { menu, el };\n if (hoverTimer.current !== null) window.clearTimeout(hoverTimer.current);\n hoverTimer.current = window.setTimeout(() => {\n hoverTimer.current = null;\n const next = pendingHover.current;\n pendingHover.current = null;\n if (!next) return;\n if (next.menu.columns?.length) openMenu(next.menu, next.el);\n else closeMenu();\n }, HOVER_INTENT);\n };\n\n useEffect(() => {\n const scroller = scrollerRef.current;\n const bar = barRef.current;\n if (!scroller || !bar) return;\n let last = scroller.scrollTop;\n let hidden = false;\n\n const onScroll = () => {\n const y = scroller.scrollTop;\n const delta = y - last;\n if (Math.abs(delta) < SCROLL_TOLERANCE) return;\n last = y;\n const next = delta > 0 && y > HIDE_AFTER && !openRef.current;\n if (next === hidden) return;\n hidden = next;\n gsap.to(bar, {\n yPercent: next ? -170 : 0,\n duration: prefersReducedMotion() ? 0 : next ? 0.5 : 0.7,\n ease: next ? 'power3.in' : 'expo.out',\n overwrite: 'auto',\n });\n };\n\n scroller.addEventListener('scroll', onScroll, { passive: true });\n return () => scroller.removeEventListener('scroll', onScroll);\n }, []);\n\n useEffect(() => {\n const onResize = () => {\n const bar = barRef.current;\n const shell = shellRef.current;\n if (!bar || !shell) return;\n const m = motion.current;\n measureInk();\n const active = activeRef.current;\n if (active && m.pillShown) {\n const base = bar.getBoundingClientRect();\n const box = active.getBoundingClientRect();\n m.left.t = box.left - base.left;\n m.right.t = m.left.t + box.width;\n }\n const key = openRef.current;\n const section = key ? sectionRefs.current[key] : null;\n const trigger = key ? triggerRefs.current[key] : null;\n if (section && trigger) {\n const layout = getLayout(trigger, section);\n m.barBottom = shell.offsetHeight;\n m.panelLeft.t = layout.left;\n m.panelRight.t = layout.left + layout.width;\n m.panelHeight.t = layout.height;\n gsap.set(section, { left: layout.left });\n }\n settleMotion(m);\n draw();\n };\n\n window.addEventListener('resize', onResize);\n return () => window.removeEventListener('resize', onResize);\n }, [draw, getLayout, measureInk]);\n\n useEffect(() => {\n if (!openKey) return;\n const desktop = window.matchMedia(DESKTOP_QUERY);\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key !== 'Escape') return;\n const key = openRef.current;\n closeMenu({ clearHighlight: true });\n if (key) {\n skipFocusOpen.current = true;\n triggerRefs.current[key]?.focus();\n skipFocusOpen.current = false;\n }\n };\n\n const onPointerDown = (event: PointerEvent) => {\n if (!barRef.current?.contains(event.target as Node)) {\n closeMenu({ clearHighlight: true });\n }\n };\n\n const onDesktopChange = (event: MediaQueryListEvent) => {\n if (!event.matches) closeMenu({ clearHighlight: true });\n };\n\n document.addEventListener('keydown', onKeyDown);\n document.addEventListener('pointerdown', onPointerDown);\n desktop.addEventListener('change', onDesktopChange);\n return () => {\n document.removeEventListener('keydown', onKeyDown);\n document.removeEventListener('pointerdown', onPointerDown);\n desktop.removeEventListener('change', onDesktopChange);\n };\n }, [openKey, closeMenu]);\n\n useEffect(() => {\n const menu = mobileRef.current;\n if (!menu) return;\n const reduced = prefersReducedMotion();\n const items = menu.querySelectorAll('[data-mobile-item]');\n\n if (mobileOpen) {\n gsap.set(menu, { visibility: 'visible' });\n gsap.fromTo(\n menu,\n { opacity: 0 },\n { opacity: 1, duration: reduced ? 0 : 0.28, ease: 'power2.out' }\n );\n gsap.fromTo(\n items,\n { y: 22, opacity: 0 },\n {\n y: 0,\n opacity: 1,\n duration: reduced ? 0 : 0.45,\n ease: 'power3.out',\n stagger: reduced ? 0 : 0.035,\n delay: reduced ? 0 : 0.08,\n }\n );\n mobileCloseRef.current?.focus();\n } else {\n gsap.to(menu, {\n opacity: 0,\n duration: reduced ? 0 : 0.22,\n ease: 'power2.out',\n onComplete: () => gsap.set(menu, { visibility: 'hidden' }),\n });\n }\n }, [mobileOpen]);\n\n useEffect(() => {\n if (!mobileOpen) return;\n const desktop = window.matchMedia(DESKTOP_QUERY);\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key !== 'Escape') return;\n setMobileOpen(false);\n toggleRef.current?.focus();\n };\n const onChange = (event: MediaQueryListEvent) => {\n if (event.matches) setMobileOpen(false);\n };\n document.addEventListener('keydown', onKeyDown);\n desktop.addEventListener('change', onChange);\n return () => {\n document.removeEventListener('keydown', onKeyDown);\n desktop.removeEventListener('change', onChange);\n };\n }, [mobileOpen]);\n\n useEffect(() => {\n return () => {\n cancelClose();\n if (hoverTimer.current !== null) window.clearTimeout(hoverTimer.current);\n pendingHover.current = null;\n };\n }, []);\n\n return (\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n {\n if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {\n closeMenu({ clearHighlight: true });\n }\n }}\n >\n \n {\n pointerPos.current = { x: event.clientX, y: event.clientY };\n scheduleClose();\n if (!openRef.current) hideBlob();\n }}\n className=\"relative flex h-15 items-center justify-between gap-3 rounded-full border border-[#9fd4d6]/12 bg-[#071d1b]/70 px-3 shadow-[0_24px_60px_-32px_rgb(0_0_0/0.95)] backdrop-blur-xl\"\n >\n \n \n \n \n
\n \n
\n
\n\n \n {logo}\n \n {brand}\n \n \n\n \n\n
\n \n {actionLabel}\n \n\n setMobileOpen((value) => !value)}\n className={cn(\n 'grid size-10 cursor-pointer place-items-center rounded-full border border-[#9fd4d6]/15 bg-white/[0.03] lg:hidden',\n focusRing\n )}\n >\n \n \n \n \n \n
\n
\n\n \n \n \n \n \n \n \n \n {menus.map((menu) => {\n if (!menu.columns?.length) return null;\n const width = panelWidth(menu);\n\n return (\n {\n sectionRefs.current[menu.label] = node;\n }}\n data-section=\"\"\n inert={openKey !== menu.label}\n onPointerLeave={hideHover}\n style={{ width }}\n className={cn(\n 'absolute top-0 left-0 p-2.5 opacity-0',\n openKey === menu.label ? 'pointer-events-auto' : 'pointer-events-none'\n )}\n >\n \n \n \n \n \n \n \n
\n {menu.columns.map((column, columnIndex) => (\n
    \n {column.map((link) => (\n
  • \n closeMenu()}\n onPointerEnter={(event) => moveHover(event.currentTarget)}\n onFocus={(event) => moveHover(event.currentTarget)}\n className={cn(\n 'group/link relative flex items-start gap-3 rounded-2xl p-2.5',\n focusRing\n )}\n >\n \n \n \n \n \n {link.label}\n \n \n {link.description ? (\n \n {link.description}\n \n ) : null}\n \n \n
  • \n ))}\n
\n ))}\n\n {menu.feature ? (\n \n
\n {menu.feature.image ? (\n \n ) : (\n \n )}\n
\n
\n

{menu.feature.title}

\n

\n {menu.feature.description}\n

\n closeMenu()}\n className={cn(\n 'group/feature mt-auto inline-flex items-center gap-1.5 pt-3 text-[12px] font-medium text-[#c6f56f]',\n focusRing\n )}\n >\n {menu.feature.cta ?? 'Explore'}\n \n \n
\n
\n ) : null}\n
\n \n );\n })}\n \n \n \n \n\n \n {children ?? }\n \n\n \n
\n \n {logo}\n {brand}\n \n {\n setMobileOpen(false);\n toggleRef.current?.focus();\n }}\n className={cn(\n 'grid size-10 cursor-pointer place-items-center rounded-full border border-[#9fd4d6]/15 bg-white/[0.04]',\n focusRing\n )}\n >\n \n \n \n \n
\n\n
\n
    \n {menus.map((menu) => {\n const panelMenu = Boolean(menu.columns?.length);\n const isOpen = accordion === menu.label;\n const links = menu.columns?.flat() ?? [];\n const contentId = `${mobileId}-${menu.label.replace(/[^a-zA-Z0-9]/g, '')}`;\n\n return (\n \n {panelMenu ? (\n <>\n setAccordion(isOpen ? null : menu.label)}\n className={cn(\n 'flex w-full cursor-pointer items-center justify-between py-4 text-left',\n focusRing\n )}\n >\n \n {menu.label}\n \n \n \n \n
    \n
      \n {links.map((link) => (\n
    • \n setMobileOpen(false)}\n className={cn(\n 'flex items-center gap-3 rounded-xl px-2 py-2.5 text-[15px] text-[#e8f3f2]/75',\n focusRing\n )}\n >\n \n {link.label}\n \n
    • \n ))}\n
    \n
    \n
\n \n ) : (\n setMobileOpen(false)}\n className={cn(\n 'block py-4 text-[26px] leading-none font-medium tracking-[-0.03em]',\n focusRing\n )}\n >\n {menu.label}\n \n )}\n \n );\n })}\n \n\n
\n {\n setMobileOpen(false);\n onActionClick?.(event);\n }}\n className=\"h-12 w-full rounded-full focus-visible:ring-[#c6f56f] focus-visible:ring-offset-[#04140f]\"\n >\n {actionLabel}\n \n
\n \n \n \n );\n};\n\nexport default NavbarGooeyBloom;\n", + "codeHtml": "
'use client';\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type ComponentPropsWithoutRef,\n  type MouseEventHandler,\n  type ReactNode,\n} from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { cn } from '@/lib/utils';\nimport DotBloomButton from './dot-bloom-button';\n\ngsap.registerPlugin(useGSAP);\n\nconst prefersReducedMotion = () =>\n  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nexport type NavbarGooeyBloomIcon =\n  'bloom' | 'drop' | 'orbit' | 'spark' | 'layers' | 'wave' | 'bolt' | 'shield';\n\nexport interface NavbarGooeyBloomLink {\n  label: string;\n  href: string;\n  description?: string;\n  icon?: NavbarGooeyBloomIcon;\n}\n\nexport interface NavbarGooeyBloomFeature {\n  title: string;\n  description: string;\n  href: string;\n  cta?: string;\n  image?: string;\n}\n\nexport interface NavbarGooeyBloomMenu {\n  label: string;\n  href?: string;\n  columns?: NavbarGooeyBloomLink[][];\n  feature?: NavbarGooeyBloomFeature;\n}\n\nexport interface NavbarGooeyBloomProps extends Omit<\n  ComponentPropsWithoutRef<'section'>,\n  'children'\n> {\n  brand?: string;\n  logo?: ReactNode;\n  homeHref?: string;\n  menus?: NavbarGooeyBloomMenu[];\n  currentHref?: string;\n  actionLabel?: string;\n  onActionClick?: MouseEventHandler<HTMLButtonElement>;\n  children?: ReactNode;\n}\n\nconst CLOSE_DELAY = 160;\nconst HOVER_INTENT = 70;\nconst HIDE_AFTER = 220;\nconst PANEL_GAP = 12;\nconst PANEL_RADIUS = 26;\nconst DROP_SIZE = 22;\nconst EDGE_LEAD = 520;\nconst EDGE_TRAIL = 170;\nconst TAIL_PULL = 110;\nconst PANEL_LEAD = 300;\nconst PANEL_TRAIL = 140;\nconst PANEL_PULL = 260;\nconst BUMP_RADIUS = 14;\nconst BUMP_RISE = 6;\nconst BUMP_PULL = 150;\nconst ICON_SIZE = 32;\nconst ICON_DROP = 14;\nconst SCROLL_TOLERANCE = 6;\nconst DESKTOP_QUERY = '(min-width: 1024px)';\n\nconst CONTAINER = 'mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8';\nconst EASE_OUT_EXPO = 'ease-[cubic-bezier(0.16,1,0.3,1)]';\nconst focusRing =\n  'focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#c6f56f]';\n\nconst TWEEN_MARK = (\n  <svg aria-hidden=\"true\" viewBox=\"0 0 24 24\" className=\"size-7 shrink-0\">\n    <rect width=\"24\" height=\"24\" rx=\"6\" fill=\"#045f64\" />\n    <path\n      d=\"M5 19 C 9.5 8.9, 5 5, 19 5\"\n      fill=\"none\"\n      stroke=\"#c6f56f\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n    />\n    <circle cx=\"19\" cy=\"5\" r=\"1.8\" fill=\"#c6f56f\" />\n  </svg>\n);\n\nconst ICON_PATHS: Record<NavbarGooeyBloomIcon, ReactNode> = {\n  bloom: (\n    <>\n      <circle cx=\"12\" cy=\"9\" r=\"3.6\" />\n      <circle cx=\"8.6\" cy=\"15\" r=\"3.6\" />\n      <circle cx=\"15.4\" cy=\"15\" r=\"3.6\" />\n    </>\n  ),\n  drop: <path d=\"M12 3.5c3.2 3.4 5.5 6 5.5 9a5.5 5.5 0 1 1-11 0c0-3 2.3-5.6 5.5-9Z\" />,\n  orbit: (\n    <>\n      <circle cx=\"12\" cy=\"12\" r=\"3.1\" />\n      <ellipse cx=\"12\" cy=\"12\" rx=\"9\" ry=\"4.4\" transform=\"rotate(-24 12 12)\" />\n    </>\n  ),\n  spark: <path d=\"M12 3.5l2 6.5 6.5 2-6.5 2-2 6.5-2-6.5-6.5-2 6.5-2 2-6.5Z\" />,\n  layers: (\n    <>\n      <path d=\"M12 4 4 8.5l8 4.5 8-4.5L12 4Z\" />\n      <path d=\"m4 12.5 8 4.5 8-4.5\" />\n    </>\n  ),\n  wave: <path d=\"M3 13.5c2-3.6 4-3.6 6 0s4 3.6 6 0 4-3.6 6 0\" />,\n  bolt: <path d=\"M13.5 3 6 13.5h5l-1 7.5 8-11h-5l.5-7Z\" />,\n  shield: <path d=\"M12 3.5 19 6v6c0 4.2-3 7-7 8.5-4-1.5-7-4.3-7-8.5V6l7-2.5Z\" />,\n};\n\nconst DEFAULT_MENUS: NavbarGooeyBloomMenu[] = [\n  {\n    label: 'Work',\n    columns: [\n      [\n        {\n          label: 'Case studies',\n          href: '#',\n          description: 'Shipped products, start to finish.',\n          icon: 'bloom',\n        },\n        {\n          label: 'Experiments',\n          href: '#',\n          description: 'Small ideas, pushed too far.',\n          icon: 'spark',\n        },\n        {\n          label: 'Playground',\n          href: '#',\n          description: 'Live code you can poke at.',\n          icon: 'orbit',\n        },\n        {\n          label: 'Archive',\n          href: '#',\n          description: 'Everything since 2019.',\n          icon: 'layers',\n        },\n      ],\n      [\n        {\n          label: 'Motion kit',\n          href: '#',\n          description: 'Easing, springs and timing.',\n          icon: 'wave',\n        },\n        {\n          label: 'Interface kit',\n          href: '#',\n          description: 'Bars, panels and overlays.',\n          icon: 'drop',\n        },\n        {\n          label: 'Brand systems',\n          href: '#',\n          description: 'Marks that know how to move.',\n          icon: 'shield',\n        },\n        {\n          label: 'Performance',\n          href: '#',\n          description: 'Sixty frames, no excuses.',\n          icon: 'bolt',\n        },\n      ],\n    ],\n    feature: {\n      title: 'Bloom 2.0',\n      description: 'The metaball engine under this bar, documented end to end.',\n      href: '#',\n      cta: 'Read the notes',\n    },\n  },\n  {\n    label: 'Components',\n    columns: [\n      [\n        {\n          label: 'Navbars',\n          href: '/components',\n          description: 'Eleven ways to hold a menu.',\n          icon: 'layers',\n        },\n        {\n          label: 'Buttons',\n          href: '/components',\n          description: 'Press, roll, bloom.',\n          icon: 'drop',\n        },\n        {\n          label: 'Carousels',\n          href: '/components',\n          description: 'Arcs, shutters and reels.',\n          icon: 'orbit',\n        },\n        {\n          label: 'Loaders',\n          href: '/components',\n          description: 'Waiting, but on purpose.',\n          icon: 'spark',\n        },\n        {\n          label: 'Type',\n          href: '/components',\n          description: 'Letters that misbehave.',\n          icon: 'wave',\n        },\n      ],\n    ],\n  },\n  {\n    label: 'Studio',\n    columns: [\n      [\n        { label: 'About', href: '#', description: 'Four people, one room.', icon: 'bloom' },\n        { label: 'Team', href: '#', description: 'Who makes what.', icon: 'shield' },\n        { label: 'Careers', href: '#', description: 'Two seats open.', icon: 'bolt' },\n        { label: 'Press', href: '#', description: 'Logos and words.', icon: 'layers' },\n      ],\n      [\n        { label: 'Contact', href: '#', description: 'Say hello properly.', icon: 'drop' },\n        {\n          label: 'Changelog',\n          href: '#',\n          description: 'What moved this week.',\n          icon: 'wave',\n        },\n        { label: 'License', href: '#', description: 'What you may ship.', icon: 'orbit' },\n        {\n          label: 'Support',\n          href: '#',\n          description: 'We answer on weekdays.',\n          icon: 'spark',\n        },\n      ],\n    ],\n  },\n  { label: 'Journal', href: '#' },\n];\n\nconst cleanHref = (href: string) => href.split(/[?#]/)[0]?.replace(/\\/+$/, '') || '/';\n\nconst isExternal = (href: string) => /^https?:/.test(href);\n\nconst externalProps = (href: string) =>\n  isExternal(href) ? { target: '_blank', rel: 'noopener noreferrer' } : {};\n\nconst clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);\n\nconst panelWidth = (menu: NavbarGooeyBloomMenu) => {\n  const columns = menu.columns?.length ?? 0;\n  if (menu.feature) return columns > 1 ? 880 : 720;\n  return columns > 1 ? 720 : 360;\n};\n\nconst panelGrid = (menu: NavbarGooeyBloomMenu) => {\n  const columns = menu.columns?.length ?? 0;\n  if (menu.feature) return columns > 1 ? 'grid-cols-[1fr_1fr_15rem]' : 'grid-cols-[1fr_15rem]';\n  return columns > 1 ? 'grid-cols-2' : 'grid-cols-1';\n};\n\ninterface Spring {\n  x: number;\n  v: number;\n  t: number;\n}\n\ninterface GooMotion {\n  left: Spring;\n  right: Spring;\n  tail: Spring;\n  panelLeft: Spring;\n  panelRight: Spring;\n  panelHeight: Spring;\n  bump: Spring;\n  rowTop: Spring;\n  rowBottom: Spring;\n  rowLeft: Spring;\n  rowWidth: Spring;\n  iconTop: Spring;\n  iconBottom: Spring;\n  iconTail: Spring;\n  iconOffset: number;\n  look: { scale: number; alpha: number };\n  hover: { alpha: number };\n  pillShown: boolean;\n  panelShown: boolean;\n  hoverShown: boolean;\n  barBottom: number;\n  awake: boolean;\n}\n\nconst spring = (): Spring => ({ x: 0, v: 0, t: 0 });\n\nconst place = (target: Spring, value: number) => {\n  target.x = value;\n  target.v = 0;\n  target.t = value;\n};\n\nconst snap = (target: Spring) => {\n  target.x = target.t;\n  target.v = 0;\n};\n\nconst stepSpring = (target: Spring, stiffness: number, dt: number) => {\n  const force = stiffness * (target.t - target.x) - 2 * Math.sqrt(stiffness) * target.v;\n  target.v += force * dt;\n  target.x += target.v * dt;\n};\n\nconst atRest = (target: Spring, epsilon: number) =>\n  Math.abs(target.t - target.x) < epsilon && Math.abs(target.v) < epsilon;\n\nconst createMotion = (): GooMotion => ({\n  left: spring(),\n  right: spring(),\n  tail: spring(),\n  panelLeft: spring(),\n  panelRight: spring(),\n  panelHeight: spring(),\n  bump: spring(),\n  rowTop: spring(),\n  rowBottom: spring(),\n  rowLeft: spring(),\n  rowWidth: spring(),\n  iconTop: spring(),\n  iconBottom: spring(),\n  iconTail: spring(),\n  iconOffset: 0,\n  look: { scale: 0.4, alpha: 0 },\n  hover: { alpha: 0 },\n  pillShown: false,\n  panelShown: false,\n  hoverShown: false,\n  barBottom: 60,\n  awake: false,\n});\n\nconst springsOf = (motion: GooMotion) => [\n  motion.left,\n  motion.right,\n  motion.tail,\n  motion.panelLeft,\n  motion.panelRight,\n  motion.panelHeight,\n  motion.bump,\n  motion.rowTop,\n  motion.rowBottom,\n  motion.rowLeft,\n  motion.rowWidth,\n  motion.iconTop,\n  motion.iconBottom,\n  motion.iconTail,\n];\n\nconst settleMotion = (motion: GooMotion) => {\n  const center = (motion.left.t + motion.right.t) / 2;\n  motion.tail.t = center;\n  motion.bump.t = center;\n  motion.iconTail.t = (motion.iconTop.t + motion.iconBottom.t) / 2;\n  springsOf(motion).forEach(snap);\n};\n\nconst motionAtRest = (motion: GooMotion) =>\n  springsOf(motion).every((item) => atRest(item, 0.05)) &&\n  !gsap.isTweening(motion.look) &&\n  !gsap.isTweening(motion.hover);\n\nconst steer = (start: Spring, end: Spring) => {\n  const heading = start.t + end.t - start.x - end.x;\n  return {\n    start: heading > 1 ? EDGE_TRAIL : EDGE_LEAD,\n    end: heading < -1 ? EDGE_TRAIL : EDGE_LEAD,\n  };\n};\n\nconst px = (value: number) => Math.round(value * 100) / 100;\n\nconst roundedRect = (left: number, right: number, top: number, height: number) => {\n  const radius = px(Math.max(0, Math.min(PANEL_RADIUS, (right - left) / 2, height / 2)));\n  const bottom = px(top + height);\n  const l = px(left);\n  const r = px(right);\n  return `M${px(l + radius)},${top}H${px(r - radius)}A${radius},${radius} 0 0 1 ${r},${px(top + radius)}V${px(bottom - radius)}A${radius},${radius} 0 0 1 ${px(r - radius)},${bottom}H${px(l + radius)}A${radius},${radius} 0 0 1 ${l},${px(bottom - radius)}V${px(top + radius)}A${radius},${radius} 0 0 1 ${px(l + radius)},${top}Z`;\n};\n\nconst ArrowIcon = ({ className }: { className?: string }) => {\n  return (\n    <svg\n      aria-hidden=\"true\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"1.8\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n    >\n      <path d=\"M5 12h13M13 6l6 6-6 6\" />\n    </svg>\n  );\n};\n\nconst ChevronIcon = ({ className }: { className?: string }) => {\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 10 6\" fill=\"none\" className={className}>\n      <path\n        d=\"M1 1l4 4 4-4\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.4\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n};\n\nconst MenuGlyph = ({ icon, className }: { icon: NavbarGooeyBloomIcon; className?: string }) => {\n  return (\n    <svg\n      aria-hidden=\"true\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"1.5\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n    >\n      {ICON_PATHS[icon]}\n    </svg>\n  );\n};\n\nconst FeatureArt = () => {\n  const rawId = useId().replace(/[^a-zA-Z0-9_-]/g, '');\n  const gooId = `feature-goo-${rawId}`;\n  const limeId = `feature-lime-${rawId}`;\n\n  return (\n    <span aria-hidden=\"true\" className=\"absolute inset-0 overflow-hidden bg-[#081f1a]\">\n      <span className=\"absolute inset-0 bg-[radial-gradient(120%_130%_at_50%_-10%,#17493c,#081f1a_70%)]\" />\n      <span className=\"absolute inset-0 [background-image:radial-gradient(rgb(159_212_214/0.16)_1px,transparent_1px)] [mask-image:radial-gradient(72%_72%_at_50%_50%,#000,transparent)] [background-size:10px_10px] opacity-60\" />\n      <span className=\"absolute top-1/2 left-1/2 size-32 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[#c6f56f]/15 blur-2xl\" />\n      <svg viewBox=\"0 0 240 112\" className=\"absolute inset-0 size-full\">\n        <defs>\n          <filter id={gooId} x=\"-25%\" y=\"-80%\" width=\"150%\" height=\"260%\">\n            <feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"3\" result=\"blur\" />\n            <feColorMatrix\n              in=\"blur\"\n              mode=\"matrix\"\n              values=\"1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 18 -7\"\n              result=\"goo\"\n            />\n            <feBlend in=\"SourceGraphic\" in2=\"goo\" />\n          </filter>\n          <linearGradient id={limeId} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n            <stop offset=\"0\" stopColor=\"#e9ffc2\" />\n            <stop offset=\"0.45\" stopColor=\"#c6f56f\" />\n            <stop offset=\"1\" stopColor=\"#a6df58\" />\n          </linearGradient>\n        </defs>\n        <g stroke=\"#9fd4d6\" fill=\"none\">\n          <ellipse cx=\"120\" cy=\"56\" rx=\"88\" ry=\"30\" strokeOpacity=\"0.14\" />\n          <ellipse cx=\"120\" cy=\"56\" rx=\"62\" ry=\"42\" strokeOpacity=\"0.07\" />\n        </g>\n        <g filter={`url(#${gooId})`} fill={`url(#${limeId})`}>\n          <rect x=\"74\" y=\"42\" width=\"84\" height=\"30\" rx=\"15\" />\n          <circle cx=\"66\" cy=\"60\" r=\"8.5\" />\n          <circle cx=\"162\" cy=\"52\" r=\"7\" />\n          <circle cx=\"168\" cy=\"63\" r=\"4.5\" />\n        </g>\n      </svg>\n    </span>\n  );\n};\n\nconst SamplePage = () => {\n  return (\n    <div id=\"top\" className=\"[container-type:size] relative h-full overflow-hidden bg-[#03110f]\">\n      <span\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0 bg-[radial-gradient(46%_60%_at_50%_-10%,rgb(198_245_111/0.12),transparent_70%),radial-gradient(50%_60%_at_90%_20%,rgb(4_95_100/0.55),transparent_72%),radial-gradient(45%_55%_at_4%_60%,rgb(4_95_100/0.3),transparent_70%)]\"\n      />\n      <span\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0 bg-[repeating-linear-gradient(90deg,rgb(159_212_214/0.05)_0_1px,transparent_1px_56px),repeating-linear-gradient(0deg,rgb(159_212_214/0.04)_0_1px,transparent_1px_56px)] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,black,transparent)]\"\n      />\n\n      <section className=\"relative mx-auto flex h-full max-w-7xl flex-col justify-center px-4 pt-[clamp(5rem,16cqh,8rem)] pb-[clamp(1.5rem,5cqh,3.5rem)] sm:px-6 lg:px-8\">\n        <p className=\"inline-flex w-fit items-center gap-2.5 rounded-full bg-white/[0.04] py-[clamp(0.4rem,1.1cqh,0.55rem)] pr-4 pl-2.5 text-[10px] leading-none font-medium tracking-[0.22em] text-[#9fd4d6] uppercase ring-1 ring-white/10\">\n          <span\n            aria-hidden=\"true\"\n            className=\"size-2 rounded-full bg-[#c6f56f] shadow-[0_0_6px_#c6f56f,0_0_16px_#c6f56f]\"\n          />\n          Metaball navigation\n        </p>\n        <h2 className=\"mt-[clamp(1.1rem,3.4cqh,1.75rem)] max-w-[36rem] text-[clamp(2.35rem,7.8cqh,4.75rem)] leading-[0.95] font-medium tracking-[-0.045em] text-balance text-[#e8f3f2]\">\n          The menu that <span className=\"text-[#c6f56f]\">melts</span> between links.\n        </h2>\n        <p className=\"mt-[clamp(0.9rem,2.4cqh,1.5rem)] max-w-[36rem] text-[clamp(0.84rem,1.9cqh,1rem)] leading-relaxed text-pretty text-[#9fd4d6]/70\">\n          Point at a link. A pool of lime stretches across the bar, sheds a few droplets on the way,\n          and settles under the label. Open a menu and the card below re-forms around it.\n        </p>\n        <div className=\"mt-[clamp(1.4rem,4cqh,2.25rem)] flex flex-wrap items-center gap-3\">\n          <a\n            href=\"#top\"\n            className={cn(\n              'inline-flex h-[clamp(2.5rem,6.2cqh,2.75rem)] items-center gap-2 rounded-full bg-[#c6f56f] px-[clamp(1rem,2.4cqh,1.25rem)] text-[clamp(0.82rem,1.8cqh,0.875rem)] font-medium text-[#03110f] transition-transform duration-300 active:scale-[0.97]',\n              EASE_OUT_EXPO,\n              focusRing\n            )}\n          >\n            See it again\n            <ArrowIcon className=\"size-4\" />\n          </a>\n          <a\n            href=\"#top\"\n            className={cn(\n              'inline-flex h-[clamp(2.5rem,6.2cqh,2.75rem)] items-center rounded-full border border-[#9fd4d6]/20 px-[clamp(1rem,2.4cqh,1.25rem)] text-[clamp(0.82rem,1.8cqh,0.875rem)] text-[#e8f3f2]/80 transition-colors duration-300 hover:border-[#9fd4d6]/45 hover:text-[#e8f3f2]',\n              focusRing\n            )}\n          >\n            Read the props\n          </a>\n        </div>\n      </section>\n    </div>\n  );\n};\n\nconst NavbarGooeyBloom = ({\n  brand = 'Tween UI',\n  logo = TWEEN_MARK,\n  homeHref = '/',\n  menus = DEFAULT_MENUS,\n  currentHref = '/',\n  actionLabel = 'Get started',\n  onActionClick,\n  children,\n  className,\n  ...props\n}: NavbarGooeyBloomProps) => {\n  const [openKey, setOpenKey] = useState<string | null>(null);\n  const [mobileOpen, setMobileOpen] = useState(false);\n  const [accordion, setAccordion] = useState<string | null>(null);\n\n  const rawId = useId().replace(/[^a-zA-Z0-9_-]/g, '');\n  const gooId = `goo-${rawId}`;\n  const linkGooId = `link-goo-${rawId}`;\n  const panelGooId = `panel-goo-${rawId}`;\n  const panelId = `panel-${rawId}`;\n  const mobileId = `mobile-${rawId}`;\n\n  const rootRef = useRef<HTMLElement>(null);\n  const barRef = useRef<HTMLDivElement>(null);\n  const shellRef = useRef<HTMLDivElement>(null);\n  const scrollerRef = useRef<HTMLDivElement>(null);\n  const panelRef = useRef<HTMLDivElement>(null);\n  const bodyRef = useRef<HTMLDivElement>(null);\n  const shapeRef = useRef<SVGPathElement>(null);\n  const bumpRef = useRef<SVGCircleElement>(null);\n  const hoverRef = useRef<{ row: HTMLElement; blob: HTMLElement; drop: HTMLElement } | null>(null);\n  const pillRef = useRef<HTMLSpanElement>(null);\n  const coreRef = useRef<HTMLSpanElement>(null);\n  const dropRef = useRef<HTMLSpanElement>(null);\n  const inkRefs = useRef<Array<HTMLSpanElement | null>>([]);\n  const inkBoxes = useRef<Array<{ left: number; width: number } | null>>([]);\n  const motion = useRef<GooMotion>(createMotion());\n  const activeRef = useRef<HTMLElement | null>(null);\n  const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({});\n  const triggerRefs = useRef<Record<string, HTMLButtonElement | null>>({});\n  const closeTimer = useRef<number | null>(null);\n  const hoverTimer = useRef<number | null>(null);\n  const pendingHover = useRef<{ menu: NavbarGooeyBloomMenu; el: HTMLElement } | null>(null);\n  const pointerPos = useRef({ x: 0, y: 0 });\n  const mobileRef = useRef<HTMLDivElement>(null);\n  const toggleRef = useRef<HTMLButtonElement>(null);\n  const mobileCloseRef = useRef<HTMLButtonElement>(null);\n  const openRef = useRef<string | null>(null);\n  const skipFocusOpen = useRef(false);\n\n  const current = (href: string) => {\n    if (href.startsWith('#')) return false;\n    const link = cleanHref(href);\n    const page = cleanHref(currentHref);\n    return link === page || (link !== '/' && page.startsWith(`${link}/`));\n  };\n\n  const { contextSafe } = useGSAP(\n    () => {\n      if (prefersReducedMotion()) return;\n      const bar = barRef.current;\n      if (!bar) return;\n      const intro = bar.querySelectorAll('[data-intro]');\n      gsap.fromTo(\n        bar,\n        { y: -28, autoAlpha: 0 },\n        {\n          y: 0,\n          autoAlpha: 1,\n          duration: 1,\n          ease: 'expo.out',\n          clearProps: 'transform,opacity,visibility',\n        }\n      );\n      gsap.fromTo(\n        intro,\n        { y: -8, autoAlpha: 0 },\n        {\n          y: 0,\n          autoAlpha: 1,\n          duration: 0.7,\n          ease: 'power3.out',\n          stagger: 0.06,\n          delay: 0.12,\n          clearProps: 'transform,opacity,visibility',\n        }\n      );\n    },\n    { scope: rootRef }\n  );\n\n  const measureInk = useCallback(() => {\n    const bar = barRef.current;\n    if (!bar) return;\n    const barRect = bar.getBoundingClientRect();\n    inkRefs.current.forEach((ink, index) => {\n      const host = ink?.parentElement;\n      if (!host) return;\n      const box = host.getBoundingClientRect();\n      inkBoxes.current[index] = { left: box.left - barRect.left, width: box.width };\n    });\n  }, []);\n\n  const draw = useCallback(() => {\n    const m = motion.current;\n    const left = m.left.x;\n    const right = m.right.x;\n    const width = Math.max(right - left, 0);\n    const stretch = width - (m.right.t - m.left.t);\n    const thin = 1 - clamp(stretch / 320, 0, 0.18);\n    const { scale, alpha } = m.look;\n    const pill = pillRef.current;\n    const core = coreRef.current;\n    const drop = dropRef.current;\n\n    if (pill && core && drop) {\n      pill.style.width = `${width}px`;\n      pill.style.transform = `translate3d(${left}px,0,0)`;\n      pill.style.opacity = `${alpha}`;\n      core.style.transform = `scale(${scale},${scale * thin})`;\n      drop.style.transform = `translate3d(${m.tail.x - DROP_SIZE / 2}px,0,0) scale(${scale})`;\n      drop.style.opacity = `${alpha}`;\n    }\n\n    inkRefs.current.forEach((ink, index) => {\n      const box = inkBoxes.current[index];\n      if (!ink || !box) return;\n      const start = clamp(left - box.left, 0, box.width);\n      const end = clamp(right - box.left, 0, box.width);\n      ink.style.clipPath = `inset(-4px ${box.width - end}px -4px ${start}px)`;\n      ink.style.opacity = `${alpha}`;\n    });\n\n    const hover = hoverRef.current;\n    if (hover) {\n      const alpha = m.hover.alpha;\n      const iconHeight = Math.max(m.iconBottom.x - m.iconTop.x, 0);\n      const iconStretch = iconHeight - (m.iconBottom.t - m.iconTop.t);\n      const iconWidth = ICON_SIZE * (1 - clamp(iconStretch / 160, 0, 0.3));\n      const iconX = m.rowLeft.x + m.iconOffset;\n      hover.row.style.transform = `translate3d(${m.rowLeft.x}px,${m.rowTop.x}px,0)`;\n      hover.row.style.width = `${Math.max(m.rowWidth.x, 0)}px`;\n      hover.row.style.height = `${Math.max(m.rowBottom.x - m.rowTop.x, 0)}px`;\n      hover.row.style.opacity = `${alpha}`;\n      hover.blob.style.transform = `translate3d(${iconX + (ICON_SIZE - iconWidth) / 2}px,${m.iconTop.x}px,0)`;\n      hover.blob.style.width = `${iconWidth}px`;\n      hover.blob.style.height = `${iconHeight}px`;\n      hover.blob.style.opacity = `${alpha}`;\n      hover.drop.style.transform = `translate3d(${iconX + (ICON_SIZE - ICON_DROP) / 2}px,${m.iconTail.x - ICON_DROP / 2}px,0)`;\n      hover.drop.style.opacity = `${alpha}`;\n    }\n\n    const shape = shapeRef.current;\n    const bump = bumpRef.current;\n    const body = bodyRef.current;\n    if (!m.panelShown || !shape || !bump || !body) return;\n    const top = m.barBottom + PANEL_GAP;\n    const height = Math.max(m.panelHeight.x, 0);\n    const panelWidth = Math.max(m.panelRight.x - m.panelLeft.x, 0);\n    const bumpRadius = Math.max(0, Math.min(BUMP_RADIUS, panelWidth / 4, height / 3));\n    shape.setAttribute('d', roundedRect(m.panelLeft.x, m.panelRight.x, top, height));\n    const edge = Math.min(PANEL_RADIUS, panelWidth / 2) + bumpRadius;\n    const bumpX =\n      panelWidth > edge * 2\n        ? clamp(m.bump.x, m.panelLeft.x + edge, m.panelRight.x - edge)\n        : m.panelLeft.x + panelWidth / 2;\n    bump.setAttribute('cx', `${px(bumpX)}`);\n    bump.setAttribute('cy', `${px(top + bumpRadius - BUMP_RISE * (bumpRadius / BUMP_RADIUS))}`);\n    bump.setAttribute('r', `${px(bumpRadius)}`);\n    body.style.top = `${top}px`;\n    body.style.clipPath = `path('${roundedRect(m.panelLeft.x, m.panelRight.x, 0, height)}')`;\n  }, []);\n\n  useEffect(() => {\n    const tick = (_time: number, deltaTime: number) => {\n      const m = motion.current;\n      if (!m.awake) return;\n      const elapsed = Math.min(deltaTime / 1000, 0.05);\n      const steps = Math.max(1, Math.ceil(elapsed * 120));\n      const dt = elapsed / steps;\n      const pill = steer(m.left, m.right);\n      const row = steer(m.rowTop, m.rowBottom);\n      const icon = steer(m.iconTop, m.iconBottom);\n      const panelHeading = m.panelLeft.t + m.panelRight.t - m.panelLeft.x - m.panelRight.x;\n      const panelLeftPull = panelHeading > 1 ? PANEL_TRAIL : PANEL_LEAD;\n      const panelRightPull = panelHeading < -1 ? PANEL_TRAIL : PANEL_LEAD;\n\n      for (let step = 0; step < steps; step += 1) {\n        stepSpring(m.left, pill.start, dt);\n        stepSpring(m.right, pill.end, dt);\n        const center = (m.left.x + m.right.x) / 2;\n        m.tail.t = center;\n        stepSpring(m.tail, TAIL_PULL, dt);\n        if (m.panelShown) {\n          m.bump.t = center;\n          stepSpring(m.panelLeft, panelLeftPull, dt);\n          stepSpring(m.panelRight, panelRightPull, dt);\n          stepSpring(m.panelHeight, PANEL_PULL, dt);\n          stepSpring(m.bump, BUMP_PULL, dt);\n        }\n        stepSpring(m.rowTop, row.start, dt);\n        stepSpring(m.rowBottom, row.end, dt);\n        stepSpring(m.rowLeft, EDGE_LEAD, dt);\n        stepSpring(m.rowWidth, EDGE_LEAD, dt);\n        stepSpring(m.iconTop, icon.start, dt);\n        stepSpring(m.iconBottom, icon.end, dt);\n        m.iconTail.t = (m.iconTop.x + m.iconBottom.x) / 2;\n        stepSpring(m.iconTail, TAIL_PULL, dt);\n      }\n\n      const center = (m.left.x + m.right.x) / 2;\n      m.tail.x = m.right.x - m.left.x > 4 ? clamp(m.tail.x, m.left.x + 2, m.right.x - 2) : center;\n      m.iconTail.x = clamp(m.iconTail.x, m.iconTop.x - 2, m.iconBottom.x + 2);\n\n      if (motionAtRest(m)) {\n        settleMotion(m);\n        m.awake = false;\n      }\n      draw();\n    };\n\n    gsap.ticker.add(tick);\n    return () => gsap.ticker.remove(tick);\n  }, [draw]);\n\n  const hideBlob = contextSafe(() => {\n    const m = motion.current;\n    if (!m.pillShown) return;\n    m.pillShown = false;\n    const center = (m.left.t + m.right.t) / 2;\n    m.left.t = center - 20;\n    m.right.t = center + 20;\n    gsap.killTweensOf(m.look);\n    if (prefersReducedMotion()) {\n      m.look.scale = 0.4;\n      m.look.alpha = 0;\n      settleMotion(m);\n      draw();\n      return;\n    }\n    gsap.to(m.look, { scale: 0.4, alpha: 0, duration: 0.32, ease: 'power2.inOut' });\n    m.awake = true;\n  });\n\n  const moveBlob = contextSafe((el: HTMLElement) => {\n    const bar = barRef.current;\n    if (!bar) return;\n    const m = motion.current;\n    const base = bar.getBoundingClientRect();\n    const box = el.getBoundingClientRect();\n    const left = box.left - base.left;\n    const center = left + box.width / 2;\n    activeRef.current = el;\n    measureInk();\n    gsap.killTweensOf(m.look);\n\n    if (!m.pillShown && m.look.alpha < 0.05) {\n      place(m.left, center - 20);\n      place(m.right, center + 20);\n      place(m.tail, center);\n      m.look.scale = 0.4;\n    }\n    m.pillShown = true;\n    m.left.t = left;\n    m.right.t = left + box.width;\n\n    if (prefersReducedMotion()) {\n      m.look.scale = 1;\n      m.look.alpha = 1;\n      settleMotion(m);\n      draw();\n      return;\n    }\n    gsap.to(m.look, { scale: 1, alpha: 1, duration: 0.45, ease: 'power3.out' });\n    m.awake = true;\n  });\n\n  const resetHover = () => {\n    const m = motion.current;\n    const hover = hoverRef.current;\n    gsap.killTweensOf(m.hover);\n    m.hover.alpha = 0;\n    m.hoverShown = false;\n    hoverRef.current = null;\n    if (hover) gsap.set([hover.row, hover.blob, hover.drop], { opacity: 0 });\n  };\n\n  const moveHover = contextSafe((link: HTMLElement) => {\n    const section = link.closest<HTMLElement>('[data-section]');\n    const icon = link.querySelector<HTMLElement>('[data-icon]');\n    const row = section?.querySelector<HTMLElement>('[data-hover-row]');\n    const blob = section?.querySelector<HTMLElement>('[data-hover-blob]');\n    const drop = section?.querySelector<HTMLElement>('[data-hover-drop]');\n    if (!section || !icon || !row || !blob || !drop) return;\n\n    const m = motion.current;\n    if (hoverRef.current && hoverRef.current.row !== row) resetHover();\n    const fresh = !hoverRef.current || (!m.hoverShown && m.hover.alpha < 0.05);\n    hoverRef.current = { row, blob, drop };\n\n    const base = section.getBoundingClientRect();\n    const box = link.getBoundingClientRect();\n    const tile = icon.getBoundingClientRect();\n    const top = box.top - base.top;\n    const left = box.left - base.left;\n    const iconTop = tile.top - base.top;\n    const iconCenter = iconTop + tile.height / 2;\n    m.iconOffset = tile.left - box.left;\n    gsap.killTweensOf(m.hover);\n\n    if (fresh) {\n      place(m.rowTop, top);\n      place(m.rowBottom, top + box.height);\n      place(m.rowLeft, left);\n      place(m.rowWidth, box.width);\n      place(m.iconTop, iconCenter - 4);\n      place(m.iconBottom, iconCenter + 4);\n      place(m.iconTail, iconCenter);\n    }\n    m.hoverShown = true;\n    m.rowTop.t = top;\n    m.rowBottom.t = top + box.height;\n    m.rowLeft.t = left;\n    m.rowWidth.t = box.width;\n    m.iconTop.t = iconTop;\n    m.iconBottom.t = iconTop + tile.height;\n\n    if (prefersReducedMotion()) {\n      m.hover.alpha = 1;\n      settleMotion(m);\n      draw();\n      return;\n    }\n    gsap.to(m.hover, { alpha: 1, duration: 0.3, ease: 'power2.out' });\n    m.awake = true;\n  });\n\n  const hideHover = contextSafe(() => {\n    const m = motion.current;\n    if (!m.hoverShown) return;\n    m.hoverShown = false;\n    const center = (m.iconTop.t + m.iconBottom.t) / 2;\n    m.iconTop.t = center - 4;\n    m.iconBottom.t = center + 4;\n    gsap.killTweensOf(m.hover);\n    if (prefersReducedMotion()) {\n      m.hover.alpha = 0;\n      settleMotion(m);\n      draw();\n      return;\n    }\n    gsap.to(m.hover, { alpha: 0, duration: 0.28, ease: 'power2.out' });\n    m.awake = true;\n  });\n\n  const getLayout = useCallback((trigger: HTMLElement, section: HTMLElement) => {\n    const bar = barRef.current;\n    const width = section.offsetWidth;\n    const height = section.offsetHeight;\n    if (!bar) return { left: 0, width, height, center: width / 2 };\n    const barRect = bar.getBoundingClientRect();\n    const triggerRect = trigger.getBoundingClientRect();\n    const center = triggerRect.left - barRect.left + triggerRect.width / 2;\n    const min = 8 - barRect.left;\n    const max = window.innerWidth - 8 - width - barRect.left;\n    return { left: clamp(center - width / 2, Math.min(min, max), max), width, height, center };\n  }, []);\n\n  const sectionNodes = () =>\n    Object.values(sectionRefs.current).filter((node): node is HTMLDivElement => Boolean(node));\n\n  const openMenu = contextSafe((menu: NavbarGooeyBloomMenu, trigger: HTMLElement) => {\n    const section = sectionRefs.current[menu.label];\n    const panel = panelRef.current;\n    const shell = shellRef.current;\n    if (!section || !panel || !shell) return;\n    if (openRef.current === menu.label) return;\n\n    const previous = openRef.current;\n    const previousSection = previous ? sectionRefs.current[previous] : null;\n    openRef.current = menu.label;\n    setOpenKey(menu.label);\n\n    const m = motion.current;\n    const layout = getLayout(trigger, section);\n    const reduced = prefersReducedMotion();\n    const items = section.querySelectorAll('[data-item]');\n    m.barBottom = shell.offsetHeight;\n    resetHover();\n\n    gsap.killTweensOf([panel, ...sectionNodes()]);\n    sectionNodes().forEach((node) => {\n      if (node !== section && node !== previousSection) gsap.set(node, { opacity: 0 });\n    });\n    gsap.set(section, { left: layout.left, opacity: 1 });\n    gsap.set(panel, { opacity: 1 });\n\n    if (!m.panelShown) {\n      place(m.panelLeft, layout.center - 12);\n      place(m.panelRight, layout.center + 12);\n      place(m.panelHeight, 24);\n      place(m.bump, layout.center);\n      m.panelShown = true;\n    }\n    m.panelLeft.t = layout.left;\n    m.panelRight.t = layout.left + layout.width;\n    m.panelHeight.t = layout.height;\n\n    if (reduced) {\n      if (previousSection && previousSection !== section) {\n        gsap.set(previousSection, { opacity: 0 });\n      }\n      gsap.set(items, { y: 0, opacity: 1 });\n      settleMotion(m);\n      draw();\n      return;\n    }\n\n    m.awake = true;\n    if (previousSection && previousSection !== section) {\n      gsap.to(previousSection, { opacity: 0, duration: 0.14, ease: 'power2.out' });\n    }\n    if (items.length) {\n      gsap.fromTo(\n        items,\n        { y: 10, opacity: 0 },\n        {\n          y: 0,\n          opacity: 1,\n          duration: 0.42,\n          ease: 'power3.out',\n          stagger: 0.02,\n          delay: previous ? 0.05 : 0.12,\n          overwrite: 'auto',\n        }\n      );\n    }\n  });\n\n  const closeMenu = contextSafe((options?: { clearHighlight?: boolean }) => {\n    cancelHover();\n    if (options?.clearHighlight) {\n      hideBlob();\n    }\n    if (!openRef.current) return;\n    openRef.current = null;\n    setOpenKey(null);\n    setAccordion(null);\n\n    const panel = panelRef.current;\n    if (!panel) return;\n    const m = motion.current;\n    const sections = sectionNodes();\n    const center = (m.left.t + m.right.t) / 2;\n    gsap.killTweensOf([panel, ...sections]);\n    m.panelLeft.t = center - 12;\n    m.panelRight.t = center + 12;\n    m.panelHeight.t = 24;\n\n    const finish = () => {\n      m.panelShown = false;\n      resetHover();\n      gsap.set(sections, { opacity: 0 });\n      gsap.set(panel, { opacity: 0 });\n    };\n\n    if (prefersReducedMotion()) {\n      finish();\n      return;\n    }\n    m.awake = true;\n    gsap.to(sections, { opacity: 0, duration: 0.12, ease: 'power2.out' });\n    gsap.to(panel, {\n      opacity: 0,\n      duration: 0.24,\n      delay: 0.08,\n      ease: 'power2.in',\n      onComplete: finish,\n    });\n  });\n\n  const cancelClose = () => {\n    if (closeTimer.current !== null) {\n      window.clearTimeout(closeTimer.current);\n      closeTimer.current = null;\n    }\n  };\n\n  const scheduleClose = contextSafe(() => {\n    cancelHover();\n    cancelClose();\n    closeTimer.current = window.setTimeout(() => {\n      const bar = barRef.current;\n      if (bar) {\n        const rect = bar.getBoundingClientRect();\n        const { x, y } = pointerPos.current;\n        if (\n          x >= rect.left - 2 &&\n          x <= rect.right + 2 &&\n          y >= rect.top - 2 &&\n          y <= rect.bottom + 2\n        ) {\n          return;\n        }\n      }\n      closeMenu({ clearHighlight: true });\n    }, CLOSE_DELAY);\n  });\n\n  const cancelHover = () => {\n    if (hoverTimer.current !== null) {\n      window.clearTimeout(hoverTimer.current);\n      hoverTimer.current = null;\n    }\n    pendingHover.current = null;\n  };\n\n  const handleTriggerEnter = (menu: NavbarGooeyBloomMenu, el: HTMLElement) => {\n    cancelClose();\n    moveBlob(el);\n    pendingHover.current = { menu, el };\n    if (hoverTimer.current !== null) window.clearTimeout(hoverTimer.current);\n    hoverTimer.current = window.setTimeout(() => {\n      hoverTimer.current = null;\n      const next = pendingHover.current;\n      pendingHover.current = null;\n      if (!next) return;\n      if (next.menu.columns?.length) openMenu(next.menu, next.el);\n      else closeMenu();\n    }, HOVER_INTENT);\n  };\n\n  useEffect(() => {\n    const scroller = scrollerRef.current;\n    const bar = barRef.current;\n    if (!scroller || !bar) return;\n    let last = scroller.scrollTop;\n    let hidden = false;\n\n    const onScroll = () => {\n      const y = scroller.scrollTop;\n      const delta = y - last;\n      if (Math.abs(delta) < SCROLL_TOLERANCE) return;\n      last = y;\n      const next = delta > 0 && y > HIDE_AFTER && !openRef.current;\n      if (next === hidden) return;\n      hidden = next;\n      gsap.to(bar, {\n        yPercent: next ? -170 : 0,\n        duration: prefersReducedMotion() ? 0 : next ? 0.5 : 0.7,\n        ease: next ? 'power3.in' : 'expo.out',\n        overwrite: 'auto',\n      });\n    };\n\n    scroller.addEventListener('scroll', onScroll, { passive: true });\n    return () => scroller.removeEventListener('scroll', onScroll);\n  }, []);\n\n  useEffect(() => {\n    const onResize = () => {\n      const bar = barRef.current;\n      const shell = shellRef.current;\n      if (!bar || !shell) return;\n      const m = motion.current;\n      measureInk();\n      const active = activeRef.current;\n      if (active && m.pillShown) {\n        const base = bar.getBoundingClientRect();\n        const box = active.getBoundingClientRect();\n        m.left.t = box.left - base.left;\n        m.right.t = m.left.t + box.width;\n      }\n      const key = openRef.current;\n      const section = key ? sectionRefs.current[key] : null;\n      const trigger = key ? triggerRefs.current[key] : null;\n      if (section && trigger) {\n        const layout = getLayout(trigger, section);\n        m.barBottom = shell.offsetHeight;\n        m.panelLeft.t = layout.left;\n        m.panelRight.t = layout.left + layout.width;\n        m.panelHeight.t = layout.height;\n        gsap.set(section, { left: layout.left });\n      }\n      settleMotion(m);\n      draw();\n    };\n\n    window.addEventListener('resize', onResize);\n    return () => window.removeEventListener('resize', onResize);\n  }, [draw, getLayout, measureInk]);\n\n  useEffect(() => {\n    if (!openKey) return;\n    const desktop = window.matchMedia(DESKTOP_QUERY);\n\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key !== 'Escape') return;\n      const key = openRef.current;\n      closeMenu({ clearHighlight: true });\n      if (key) {\n        skipFocusOpen.current = true;\n        triggerRefs.current[key]?.focus();\n        skipFocusOpen.current = false;\n      }\n    };\n\n    const onPointerDown = (event: PointerEvent) => {\n      if (!barRef.current?.contains(event.target as Node)) {\n        closeMenu({ clearHighlight: true });\n      }\n    };\n\n    const onDesktopChange = (event: MediaQueryListEvent) => {\n      if (!event.matches) closeMenu({ clearHighlight: true });\n    };\n\n    document.addEventListener('keydown', onKeyDown);\n    document.addEventListener('pointerdown', onPointerDown);\n    desktop.addEventListener('change', onDesktopChange);\n    return () => {\n      document.removeEventListener('keydown', onKeyDown);\n      document.removeEventListener('pointerdown', onPointerDown);\n      desktop.removeEventListener('change', onDesktopChange);\n    };\n  }, [openKey, closeMenu]);\n\n  useEffect(() => {\n    const menu = mobileRef.current;\n    if (!menu) return;\n    const reduced = prefersReducedMotion();\n    const items = menu.querySelectorAll('[data-mobile-item]');\n\n    if (mobileOpen) {\n      gsap.set(menu, { visibility: 'visible' });\n      gsap.fromTo(\n        menu,\n        { opacity: 0 },\n        { opacity: 1, duration: reduced ? 0 : 0.28, ease: 'power2.out' }\n      );\n      gsap.fromTo(\n        items,\n        { y: 22, opacity: 0 },\n        {\n          y: 0,\n          opacity: 1,\n          duration: reduced ? 0 : 0.45,\n          ease: 'power3.out',\n          stagger: reduced ? 0 : 0.035,\n          delay: reduced ? 0 : 0.08,\n        }\n      );\n      mobileCloseRef.current?.focus();\n    } else {\n      gsap.to(menu, {\n        opacity: 0,\n        duration: reduced ? 0 : 0.22,\n        ease: 'power2.out',\n        onComplete: () => gsap.set(menu, { visibility: 'hidden' }),\n      });\n    }\n  }, [mobileOpen]);\n\n  useEffect(() => {\n    if (!mobileOpen) return;\n    const desktop = window.matchMedia(DESKTOP_QUERY);\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key !== 'Escape') return;\n      setMobileOpen(false);\n      toggleRef.current?.focus();\n    };\n    const onChange = (event: MediaQueryListEvent) => {\n      if (event.matches) setMobileOpen(false);\n    };\n    document.addEventListener('keydown', onKeyDown);\n    desktop.addEventListener('change', onChange);\n    return () => {\n      document.removeEventListener('keydown', onKeyDown);\n      desktop.removeEventListener('change', onChange);\n    };\n  }, [mobileOpen]);\n\n  useEffect(() => {\n    return () => {\n      cancelClose();\n      if (hoverTimer.current !== null) window.clearTimeout(hoverTimer.current);\n      pendingHover.current = null;\n    };\n  }, []);\n\n  return (\n    <section\n      ref={rootRef}\n      aria-label={`${brand} site`}\n      className={cn(\n        'relative isolate h-svh w-full overflow-hidden bg-[#03110f] text-[#e8f3f2]',\n        className\n      )}\n      {...props}\n    >\n      <svg aria-hidden=\"true\" className=\"pointer-events-none absolute size-0\">\n        <defs>\n          <filter\n            id={gooId}\n            x=\"-40%\"\n            y=\"-250%\"\n            width=\"180%\"\n            height=\"600%\"\n            colorInterpolationFilters=\"sRGB\"\n          >\n            <feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"5\" result=\"blur\" />\n            <feColorMatrix\n              in=\"blur\"\n              mode=\"matrix\"\n              values=\"1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 22 -9\"\n              result=\"goo\"\n            />\n            <feBlend in=\"SourceGraphic\" in2=\"goo\" />\n          </filter>\n          <filter\n            id={linkGooId}\n            x=\"-10%\"\n            y=\"-10%\"\n            width=\"120%\"\n            height=\"120%\"\n            colorInterpolationFilters=\"sRGB\"\n          >\n            <feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"3.5\" result=\"blur\" />\n            <feColorMatrix\n              in=\"blur\"\n              mode=\"matrix\"\n              values=\"1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 20 -8\"\n              result=\"goo\"\n            />\n            <feComposite in=\"SourceGraphic\" in2=\"goo\" operator=\"atop\" />\n          </filter>\n          <filter\n            id={panelGooId}\n            x=\"-15%\"\n            y=\"-15%\"\n            width=\"130%\"\n            height=\"130%\"\n            colorInterpolationFilters=\"sRGB\"\n          >\n            <feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"7\" result=\"blur\" />\n            <feColorMatrix\n              in=\"blur\"\n              mode=\"matrix\"\n              values=\"1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 22 -10\"\n            />\n          </filter>\n        </defs>\n      </svg>\n\n      <header\n        className={cn(CONTAINER, 'pointer-events-none absolute inset-x-0 top-0 z-30 pt-5')}\n        onBlur={(event) => {\n          if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {\n            closeMenu({ clearHighlight: true });\n          }\n        }}\n      >\n        <div\n          ref={barRef}\n          className=\"pointer-events-auto relative mx-auto w-full max-w-[26rem] md:max-w-[40rem] lg:max-w-[61.25rem] xl:max-w-[73.75rem]\"\n        >\n          <div\n            ref={shellRef}\n            onPointerEnter={cancelClose}\n            onPointerLeave={(event) => {\n              pointerPos.current = { x: event.clientX, y: event.clientY };\n              scheduleClose();\n              if (!openRef.current) hideBlob();\n            }}\n            className=\"relative flex h-15 items-center justify-between gap-3 rounded-full border border-[#9fd4d6]/12 bg-[#071d1b]/70 px-3 shadow-[0_24px_60px_-32px_rgb(0_0_0/0.95)] backdrop-blur-xl\"\n          >\n            <span\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute inset-0 overflow-hidden rounded-full\"\n            >\n              <span\n                className=\"absolute inset-0\"\n                style={{\n                  filter: `url(#${gooId}) drop-shadow(0 0 14px rgb(198 245 111 / 0.2))`,\n                }}\n              >\n                <span\n                  ref={pillRef}\n                  className=\"absolute top-1/2 left-0 h-10 w-0 -translate-y-1/2 opacity-0\"\n                >\n                  <span\n                    ref={coreRef}\n                    className=\"block size-full rounded-full bg-[radial-gradient(120%_160%_at_30%_0%,#eaffc4,#c6f56f_48%,#a8e35a)]\"\n                  />\n                </span>\n                <span\n                  ref={dropRef}\n                  style={{ width: DROP_SIZE, height: DROP_SIZE }}\n                  className=\"absolute top-1/2 left-0 -translate-y-1/2 rounded-full bg-[#c6f56f] opacity-0\"\n                />\n              </span>\n            </span>\n\n            <a\n              href={homeHref}\n              data-intro=\"\"\n              aria-label={`${brand} home`}\n              className={cn(\n                'relative z-10 flex shrink-0 items-center gap-2.5 rounded-full pr-1',\n                focusRing\n              )}\n            >\n              {logo}\n              <span className=\"hidden text-[17px] font-medium tracking-tight sm:block\">\n                {brand}\n              </span>\n            </a>\n\n            <nav aria-label=\"Primary\" className=\"pointer-events-auto relative hidden lg:block\">\n              <ul className=\"relative z-10 flex items-center\">\n                {menus.map((menu, index) => {\n                  const panelMenu = Boolean(menu.columns?.length);\n                  const isOpen = openKey === menu.label;\n                  const labelClass = cn(\n                    'relative flex h-10 items-center rounded-full px-3.5 text-[13.5px] font-medium tracking-[-0.01em] transition-colors duration-200 text-[#e8f3f2]/75 hover:text-[#e8f3f2]'\n                  );\n                  const chevronClass = cn(\n                    'w-[9px] transition-transform duration-400',\n                    EASE_OUT_EXPO,\n                    isOpen && 'rotate-180'\n                  );\n\n                  return (\n                    <li key={menu.label} className=\"relative\">\n                      {panelMenu ? (\n                        <button\n                          ref={(node) => {\n                            triggerRefs.current[menu.label] = node;\n                          }}\n                          type=\"button\"\n                          aria-expanded={isOpen}\n                          aria-controls={panelId}\n                          aria-haspopup=\"true\"\n                          onPointerEnter={(event) => handleTriggerEnter(menu, event.currentTarget)}\n                          onFocus={(event) => {\n                            cancelHover();\n                            moveBlob(event.currentTarget);\n                            if (skipFocusOpen.current) {\n                              skipFocusOpen.current = false;\n                              return;\n                            }\n                            openMenu(menu, event.currentTarget);\n                          }}\n                          onClick={() => {\n                            const trigger = triggerRefs.current[menu.label];\n                            if (!trigger) return;\n                            if (isOpen) closeMenu();\n                            else openMenu(menu, trigger);\n                          }}\n                          className={cn(labelClass, 'cursor-pointer', focusRing)}\n                        >\n                          <span className=\"relative flex items-center gap-1.5\">\n                            <span>{menu.label}</span>\n                            <ChevronIcon className={chevronClass} />\n                            <span\n                              aria-hidden=\"true\"\n                              ref={(node) => {\n                                inkRefs.current[index] = node;\n                              }}\n                              style={{ clipPath: 'inset(0 100% 0 0)' }}\n                              className=\"pointer-events-none absolute inset-0 flex items-center gap-1.5 text-[#03110f] opacity-0\"\n                            >\n                              {menu.label}\n                              <ChevronIcon className={chevronClass} />\n                            </span>\n                          </span>\n                        </button>\n                      ) : (\n                        <a\n                          href={menu.href ?? '#'}\n                          {...externalProps(menu.href ?? '')}\n                          aria-current={menu.href && current(menu.href) ? 'page' : undefined}\n                          onPointerEnter={(event) => handleTriggerEnter(menu, event.currentTarget)}\n                          onFocus={(event) => {\n                            cancelHover();\n                            moveBlob(event.currentTarget);\n                            closeMenu();\n                          }}\n                          className={cn(labelClass, focusRing)}\n                        >\n                          <span className=\"relative block\">\n                            {menu.label}\n                            <span\n                              aria-hidden=\"true\"\n                              ref={(node) => {\n                                inkRefs.current[index] = node;\n                              }}\n                              style={{ clipPath: 'inset(0 100% 0 0)' }}\n                              className=\"pointer-events-none absolute inset-0 block text-[#03110f] opacity-0\"\n                            >\n                              {menu.label}\n                            </span>\n                          </span>\n                        </a>\n                      )}\n                    </li>\n                  );\n                })}\n              </ul>\n            </nav>\n\n            <div data-intro=\"\" className=\"relative z-10 flex shrink-0 items-center gap-2\">\n              <DotBloomButton\n                variant=\"secondary\"\n                onClick={onActionClick}\n                className=\"hidden h-10 rounded-full px-4 text-[12.5px] focus-visible:ring-[#c6f56f] focus-visible:ring-offset-[#071d1b] sm:inline-flex\"\n              >\n                {actionLabel}\n              </DotBloomButton>\n\n              <button\n                ref={toggleRef}\n                type=\"button\"\n                aria-label={mobileOpen ? 'Close menu' : 'Open menu'}\n                aria-expanded={mobileOpen}\n                aria-controls={mobileId}\n                onClick={() => setMobileOpen((value) => !value)}\n                className={cn(\n                  'grid size-10 cursor-pointer place-items-center rounded-full border border-[#9fd4d6]/15 bg-white/[0.03] lg:hidden',\n                  focusRing\n                )}\n              >\n                <span aria-hidden=\"true\" className=\"relative block h-2.5 w-5\">\n                  <span\n                    className={cn(\n                      'absolute inset-x-0 top-0 h-px bg-[#e8f3f2] transition-transform duration-500 motion-reduce:transition-none',\n                      EASE_OUT_EXPO,\n                      mobileOpen && 'translate-y-[4.5px] rotate-45'\n                    )}\n                  />\n                  <span\n                    className={cn(\n                      'absolute inset-x-0 bottom-0 h-px bg-[#e8f3f2] transition-transform duration-500 motion-reduce:transition-none',\n                      EASE_OUT_EXPO,\n                      mobileOpen && '-translate-y-[4.5px] -rotate-45'\n                    )}\n                  />\n                </span>\n              </button>\n            </div>\n          </div>\n\n          <div\n            ref={panelRef}\n            id={panelId}\n            inert={!openKey}\n            onPointerEnter={cancelClose}\n            onPointerLeave={scheduleClose}\n            className=\"pointer-events-none absolute inset-x-0 top-0 z-40 h-0 opacity-0\"\n          >\n            <svg\n              aria-hidden=\"true\"\n              className=\"absolute top-0 left-0 h-[44rem] w-full overflow-visible\"\n              style={{\n                filter:\n                  'drop-shadow(0 0 0.6px rgb(159 212 214 / 0.45)) drop-shadow(0 32px 40px rgb(0 0 0 / 0.55))',\n              }}\n            >\n              <g\n                filter={`url(#${panelGooId})`}\n                fill=\"#071d1b\"\n                className={openKey ? 'pointer-events-auto' : 'pointer-events-none'}\n              >\n                <path ref={shapeRef} />\n                <circle ref={bumpRef} r=\"0\" />\n              </g>\n            </svg>\n            <div\n              ref={bodyRef}\n              className={cn(\n                'absolute top-0 left-0',\n                openKey ? 'pointer-events-auto' : 'pointer-events-none'\n              )}\n            >\n              {menus.map((menu) => {\n                if (!menu.columns?.length) return null;\n                const width = panelWidth(menu);\n\n                return (\n                  <div\n                    key={menu.label}\n                    ref={(node) => {\n                      sectionRefs.current[menu.label] = node;\n                    }}\n                    data-section=\"\"\n                    inert={openKey !== menu.label}\n                    onPointerLeave={hideHover}\n                    style={{ width }}\n                    className={cn(\n                      'absolute top-0 left-0 p-2.5 opacity-0',\n                      openKey === menu.label ? 'pointer-events-auto' : 'pointer-events-none'\n                    )}\n                  >\n                    <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\">\n                      <span\n                        data-hover-row=\"\"\n                        className=\"absolute top-0 left-0 rounded-2xl bg-[radial-gradient(140%_120%_at_0%_50%,rgb(198_245_111/0.12),transparent_60%),linear-gradient(#0c2924,#0c2924)] opacity-0 shadow-[inset_0_1px_0_rgb(232_243_242/0.06)]\"\n                      />\n                      <span className=\"absolute inset-0\" style={{ filter: `url(#${linkGooId})` }}>\n                        <span\n                          data-hover-blob=\"\"\n                          className=\"absolute top-0 left-0 rounded-[11px] bg-[#c6f56f] opacity-0\"\n                        />\n                        <span\n                          data-hover-drop=\"\"\n                          style={{ width: ICON_DROP, height: ICON_DROP }}\n                          className=\"absolute top-0 left-0 rounded-full bg-[#c6f56f] opacity-0\"\n                        />\n                      </span>\n                    </span>\n                    <div className={cn('relative grid gap-2.5', panelGrid(menu))}>\n                      {menu.columns.map((column, columnIndex) => (\n                        <ul key={columnIndex} className=\"flex flex-col\">\n                          {column.map((link) => (\n                            <li key={`${link.label}-${link.href}`} data-item=\"\">\n                              <a\n                                href={link.href}\n                                {...externalProps(link.href)}\n                                aria-current={current(link.href) ? 'page' : undefined}\n                                onClick={() => closeMenu()}\n                                onPointerEnter={(event) => moveHover(event.currentTarget)}\n                                onFocus={(event) => moveHover(event.currentTarget)}\n                                className={cn(\n                                  'group/link relative flex items-start gap-3 rounded-2xl p-2.5',\n                                  focusRing\n                                )}\n                              >\n                                <span\n                                  data-icon=\"\"\n                                  className=\"mt-0.5 grid size-8 shrink-0 place-items-center rounded-[11px] border border-[#9fd4d6]/12 bg-[#0a2420]/45 text-[#c6f56f] transition-[color,background-color,border-color] duration-300 group-hover/link:border-transparent group-hover/link:bg-transparent group-hover/link:text-[#03110f] group-hover/link:delay-75 group-focus-visible/link:border-transparent group-focus-visible/link:bg-transparent group-focus-visible/link:text-[#03110f]\"\n                                >\n                                  <MenuGlyph icon={link.icon ?? 'bloom'} className=\"size-4\" />\n                                </span>\n                                <span\n                                  className={cn(\n                                    'min-w-0 transition-transform duration-500 group-hover/link:translate-x-1',\n                                    EASE_OUT_EXPO\n                                  )}\n                                >\n                                  <span className=\"flex items-center gap-1.5 text-[13.5px] leading-tight font-medium text-[#e8f3f2]\">\n                                    {link.label}\n                                    <ArrowIcon\n                                      className={cn(\n                                        'size-3 -translate-x-1 text-[#c6f56f] opacity-0 transition-[opacity,translate] duration-500 group-hover/link:translate-x-0 group-hover/link:opacity-100',\n                                        EASE_OUT_EXPO\n                                      )}\n                                    />\n                                  </span>\n                                  {link.description ? (\n                                    <span className=\"mt-1 block text-[12.5px] leading-snug text-[#9fd4d6]/60 transition-colors duration-300 group-hover/link:text-[#9fd4d6]/85\">\n                                      {link.description}\n                                    </span>\n                                  ) : null}\n                                </span>\n                              </a>\n                            </li>\n                          ))}\n                        </ul>\n                      ))}\n\n                      {menu.feature ? (\n                        <div\n                          data-item=\"\"\n                          onPointerEnter={hideHover}\n                          className=\"flex flex-col overflow-hidden rounded-2xl border border-[#9fd4d6]/12 bg-[#0a2420]\"\n                        >\n                          <div className=\"relative h-28 shrink-0 overflow-hidden\">\n                            {menu.feature.image ? (\n                              <img\n                                src={menu.feature.image}\n                                alt=\"\"\n                                className=\"size-full object-cover\"\n                                loading=\"lazy\"\n                              />\n                            ) : (\n                              <FeatureArt />\n                            )}\n                          </div>\n                          <div className=\"flex flex-1 flex-col p-3\">\n                            <p className=\"text-[13px] font-medium\">{menu.feature.title}</p>\n                            <p className=\"mt-1 text-[12px] leading-snug text-[#9fd4d6]/60\">\n                              {menu.feature.description}\n                            </p>\n                            <a\n                              href={menu.feature.href}\n                              {...externalProps(menu.feature.href)}\n                              onClick={() => closeMenu()}\n                              className={cn(\n                                'group/feature mt-auto inline-flex items-center gap-1.5 pt-3 text-[12px] font-medium text-[#c6f56f]',\n                                focusRing\n                              )}\n                            >\n                              {menu.feature.cta ?? 'Explore'}\n                              <ArrowIcon className=\"size-3.5 transition-transform duration-300 group-hover/feature:translate-x-0.5\" />\n                            </a>\n                          </div>\n                        </div>\n                      ) : null}\n                    </div>\n                  </div>\n                );\n              })}\n            </div>\n          </div>\n        </div>\n      </header>\n\n      <div\n        ref={scrollerRef}\n        inert={mobileOpen}\n        data-lenis-prevent=\"\"\n        className=\"absolute inset-0 z-10 [scrollbar-width:none] overflow-y-auto [&::-webkit-scrollbar]:hidden\"\n      >\n        {children ?? <SamplePage />}\n      </div>\n\n      <div\n        ref={mobileRef}\n        id={mobileId}\n        inert={!mobileOpen}\n        className=\"invisible fixed inset-0 z-50 flex flex-col bg-[#04140f] lg:hidden\"\n      >\n        <div className=\"flex items-center justify-between px-4 pt-5 pb-4 sm:px-6\">\n          <a href={homeHref} className={cn('flex items-center gap-2.5 rounded-full', focusRing)}>\n            {logo}\n            <span className=\"text-[17px] font-medium tracking-tight\">{brand}</span>\n          </a>\n          <button\n            ref={mobileCloseRef}\n            type=\"button\"\n            aria-label=\"Close menu\"\n            onClick={() => {\n              setMobileOpen(false);\n              toggleRef.current?.focus();\n            }}\n            className={cn(\n              'grid size-10 cursor-pointer place-items-center rounded-full border border-[#9fd4d6]/15 bg-white/[0.04]',\n              focusRing\n            )}\n          >\n            <svg aria-hidden=\"true\" viewBox=\"0 0 24 24\" className=\"size-4\" fill=\"none\">\n              <path\n                d=\"M6 6l12 12M18 6L6 18\"\n                stroke=\"currentColor\"\n                strokeWidth=\"1.4\"\n                strokeLinecap=\"round\"\n              />\n            </svg>\n          </button>\n        </div>\n\n        <div className=\"min-h-0 flex-1 overflow-y-auto px-4 pb-8 sm:px-6\">\n          <ul className=\"flex flex-col\">\n            {menus.map((menu) => {\n              const panelMenu = Boolean(menu.columns?.length);\n              const isOpen = accordion === menu.label;\n              const links = menu.columns?.flat() ?? [];\n              const contentId = `${mobileId}-${menu.label.replace(/[^a-zA-Z0-9]/g, '')}`;\n\n              return (\n                <li\n                  key={menu.label}\n                  data-mobile-item=\"\"\n                  className=\"border-b border-[#9fd4d6]/10 last:border-b-0\"\n                >\n                  {panelMenu ? (\n                    <>\n                      <button\n                        type=\"button\"\n                        aria-expanded={isOpen}\n                        aria-controls={contentId}\n                        onClick={() => setAccordion(isOpen ? null : menu.label)}\n                        className={cn(\n                          'flex w-full cursor-pointer items-center justify-between py-4 text-left',\n                          focusRing\n                        )}\n                      >\n                        <span className=\"text-[26px] leading-none font-medium tracking-[-0.03em]\">\n                          {menu.label}\n                        </span>\n                        <ChevronIcon\n                          className={cn(\n                            'w-3 text-[#c6f56f] transition-transform duration-300',\n                            isOpen && 'rotate-180'\n                          )}\n                        />\n                      </button>\n                      <div\n                        id={contentId}\n                        inert={!isOpen}\n                        className={cn(\n                          'grid transition-[grid-template-rows] duration-400 motion-reduce:transition-none',\n                          EASE_OUT_EXPO,\n                          isOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'\n                        )}\n                      >\n                        <div className=\"overflow-hidden\">\n                          <ul className=\"flex flex-col gap-1 pb-3\">\n                            {links.map((link) => (\n                              <li key={`${link.label}-${link.href}`}>\n                                <a\n                                  href={link.href}\n                                  {...externalProps(link.href)}\n                                  onClick={() => setMobileOpen(false)}\n                                  className={cn(\n                                    'flex items-center gap-3 rounded-xl px-2 py-2.5 text-[15px] text-[#e8f3f2]/75',\n                                    focusRing\n                                  )}\n                                >\n                                  <MenuGlyph\n                                    icon={link.icon ?? 'bloom'}\n                                    className=\"size-4 text-[#c6f56f]\"\n                                  />\n                                  {link.label}\n                                </a>\n                              </li>\n                            ))}\n                          </ul>\n                        </div>\n                      </div>\n                    </>\n                  ) : (\n                    <a\n                      href={menu.href ?? '#'}\n                      {...externalProps(menu.href ?? '')}\n                      onClick={() => setMobileOpen(false)}\n                      className={cn(\n                        'block py-4 text-[26px] leading-none font-medium tracking-[-0.03em]',\n                        focusRing\n                      )}\n                    >\n                      {menu.label}\n                    </a>\n                  )}\n                </li>\n              );\n            })}\n          </ul>\n\n          <div data-mobile-item=\"\" className=\"mt-6\">\n            <DotBloomButton\n              variant=\"secondary\"\n              onClick={(event) => {\n                setMobileOpen(false);\n                onActionClick?.(event);\n              }}\n              className=\"h-12 w-full rounded-full focus-visible:ring-[#c6f56f] focus-visible:ring-offset-[#04140f]\"\n            >\n              {actionLabel}\n            </DotBloomButton>\n          </div>\n        </div>\n      </div>\n    </section>\n  );\n};\n\nexport default NavbarGooeyBloom;\n
" + } } }; diff --git a/apps/docs/registry/demos/navbar-corner-panel.tsx b/apps/docs/registry/demos/navbar-corner-panel.tsx index 75254a4..f592b1d 100644 --- a/apps/docs/registry/demos/navbar-corner-panel.tsx +++ b/apps/docs/registry/demos/navbar-corner-panel.tsx @@ -4,7 +4,7 @@ import NavbarCornerPanel from '@/registry/tweenui/navbar-corner-panel'; const NavbarCornerPanelDemo = () => { return ( -
+
); diff --git a/apps/docs/registry/demos/navbar-gooey-bloom.tsx b/apps/docs/registry/demos/navbar-gooey-bloom.tsx new file mode 100644 index 0000000..8cb0ddc --- /dev/null +++ b/apps/docs/registry/demos/navbar-gooey-bloom.tsx @@ -0,0 +1,13 @@ +'use client'; + +import NavbarGooeyBloom from '@/registry/tweenui/navbar-gooey-bloom'; + +const NavbarGooeyBloomDemo = () => { + return ( +
+ +
+ ); +}; + +export default NavbarGooeyBloomDemo; diff --git a/apps/docs/registry/demos/navbar-pill-glide.tsx b/apps/docs/registry/demos/navbar-pill-glide.tsx index f2b3efa..fad4787 100644 --- a/apps/docs/registry/demos/navbar-pill-glide.tsx +++ b/apps/docs/registry/demos/navbar-pill-glide.tsx @@ -4,7 +4,7 @@ import NavbarPillGlide from '@/registry/tweenui/navbar-pill-glide'; const NavbarPillGlideDemo = () => { return ( -
+
); diff --git a/apps/docs/registry/demos/navbar-push-menu.tsx b/apps/docs/registry/demos/navbar-push-menu.tsx index c5cb6cb..08527e2 100644 --- a/apps/docs/registry/demos/navbar-push-menu.tsx +++ b/apps/docs/registry/demos/navbar-push-menu.tsx @@ -4,7 +4,7 @@ import NavbarPushMenu from '@/registry/tweenui/navbar-push-menu'; const NavbarPushMenuDemo = () => { return ( -
+
); diff --git a/apps/docs/registry/registry-blocks.ts b/apps/docs/registry/registry-blocks.ts index 4617713..f1b96a3 100644 --- a/apps/docs/registry/registry-blocks.ts +++ b/apps/docs/registry/registry-blocks.ts @@ -1146,4 +1146,38 @@ export default function Example() { }, docs: 'Pass your own `items`; eight or more keep the arc full and the wrap unseen. Colors are inline Tailwind classes, so there is nothing to add to your theme.', }, + { + name: 'navbar-gooey-bloom', + type: 'block', + addedAt: '2026-10-20', + title: 'Navbar Gooey Bloom', + description: + 'A glass navbar whose hover highlight is liquid: a lime pool stretches from link to link with a droplet dragging behind, while the label inverts to ink. Menus swell out of a drop into one gooey panel that leans toward its trigger and morphs between layouts, and inside it a lime blob flows from icon to icon.', + media: {}, + dependencies: ['gsap', '@gsap/react'], + registryDependencies: ['dot-bloom-button'], + cssVars: {}, + files: [ + { + path: 'registry/tweenui/navbar-gooey-bloom.tsx', + target: 'components/tweenui/navbar-gooey-bloom.tsx', + kind: 'react', + }, + ], + variants: [ + { + id: 'default', + label: 'Preview', + reactSource: 'registry/tweenui/navbar-gooey-bloom.tsx', + }, + ], + usage: { + react: `import NavbarGooeyBloom from '@/components/tweenui/navbar-gooey-bloom'; + +export default function Example() { + return ; +}`, + }, + docs: 'Pass `menus` (a `label` plus `columns` and an optional `feature`, or just an `href` for a plain link) and put your page inside as `children`. The goo filter is generated per instance, so several bars can share a page.', + }, ]; diff --git a/apps/docs/registry/tweenui/navbar-gooey-bloom.tsx b/apps/docs/registry/tweenui/navbar-gooey-bloom.tsx new file mode 100644 index 0000000..f1b5ea4 --- /dev/null +++ b/apps/docs/registry/tweenui/navbar-gooey-bloom.tsx @@ -0,0 +1,1819 @@ +'use client'; + +import { + useCallback, + useEffect, + useId, + useRef, + useState, + type ComponentPropsWithoutRef, + type MouseEventHandler, + type ReactNode, +} from 'react'; +import { useGSAP } from '@gsap/react'; +import gsap from 'gsap'; +import { cn } from '@/lib/utils'; +import DotBloomButton from './dot-bloom-button'; + +gsap.registerPlugin(useGSAP); + +const prefersReducedMotion = () => + typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + +export type NavbarGooeyBloomIcon = + 'bloom' | 'drop' | 'orbit' | 'spark' | 'layers' | 'wave' | 'bolt' | 'shield'; + +export interface NavbarGooeyBloomLink { + label: string; + href: string; + description?: string; + icon?: NavbarGooeyBloomIcon; +} + +export interface NavbarGooeyBloomFeature { + title: string; + description: string; + href: string; + cta?: string; + image?: string; +} + +export interface NavbarGooeyBloomMenu { + label: string; + href?: string; + columns?: NavbarGooeyBloomLink[][]; + feature?: NavbarGooeyBloomFeature; +} + +export interface NavbarGooeyBloomProps extends Omit< + ComponentPropsWithoutRef<'section'>, + 'children' +> { + brand?: string; + logo?: ReactNode; + homeHref?: string; + menus?: NavbarGooeyBloomMenu[]; + currentHref?: string; + actionLabel?: string; + onActionClick?: MouseEventHandler; + children?: ReactNode; +} + +const CLOSE_DELAY = 160; +const HOVER_INTENT = 70; +const HIDE_AFTER = 220; +const PANEL_GAP = 12; +const PANEL_RADIUS = 26; +const DROP_SIZE = 22; +const EDGE_LEAD = 520; +const EDGE_TRAIL = 170; +const TAIL_PULL = 110; +const PANEL_LEAD = 300; +const PANEL_TRAIL = 140; +const PANEL_PULL = 260; +const BUMP_RADIUS = 14; +const BUMP_RISE = 6; +const BUMP_PULL = 150; +const ICON_SIZE = 32; +const ICON_DROP = 14; +const SCROLL_TOLERANCE = 6; +const DESKTOP_QUERY = '(min-width: 1024px)'; + +const CONTAINER = 'mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'; +const EASE_OUT_EXPO = 'ease-[cubic-bezier(0.16,1,0.3,1)]'; +const focusRing = + 'focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#c6f56f]'; + +const TWEEN_MARK = ( + +); + +const ICON_PATHS: Record = { + bloom: ( + <> + + + + + ), + drop: , + orbit: ( + <> + + + + ), + spark: , + layers: ( + <> + + + + ), + wave: , + bolt: , + shield: , +}; + +const DEFAULT_MENUS: NavbarGooeyBloomMenu[] = [ + { + label: 'Work', + columns: [ + [ + { + label: 'Case studies', + href: '#', + description: 'Shipped products, start to finish.', + icon: 'bloom', + }, + { + label: 'Experiments', + href: '#', + description: 'Small ideas, pushed too far.', + icon: 'spark', + }, + { + label: 'Playground', + href: '#', + description: 'Live code you can poke at.', + icon: 'orbit', + }, + { + label: 'Archive', + href: '#', + description: 'Everything since 2019.', + icon: 'layers', + }, + ], + [ + { + label: 'Motion kit', + href: '#', + description: 'Easing, springs and timing.', + icon: 'wave', + }, + { + label: 'Interface kit', + href: '#', + description: 'Bars, panels and overlays.', + icon: 'drop', + }, + { + label: 'Brand systems', + href: '#', + description: 'Marks that know how to move.', + icon: 'shield', + }, + { + label: 'Performance', + href: '#', + description: 'Sixty frames, no excuses.', + icon: 'bolt', + }, + ], + ], + feature: { + title: 'Bloom 2.0', + description: 'The metaball engine under this bar, documented end to end.', + href: '#', + cta: 'Read the notes', + }, + }, + { + label: 'Components', + columns: [ + [ + { + label: 'Navbars', + href: '/components', + description: 'Eleven ways to hold a menu.', + icon: 'layers', + }, + { + label: 'Buttons', + href: '/components', + description: 'Press, roll, bloom.', + icon: 'drop', + }, + { + label: 'Carousels', + href: '/components', + description: 'Arcs, shutters and reels.', + icon: 'orbit', + }, + { + label: 'Loaders', + href: '/components', + description: 'Waiting, but on purpose.', + icon: 'spark', + }, + { + label: 'Type', + href: '/components', + description: 'Letters that misbehave.', + icon: 'wave', + }, + ], + ], + }, + { + label: 'Studio', + columns: [ + [ + { label: 'About', href: '#', description: 'Four people, one room.', icon: 'bloom' }, + { label: 'Team', href: '#', description: 'Who makes what.', icon: 'shield' }, + { label: 'Careers', href: '#', description: 'Two seats open.', icon: 'bolt' }, + { label: 'Press', href: '#', description: 'Logos and words.', icon: 'layers' }, + ], + [ + { label: 'Contact', href: '#', description: 'Say hello properly.', icon: 'drop' }, + { + label: 'Changelog', + href: '#', + description: 'What moved this week.', + icon: 'wave', + }, + { label: 'License', href: '#', description: 'What you may ship.', icon: 'orbit' }, + { + label: 'Support', + href: '#', + description: 'We answer on weekdays.', + icon: 'spark', + }, + ], + ], + }, + { label: 'Journal', href: '#' }, +]; + +const cleanHref = (href: string) => href.split(/[?#]/)[0]?.replace(/\/+$/, '') || '/'; + +const isExternal = (href: string) => /^https?:/.test(href); + +const externalProps = (href: string) => + isExternal(href) ? { target: '_blank', rel: 'noopener noreferrer' } : {}; + +const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); + +const panelWidth = (menu: NavbarGooeyBloomMenu) => { + const columns = menu.columns?.length ?? 0; + if (menu.feature) return columns > 1 ? 880 : 720; + return columns > 1 ? 720 : 360; +}; + +const panelGrid = (menu: NavbarGooeyBloomMenu) => { + const columns = menu.columns?.length ?? 0; + if (menu.feature) return columns > 1 ? 'grid-cols-[1fr_1fr_15rem]' : 'grid-cols-[1fr_15rem]'; + return columns > 1 ? 'grid-cols-2' : 'grid-cols-1'; +}; + +interface Spring { + x: number; + v: number; + t: number; +} + +interface GooMotion { + left: Spring; + right: Spring; + tail: Spring; + panelLeft: Spring; + panelRight: Spring; + panelHeight: Spring; + bump: Spring; + rowTop: Spring; + rowBottom: Spring; + rowLeft: Spring; + rowWidth: Spring; + iconTop: Spring; + iconBottom: Spring; + iconTail: Spring; + iconOffset: number; + look: { scale: number; alpha: number }; + hover: { alpha: number }; + pillShown: boolean; + panelShown: boolean; + hoverShown: boolean; + barBottom: number; + awake: boolean; +} + +const spring = (): Spring => ({ x: 0, v: 0, t: 0 }); + +const place = (target: Spring, value: number) => { + target.x = value; + target.v = 0; + target.t = value; +}; + +const snap = (target: Spring) => { + target.x = target.t; + target.v = 0; +}; + +const stepSpring = (target: Spring, stiffness: number, dt: number) => { + const force = stiffness * (target.t - target.x) - 2 * Math.sqrt(stiffness) * target.v; + target.v += force * dt; + target.x += target.v * dt; +}; + +const atRest = (target: Spring, epsilon: number) => + Math.abs(target.t - target.x) < epsilon && Math.abs(target.v) < epsilon; + +const createMotion = (): GooMotion => ({ + left: spring(), + right: spring(), + tail: spring(), + panelLeft: spring(), + panelRight: spring(), + panelHeight: spring(), + bump: spring(), + rowTop: spring(), + rowBottom: spring(), + rowLeft: spring(), + rowWidth: spring(), + iconTop: spring(), + iconBottom: spring(), + iconTail: spring(), + iconOffset: 0, + look: { scale: 0.4, alpha: 0 }, + hover: { alpha: 0 }, + pillShown: false, + panelShown: false, + hoverShown: false, + barBottom: 60, + awake: false, +}); + +const springsOf = (motion: GooMotion) => [ + motion.left, + motion.right, + motion.tail, + motion.panelLeft, + motion.panelRight, + motion.panelHeight, + motion.bump, + motion.rowTop, + motion.rowBottom, + motion.rowLeft, + motion.rowWidth, + motion.iconTop, + motion.iconBottom, + motion.iconTail, +]; + +const settleMotion = (motion: GooMotion) => { + const center = (motion.left.t + motion.right.t) / 2; + motion.tail.t = center; + motion.bump.t = center; + motion.iconTail.t = (motion.iconTop.t + motion.iconBottom.t) / 2; + springsOf(motion).forEach(snap); +}; + +const motionAtRest = (motion: GooMotion) => + springsOf(motion).every((item) => atRest(item, 0.05)) && + !gsap.isTweening(motion.look) && + !gsap.isTweening(motion.hover); + +const steer = (start: Spring, end: Spring) => { + const heading = start.t + end.t - start.x - end.x; + return { + start: heading > 1 ? EDGE_TRAIL : EDGE_LEAD, + end: heading < -1 ? EDGE_TRAIL : EDGE_LEAD, + }; +}; + +const px = (value: number) => Math.round(value * 100) / 100; + +const roundedRect = (left: number, right: number, top: number, height: number) => { + const radius = px(Math.max(0, Math.min(PANEL_RADIUS, (right - left) / 2, height / 2))); + const bottom = px(top + height); + const l = px(left); + const r = px(right); + return `M${px(l + radius)},${top}H${px(r - radius)}A${radius},${radius} 0 0 1 ${r},${px(top + radius)}V${px(bottom - radius)}A${radius},${radius} 0 0 1 ${px(r - radius)},${bottom}H${px(l + radius)}A${radius},${radius} 0 0 1 ${l},${px(bottom - radius)}V${px(top + radius)}A${radius},${radius} 0 0 1 ${px(l + radius)},${top}Z`; +}; + +const ArrowIcon = ({ className }: { className?: string }) => { + return ( + + ); +}; + +const ChevronIcon = ({ className }: { className?: string }) => { + return ( + + ); +}; + +const MenuGlyph = ({ icon, className }: { icon: NavbarGooeyBloomIcon; className?: string }) => { + return ( + + ); +}; + +const FeatureArt = () => { + const rawId = useId().replace(/[^a-zA-Z0-9_-]/g, ''); + const gooId = `feature-goo-${rawId}`; + const limeId = `feature-lime-${rawId}`; + + return ( +