Skip to content

Commit 1bd8212

Browse files
committed
design: 모바일 탭바 디자인 수정
1 parent 2920d5b commit 1bd8212

1 file changed

Lines changed: 209 additions & 63 deletions

File tree

src/components/mobile-bottom-tab.tsx

Lines changed: 209 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -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";
1313
import 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

1717
import { 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+
3142
export 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

Comments
 (0)