Astro·React에서 useGSAP으로 애니메이션 구현하기
2025년 10월 11일 • 김소원Astro·React 프로젝트에서 useGSAP 훅을 활용해 GSAP을 사용하는 방법을 알아봅니다. 컴포넌트 기반 구조에서 부드럽고 직관적인 애니메이션을 구현할 수 있습니다.
나는 Astro로 구현한 정적 웹페이지에서 위와 같은 GSAP의 Drop on Target이라는 Draggable 예제 데모와 비슷한 컴포넌트를 구현하고 싶었다. 다음 예제와 같이 왼쪽에 스티커를 오른쪽 Drop Area 안에서 원하는 위치에 붙일 수 있는 사용자 인터랙션을 구현한다.
GSAP - Draggable: drop on target only
먼저 index.astro에서 인터랙션을 구현할 컴포넌트를 client:only="react"로 불러온다. 이는 React 컴포넌트를 HTML 서버 렌더링 하지 않고, 클라이언트에서만 렌더링하겠다는 의미이다. GSAP처럼 브라우저 환경에 직접 접근(DOM 조작)하는 라이브러리를 서버에서 렌더링될 수 없으므로 해당 속성을 사용하는 것이다.
---
import BaseLayout from '../layouts/BaseLayout.astro'
import Sticker from '../components/Sticker'
---
<BaseLayout>
<Sticker client:only="react" />
</BaseLayout>
client:only를 포함한 Astro의 지시어에 관하여
먼저 템플릿 지시어는 Astro 컴포넌트 템플릿 (.astro 파일)에서 사용할 수 있는 HTML 속성이다. 요소나 컴포넌트의 동작을 제어하는 데 사용된다. 컴파일러 기능을 활성화할 수 있고(예: class 대신 class:list 사용) 컴파일러에게 특별한 작업을 수행하도록 지시할 수도 있다. (예: client:load를 사용하여 hydration)
지금 우리가 알고 싶은 부분은 client:only, client:load 이므로 이 부분에 대해서만 간단하게 설명한다. Astro에서 제공하는 나머지 클라이언트 지시어 및 서버 지시어, 공통 지시어 목록과 자세한 설명은 위의 공식 문서에서 확인할 수 있다. 템플릿 지시어 참조
클라이언트 지시어
UI 컴포넌트가 페이지에서 hydration되는 방식을 제어한다. 기본적으로 컴포넌트는 클라이언트에서 수화되지 않는다. client:* 지시어가 제공되지 않으면 HTML이 JavaScript 없이 페이지에 렌더링된다. 정적 Astro 앱에서 컴포넌트와 사용자의 동적 상호작용을 구현하고 싶을 때 사용한다.
클라이언트 지시어는 .astro 컴포넌트로 직접 가져온 컴포넌트에서만 사용할 수 있다. 동적 태그와 components prop을 통해 전달된 사용자 정의 컴포넌트를 사용할 때는 지원되지 않는다.
다음의 세 가지 지시어는 JavaScript를 로드할 시점에 의해 달라진다
client:load페이지 로드 시 컴포넌트의 JavaScript를 즉시 로드하여 hydration하기 위한 지시어이다. 가능한 한 빨리 동적으로 작동해야 하는 즉시 표시되는 UI 요소를 생성할 때 유용하다.client:visible지시어가 적용되는 컴포넌트가 페이지에 표시될 때 JavaScript를 로드하여 hydration하기 위한 지시어이다. 페이지 아래쪽(스크롤해야 볼 수 있는 부분)에 있거나, 로드하는 데 리소스를 많이 사용하여 사용자가 해당 요소를 보기 어려운 경우 보여주지 않는 우선 순위가 낮은 UI 요소를 생성할 때 유용하다.client:visible={{rootMargin}}선택적으로rootMargin값을 기본IntersectionObserver에 전달하여 컴포넌트 주변 지정 마진(픽셀)이 뷰 포트에 들어갈 때 JavaScript를 hydration할 수 있다.
client:onlyclient:only={string}HTML 서버 렌더링을 건너뛰고 클라이언트에서만 렌더링한다. 페이지 로드 시 즉시 컴포넌트를 로드, 렌더링 및 hydration한다는 점에서client:load와 유사하게 동작한다. 해당 지시어를 사용할 때는 컴포넌트가 사용하는 프레임워크 값을 올바르게 전달할 것을 주의해야 한다. Astro는 서버에서 빌드하는 동안 컴포넌트를 실행하지 않기에 명시적으로 지정하지 않으면 컴포넌트가 어떤 프레임워크에서 JS를 동작하는지 사용하는지 알 수 없다.
<SomeReactComponent client:only="react" />
<SomePreactComponent client:only="preact" />
<SomeSvelteComponent client:only="svelte" />
<SomeVueComponent client:only="vue" />
<SomeSolidComponent client:only="solid-js" />
클라이언트에서만 렌더링되는 컴포넌트의 경우, 로딩하는 동안 대체 콘텐츠를 표시할 수 있다. 클라이언트 컴포넌트를 사용할 수 있을 때까지만 표시되는 콘텐츠를 만들려면 모든 하위 요소에 slot=”fallback”을 사용한다.
<ClientComponent client:only="vue">
<div slot="fallback">Loading</div>
</ClientComponent>
Astro의 클라이언트 지시어에 대한 설명이 너무 길어졌다. 이제부터는 본격적으로 Astro에 사용할 컴포넌트에서 GSAP을 사용해 사용자 인터랙션과 애니메이션을 구현하는 지 살펴본다.
먼저 GSAP이 제공하는 데모 코드에서는 인터랙션 JavaScript를 다음과 같이 구현하고 있다. 특정 영역(dropArea)에 드래그 가능한 박스(dragables)를 가져다 놓으면 스타일이 바뀌고, 영역 밖에서 드롭하면 원래 위치로 돌아가는 인터랙션이다.
// See https://www.greensock.com/draggable/ for more details.
const droppables = document.querySelectorAll('.box')
const dropArea = document.getElementById('dropArea')
const overlapThreshold = '99%'
// Utility to add/remove class
function addClass(el, className) {
el.classList.add(className)
}
function removeClass(el, className) {
el.classList.remove(className)
}
function hasClass(el, className) {
return el.classList.contains(className)
}
Draggable.create(droppables, {
bounds: window,
onDrag: function () {
if (this.hitTest(dropArea, overlapThreshold)) {
addClass(this.target, 'highlight')
} else {
removeClass(this.target, 'highlight')
}
},
onDragEnd: function () {
if (!hasClass(this.target, 'highlight')) {
gsap.to(this.target, {
duration: 0.2,
x: 0,
y: 0,
})
}
},
})
이 바닐라 JS 코드는 React에서 그대로 사용할 수 없다. React는 가상 DOM으로 UI를 관리하기 때문에 document.querySelector 같은 명령어로 DOM을 직접 조작할 수 없다. (React 상태와 실제 DOM 간에 불일치가 발생하여 오류가 발생한다)
이때 useRef 훅을 사용하여 DOM과 React를 연결할 수 있다.
const container = useRef(null):container라는 ‘빈 상자’를 만든다.<section ref={container}>: 상자를 실제 DOM 요소와 연결한다.
이제 우리는 container.current를 통해 section DOM 노드에 안전하게 접근할 수 있다.
gsap.registerPlugin(Draggable) // Draggable 플러그인을 명시적으로 등록
const container = useRef(null)
... (생략)
return (
<section ref={container}>
<h1>Draggable: Drop on target only</h1>
<div className="boxes">
<div id="container">
<img id="box1" className="box" src={kiwi.src} />
<img id="box2" className="box" src={fly.src} />
<img id="box3" className="box" src={paint.src} />
</div>
<div id="dropArea">Drop Area</div>
</div>
</section>
)
과거에는 useEffect 훅 안에서 GSAP 애니메이션을 생성하고, return 함수에서 kill() 메소드로 직접 cleanup해야 했지만, GSAP v3.11부터 도입된 useGSAP 훅으로 간단하게 React에서 애니메이션을 구현할 수 있다.
useGSAP 훅은 컴포넌트가 화면에서 unmount될 때 자동으로 내부에 생성된 모든 GSAP 애니메이션과 인스턴스(여기선 Draggable)를 깔끔하게 정리해준다. 또한, 훅의 두 번째 인자로 scope를 전달하면, useGSAP 내부의 모든 선택자는 오직 scope로 전달된 ref가 연결된 요소 내부에서만 작동한다.
useGSAP(
() => {
const droppables = document.querySelectorAll('.box')
const dropArea = document.getElementById('dropArea')
const overlapThreshold = '99%'
function addClass(el: HTMLElement, className: string) {
el.classList.add(className)
}
function removeClass(el: HTMLElement, className: string) {
el.classList.remove(className)
}
function hasClass(el: HTMLElement, className: string) {
return el.classList.contains(className)
}
Draggable.create(droppables, {
bounds: window,
onDrag: function () {
if (this.hitTest(dropArea, overlapThreshold)) {
addClass(this.target, 'highlight')
} else {
removeClass(this.target, 'highlight')
}
},
onDragEnd: function () {
if (!hasClass(this.target, 'highlight')) {
gsap.to(this.target, {
duration: 0.2,
x: 0,
y: 0,
})
}
},
})
},
{ scope: container }
)
useRef와 useGSAP만 있으면 다른 모든 GSAP에서 제공하는 데모 예제 까지도 적용해볼 수 있다! 물론, 본인만의 재밌는 애니메이션과 인터랙션도 커스텀하게 구현해볼 수 있다.