Magnetic Navigation UI with Next.js

Nacho·2024년 2월 9일
0

simple ui

목록 보기
1/1

Demo 사이트

Framer motion을 활용하여 쉽게 위와 같은 UI를 쉽게 만들 수 있습니다. 배워두면 여러군데 응용할만하니 한번 살펴보도록 합시다.

install

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을 설명합니다.

#1 mapRange 함수

값을 들어오는 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에 맞춰 재조정됩니다.

#2 Motion Wrapper함수

대부분의 HTML 컴포넌트는 motion에서 제공해주기 떄문에 <motion.div> 와 같이 사용할 수 있지만, Link는 Next의 컴포넌트이기 떄문에 아래와 같이 선언해서 사용해야합니다.

const 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

'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을 재구성하여 만들었습니다.

profile
풀스택 개발자

0개의 댓글