GODRICH

위로가기 버튼 CSS 애니메이션 9종 — 스크롤하면 나타나는

위로가기 버튼은 스크롤로 한참 내려간 화면에서 다시 맨 위로 데려다주는 작은 버튼입니다. AOS·GSAP 없이, JS 0줄로 되는 것부터 스크롤 위치를 직접 읽는 것까지 아홉 가지를 모았고, 그중 하나는 애니메이션 없이 scroll-behavior 한 줄만으로 화면이 매끄럽게

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

아홉 개는 이름 가나다순이 아니라 버튼이 화면에서 겪는 순서로 놓았습니다. 먼저 버튼이 등장하는 방식(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 클래스를 더해 서서히 떠오릅니다. 긴 목록이나 블로그 글처럼 스크롤이 긴 페이지 하단에 가장 흔히 쓰는 방식입니다.

scroll 이벤트opacity+translateY260ms
$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 하나로 "여기를 누르면 위로 간다"는 걸 말없이 알려줍니다.

translateY$ease-out1.4s
.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 --pinherits: true로 등록해 부모 버튼에서 바뀐 값을 자식 SVG 원이 그대로 물려받게 한 게 핵심입니다.

stroke-dashoffset@propertyJS 7줄
@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로켓 모양

버튼 자체가 로켓 아이콘이고, 마우스를 올리거나 키보드로 포커스하면 몸체가 기울며 살짝 솟구치고 뒤에서 불꽃이 깜빡입니다. 재미 요소가 필요한 게임·스타트업 랜딩 페이지에 어울립니다.

rotate+translateYopacity 깜빡임700ms
.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가 전담합니다.

translateX@starting-style320ms
.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로 원형 배치돼 천천히 계속 돌고, 가운데 화살표만 따로 고정돼 있습니다. 브랜드 개성이 강한 사이트나 포트폴리오 하단에 어울립니다.

SVG textPathrotate8s
.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 하나로 처리합니다.

IntersectionObservertranslateYJS 9줄
.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 같은 값보다는 레이아웃을 더 건드리지만, 버튼 하나만 다시 그리므로 비용이 작습니다.

width transition:hover·:focus-visible220ms
.totop {
  width: 48px;
  overflow: hidden;
  transition: width 220ms ease-out;
}
.totop:hover,
.totop:focus-visible { width: 132px; }

09scroll-behavior 부드러운 이동

앞의 여덟 개와 달리 이 항목엔 애니메이션도 자바스크립트도 없습니다. htmlscroll-behavior: smooth 한 줄만 넣으면 앵커 링크를 누른 화면이 브라우저가 알아서 매끄럽게 위로 움직입니다.

href=#mp-topJS 0줄앵커 링크
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 진행 링 표시를 처음 만들 때 --pinherits: 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 판과 다릅니다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.