위로가기 버튼 CSS 애니메이션 9종 — 스크롤하면 나타나는
위로가기 버튼은 스크롤로 한참 내려간 화면에서 다시 맨 위로 데려다주는 작은 버튼입니다. AOS·GSAP 없이, JS 0줄로 되는 것부터 스크롤 위치를 직접 읽는 것까지 아홉 가지를 모았고, 그중 하나는 애니메이션 없이 scroll-behavior 한 줄만으로 화면이 매끄럽게
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 스크롤하면 등장
- 02 화살표 바운스
- 03 진행 링 표시
- 04 로켓 모양
- 05 옆에서 슬라이드
- 06 회전하는 텍스트
- 07 푸터에서 멈춤
- 08 호버 시 필 확장
- 09 scroll-behavior 부드러운 이동
아홉 개는 이름 가나다순이 아니라 버튼이 화면에서 겪는 순서로 놓았습니다. 먼저 버튼이 등장하는 방식(01~03), 그다음 눈길을 끄는 아이디어형 모양(04·06), 등장의 다른 갈래(05), 화면 아래쪽과 부딪히지 않게 처신하는 법(07), 손을 올렸을 때의 반응(08), 마지막으로 애니메이션 없이 브라우저에 맡기는 방법(09)입니다.
| 항목 | 기법 | 시간 | 트리거 |
|---|---|---|---|
| 01 스크롤하면 등장 | opacity+translateY | 260ms | 스크롤(JS) |
| 02 화살표 바운스 | translateY | 1.4s | 상시 |
| 03 진행 링 표시 | stroke-dashoffset | 2s(데모) | 스크롤(JS) |
| 04 로켓 모양 | translateY+rotate | 700ms | 호버·포커스 |
| 05 옆에서 슬라이드 | translateX+@starting-style | 320ms | 스크롤(JS) |
| 06 회전하는 텍스트 | rotate | 8s | 상시 |
| 07 푸터에서 멈춤 | translateY | 220ms | 스크롤(JS) |
| 08 호버 시 필 확장 | width | 220ms | 호버·포커스 |
| 09 scroll-behavior | (네이티브 스크롤) | — | 클릭 |
01스크롤하면 등장
버튼은 평소 opacity:0으로 숨어 있다가, 스크롤이 240px를 넘으면 자바스크립트 7줄이 .is-visible 클래스를 더해 서서히 떠오릅니다. 긴 목록이나 블로그 글처럼 스크롤이 긴 페이지 하단에 가장 흔히 쓰는 방식입니다.
$duration: 260ms;
$easing: ease-out;
.totop {
opacity: 0;
transform: translateY(16px);
transition: opacity $duration $easing, transform $duration $easing;
}
.totop.is-visible { opacity: 1; transform: translateY(0); }
var btn = document.querySelector('.totop');
function sync() { btn.classList.toggle('is-visible', window.scrollY > 240); }
window.addEventListener('scroll', sync, { passive: true });
sync();
02화살표 바운스
버튼 배경은 고정한 채 안쪽 화살표 아이콘만 위아래로 계속 통통 튑니다. 자바스크립트 없이 CSS keyframes 하나로 "여기를 누르면 위로 간다"는 걸 말없이 알려줍니다.
.totop__arrow {
animation: arrow-bounce 1.4s ease-out infinite;
}
@keyframes arrow-bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-5px); }
}
03진행 링 표시
버튼 테두리를 SVG 원이 감싸고, stroke-dashoffset이 스크롤한 비율만큼 링을 채웁니다. @property --p를 inherits: true로 등록해 부모 버튼에서 바뀐 값을 자식 SVG 원이 그대로 물려받게 한 게 핵심입니다.
@property --p {
syntax: '<number>';
initial-value: 0;
inherits: true;
}
.totop { --p: 0; }
.totop__fill {
stroke-dasharray: 150.8px;
stroke-dashoffset: calc(150.8px * (1 - var(--p) / 100));
}
var max = document.documentElement.scrollHeight - window.innerHeight;
var 비율 = max > 0 ? Math.min(100, (window.scrollY / max) * 100) : 0;
el.style.setProperty('--p', 비율);
04로켓 모양
버튼 자체가 로켓 아이콘이고, 마우스를 올리거나 키보드로 포커스하면 몸체가 기울며 살짝 솟구치고 뒤에서 불꽃이 깜빡입니다. 재미 요소가 필요한 게임·스타트업 랜딩 페이지에 어울립니다.
.totop:hover .totop__rocket,
.totop:focus-visible .totop__rocket { animation: rocket-launch 700ms ease-out 1; }
.totop:hover .totop__flame,
.totop:focus-visible .totop__flame { animation: flame-flicker 700ms ease-out 1; }
@keyframes rocket-launch {
0% { transform: translateY(0) rotate(0deg); }
40% { transform: translateY(-9px) rotate(-9deg); }
100% { transform: translateY(-2px) rotate(-4deg); }
}
@keyframes flame-flicker {
0%, 100% { opacity: 0; transform: scaleY(.6); }
40% { opacity: 1; transform: scaleY(1.3); }
70% { opacity: .7; transform: scaleY(.9); }
}
05옆에서 슬라이드
버튼이 화면 오른쪽 바깥에서 안으로 미끄러지듯 들어옵니다. display: none에서 바뀔 때 진입 시작값을 정해 주는 @starting-style을 써서, 자바스크립트는 스크롤 위치만 확인하고 등장 모양은 CSS가 전담합니다.
.totop {
display: none;
opacity: 0;
transform: translateX(40px);
transition: opacity 320ms, transform 320ms, display 320ms allow-discrete;
}
.totop.is-visible {
display: grid;
opacity: 1;
transform: translateX(0);
@starting-style { opacity: 0; transform: translateX(40px); }
}
06회전하는 텍스트
버튼 테두리를 따라 "SCROLL TO TOP" 글자가 SVG textPath로 원형 배치돼 천천히 계속 돌고, 가운데 화살표만 따로 고정돼 있습니다. 브랜드 개성이 강한 사이트나 포트폴리오 하단에 어울립니다.
.totop__ring {
animation: ring-spin 8s linear infinite;
}
@keyframes ring-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
<text><textPath href="#ring-path">SCROLL TO TOP · SCROLL TO TOP · </textPath></text>
07푸터에서 멈춤
평소엔 화면에 붙어 떠 있다가, 푸터가 올라오면 그 앞을 가리지 않도록 translateY로 위치를 살짝 올려 멈춥니다. IntersectionObserver 9줄이 푸터와 겹친 높이를 CSS 변수 --lift에 대입할 뿐, 애니메이션은 여전히 transform 하나로 처리합니다.
.totop {
transform: translateY(calc(-1 * var(--lift, 0px)));
transition: transform 220ms ease-out;
}
new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var 겹침 = Math.max(0, e.intersectionRect.height);
btn.style.setProperty('--lift', 겹침 + 'px');
});
}, { threshold: [0, 0.01, 1] }).observe(footer);
08호버 시 필 확장
평소엔 동그란 아이콘 버튼이다가 마우스를 올리면 옆으로 늘어나며 "맨 위로" 글자가 함께 드러납니다. width 전환이라 top·left 같은 값보다는 레이아웃을 더 건드리지만, 버튼 하나만 다시 그리므로 비용이 작습니다.
.totop {
width: 48px;
overflow: hidden;
transition: width 220ms ease-out;
}
.totop:hover,
.totop:focus-visible { width: 132px; }
09scroll-behavior 부드러운 이동
앞의 여덟 개와 달리 이 항목엔 애니메이션도 자바스크립트도 없습니다. html에 scroll-behavior: smooth 한 줄만 넣으면 앵커 링크를 누른 화면이 브라우저가 알아서 매끄럽게 위로 움직입니다.
html { scroll-behavior: smooth; }
.totop {
text-decoration: none;
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
}
<a href="#top" class="totop" aria-label="맨 위로">
<svg viewBox="0 0 24 24" aria-hidden="true">
<polyline points="5,15 12,8 19,15" />
</svg>
</a>
어디서 깨지는가 — @property를 부모에서만 쓰면 자식이 못 읽는다
03 진행 링 표시를 처음 만들 때 --p를 inherits: false로 등록했다가 누적면적이 0.00%로 통째로 실패했습니다. 애니메이션은 부모 버튼(.totop)에서 도는데, 실제로 링을 그리는 stroke-dashoffset은 자식 SVG 원(.totop__fill)에 있었고, inherits: false라 자식이 부모의 바뀐 값을 물려받지 못해 링이 계속 0%로 멈춰 있었던 것입니다. inherits: true로 고치자 바로 움직였습니다. 07 푸터에서 멈춤도 비슷한 함정이 있는데, 데모는 실제 스크롤 없이 footer-mock과 버튼을 같은 타이밍의 keyframes로 동시에 움직여야 하고, 하나라도 박자가 어긋나면 버튼이 푸터를 뚫고 지나가는 것처럼 보입니다. 압축 비밀번호 9sbsj52d로 여는 zip에는 아홉 개 모두 duration·easing을 맞춘 vanilla·React 파일이 들어 있습니다.
접근성
운영체제에 동작 줄이기 설정이 켜져 있으면 아홉 개 모두 @media (prefers-reduced-motion: reduce) 블록이 움직임을 지웁니다. 01·05·07처럼 등장·이동 자체가 정보인 항목은 reduced-motion에서도 최종 위치(보이는 상태·멈춘 위치)로 고정돼 있어 존재를 잃지 않고, 02·03·04·06처럼 장식성 반복 동작은 지워도 버튼의 클릭 기능이 그대로 남습니다. 09는 scroll-behavior: smooth 자체가 이미 reduced-motion 설정을 브라우저가 존중하는 값이라 별도 처리가 필요 없습니다. 이 미디어 쿼리의 브라우저 지원 범위는 MDN의 prefers-reduced-motion 문서에서 확인할 수 있고, scroll-behavior의 지원 범위는 caniuse에서 확인할 수 있습니다.
이 시리즈의 다른 묶음은 CSS 애니메이션 카테고리에서 모아 보고, 스크롤에 반응하는 인디케이터를 더 찾는다면 스크롤 진행률 표시 9종도 함께 참고하십시오. 어떤 사이트인지 궁금하면 소개 페이지에 제작 방식을 적어 뒀습니다.
FAQ
위로가기 버튼은 몇 px 스크롤해야 나타나야 합니까?
정해진 값은 없지만 화면 높이(뷰포트)의 절반에서 한 번 정도가 많이 쓰입니다. 이 글의 데모는 240px로 고정했는데, 실제 페이지에서는 window.innerHeight를 기준으로 계산해 화면 크기가 달라도 같은 비율에서 나타나게 하는 편이 낫습니다.
09처럼 scroll-behavior만 쓰면 다른 항목이 다 필요 없습니까?
아닙니다. scroll-behavior: smooth는 "어떻게 올라갈지"만 정할 뿐 "버튼이 언제 나타날지", "푸터와 겹치지 않을지" 같은 문제는 그대로 남습니다. 09는 01~08과 경쟁하는 게 아니라, 01~08 중 하나로 등장을 처리한 뒤 실제 스크롤 동작에 얹어 쓰는 조합이 흔합니다.
React 컴포넌트에서도 IntersectionObserver를 그대로 씁니까?
네, 07의 React 판(StopFooterButton)도 useEffect 안에서 같은 IntersectionObserver를 만들어 푸터 요소를 감시합니다. 컴포넌트가 언마운트될 때 disconnect()로 정리하는 것만 vanilla 판과 다릅니다.