Skip to content

Commit a6967f6

Browse files
committed
feat: navigation back to floating
1 parent 415cfa0 commit a6967f6

2 files changed

Lines changed: 163 additions & 35 deletions

File tree

src/components/navigation/NavigationDesktop.tsx

Lines changed: 113 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@ import {
1515
NavigationMenuTrigger,
1616
} from "@/components/ui/NavigationMenu"
1717
import { Container } from "@code0-tech/pictor"
18+
import { AnimatePresence, m as motion } from "motion/react"
1819
import Image from "next/image"
1920
import Link from "next/link"
20-
import React, { useEffect, useRef, useState } from "react"
21+
import React, { useEffect, useLayoutEffect, useRef, useState } from "react"
2122
import { NavigationCursor, type NavigationCursorHandle } from "./NavigationCursor"
2223
import { NavigationLink } from "./NavigationLink"
2324
import { NavigationSubMenu } from "./NavigationSubMenu"
@@ -30,9 +31,11 @@ type NavigationDesktopProps = {
3031

3132
const NavigationDesktop: React.FC<NavigationDesktopProps> = ({ locale, items, buttons }) => {
3233
const navTabsRef = useRef<HTMLDivElement>(null)
34+
const submenuContentRef = useRef<HTMLDivElement>(null)
3335
const cursorRef = useRef<NavigationCursorHandle>(null)
3436
const suppressMenuOpenRef = useRef(false)
3537
const [activeMenuValue, setActiveMenuValue] = useState<string | null>(null)
38+
const [submenuHeight, setSubmenuHeight] = useState(0)
3639
const { homeHref, navbarItems, navbarButtons } = useNavigationViewModel(locale, items, buttons)
3740
const isScrolled = useNavigationScrollState({
3841
onScroll: () => {
@@ -44,6 +47,13 @@ const NavigationDesktop: React.FC<NavigationDesktopProps> = ({ locale, items, bu
4447

4548
const navItemClassName = "relative z-50 flex cursor-pointer items-center gap-2 rounded-xl px-4 py-1 font-medium"
4649
const navTriggerClassName = cn(navItemClassName, "h-auto pr-1 bg-transparent text-base text-white hover:bg-transparent focus:bg-transparent data-popup-open:bg-transparent data-open:bg-transparent")
50+
const activeMenuItem = navbarItems.find((item) => item.title === activeMenuValue)
51+
const activeSubMenu = activeMenuItem?.subMenu?.length ? activeMenuItem.subMenu : null
52+
const shellTransition = {
53+
type: "spring",
54+
stiffness: 40,
55+
damping: 10,
56+
} as const
4757

4858
useEffect(() => {
4959
if (!isScrolled) return
@@ -52,23 +62,36 @@ const NavigationDesktop: React.FC<NavigationDesktopProps> = ({ locale, items, bu
5262
cursorRef.current?.hide()
5363
}, [isScrolled])
5464

65+
useLayoutEffect(() => {
66+
const element = submenuContentRef.current
67+
68+
if (!element || !activeSubMenu || !isScrolled) {
69+
setSubmenuHeight(0)
70+
return
71+
}
72+
73+
const measure = () => {
74+
setSubmenuHeight(element.scrollHeight)
75+
}
76+
77+
measure()
78+
79+
const resizeObserver = new ResizeObserver(measure)
80+
resizeObserver.observe(element)
81+
82+
return () => resizeObserver.disconnect()
83+
}, [activeSubMenu, isScrolled])
84+
5585
const updateIndicatorFromElement = (element: HTMLElement) => {
5686
if (!navTabsRef.current) return
5787
cursorRef.current?.moveTo(element, navTabsRef.current)
5888
}
5989

6090
return (
61-
<div
62-
className={cn(
63-
"fixed z-50 h-max w-full border-b transition-[padding,background-color,border-color,backdrop-filter] duration-200 ease-out",
64-
isScrolled
65-
? "border-white/10 bg-primary/50 pt-1 backdrop-blur-lg"
66-
: "border-transparent bg-transparent pt-3"
67-
)}
68-
>
91+
<div className="fixed z-50 h-max w-full pt-3">
6992
<Container>
7093
<div
71-
className={cn("relative transition-[padding] duration-200 ease-out", isScrolled ? "py-2" : "py-4")}
94+
className="relative my-4"
7295
onPointerMove={() => {
7396
suppressMenuOpenRef.current = false
7497
}}
@@ -77,6 +100,40 @@ const NavigationDesktop: React.FC<NavigationDesktopProps> = ({ locale, items, bu
77100
setActiveMenuValue(null)
78101
}}
79102
>
103+
<motion.div
104+
className={cn(
105+
"pointer-events-none absolute inset-0 rounded-2xl shadow-sm",
106+
isScrolled ? "bg-primary/50 backdrop-blur-lg" : "bg-transparent shadow-none",
107+
)}
108+
initial={{
109+
clipPath: "inset(0% 0% 0% 0% round 1rem)",
110+
}}
111+
animate={{
112+
clipPath: isScrolled
113+
? "inset(0% 10% 0% 10% round 1rem)"
114+
: "inset(0% 0% 0% 0% round 1rem)",
115+
}}
116+
transition={shellTransition}
117+
/>
118+
<motion.div
119+
className="pointer-events-none absolute inset-0 z-10 rounded-2xl border border-white/5"
120+
initial={false}
121+
animate={{
122+
left: isScrolled ? "10%" : "0%",
123+
right: isScrolled ? "10%" : "0%",
124+
opacity: isScrolled ? 1 : 0,
125+
}}
126+
transition={shellTransition}
127+
/>
128+
<motion.div
129+
className="relative z-10 flex flex-col overflow-visible rounded-2xl p-1.5"
130+
initial={false}
131+
animate={{
132+
paddingLeft: isScrolled ? "10%" : "0%",
133+
paddingRight: isScrolled ? "calc(10% + 0.5rem)" : "0%",
134+
}}
135+
transition={shellTransition}
136+
>
80137
<div className={"w-full h-full flex items-center justify-between gap-2"}>
81138

82139
<Link href={homeHref}>
@@ -100,6 +157,7 @@ const NavigationDesktop: React.FC<NavigationDesktopProps> = ({ locale, items, bu
100157
value={activeMenuValue}
101158
onValueChange={(value) => {
102159
if (value && suppressMenuOpenRef.current) return
160+
if (isScrolled && !value) return
103161

104162
setActiveMenuValue(value)
105163
}}
@@ -119,19 +177,33 @@ const NavigationDesktop: React.FC<NavigationDesktopProps> = ({ locale, items, bu
119177
<>
120178
<div
121179
className="relative z-50"
122-
onMouseEnter={(event) => updateIndicatorFromElement(event.currentTarget)}
123-
onFocusCapture={(event) => updateIndicatorFromElement(event.currentTarget)}
180+
onMouseEnter={(event) => {
181+
updateIndicatorFromElement(event.currentTarget)
182+
183+
if (isScrolled && !suppressMenuOpenRef.current) {
184+
setActiveMenuValue(itemValue)
185+
}
186+
}}
187+
onFocusCapture={(event) => {
188+
updateIndicatorFromElement(event.currentTarget)
189+
190+
if (isScrolled) {
191+
setActiveMenuValue(itemValue)
192+
}
193+
}}
124194
>
125195
<NavigationMenuTrigger className={navTriggerClassName}>
126196
{item.title}
127197
</NavigationMenuTrigger>
128198
</div>
129-
<NavigationMenuContent className="p-2">
130-
<NavigationSubMenu
131-
items={item.subMenu!}
132-
onSelect={() => setActiveMenuValue(null)}
133-
/>
134-
</NavigationMenuContent>
199+
{!isScrolled && (
200+
<NavigationMenuContent className="p-2">
201+
<NavigationSubMenu
202+
items={item.subMenu!}
203+
onSelect={() => setActiveMenuValue(null)}
204+
/>
205+
</NavigationMenuContent>
206+
)}
135207
</>
136208
) : item.href ? (
137209
<div
@@ -169,6 +241,29 @@ const NavigationDesktop: React.FC<NavigationDesktopProps> = ({ locale, items, bu
169241
))}
170242
</div>
171243
</div>
244+
<AnimatePresence initial={false}>
245+
{isScrolled && activeSubMenu && (
246+
<motion.div
247+
initial={{ opacity: 0, y: -6, height: 0 }}
248+
animate={{ opacity: 1, y: 0, height: submenuHeight }}
249+
exit={{ opacity: 0, y: -6, height: 0 }}
250+
transition={{
251+
height: { duration: 0.24, ease: [0.22, 1, 0.36, 1] },
252+
opacity: { duration: 0.16, ease: "easeOut" },
253+
y: { duration: 0.2, ease: [0.22, 1, 0.36, 1] },
254+
}}
255+
className="overflow-hidden px-2"
256+
>
257+
<div ref={submenuContentRef} className="pt-1">
258+
<NavigationSubMenu
259+
items={activeSubMenu}
260+
onSelect={() => setActiveMenuValue(null)}
261+
/>
262+
</div>
263+
</motion.div>
264+
)}
265+
</AnimatePresence>
266+
</motion.div>
172267
</div>
173268
</Container>
174269
</div>

src/components/navigation/NavigationMobile.tsx

Lines changed: 50 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,12 @@ const NavigationMobile: React.FC<NavigationMobileProps> = ({ locale, items, butt
3232
const [isMenuClosing, setIsMenuClosing] = useState(false)
3333
const { homeHref, navbarItems, navbarButtons } = useNavigationViewModel(locale, items, buttons)
3434
const menuTransition = {
35-
duration: 0.24,
36-
ease: [0.22, 1, 0.36, 1] as const,
35+
duration: 0.34,
36+
ease: [0.16, 1, 0.3, 1] as const,
37+
}
38+
const shellTransition = {
39+
duration: 0.46,
40+
ease: [0.16, 1, 0.3, 1] as const,
3741
}
3842
const isShellExpanded = isOpen || isMenuClosing
3943
const isScrolled = useNavigationScrollState({
@@ -47,7 +51,7 @@ const NavigationMobile: React.FC<NavigationMobileProps> = ({ locale, items, butt
4751
setIsMenuClosing(false)
4852
} else if (wasOpenRef.current) {
4953
setIsMenuClosing(true)
50-
timeoutId = setTimeout(() => setIsMenuClosing(false), 320)
54+
timeoutId = setTimeout(() => setIsMenuClosing(false), 460)
5155
}
5256

5357
wasOpenRef.current = isOpen
@@ -67,18 +71,47 @@ const NavigationMobile: React.FC<NavigationMobileProps> = ({ locale, items, butt
6771

6872
return (
6973
<header
70-
className={cn(
71-
"fixed z-50 w-full overflow-hidden border-b transition-[padding,background-color,border-color,backdrop-filter] duration-200 ease-out",
72-
(isScrolled || isShellExpanded)
73-
? "border-white/10 bg-primary/50 pt-1 backdrop-blur-lg"
74-
: "border-transparent bg-transparent pt-3"
75-
)}
74+
className="fixed z-50 w-full overflow-hidden pt-3"
7675
ref={menuRef}
7776
>
7877
<Container>
79-
<div className={cn("relative transition-[padding] duration-200 ease-out", (isScrolled || isShellExpanded) ? "py-2" : "py-4")}>
80-
<div className="relative z-10 flex flex-col overflow-hidden">
81-
<div className={"w-full flex items-center justify-between gap-2"}>
78+
<div className="relative my-6">
79+
<motion.div
80+
className={cn(
81+
"pointer-events-none absolute inset-0 rounded-2xl shadow-sm",
82+
(isScrolled || isShellExpanded) ? "bg-primary/50 backdrop-blur-lg" : "bg-transparent shadow-none",
83+
)}
84+
initial={false}
85+
animate={{
86+
clipPath: isScrolled && !isShellExpanded
87+
? "inset(0% 10% 0% 10% round 1rem)"
88+
: "inset(0% 0% 0% 0% round 1rem)",
89+
}}
90+
transition={shellTransition}
91+
/>
92+
<motion.div
93+
className="pointer-events-none absolute inset-0 z-10 rounded-2xl border border-white/5"
94+
initial={false}
95+
animate={{
96+
left: isScrolled && !isShellExpanded ? "10%" : "0%",
97+
right: isScrolled && !isShellExpanded ? "10%" : "0%",
98+
opacity: isScrolled || isShellExpanded ? 1 : 0,
99+
}}
100+
transition={shellTransition}
101+
/>
102+
<motion.div
103+
className="relative z-10 flex flex-col overflow-hidden rounded-2xl p-1.5"
104+
initial={false}
105+
>
106+
<motion.div
107+
className={"w-full flex items-center justify-between gap-2"}
108+
initial={false}
109+
animate={{
110+
paddingLeft: isScrolled ? "calc(10% + 0rem)" : "calc(0% + 0rem)",
111+
paddingRight: isScrolled ? "calc(10% + 0.5rem)" : "calc(0% + 0rem)",
112+
}}
113+
transition={shellTransition}
114+
>
82115
<Link
83116
href={homeHref}
84117
onClick={() => {
@@ -103,22 +136,22 @@ const NavigationMobile: React.FC<NavigationMobileProps> = ({ locale, items, butt
103136
>
104137
{isOpen ? <IconX className="text-white/75"/> : <IconMenu2 className="text-white/75"/>}
105138
</button>
106-
</div>
139+
</motion.div>
107140
<AnimatePresence initial={false}>
108141
{isOpen && (
109142
<motion.div
110143
id="mobile-navigation-menu"
111144
key="mobile-menu"
112145
layout
113-
initial={{ opacity: 0, y: -6, height: 0 }}
146+
initial={{ opacity: 0, y: -4, height: 0 }}
114147
animate={{ opacity: 1, y: 0, height: "auto" }}
115-
exit={{ opacity: 0, y: -6, height: 0 }}
148+
exit={{ opacity: 0, y: -4, height: 0 }}
116149
transition={{
117150
...menuTransition,
118151
delay: isScrolled ? 0.08 : 0,
119152
}}
120153
style={{ overflow: "hidden" }}
121-
className="flex flex-col gap-2 pt-3"
154+
className="flex flex-col gap-2 px-2"
122155
>
123156
<div className="flex flex-col gap-1">
124157
{navbarItems.map((item) => {
@@ -151,7 +184,7 @@ const NavigationMobile: React.FC<NavigationMobileProps> = ({ locale, items, butt
151184
</motion.div>
152185
)}
153186
</AnimatePresence>
154-
</div>
187+
</motion.div>
155188
</div>
156189
</Container>
157190
</header>

0 commit comments

Comments
 (0)