Framer motion을 활용하여 쉽게 위와 같은 UI를 쉽게 만들 수 있습니다. 배워두면 여러군데 응용할만하니 한번 살펴보도록 합시다.
npx create-next-app@latest magnetic-nav
npx shadcn-ui@latest init
npm i framer-motion
import 문은 생략하도록 하겠습니다. 맨아래 전체 코드를 참고해주세요.
nav.tsx
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>
)
}
다음으로 NavItem을 정의합니다. 저는 같은 파일에 작성했습니다.
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) // #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
을 설명합니다.
값을 들어오는 input값을 바운드값으로 매핑합니다.
ex)
const mr = mapRange(0, 200, -100, 100)
console.log(mr(200)) // 100
console.log(mr(0)) // -100
console.log(mr(100)) // 0
위의 코드처럼 input값이 output에 맞춰 재조정됩니다.
대부분의 HTML 컴포넌트는 motion에서 제공해주기 떄문에 <motion.div>
와 같이 사용할 수 있지만, Link는 Next의 컴포넌트이기 떄문에 아래와 같이 선언해서 사용해야합니다.
const MotionLink = motion(Link) // #2
마우스 이벤트 핸들러입니다. curry를 사용하여 헷갈릴 수 있지만 별거없습니다. 그냥 엘리먼트 크기와 마우스위치에 맞춰 mapRange
를 활용하여 이벤트에 사용할 값으로 조정합니다.
마우스가 왼쪽으로 가면 x
는 -10에 가까운 수로 설정되고 아래쪽으로 가면 y
는 10에 가깝게 설정됩니다.
framer-motion에서 layoutId를 설정해주면 마치 하나의 엘리멘트인것처럼 동작하도록 애니메이션을 처리해줍니다. 눌렀을때 파란 배경이 이동하는 애니메이션입니다.
text의 움직임과 box의 움직임에 차이를 두기위해 사용합니다. 코드를 보면 직관적으로 이해할 수 있을거라 생각됩니다.
보기에는 어려워보였지만 생각보다 쉬웠습니다. 앞으로 괜찮은 UI를 만들어서 게시글을 작성할 생각입니다.
다음에 또 뵈요 :)
'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
span,
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을 재구성하여 만들었습니다.