๋ณธ๋ฌธ ๋ฐ”๋กœ๊ฐ€๊ธฐ
React

React ์นด๋“œ ์ธํ„ฐ๋ž™์…˜ ์• ๋‹ˆ๋ฉ”์ด์…˜ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ

by LasBe 2024. 9. 21.
๋ฐ˜์‘ํ˜•

๐Ÿ“’ React ์นด๋“œ ์ธํ„ฐ๋ž™์…˜ ์• ๋‹ˆ๋ฉ”์ด์…˜ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ

์นด๋“œ ๋ทฐ์— ์ธํ„ฐ๋ž™์…˜ ์• ๋‹ˆ๋ฉ”์ด์…˜์„ ์ ์šฉํ•˜๋Š” ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ํ•˜๋‚˜ ๋งŒ๋“ค์–ด ๋ดค์Šต๋‹ˆ๋‹ค.

https://www.youtube.com/watch?v=YDCCauu4lIk

์˜ˆ์ „์— ๋ณธ ์ฝ”๋”ฉ ์• ํ”Œ๋‹˜์˜ ์˜์ƒ์„ ๊ธฐ์–ต์— ๋‹ด์•„๋’€๋‹ค ์‹ฌ์‹ฌํ–ˆ๋˜ ์ฐจ์— ๋ฆฌ์•กํŠธ ํŒจํ‚ค์ง€๋กœ ์žฌ๊ตฌ์„ฑํ–ˆ์Šต๋‹ˆ๋‹ค.

๐Ÿ“Œ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์ •๋ณด

๐Ÿ”Ž install

$ npm i @lasbe/react-card-animation

๐Ÿ”Ž example

import { CardAnimation } from '@lasbe/react-card-animation';

export default function App() {
  return (
    <div>
      <CardAnimation>
        <div className={`w-[300px] h-[200px]`}>...</div>
      </CardAnimation>
      <CardAnimation angle={10}>
        <div className={`w-[300px] h-[200px]`}>...</div>
      </CardAnimation>
    </div>
  );
}

์ ์šฉํ•  ์ปจํ…Œ์ด๋„ˆ ๋ฐ”๊นฅ์— CardAnimation์„ ๊ฐ์•„์ค๋‹ˆ๋‹ค.

๐Ÿ”Ž Props

๐Ÿ“Œ ์ฝ”๋“œ

'use client';

import React, { useCallback, useEffect, useRef, useState } from 'react';
import './react-card-animation.css';

type CardAnimationType = {
  children: React.ReactElement;
  angle?: number;
};

type MousePositionType = {
  x: null | number;
  y: null | number;
};

export const CardAnimation = ({ children, angle = 30 }: CardAnimationType) => {
  const [mousePosition, setMousePosition] = useState<MousePositionType>({
    x: null,
    y: null,
  });
  const [isInMouse, setIsInMouse] = useState(false);
  const mousePositionRef = useRef<MousePositionType>({ x: null, y: null });
  const requestRef = useRef<number | null>(null);
  const containerRef = useRef<HTMLDivElement>(null);

  const handleMouseMove = useCallback(
    (e: React.MouseEvent<HTMLDivElement>) => {
      const containerWidth = containerRef?.current?.offsetWidth;
      const containerHeight = containerRef?.current?.offsetHeight;

      const mouseX = e.nativeEvent.offsetX; // ๋งˆ์šฐ์Šค X ์ขŒํ‘œ
      const mouseY = e.nativeEvent.offsetY; // ๋งˆ์šฐ์Šค Y ์ขŒํ‘œ

      document.body?.style.setProperty('--card-animation-lighting-x', `${mouseX}px`);
      document.body?.style.setProperty('--card-animation-lighting-y', `${mouseY}px`);

      const xRatio = mouseX / (containerWidth ?? 1);
      const yRatio = mouseY / (containerHeight ?? 1);

      const ratio = angle;

      const xValue = ratio - xRatio * (ratio * 2);
      const yValue = ratio - yRatio * (ratio * 2);

      mousePositionRef.current = { x: -yValue, y: xValue };
      setIsInMouse(true);
    },
    [angle],
  );

  const handleMouseOut = useCallback(() => {
    setTimeout(() => {
      setIsInMouse(false);
    }, 500);
  }, []);

  const loop = useCallback(() => {
    setMousePosition(mousePositionRef.current);
    requestRef.current = requestAnimationFrame(loop);
  }, []);

  useEffect(() => {
    requestRef.current = requestAnimationFrame(loop);
    return () => {
      cancelAnimationFrame(requestRef.current ?? 0);
      document.body.style.removeProperty('--card-animation-lighting-x');
      document.body.style.removeProperty('--card-animation-lighting-y');
    };
  }, [loop]);

  return React.cloneElement(children, {
    ref: containerRef,
    onMouseMove: handleMouseMove,
    onMouseOut: handleMouseOut,
    style: {
      ...children.props.style,
      transform: `perspective(800px) rotateX(${isInMouse ? mousePosition.x : 0}deg) rotateY(${isInMouse ? mousePosition.y : 0}deg)`,
    },
    className: `${children.props.className ?? ''} card-animation`,
  });
};
.card-animation {
  transition: 0.25s cubic-bezier(0.08, 0.59, 0.29, 0.99);
  transform-style: preserve-3d;
}

.card-animation::after,
.card-animation::before {
  content: '';
  position: absolute;
  top: 0px;
  left: 0px;
  width: 100%;
  height: 100%;
  border-radius: inherit;
  z-index: 1;
  transition: opacity 0.25s;
}

.card-animation::before {
  background: radial-gradient(800px circle at 30% 30%, rgba(255, 255, 255, 0.25), transparent 40%);
}

.card-animation:hover::before {
  opacity: 0;
}

.card-animation::after {
  opacity: 0;
  background: radial-gradient(
    500px circle at var(--card-animation-lighting-x) var(--card-animation-lighting-y),
    rgba(255, 255, 255, 0.4),
    transparent 40%
  );
}

.card-animation:hover::after {
  opacity: 1;
}

๐Ÿ”Ž ์ตœ์ ํ™”

onMouseMove ์ด๋ฒคํŠธ๋กœ ์ธํ•œ ์žฆ์€ ์ƒํƒœ ๋ณ€ํ™”๋ฅผ ์ค„์ด๊ธฐ ์œ„ํ•ด requestAnimationFrame๋ฅผ ์‚ฌ์šฉํ–ˆ์Šต๋‹ˆ๋‹ค.

ํ”„๋ ˆ์ž„์„ ์ฃผ์‚ฌ์œจ์— ๋งž์ถฐ ์ฝœ๋ฐฑ์„ ํ˜ธ์ถœํ•˜๊ธฐ์— ๋ถ€๋“œ๋Ÿฌ์šด ์• ๋‹ˆ๋ฉ”์ด์…˜ ํšจ๊ณผ์™€ ํšจ์œจ์„ ํ•จ๊ป˜ ์ฑ™๊ธธ ์ˆ˜ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ๊ด‘์›ํšจ๊ณผ

์œ„ ์œ ํŠœ๋ธŒ ์˜์ƒ๊ณผ๋Š” ๋‹ค๋ฅด๊ฒŒ ์นด๋“œ ๋ทฐ์— ์ด๋ฏธ์ง€๋ฅผ ์ ์šฉํ•˜์ง€ ์•Š๊ณ  ์ƒ‰์ƒ์„ ๋„ฃ์„ ์šฉ๋„๋กœ ๋งŒ๋“ค์—ˆ๊ธฐ ๋•Œ๋ฌธ์— css์˜ filter ์˜ต์…˜์„ ์‚ฌ์šฉํ•˜๊ธฐ์— ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์ œ์•ฝ์ด ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.

  • ์นด๋“œ ๋ ˆ์ด์–ด๋ฅผ ์ƒ๋‹จ, ๊ด‘์› ํšจ๊ณผ๋ฅผ ํ•˜๋‹จ ๋ ˆ์ด์–ด๋กœ ๋ฐฐ์น˜
    ์นด๋“œ ๋ ˆ์ด์–ด์— ๋ฐฐ๊ฒฝ ์ƒ‰์„ ์ ์šฉํ•  ๊ฒฝ์šฐ ๊ด‘์› ํšจ๊ณผ๊ฐ€ ๋ณด์ด์ง€ ์•Š์Œ
  • ์นด๋“œ ๋ ˆ์ด์–ด๋ฅผ ํ•˜๋‹จ, ๊ด‘์› ํšจ๊ณผ๋ฅผ ์ƒ๋‹จ ๋ ˆ์ด์–ด๋กœ ๋ฐฐ์น˜
    • ๋ฐฐ๊ฒฝ์— ๊ด‘์› ํšจ๊ณผ๊ฐ€ ํ•ฉ์„ฑ๋จ
    • ๋งˆ์šฐ์Šค ์ขŒํ‘œ ๊ฐ’์— ๋”ฐ๋ฅธ ์›€์ง์ž„ ์ฐจ์ด๊ฐ€ ์‹ฌํ•ด์ง
    • React.cloneElement ์‚ฌ์šฉ์— ์ œ์•ฝ์ด ์ƒ๊น€

๊ทธ๋ž˜์„œ body์— css ์ „์—ญ ๊ฐ’(var(--card-animation-lighting-x))์„ ๊ณ„์†ํ•ด์„œ ์ˆ˜์ •ํ•˜๊ณ  ๊ฐ€์ƒ ์„ ํƒ์ž๋ฅผ ํ†ตํ•ด ๊ทธ๋ž˜๋””์–ธํŠธ์˜ ์œ„์น˜๋ฅผ ๋ณ€๊ฒฝํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ํ‘œํ˜„ํ•˜๊ฒŒ ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

๋ฐ˜์‘ํ˜•

๋Œ“๊ธ€


์˜คํ”ˆ ์ฑ„ํŒ