@@ -9,10 +9,10 @@ import {
99 Sun ,
1010 UserRoundSearch ,
1111} from "lucide-react" ;
12- import { AnimatePresence , motion } from "motion/react" ;
12+ import { AnimatePresence , motion , useMotionValue , useSpring , useTransform } from "motion/react" ;
1313import Link from "next/link" ;
14- import { usePathname } from "next/navigation" ;
15- import React , { useEffect , useMemo , useRef , useState } from "react" ;
14+ import { usePathname , useRouter } from "next/navigation" ;
15+ import React , { useCallback , useEffect , useMemo , useRef , useState } from "react" ;
1616
1717import { cn } from "@/lib/utils" ;
1818
@@ -28,8 +28,20 @@ function matchRoute(pathname: string, matcher: RouteMatcher) {
2828 return matcher ( pathname ) ;
2929}
3030
31+ const tabs = [
32+ { href : "/search" , icon : FileSearchCorner , match : ( p : string ) => p === "/search" } ,
33+ { href : "/category" , icon : Folder , match : ( p : string ) => p . startsWith ( "/category" ) } ,
34+ { href : "/" , icon : Home , match : ( p : string ) => p === "/" } ,
35+ { href : "/about" , icon : UserRoundSearch , match : ( p : string ) => p === "/about" } ,
36+ ] as const ;
37+
38+ const TAB_WIDTH = 56 ;
39+ const TAB_GAP = 8 ;
40+ const TAB_STEP = TAB_WIDTH + TAB_GAP ;
41+
3142export function MobileBottomTab ( ) {
3243 const pathname = usePathname ( ) ;
44+ const router = useRouter ( ) ;
3345 const { theme, toggleTheme } = useTheme ( ) ;
3446
3547 const enableHideByScroll = useMemo ( ( ) => {
@@ -79,77 +91,211 @@ export function MobileBottomTab() {
7991 return ( ) => window . removeEventListener ( "scroll" , onScroll ) ;
8092 } , [ enableHideByScroll ] ) ;
8193
94+ const activeIndex = useMemo ( ( ) => {
95+ const idx = tabs . findIndex ( ( t ) => t . match ( pathname ) ) ;
96+ return idx >= 0 ? idx : 2 ; // default to Home
97+ } , [ pathname ] ) ;
98+
99+ // Drag state
100+ const containerRef = useRef < HTMLDivElement > ( null ) ;
101+ const isDragging = useRef ( false ) ;
102+ const dragStartX = useRef ( 0 ) ;
103+ const dragStartIndex = useRef ( 0 ) ;
104+
105+ const indicatorX = useMotionValue ( activeIndex * TAB_STEP ) ;
106+ const springX = useSpring ( indicatorX , { stiffness : 400 , damping : 30 } ) ;
107+
108+ // Scale for the indicator during drag
109+ const indicatorScale = useMotionValue ( 1 ) ;
110+ const springScale = useSpring ( indicatorScale , { stiffness : 400 , damping : 25 } ) ;
111+
112+ // Sync indicator position when activeIndex changes (route change)
113+ useEffect ( ( ) => {
114+ if ( ! isDragging . current ) {
115+ indicatorX . set ( activeIndex * TAB_STEP ) ;
116+ }
117+ } , [ activeIndex , indicatorX ] ) ;
118+
119+ // Per-tab icon scale and opacity based on proximity to indicator
120+ const tabScales = tabs . map ( ( _ , i ) => {
121+ // eslint-disable-next-line react-hooks/rules-of-hooks
122+ return useTransform ( springX , ( x ) => {
123+ const tabCenter = i * TAB_STEP ;
124+ const dist = Math . abs ( x - tabCenter ) ;
125+ const maxDist = TAB_STEP * 2 ;
126+ const scale = 1 + 0.2 * Math . max ( 0 , 1 - dist / maxDist ) ;
127+ return scale ;
128+ } ) ;
129+ } ) ;
130+
131+ const tabOpacities = tabs . map ( ( _ , i ) => {
132+ // eslint-disable-next-line react-hooks/rules-of-hooks
133+ return useTransform ( springX , ( x ) => {
134+ const tabCenter = i * TAB_STEP ;
135+ const dist = Math . abs ( x - tabCenter ) ;
136+ const t = Math . max ( 0 , 1 - dist / TAB_STEP ) ;
137+ return 0.4 + 0.6 * t ; // 0.4 (far) → 1.0 (on top)
138+ } ) ;
139+ } ) ;
140+
141+ const handleTouchStart = useCallback (
142+ ( e : React . TouchEvent ) => {
143+ isDragging . current = true ;
144+ dragStartX . current = e . touches [ 0 ] . clientX ;
145+ dragStartIndex . current = activeIndex ;
146+ indicatorScale . set ( 1.15 ) ;
147+ } ,
148+ [ activeIndex , indicatorScale ] ,
149+ ) ;
150+
151+ const handleTouchMove = useCallback (
152+ ( e : React . TouchEvent ) => {
153+ if ( ! isDragging . current ) return ;
154+
155+ const dx = e . touches [ 0 ] . clientX - dragStartX . current ;
156+ const indexOffset = dx / TAB_STEP ;
157+ const rawIndex = dragStartIndex . current + indexOffset ;
158+ const clampedIndex = Math . max ( 0 , Math . min ( tabs . length - 1 , rawIndex ) ) ;
159+
160+ indicatorX . set ( clampedIndex * TAB_STEP ) ;
161+ } ,
162+ [ indicatorX ] ,
163+ ) ;
164+
165+ const handleTouchEnd = useCallback ( ( ) => {
166+ if ( ! isDragging . current ) return ;
167+ isDragging . current = false ;
168+ indicatorScale . set ( 1 ) ;
169+
170+ // Snap to nearest tab
171+ const currentX = indicatorX . get ( ) ;
172+ const nearestIndex = Math . round ( currentX / TAB_STEP ) ;
173+ const clampedIndex = Math . max ( 0 , Math . min ( tabs . length - 1 , nearestIndex ) ) ;
174+
175+ indicatorX . set ( clampedIndex * TAB_STEP ) ;
176+
177+ if ( clampedIndex !== activeIndex ) {
178+ router . push ( tabs [ clampedIndex ] . href ) ;
179+ }
180+ } , [ activeIndex , indicatorX , indicatorScale , router ] ) ;
181+
82182 return (
83183 < AnimatePresence >
84184 < motion . nav
85185 initial = { false }
86186 animate = { { y : isHidden ? 120 : 0 } }
87187 transition = { { type : "spring" , stiffness : 260 , damping : 25 } }
88- className = "fixed inset-x-0 bottom-0 z-50 px-4 pb-4 md:hidden"
188+ className = "fixed inset-x-0 bottom-0 z-50 flex items-center justify-center px-4 pb-4 md:hidden"
89189 aria-label = "Mobile bottom navigation"
90190 >
91- < div className = "flex w-full justify-between rounded-[32px] border bg-white/60 px-6 py-1 shadow-lg backdrop-blur-sm dark:border-neutral-700 dark:bg-neutral-900/50" >
92- < TabItem
93- href = "/search"
94- icon = { < FileSearchCorner className = "size-6" /> }
95- isActive = { pathname === "/search" }
96- />
97- < TabItem
98- href = "/category"
99- icon = { < Folder className = "size-6" /> }
100- isActive = { pathname . startsWith ( "/category" ) }
101- />
102- < TabItem
103- href = "/"
104- icon = { < Home className = "size-6" /> }
105- isActive = { pathname === "/" }
106- />
107- < TabItem
108- href = "/about"
109- icon = { < UserRoundSearch className = "size-6" /> }
110- isActive = { pathname === "/about" }
111- />
112- < button
113- onClick = { toggleTheme }
114- className = "flex cursor-pointer flex-col items-center justify-center gap-1 rounded-md text-[11px] font-semibold text-neutral-500 transition-colors duration-200 dark:text-neutral-100"
191+ { /* Outer glass container */ }
192+ < div
193+ className = "relative mx-auto flex w-fit items-center rounded-[28px] border border-white/15 bg-white/10 p-1.5 backdrop-blur-xl dark:border-white/8 dark:bg-white/4"
194+ style = { {
195+ backdropFilter : "blur(20px) contrast(80%) saturate(120%)" ,
196+ WebkitBackdropFilter : "blur(20px) contrast(80%) saturate(120%)" ,
197+ boxShadow : `
198+ 0 8px 32px rgba(0,0,0,0.06),
199+ inset 8px 8px 16px rgba(153,192,255,0.08),
200+ inset 2px 2px 4px rgba(195,218,255,0.12),
201+ inset -8px -8px 16px rgba(229,253,190,0.06),
202+ inset -2px -2px 4px rgba(247,255,226,0.1)
203+ ` ,
204+ } }
205+ ref = { containerRef }
206+ onTouchStart = { handleTouchStart }
207+ onTouchMove = { handleTouchMove }
208+ onTouchEnd = { handleTouchEnd }
209+ >
210+ { /* Animated indicator (the liquid glass pill) */ }
211+ < motion . div
212+ className = "pointer-events-none absolute top-1.5 bottom-1.5 z-0 rounded-[22px]"
213+ style = { {
214+ width : TAB_WIDTH ,
215+ x : springX ,
216+ scale : springScale ,
217+ } }
115218 >
116- { theme === "dark" ? (
117- < Sun className = "size-6" />
118- ) : (
119- < Moon className = "size-6" />
120- ) }
121- </ button >
219+ { /* Outer shadow layer (needs separate div so inset shadows don't conflict) */ }
220+ < div
221+ className = "absolute -inset-px rounded-[23px]"
222+ style = { {
223+ boxShadow : `
224+ 0 2px 8px rgba(0,0,0,0.08),
225+ 0 6px 24px rgba(0,0,0,0.04)
226+ ` ,
227+ } }
228+ />
229+ { /* Glass pill */ }
230+ < div
231+ className = "absolute inset-0 overflow-hidden rounded-[22px] border border-white/60 bg-white/70 dark:border-white/15 dark:bg-white/12"
232+ style = { {
233+ boxShadow : `
234+ inset 10px 10px 20px rgba(153,192,255,0.15),
235+ inset 2px 2px 5px rgba(195,218,255,0.25),
236+ inset -10px -10px 20px rgba(229,253,190,0.12),
237+ inset -2px -2px 30px rgba(247,255,226,0.2),
238+ inset 0 1px 0 rgba(255,255,255,0.8)
239+ ` ,
240+ } }
241+ >
242+ { /* Top specular highlight — subtle curved shine */ }
243+ < div
244+ className = "absolute inset-x-0 top-0 h-1/2"
245+ style = { {
246+ background : "linear-gradient(to bottom, rgba(255,255,255,0.5) 0%, transparent 100%)" ,
247+ borderRadius : "22px 22px 50% 50%" ,
248+ } }
249+ />
250+ </ div >
251+ </ motion . div >
252+
253+ { /* Tab items */ }
254+ < div className = "relative z-10 flex" style = { { gap : TAB_GAP } } >
255+ { tabs . map ( ( tab , i ) => {
256+ const Icon = tab . icon ;
257+ const isActive = i === activeIndex ;
258+ return (
259+ < Link
260+ key = { tab . href }
261+ href = { tab . href }
262+ className = "flex items-center justify-center rounded-[22px] text-neutral-900 dark:text-white"
263+ style = { { width : TAB_WIDTH , height : 48 } }
264+ onClick = { ( e ) => {
265+ if ( isDragging . current ) {
266+ e . preventDefault ( ) ;
267+ }
268+ } }
269+ >
270+ < motion . div style = { { scale : tabScales [ i ] , opacity : tabOpacities [ i ] } } >
271+ < Icon className = "size-[22px]" strokeWidth = { isActive ? 2.2 : 1.8 } />
272+ </ motion . div >
273+ </ Link >
274+ ) ;
275+ } ) }
276+
277+ </ div >
122278 </ div >
279+
280+ { /* Theme toggle — separate glass button */ }
281+ < button
282+ onClick = { toggleTheme }
283+ className = "ml-1.5 flex items-center justify-center rounded-[28px] border border-white/10 bg-white/5 text-neutral-500 dark:border-white/5 dark:bg-white/[0.02] dark:text-neutral-400"
284+ style = { {
285+ width : 62 ,
286+ height : 60 ,
287+ backdropFilter : "blur(24px) saturate(130%)" ,
288+ WebkitBackdropFilter : "blur(24px) saturate(130%)" ,
289+ boxShadow : "0 4px 24px rgba(0,0,0,0.04)" ,
290+ } }
291+ >
292+ { theme === "dark" ? (
293+ < Sun className = "size-7" strokeWidth = { 1.8 } />
294+ ) : (
295+ < Moon className = "size-7" strokeWidth = { 1.8 } />
296+ ) }
297+ </ button >
123298 </ motion . nav >
124299 </ AnimatePresence >
125300 ) ;
126301}
127-
128- function TabItem ( {
129- href,
130- icon,
131- isActive,
132- } : {
133- href : string ;
134- icon : React . ReactNode ;
135- isActive : boolean ;
136- } ) {
137- return (
138- < Link
139- href = { href }
140- className = { cn (
141- "relative flex flex-col items-center justify-center gap-1 rounded-2xl px-4 py-4 text-[11px] font-semibold text-neutral-500 transition-colors duration-200 dark:text-neutral-100" ,
142- isActive && "text-white" ,
143- ) }
144- >
145- { isActive && (
146- < motion . div
147- layoutId = "active-tab-item"
148- transition = { { type : "spring" , stiffness : 200 , damping : 20 } }
149- className = "absolute right-0 bottom-0 left-0 h-full w-full rounded-2xl bg-linear-to-r from-neutral-600/40 to-neutral-600/30 dark:from-white/40 dark:to-white/30"
150- />
151- ) }
152- < div className = "relative z-10" > { icon } </ div >
153- </ Link >
154- ) ;
155- }
0 commit comments