Magnetic Navigation UI
Created at2024.02.09
Latest update10 months ago
Framer motion을 활용하여 쉽게 위와 같은 UI를 쉽게 만들 수 있습니다. 배워두면 여러군데 응용할만하니 한번 살펴보도록 합시다.
install
bashnpx create-next-app@latest magnetic-nav
bashnpx shadcn-ui@latest init npm i framer-motion
Navigation components
import 문은 생략하도록 하겠습니다. 맨아래 전체 코드를 참고해주세요.
nav.tsx
typescriptconst links = [ { id: 1, title: 'home', url: '/magnetic-nav-link/example/home', }, { id: 2, title: 'about', url: '/magnetic-nav-link/example/about', }, { id: 3, title: 'contact', url: '/magnetic-nav-link/example/contact', }, ] export const Nav = ({ className, ...props }: NavProps) => { return ( <nav className="py-8"> <ul className="flex gap-12"> {links.map((link) => { return <NavItem key={link.id} title={link.title} url={link.url} /> })} </ul> </nav> ) }
NavItem
다음으로 NavItem을 정의합니다. 저는 같은 파일에 작성했습니다.
typescriptconst NavItem = ({ title, url }: { title: string; url: string }) => { const pathname = usePathname() const isActive = pathname === url const x = useMotionValue(0) const y = useMotionValue(0) const textX = useTransform(x, (value) => value * 0.5) // #5 const textY = useTransform(y, (value) => value * 0.5) // #5 const mapRange = ( inputLower: number, inputUpper: number, outputLower: number, outputUpper: number ) => { const INPUT_RANGE = inputUpper - inputLower const OUTPUT_RANGE = outputUpper - outputLower return (value: number) => outputLower + (((value - inputLower) / INPUT_RANGE) * OUTPUT_RANGE || 0) } // #1 const MotionLink = motion(Link) // #2 const setTransform = (x: MotionValue<number>, y: MotionValue<number>) => (event: React.PointerEvent) => { const item = event.currentTarget as HTMLElement const bounds = item.getBoundingClientRect() const relativeX = event.clientX - bounds.left const relativeY = event.clientY - bounds.top const xRange = mapRange(0, bounds.width, -1, 1)(relativeX) const yRange = mapRange(0, bounds.height, -1, 1)(relativeY) x.set(xRange * 10) y.set(yRange * 10) } // #3 return ( <motion.li style={{ x, y }} onPointerLeave={() => { x.set(0) y.set(0) }} onPointerMove={setTransform(x, y)} > <MotionLink className={cn( 'font-medium relative text-sm py-4 px-6 transition-all duration-500 ease-out hover:bg-slate-200', isActive ? 'bg-slate-300' : '' )} href={url} > <motion.span className="relative z-50" transition={{ type: 'spring' }} style={{ x: textX, y: textY }} > {title} </motion.span> {isActive && ( <motion.div transition={{ type: 'spring' }} layoutId="underline" className="absolute left-0 bottom-0 w-full h-full bg-blue-300" /> )} // #4 </MotionLink> </motion.li> ) }
코드 설명 #number
아래는 위의 코드에서 주석부분 #number
을 설명합니다.
#1 mapRange 함수
값을 들어오는 input값을 바운드값으로 매핑합니다. ex)
typescriptconst mr = mapRange(0, 200, -100, 100) console.log(mr(200)) // 100 console.log(mr(0)) // -100 console.log(mr(100)) // 0
위의 코드처럼 input값이 output에 맞춰 재조정됩니다.
#2 Motion Wrapper함수
대부분의 HTML 컴포넌트는 motion에서 제공해주기 떄문에 <motion.div>
와 같이 사용할 수 있지만, Link는 Next의 컴포넌트이기 떄문에 아래와 같이 선언해서 사용해야합니다.
typescriptconst MotionLink = motion(Link) // #2
#3 setTransform 함수
마우스 이벤트 핸들러입니다. curry를 사용하여 헷갈릴 수 있지만 별거없습니다. 그냥 엘리먼트 크기와 마우스위치에 맞춰 mapRange
를 활용하여 이벤트에 사용할 값으로 조정합니다.
마우스가 왼쪽으로 가면 x
는 -10에 가까운 수로 설정되고 아래쪽으로 가면 y
는 10에 가깝게 설정됩니다.
#4 layoutId를 활용한 애니메이션
framer-motion에서 layoutId를 설정해주면 마치 하나의 엘리멘트인것처럼 동작하도록 애니메이션을 처리해줍니다. 눌렀을때 파란 배경이 이동하는 애니메이션입니다.
#5 transform
text의 움직임과 box의 움직임에 차이를 두기위해 사용합니다. 코드를 보면 직관적으로 이해할 수 있을거라 생각됩니다.
마무리
보기에는 어려워보였지만 생각보다 쉬웠습니다. 앞으로 괜찮은 UI를 만들어서 게시글을 작성할 생각입니다. 다음에 또 뵈요 :)
Full Source Code
typescript'use client' import { cn } from '@/lib/utils' import { MotionValue, motion, useMotionValue, useTransform, } from 'framer-motion' import { default as Link } from 'next/link' import { usePathname } from 'next/navigation' import { HTMLAttributes } from 'react' import './nav-style.css' interface NavProps extends HTMLAttributes<HTMLDivElement> {} const links = [ { id: 1, title: 'home', url: '/magnetic-nav-link/example/home', }, { id: 2, title: 'about', url: '/magnetic-nav-link/example/about', }, { id: 3, title: 'contact', url: '/magnetic-nav-link/example/contact', }, ] export const Nav = ({ className, ...props }: NavProps) => { return ( <nav className="py-8"> <ul className="flex gap-12"> {links.map((link) => { return <NavItem key={link.id} title={link.title} url={link.url} /> })} </ul> </nav> ) } const NavItem = ({ title, url }: { title: string; url: string }) => { const pathname = usePathname() const isActive = pathname === url const x = useMotionValue(0) const y = useMotionValue(0) const textX = useTransform(x, (value) => value * 0.5) const textY = useTransform(y, (value) => value * 0.5) const mapRange = ( inputLower: number, inputUpper: number, outputLower: number, outputUpper: number ) => { const INPUT_RANGE = inputUpper - inputLower const OUTPUT_RANGE = outputUpper - outputLower return (value: number) => outputLower + (((value - inputLower) / INPUT_RANGE) * OUTPUT_RANGE || 0) } const MotionLink = motion(Link) const setTransform = (x: MotionValue<number>, y: MotionValue<number>) => (event: React.PointerEvent) => { const item = event.currentTarget as HTMLElement const bounds = item.getBoundingClientRect() const relativeX = event.clientX - bounds.left const relativeY = event.clientY - bounds.top const xRange = mapRange(0, bounds.width, -1, 1)(relativeX) const yRange = mapRange(0, bounds.height, -1, 1)(relativeY) x.set(xRange * 10) y.set(yRange * 10) } return ( <motion.li style={{ x, y }} onPointerLeave={() => { x.set(0) y.set(0) }} onPointerMove={setTransform(x, y)} > <MotionLink className={cn( 'font-medium relative text-sm py-4 px-6 transition-all duration-500 ease-out hover:bg-slate-200', isActive ? 'bg-slate-300' : '' )} href={url} > <motion.span className="relative z-50" transition={{ type: 'spring' }} style={{ x: textX, y: textY }} > {title} </motion.span> {isActive && ( <motion.div transition={{ type: 'spring' }} layoutId="underline" className="absolute left-0 bottom-0 w-full h-full bg-blue-300" /> )} </MotionLink> </motion.li> ) }
nav-style.css
cssspan, a { display: inline-block; } a, span, li { --elastic-out: linear( 0, 0.2178 2.1%, 1.1144 8.49%, 1.2959 10.7%, 1.3463 11.81%, 1.3705 12.94%, 1.3726, 1.3643 14.48%, 1.3151 16.2%, 1.0317 21.81%, 0.941 24.01%, 0.8912 25.91%, 0.8694 27.84%, 0.8698 29.21%, 0.8824 30.71%, 1.0122 38.33%, 1.0357, 1.046 42.71%, 1.0416 45.7%, 0.9961 53.26%, 0.9839 57.54%, 0.9853 60.71%, 1.0012 68.14%, 1.0056 72.24%, 0.9981 86.66%, 1 ); transition: all 1s var(--elastic-out); }
위 기능은 유튜브 영상 - This Magnetic Nav Link Animation Is Sick!! | Nextjs 14, Framer Motion Tutoria을 재구성하여 만들었습니다.