위로가기 버튼 CSS 애니메이션 9종 — 스크롤하면 나타나는
위로가기 버튼은 스크롤로 한참 내려간 화면에서 다시 맨 위로 데려다주는 작은 버튼입니다. AOS·GSAP 없이, JS 0줄로 되는 것부터 스크롤 위치를 직접 읽는 것까지 아홉 가지를 모았고, 그중 하나는 애니메이션 없이 scroll-behavior 한 줄만으로 화면이 매끄럽게
01 back-to-top-button-totop-fade-scroll
02 back-to-top-button-totop-arrow-bounce
03 back-to-top-button-totop-circle-progress
04 back-to-top-button-totop-rocket
05 back-to-top-button-totop-slide-in
06 back-to-top-button-totop-text-rotate
07 back-to-top-button-totop-stop-footer
08 back-to-top-button-totop-pill-expand
09 back-to-top-button-totop-smooth-anchor
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 스크롤하면 등장
- 02 화살표 바운스
- 03 진행 링 표시
- 04 로켓 모양
- 05 옆에서 슬라이드
- 06 회전하는 텍스트
- 07 푸터에서 멈춤
- 08 호버 시 필 확장
- 09 scroll-behavior 부드러운 이동
아홉 개는 이름 가나다순이 아니라 버튼이 화면에서 겪는 순서로 놓았습니다. 먼저 버튼이 등장하는 방식(01~03), 그다음 눈길을 끄는 아이디어형 모양(04·06), 등장의 다른 갈래(05), 화면 아래쪽과 부딪히지 않게 처신하는 법(07), 손을 올렸을 때의 반응(08), 마지막으로 애니메이션 없이 브라우저에 맡기는 방법(09)입니다. 내려가는 길이 이야기를 품고 있다면 타임라인 애니메이션이 그 서사를 싣고, 한 화면을 가로로 넘기는 대안은 CSS 슬라이더 캐러셀이 마련합니다.
| 항목 | 기법 | 시간 | 트리거 |
|---|---|---|---|
| 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 클래스를 더해 서서히 떠오릅니다.

<main class="stage">
<div class="page-mock" aria-hidden="true">
<span class="page-mock__line"></span>
<span class="page-mock__line"></span>
<span class="page-mock__line"></span>
</div>
<button type="button" class="totop is-demo" aria-label="맨 위로">
<svg class="totop__arrow" viewBox="0 0 24 24" aria-hidden="true"><polyline points="5,15 12,8 19,15"/></svg>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 260ms; // opacity·translateY 전환 시간 (실사용 값)
$easing: $ease-out;
$color: $subject-ink; // 화살표 색
@include stage($stage-ink);
.stage { position: relative; }
.page-mock {
position: absolute;
inset: $sp-6 $sp-10 auto $sp-6;
display: flex;
flex-direction: column;
gap: $sp-4;
}
.page-mock__line {
display: block;
height: $sp-2;
border-radius: $r-xs;
background: rgba(#fff, .14);
}
.page-mock__line:nth-child(1) { width: 72%; }
.page-mock__line:nth-child(2) { width: 88%; }
.page-mock__line:nth-child(3) { width: 56%; }
.totop {
position: absolute;
right: $sp-6; bottom: $sp-6;
width: 48px; height: 48px;
border: 0;
border-radius: $r-pill;
background: $stage-yellow;
display: grid;
place-items: center;
box-shadow: $shadow-raised;
cursor: pointer;
opacity: 0;
transform: translateY($sp-4);
transition: opacity $duration $easing, transform $duration $easing;
}
.totop.is-visible { opacity: 1; transform: translateY(0); }
// 데모 루프 — 실제 스크롤 대신 자동으로 나타났다 사라진다 (실사용 트리거와는 별도 규칙, 캡처 주기 2s 고정)
.totop.is-demo { animation: totop-loop 2s $easing infinite; }
@keyframes totop-loop {
0%, 100% { opacity: 0; transform: translateY($sp-4); }
20%, 80% { opacity: 1; transform: translateY(0); }
}
.totop__arrow {
width: 22px; height: 22px;
fill: none;
stroke: $color;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
}
@media (prefers-reduced-motion: reduce) {
.totop, .totop.is-demo { transition: none; animation: none; opacity: 1; transform: translateY(0); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.page-mock {
position: absolute;
inset: 24px 40px auto 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.page-mock__line {
display: block;
height: 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.14);
}
.page-mock__line:nth-child(1) {
width: 72%;
}
.page-mock__line:nth-child(2) {
width: 88%;
}
.page-mock__line:nth-child(3) {
width: 56%;
}
.totop {
position: absolute;
right: 24px;
bottom: 24px;
width: 48px;
height: 48px;
border: 0;
border-radius: 999px;
background: #ffd23f;
display: grid;
place-items: center;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
cursor: pointer;
opacity: 0;
transform: translateY(16px);
transition: opacity 260ms ease-out, transform 260ms ease-out;
}
.totop.is-visible {
opacity: 1;
transform: translateY(0);
}
.totop.is-demo {
animation: totop-loop 2s ease-out infinite;
}
@keyframes totop-loop {
0%, 100% {
opacity: 0;
transform: translateY(16px);
}
20%, 80% {
opacity: 1;
transform: translateY(0);
}
}
.totop__arrow {
width: 22px;
height: 22px;
fill: none;
stroke: #17141a;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
}
@media (prefers-reduced-motion: reduce) {
.totop, .totop.is-demo {
transition: none;
animation: none;
opacity: 1;
transform: translateY(0);
}
}// 실제 사용: 스크롤이 임계값을 넘으면 .is-visible 을 더해 opacity·translateY 전환이 켜진다 (7줄)
var btn = document.querySelector('.totop');
var threshold = 240;
function sync() { btn.classList.toggle('is-visible', window.scrollY > threshold); }
window.addEventListener('scroll', sync, { passive: true });
sync();import { useEffect, useState, type CSSProperties } from "react";
import styles from "./FadeScrollButton.module.scss";
export type FadeScrollButtonProps = {
/** 몇 px 내려가야 나타나는가 (기본 240) */
threshold?: number;
/** opacity·translateY 전환 시간 (기본 300ms) */
duration?: number;
easing?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string };
// 실제 사용: 스크롤이 threshold 를 넘으면 opacity·translateY 전환으로 떠오른다
export default function FadeScrollButton({
threshold = 240,
duration = 260,
easing = "ease-out",
}: FadeScrollButtonProps) {
const [visible, setVisible] = useState(false);
useEffect(() => {
const sync = () => setVisible(window.scrollY > threshold);
sync();
window.addEventListener("scroll", sync, { passive: true });
return () => window.removeEventListener("scroll", sync);
}, [threshold]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing };
return (
<button
type="button"
className={`${styles.totop} ${visible ? styles.visible : ""}`}
style={vars}
aria-label="맨 위로"
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
>
<svg className={styles.arrow} viewBox="0 0 24 24" aria-hidden="true">
<polyline points="5,15 12,8 19,15" />
</svg>
</button>
);
}01 · 스크롤하면 등장 — 측정 누적면적 1.764% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
짚어 둘 점
긴 목록이나 블로그 글처럼 스크롤이 긴 페이지 하단에 가장 흔히 쓰는 방식입니다.
$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화살표 바운스
눈에 보이는 것
버튼 배경은 고정한 채 안쪽 화살표 아이콘만 위아래로 계속 통통 튑니다.

<main class="stage">
<button type="button" class="totop" aria-label="맨 위로">
<svg class="totop__arrow is-demo" viewBox="0 0 24 24" aria-hidden="true"><polyline points="5,14 12,7 19,14"/><polyline points="5,19 12,12 19,19"/></svg>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1.4s;
$easing: $ease-out;
$color: $stage-yellow; // 화살표 색
@include stage($stage-yellow);
.totop {
position: relative;
width: 52px; height: 52px;
border: 0;
border-radius: $r-pill;
background: $subject-ink;
display: grid;
place-items: center;
overflow: hidden;
box-shadow: $shadow-raised;
cursor: pointer;
}
.totop__arrow {
width: 24px; height: 24px;
fill: none;
stroke: $color;
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
}
// 상시 반복 — 실제 사용도 버튼이 화면에 있는 동안 계속 튄다
.totop__arrow.is-demo { animation: arrow-bounce $duration $easing infinite; }
@keyframes arrow-bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-5px); }
}
@media (prefers-reduced-motion: reduce) {
.totop__arrow.is-demo { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.totop {
position: relative;
width: 52px;
height: 52px;
border: 0;
border-radius: 999px;
background: #17141a;
display: grid;
place-items: center;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
cursor: pointer;
}
.totop__arrow {
width: 24px;
height: 24px;
fill: none;
stroke: #ffd23f;
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
}
.totop__arrow.is-demo {
animation: arrow-bounce 1.4s ease-out infinite;
}
@keyframes arrow-bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-5px);
}
}
@media (prefers-reduced-motion: reduce) {
.totop__arrow.is-demo {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./ArrowBounceButton.module.scss";
export type ArrowBounceButtonProps = {
/** 한 번 튕기는 데 걸리는 시간 (기본 1.4s) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ArrowBounceButton({
duration = 1400,
easing = "ease-out",
color = "#ffd23f",
}: ArrowBounceButtonProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<button
type="button"
className={styles.totop}
style={vars}
aria-label="맨 위로"
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
>
<svg className={styles.arrow} viewBox="0 0 24 24" aria-hidden="true">
<polyline points="5,14 12,7 19,14" />
<polyline points="5,19 12,12 19,19" />
</svg>
</button>
);
}02 · 화살표 바운스 — 측정 누적면적 0.17% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어떻게 만들었나
자바스크립트 없이 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이 스크롤한 비율만큼 링을 채웁니다.

<main class="stage">
<button type="button" class="totop is-demo" role="progressbar" aria-label="읽은 비율" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<svg class="totop__ring" viewBox="0 0 56 56" aria-hidden="true">
<circle class="totop__track" cx="28" cy="28" r="24"/>
<circle class="totop__fill" cx="28" cy="28" r="24"/>
</svg>
<svg class="totop__arrow" viewBox="0 0 24 24" aria-hidden="true"><polyline points="5,15 12,8 19,15"/></svg>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: linear;
$color: $subject-ink; // 링 색
@include stage($stage-orange);
// 정수라야 링이 매끄럽게 보간된다 (047 스크롤 진행 배지와 같은 기법)
// inherits:true — .totop 에서 애니메이션하고 자식 SVG 원(.totop__fill)이 그 값을 물려받아 쓴다
@property --p {
syntax: '<number>';
initial-value: 0;
inherits: true;
}
.totop {
--p: 0;
position: relative;
width: 56px; height: 56px;
border: 0;
border-radius: $r-pill;
background: $stage-paper;
display: grid;
place-items: center;
box-shadow: $shadow-raised;
cursor: pointer;
}
// 데모 루프 — 실제로는 JS 가 스크롤 비율을 --p 에 직접 대입하지만, 여기선 0→100 을 자동으로 돈다
.totop.is-demo { animation: totop-fill $duration $easing infinite; }
@keyframes totop-fill { from { --p: 0; } to { --p: 100; } }
.totop__ring {
position: absolute;
inset: 0;
width: 56px; height: 56px;
transform: rotate(-90deg);
}
.totop__track, .totop__fill {
fill: none;
stroke-width: 3.4;
}
.totop__track { stroke: rgba($stage-ink, .14); }
.totop__fill {
stroke: $color;
stroke-linecap: round;
stroke-dasharray: 150.8px;
stroke-dashoffset: calc(150.8px * (1 - var(--p) / 100));
}
.totop__arrow {
width: 20px; height: 20px;
fill: none;
stroke: $color;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
}
@media (prefers-reduced-motion: reduce) {
.totop.is-demo { animation: none; --p: 100; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --p {
syntax: "<number>";
initial-value: 0;
inherits: true;
}
.totop {
--p: 0;
position: relative;
width: 56px;
height: 56px;
border: 0;
border-radius: 999px;
background: #ffffff;
display: grid;
place-items: center;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
cursor: pointer;
}
.totop.is-demo {
animation: totop-fill 2s linear infinite;
}
@keyframes totop-fill {
from {
--p: 0;
}
to {
--p: 100;
}
}
.totop__ring {
position: absolute;
inset: 0;
width: 56px;
height: 56px;
transform: rotate(-90deg);
}
.totop__track, .totop__fill {
fill: none;
stroke-width: 3.4;
}
.totop__track {
stroke: rgba(23, 20, 26, 0.14);
}
.totop__fill {
stroke: #17141a;
stroke-linecap: round;
stroke-dasharray: 150.8px;
stroke-dashoffset: calc(150.8px * (1 - var(--p) / 100));
}
.totop__arrow {
width: 20px;
height: 20px;
fill: none;
stroke: #17141a;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
}
@media (prefers-reduced-motion: reduce) {
.totop.is-demo {
animation: none;
--p: 100;
}
}// 실제 사용: 스크롤 비율을 계산해 --p 에 직접 대입한다 (7줄). 데모 루프는 별도 keyframes 가 담당한다
var el = document.querySelector('.totop');
function sync() {
var max = document.documentElement.scrollHeight - window.innerHeight;
var ratio = max > 0 ? Math.min(100, (window.scrollY / max) * 100) : 0;
el.style.setProperty('--p', ratio);
}
window.addEventListener('scroll', sync, { passive: true });import { useEffect, useState, type CSSProperties } from "react";
import styles from "./CircleProgressButton.module.scss";
export type CircleProgressButtonProps = {
color?: string;
};
type Vars = CSSProperties & { "--p"?: number };
// 실제 사용: 스크롤 비율을 계산해 --p 에 직접 대입한다. 애니메이션이 아니라 값 그대로 반영한다
export default function CircleProgressButton({ color = "#17141a" }: CircleProgressButtonProps) {
const [p, setP] = useState(0);
useEffect(() => {
const sync = () => {
const max = document.documentElement.scrollHeight - window.innerHeight;
setP(max > 0 ? Math.min(100, (window.scrollY / max) * 100) : 0);
};
sync();
window.addEventListener("scroll", sync, { passive: true });
return () => window.removeEventListener("scroll", sync);
}, []);
const vars: Vars = { "--p": p };
const circumference = 150.8;
return (
<button
type="button"
className={styles.totop}
style={{ ...vars, color }}
role="progressbar"
aria-label="읽은 비율"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(p)}
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
>
<svg className={styles.ring} viewBox="0 0 56 56" aria-hidden="true">
<circle className={styles.track} cx="28" cy="28" r="24" />
<circle
className={styles.fill}
cx="28"
cy="28"
r="24"
style={{ strokeDasharray: circumference, strokeDashoffset: circumference * (1 - p / 100) }}
/>
</svg>
<svg className={styles.arrow} viewBox="0 0 24 24" aria-hidden="true">
<polyline points="5,15 12,8 19,15" />
</svg>
</button>
);
}03 · 진행 링 표시 — 측정 누적면적 0.454% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
@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 ratio = max > 0 ? Math.min(100, (window.scrollY / max) * 100) : 0;
el.style.setProperty('--p', ratio);
04로켓 모양
무슨 일이 일어나나
버튼 자체가 로켓 아이콘이고, 마우스를 올리거나 키보드로 포커스하면 몸체가 기울며 살짝 솟구치고 뒤에서 불꽃이 깜빡입니다.

<main class="stage">
<button type="button" class="totop is-demo" aria-label="맨 위로 발사">
<svg class="totop__rocket" viewBox="0 0 24 28" aria-hidden="true">
<path class="totop__body" d="M12 1 C17 6 18 12 16 18 L8 18 C6 12 7 6 12 1 Z"/>
<circle class="totop__window" cx="12" cy="10" r="2.4"/>
<path class="totop__fin totop__fin--l" d="M8 15 L3 20 L8 19 Z"/>
<path class="totop__fin totop__fin--r" d="M16 15 L21 20 L16 19 Z"/>
<path class="totop__flame" d="M9.5 18 L12 25 L14.5 18 Z"/>
</svg>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 700ms;
$easing: $ease-out;
$color: $stage-orange; // 불꽃 색
@include stage($stage-paper);
.totop {
width: 60px; height: 60px;
border: 0;
border-radius: $r-pill;
background: $subject-ink;
display: grid;
place-items: center;
box-shadow: $shadow-raised;
cursor: pointer;
}
.totop__rocket {
width: 26px; height: 30px;
transform-origin: center bottom;
}
.totop__body { fill: $stage-paper; }
.totop__window { fill: $subject-ink; }
.totop__fin { fill: $subject-cream; }
.totop__flame {
fill: $color;
transform-origin: center top;
opacity: 0;
}
// 실제 사용: 마우스를 올리거나 포커스하면 1회 발사
.totop:hover .totop__rocket,
.totop:focus-visible .totop__rocket { animation: rocket-launch $duration $easing 1; }
.totop:hover .totop__flame,
.totop:focus-visible .totop__flame { animation: flame-flicker $duration $easing 1; }
// 데모 루프 — 같은 모양을 2초 주기 뒤쪽에 압축해 자동 재생 (064-02 와 같은 기법)
.totop.is-demo .totop__rocket { animation: rocket-launch-loop 2s $easing infinite; }
.totop.is-demo .totop__flame { animation: flame-flicker-loop 2s $easing infinite; }
@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); }
}
@keyframes rocket-launch-loop {
0%, 55% { transform: translateY(0) rotate(0deg); }
75% { transform: translateY(-9px) rotate(-9deg); }
100% { transform: translateY(-2px) rotate(-4deg); }
}
@keyframes flame-flicker-loop {
0%, 55%, 100% { opacity: 0; transform: scaleY(.6); }
75% { opacity: 1; transform: scaleY(1.3); }
90% { opacity: .7; transform: scaleY(.9); }
}
@media (prefers-reduced-motion: reduce) {
.totop:hover .totop__rocket, .totop:focus-visible .totop__rocket,
.totop.is-demo .totop__rocket,
.totop:hover .totop__flame, .totop:focus-visible .totop__flame,
.totop.is-demo .totop__flame { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.totop {
width: 60px;
height: 60px;
border: 0;
border-radius: 999px;
background: #17141a;
display: grid;
place-items: center;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
cursor: pointer;
}
.totop__rocket {
width: 26px;
height: 30px;
transform-origin: center bottom;
}
.totop__body {
fill: #ffffff;
}
.totop__window {
fill: #17141a;
}
.totop__fin {
fill: #fff7e6;
}
.totop__flame {
fill: #ff4d1f;
transform-origin: center top;
opacity: 0;
}
.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;
}
.totop.is-demo .totop__rocket {
animation: rocket-launch-loop 2s ease-out infinite;
}
.totop.is-demo .totop__flame {
animation: flame-flicker-loop 2s ease-out infinite;
}
@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(0.6);
}
40% {
opacity: 1;
transform: scaleY(1.3);
}
70% {
opacity: 0.7;
transform: scaleY(0.9);
}
}
@keyframes rocket-launch-loop {
0%, 55% {
transform: translateY(0) rotate(0deg);
}
75% {
transform: translateY(-9px) rotate(-9deg);
}
100% {
transform: translateY(-2px) rotate(-4deg);
}
}
@keyframes flame-flicker-loop {
0%, 55%, 100% {
opacity: 0;
transform: scaleY(0.6);
}
75% {
opacity: 1;
transform: scaleY(1.3);
}
90% {
opacity: 0.7;
transform: scaleY(0.9);
}
}
@media (prefers-reduced-motion: reduce) {
.totop:hover .totop__rocket, .totop:focus-visible .totop__rocket,
.totop.is-demo .totop__rocket,
.totop:hover .totop__flame, .totop:focus-visible .totop__flame,
.totop.is-demo .totop__flame {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./RocketButton.module.scss";
export type RocketButtonProps = {
/** 발사 애니메이션 시간 (기본 700ms) */
duration?: number;
easing?: string;
flameColor?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--flame"?: string };
// 실제 사용: 마우스를 올리거나 키보드로 포커스하면 로켓이 한 번 발사된다. 상태 없이 CSS 로 처리한다
export default function RocketButton({
duration = 700,
easing = "ease-out",
flameColor = "#ff4d1f",
}: RocketButtonProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--flame": flameColor };
return (
<button
type="button"
className={styles.totop}
style={vars}
aria-label="맨 위로 발사"
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
>
<svg className={styles.rocket} viewBox="0 0 24 28" aria-hidden="true">
<path className={styles.body} d="M12 1 C17 6 18 12 16 18 L8 18 C6 12 7 6 12 1 Z" />
<circle className={styles.window} cx="12" cy="10" r="2.4" />
<path className={styles.fin} d="M8 15 L3 20 L8 19 Z" />
<path className={styles.fin} d="M16 15 L21 20 L16 19 Z" />
<path className={styles.flame} d="M9.5 18 L12 25 L14.5 18 Z" />
</svg>
</button>
);
}04 · 로켓 모양 — 측정 누적면적 0.289% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드의 요점
재미 요소가 필요한 게임·스타트업 랜딩 페이지에 어울립니다.
.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옆에서 슬라이드
화면에서 벌어지는 일
버튼이 화면 오른쪽 바깥에서 안으로 미끄러지듯 들어옵니다.

<main class="stage">
<div class="page-mock" aria-hidden="true">
<span class="page-mock__line"></span>
<span class="page-mock__line"></span>
<span class="page-mock__line"></span>
</div>
<button type="button" class="totop is-demo" aria-label="맨 위로">
<svg class="totop__arrow" viewBox="0 0 24 24" aria-hidden="true"><polyline points="5,15 12,8 19,15"/></svg>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 320ms;
$easing: $ease-spring;
$color: $subject-ink; // 화살표 색
@include stage($stage-ink);
.stage { position: relative; }
.page-mock {
position: absolute;
inset: $sp-6 $sp-10 auto $sp-6;
display: flex;
flex-direction: column;
gap: $sp-4;
}
.page-mock__line {
display: block;
height: $sp-2;
border-radius: $r-xs;
background: rgba(#fff, .14);
}
.page-mock__line:nth-child(1) { width: 68%; }
.page-mock__line:nth-child(2) { width: 84%; }
.page-mock__line:nth-child(3) { width: 60%; }
// 실제 사용: 평소엔 표시 안 함 → .is-visible 이 더해지면 display 전환 + @starting-style 로 오른쪽에서 미끄러져 들어온다
.totop {
position: absolute;
right: $sp-6; bottom: $sp-6;
width: 48px; height: 48px;
border: 0;
border-radius: $r-pill;
background: $stage-yellow;
display: none;
place-items: center;
box-shadow: $shadow-raised;
cursor: pointer;
opacity: 0;
transform: translateX($sp-10);
transition: opacity $duration $easing, transform $duration $easing, display $duration allow-discrete;
}
.totop.is-visible {
display: grid;
opacity: 1;
transform: translateX(0);
@starting-style { opacity: 0; transform: translateX($sp-10); }
}
// 데모 루프 — 실사용 트리거와 별도로 항상 보이며 2초마다 들어왔다 나간다
.totop.is-demo {
display: grid !important;
animation: totop-slide-loop $dur-loop $easing infinite;
}
@keyframes totop-slide-loop {
0%, 100% { opacity: 0; transform: translateX($sp-10); }
20%, 80% { opacity: 1; transform: translateX(0); }
}
.totop__arrow {
width: 22px; height: 22px;
fill: none;
stroke: $color;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
}
@media (prefers-reduced-motion: reduce) {
.totop, .totop.is-demo { transition: none; animation: none; opacity: 1; transform: translateX(0); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.page-mock {
position: absolute;
inset: 24px 40px auto 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.page-mock__line {
display: block;
height: 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.14);
}
.page-mock__line:nth-child(1) {
width: 68%;
}
.page-mock__line:nth-child(2) {
width: 84%;
}
.page-mock__line:nth-child(3) {
width: 60%;
}
.totop {
position: absolute;
right: 24px;
bottom: 24px;
width: 48px;
height: 48px;
border: 0;
border-radius: 999px;
background: #ffd23f;
display: none;
place-items: center;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
cursor: pointer;
opacity: 0;
transform: translateX(40px);
transition: opacity 320ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 320ms cubic-bezier(0.2, 0.8, 0.2, 1), display 320ms allow-discrete;
}
.totop.is-visible {
display: grid;
opacity: 1;
transform: translateX(0);
}
@starting-style {
.totop.is-visible {
opacity: 0;
transform: translateX(40px);
}
}
.totop.is-demo {
display: grid !important;
animation: totop-slide-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes totop-slide-loop {
0%, 100% {
opacity: 0;
transform: translateX(40px);
}
20%, 80% {
opacity: 1;
transform: translateX(0);
}
}
.totop__arrow {
width: 22px;
height: 22px;
fill: none;
stroke: #17141a;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
}
@media (prefers-reduced-motion: reduce) {
.totop, .totop.is-demo {
transition: none;
animation: none;
opacity: 1;
transform: translateX(0);
}
}// 실제 사용: 스크롤이 임계값을 넘으면 .is-visible 을 더한다. display 전환 + @starting-style 이 등장을 맡는다 (5줄)
var btn = document.querySelector('.totop');
function sync() { btn.classList.toggle('is-visible', window.scrollY > 240); }
window.addEventListener('scroll', sync, { passive: true });import { useEffect, useState, type CSSProperties } from "react";
import styles from "./SlideInButton.module.scss";
export type SlideInButtonProps = {
threshold?: number;
/** translateX 전환 시간 (기본 320ms) */
duration?: number;
easing?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string };
// 실제 사용: 스크롤이 threshold 를 넘으면 display 전환 + @starting-style 로 오른쪽에서 미끄러져 들어온다
export default function SlideInButton({
threshold = 240,
duration = 320,
easing = "cubic-bezier(.2,.8,.2,1)",
}: SlideInButtonProps) {
const [visible, setVisible] = useState(false);
useEffect(() => {
const sync = () => setVisible(window.scrollY > threshold);
sync();
window.addEventListener("scroll", sync, { passive: true });
return () => window.removeEventListener("scroll", sync);
}, [threshold]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing };
if (!visible) return null;
return (
<button
type="button"
className={styles.totop}
style={vars}
aria-label="맨 위로"
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
>
<svg className={styles.arrow} viewBox="0 0 24 24" aria-hidden="true">
<polyline points="5,15 12,8 19,15" />
</svg>
</button>
);
}05 · 옆에서 슬라이드 — 측정 누적면적 2.232% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
동작 원리
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로 원형 배치돼 천천히 계속 돌고, 가운데 화살표만 따로 고정돼 있습니다.

<main class="stage">
<button type="button" class="totop" aria-label="맨 위로">
<svg class="totop__ring is-demo" viewBox="0 0 64 64" aria-hidden="true">
<defs>
<path id="ring-path" d="M32,32 m-24,0 a24,24 0 1,1 48,0 a24,24 0 1,1 -48,0"/>
</defs>
<text class="totop__ring-text">
<textPath href="#ring-path" startOffset="0%">SCROLL TO TOP · SCROLL TO TOP · </textPath>
</text>
</svg>
<svg class="totop__arrow" viewBox="0 0 24 24" aria-hidden="true"><polyline points="5,15 12,8 19,15"/></svg>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 8s;
$easing: linear;
$color: $subject-ink; // 글자 색
@include stage($stage-yellow);
.totop {
position: relative;
width: 72px; height: 72px;
border: 0;
border-radius: $r-pill;
background: transparent;
display: grid;
place-items: center;
cursor: pointer;
}
.totop__ring {
position: absolute;
inset: 0;
width: 72px; height: 72px;
transform-origin: center;
}
// 상시 반복 — 실제 사용도 버튼이 떠 있는 동안 계속 돈다
.totop__ring.is-demo { animation: ring-spin $duration $easing infinite; }
@keyframes ring-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.totop__ring-text {
font: 700 6.4px/1 -apple-system, "Segoe UI", sans-serif;
letter-spacing: .12em;
fill: $color;
}
.totop__arrow {
position: relative;
display: block;
width: 22px; height: 22px;
fill: none;
stroke: $subject-ink;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
background: $stage-paper;
border-radius: $r-pill;
padding: $sp-2;
box-shadow: $shadow-raised;
box-sizing: content-box;
}
@media (prefers-reduced-motion: reduce) {
.totop__ring.is-demo { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.totop {
position: relative;
width: 72px;
height: 72px;
border: 0;
border-radius: 999px;
background: transparent;
display: grid;
place-items: center;
cursor: pointer;
}
.totop__ring {
position: absolute;
inset: 0;
width: 72px;
height: 72px;
transform-origin: center;
}
.totop__ring.is-demo {
animation: ring-spin 8s linear infinite;
}
@keyframes ring-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.totop__ring-text {
font: 700 6.4px/1 -apple-system, "Segoe UI", sans-serif;
letter-spacing: 0.12em;
fill: #17141a;
}
.totop__arrow {
position: relative;
display: block;
width: 22px;
height: 22px;
fill: none;
stroke: #17141a;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
background: #ffffff;
border-radius: 999px;
padding: 8px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
box-sizing: content-box;
}
@media (prefers-reduced-motion: reduce) {
.totop__ring.is-demo {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./TextRotateButton.module.scss";
export type TextRotateButtonProps = {
/** 한 바퀴 도는 시간 (기본 8s) */
duration?: number;
label?: string;
/** SVG textPath id — 한 페이지에 여러 개 두면 서로 다른 값을 준다 (기본 "totop-ring-path") */
pathId?: string;
};
type Vars = CSSProperties & { "--duration"?: string };
export default function TextRotateButton({
duration = 8000,
label = "SCROLL TO TOP",
pathId = "totop-ring-path",
}: TextRotateButtonProps) {
const vars: Vars = { "--duration": `${duration}ms` };
const text = `${label} · ${label} · `;
return (
<button
type="button"
className={styles.totop}
style={vars}
aria-label="맨 위로"
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
>
<svg className={styles.ring} viewBox="0 0 64 64" aria-hidden="true">
<defs>
<path id={pathId} d="M32,32 m-24,0 a24,24 0 1,1 48,0 a24,24 0 1,1 -48,0" />
</defs>
<text className={styles.ringText}>
<textPath href={`#${pathId}`} startOffset="0%">{text}</textPath>
</text>
</svg>
<svg className={styles.arrow} viewBox="0 0 24 24" aria-hidden="true">
<polyline points="5,15 12,8 19,15" />
</svg>
</button>
);
}06 · 회전하는 텍스트 — 측정 누적면적 0.895% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
만드는 방식
브랜드 개성이 강한 사이트나 포트폴리오 하단에 어울립니다.
.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로 위치를 살짝 올려 멈춥니다.

<main class="stage is-demo">
<div class="page-mock" aria-hidden="true">
<span class="page-mock__line"></span>
<span class="page-mock__line"></span>
</div>
<footer class="footer-mock" aria-hidden="true"></footer>
<button type="button" class="totop" aria-label="맨 위로">
<svg class="totop__arrow" viewBox="0 0 24 24" aria-hidden="true"><polyline points="5,15 12,8 19,15"/></svg>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 220ms; // 버튼이 들어 올려지는 실사용 전환 시간
$easing: $ease-out;
$color: $subject-ink; // 화살표 색
@include stage($stage-orange);
.stage { position: relative; overflow: hidden; }
.page-mock {
position: absolute;
inset: $sp-6 $sp-10 auto $sp-6;
display: flex;
flex-direction: column;
gap: $sp-4;
}
.page-mock__line {
display: block;
height: $sp-2;
border-radius: $r-xs;
background: rgba(#fff, .18);
}
.page-mock__line:nth-child(1) { width: 70%; }
.page-mock__line:nth-child(2) { width: 50%; }
.footer-mock {
position: absolute;
left: 0; right: 0; bottom: 0;
height: 64px;
background: $subject-ink;
transform: translateY(100%);
}
// 데모 루프 — 푸터가 올라오고, 버튼은 겹치지 않게 같은 박자로 위로 비켜준다 (캡처 주기 2s 고정)
.stage.is-demo .footer-mock { animation: footer-rise 2s $easing infinite; }
@keyframes footer-rise {
0%, 15% { transform: translateY(100%); }
55%, 100% { transform: translateY(0); }
}
.totop {
position: absolute;
right: $sp-6; bottom: $sp-6;
width: 48px; height: 48px;
border: 0;
border-radius: $r-pill;
background: $stage-paper;
display: grid;
place-items: center;
box-shadow: $shadow-raised;
cursor: pointer;
// 실제 사용: --lift 는 JS 가 IntersectionObserver 로 채운다
transform: translateY(calc(-1 * var(--lift, 0px)));
transition: transform $duration $easing;
}
.stage.is-demo .totop { animation: totop-lift 2s $easing infinite; }
@keyframes totop-lift {
0%, 15% { transform: translateY(0); }
55%, 100% { transform: translateY(-72px); }
}
.totop__arrow {
width: 22px; height: 22px;
fill: none;
stroke: $color;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
}
@media (prefers-reduced-motion: reduce) {
.footer-mock, .totop, .stage.is-demo .footer-mock, .stage.is-demo .totop { animation: none; transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
overflow: hidden;
}
.page-mock {
position: absolute;
inset: 24px 40px auto 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.page-mock__line {
display: block;
height: 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.18);
}
.page-mock__line:nth-child(1) {
width: 70%;
}
.page-mock__line:nth-child(2) {
width: 50%;
}
.footer-mock {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 64px;
background: #17141a;
transform: translateY(100%);
}
.stage.is-demo .footer-mock {
animation: footer-rise 2s ease-out infinite;
}
@keyframes footer-rise {
0%, 15% {
transform: translateY(100%);
}
55%, 100% {
transform: translateY(0);
}
}
.totop {
position: absolute;
right: 24px;
bottom: 24px;
width: 48px;
height: 48px;
border: 0;
border-radius: 999px;
background: #ffffff;
display: grid;
place-items: center;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
cursor: pointer;
transform: translateY(calc(-1 * var(--lift, 0px)));
transition: transform 220ms ease-out;
}
.stage.is-demo .totop {
animation: totop-lift 2s ease-out infinite;
}
@keyframes totop-lift {
0%, 15% {
transform: translateY(0);
}
55%, 100% {
transform: translateY(-72px);
}
}
.totop__arrow {
width: 22px;
height: 22px;
fill: none;
stroke: #17141a;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
}
@media (prefers-reduced-motion: reduce) {
.footer-mock, .totop, .stage.is-demo .footer-mock, .stage.is-demo .totop {
animation: none;
transition: none;
}
}// 실제 사용: 푸터 센티널이 화면에 들어오면 버튼을 그만큼 translateY 로 밀어 올린다 (9줄)
var btn = document.querySelector('.totop');
var footer = document.querySelector('footer');
new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var overlap = Math.max(0, e.intersectionRect.height);
btn.style.setProperty('--lift', overlap + 'px');
btn.classList.toggle('is-near-footer', e.isIntersecting);
});
}, { threshold: [0, 0.01, 1] }).observe(footer);import { useEffect, useRef, useState, type CSSProperties } from "react";
import styles from "./StopFooterButton.module.scss";
export type StopFooterButtonProps = {
/** 겹치지 않게 감시할 푸터 요소를 가리키는 선택자 (기본 "footer") */
footerSelector?: string;
duration?: number;
easing?: string;
};
type Vars = CSSProperties & { "--lift"?: string; "--duration"?: string; "--easing"?: string };
// 실제 사용: 푸터가 화면에 들어오면 겹친 높이만큼 translateY 로 버튼을 밀어 올린다
export default function StopFooterButton({
footerSelector = "footer",
duration = 2000,
easing = "ease-out",
}: StopFooterButtonProps) {
const [lift, setLift] = useState(0);
const observed = useRef(false);
useEffect(() => {
const footer = document.querySelector(footerSelector);
if (!footer || observed.current) return;
observed.current = true;
const io = new IntersectionObserver((entries) => {
entries.forEach((e) => setLift(Math.max(0, e.intersectionRect.height)));
}, { threshold: [0, 0.01, 1] });
io.observe(footer);
return () => io.disconnect();
}, [footerSelector]);
const vars: Vars = { "--lift": `${lift}px`, "--duration": `${duration}ms`, "--easing": easing };
return (
<button
type="button"
className={styles.totop}
style={vars}
aria-label="맨 위로"
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
>
<svg className={styles.arrow} viewBox="0 0 24 24" aria-hidden="true">
<polyline points="5,15 12,8 19,15" />
</svg>
</button>
);
}07 · 푸터에서 멈춤 — 측정 누적면적 24.521% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
왜 이렇게 만들었나
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 overlap = Math.max(0, e.intersectionRect.height);
btn.style.setProperty('--lift', overlap + 'px');
});
}, { threshold: [0, 0.01, 1] }).observe(footer);
08호버 시 필 확장
어떤 화면인가
평소엔 동그란 아이콘 버튼이다가 마우스를 올리면 옆으로 늘어나며 "맨 위로" 글자가 함께 드러납니다.

<main class="stage">
<button type="button" class="totop" aria-label="맨 위로">
<svg class="totop__arrow" viewBox="0 0 24 24" aria-hidden="true"><polyline points="5,15 12,8 19,15"/></svg>
<span class="totop__label">맨 위로</span>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 220ms;
$easing: $ease-out;
$color: $subject-ink; // 글자·화살표 색
@include stage($stage-paper);
.totop {
display: inline-flex;
align-items: center;
gap: $sp-2;
width: 48px;
height: 48px;
padding: 0 $sp-3;
border: 0;
border-radius: $r-pill;
background: $stage-yellow;
overflow: hidden;
box-shadow: $shadow-raised;
cursor: pointer;
transition: width $duration $easing;
}
// 실제 사용: 마우스를 올리거나 포커스하면 폭이 넓어져 라벨이 드러난다
.totop:hover,
.totop:focus-visible,
.totop.is-demo-open { width: 132px; }
.totop__arrow {
flex: none;
width: 20px; height: 20px;
fill: none;
stroke: $color;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
}
.totop__label {
font: 600 clamp(13px, 3.6vw, 14px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $color;
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.totop { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.totop {
display: inline-flex;
align-items: center;
gap: 8px;
width: 48px;
height: 48px;
padding: 0 12px;
border: 0;
border-radius: 999px;
background: #ffd23f;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
cursor: pointer;
transition: width 220ms ease-out;
}
.totop:hover,
.totop:focus-visible,
.totop.is-demo-open {
width: 132px;
}
.totop__arrow {
flex: none;
width: 20px;
height: 20px;
fill: none;
stroke: #17141a;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
}
.totop__label {
font: 600 clamp(13px, 3.6vw, 14px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.totop {
transition: none;
}
}// .is-demo 자동 재생: 진짜 :hover·:focus-visible 규칙을 그대로 타도록 is-demo-open 만 오간다.
// 500ms 간격 — 2초 측정 창 안에 전이(220ms)가 4번 들어가야 움직인 프레임 하한(6)에 여유가 생긴다
// (실측 2026-09-10: 1000ms 간격은 전이 2번뿐이라 5~6 프레임 경계에서 흔들렸다)
var btn = document.querySelector('.totop');
var t = function () { btn.classList.toggle('is-demo-open'); };
t(); setInterval(t, 500);import type { CSSProperties } from "react";
import styles from "./PillExpandButton.module.scss";
export type PillExpandButtonProps = {
label?: string;
/** 폭이 넓어지는 시간 (기본 220ms) */
duration?: number;
easing?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string };
// 실제 사용: 마우스를 올리거나 포커스하면 폭이 넓어져 라벨이 드러난다. 상태 없이 CSS 로 처리한다
export default function PillExpandButton({
label = "맨 위로",
duration = 220,
easing = "ease-out",
}: PillExpandButtonProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing };
return (
<button
type="button"
className={styles.totop}
style={vars}
aria-label={label}
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
>
<svg className={styles.arrow} viewBox="0 0 24 24" aria-hidden="true">
<polyline points="5,15 12,8 19,15" />
</svg>
<span className={styles.label}>{label}</span>
</button>
);
}08 · 호버 시 필 확장 — 측정 누적면적 5.592% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
구현의 핵심
width 전환이라 top·left 같은 값보다는 레이아웃을 더 건드리지만, 버튼 하나만 다시 그리므로 비용이 작습니다.
.totop {
width: 48px;
overflow: hidden;
transition: width 220ms ease-out;
}
.totop:hover,
.totop:focus-visible { width: 132px; }
09scroll-behavior 부드러운 이동
무엇이 움직이나
앞의 여덟 개와 달리 이 항목엔 애니메이션도 자바스크립트도 없습니다.

<main class="stage">
<div class="mini-page" aria-hidden="true">
<div class="mini-page__track is-demo">
<span id="mp-top" class="mini-page__line mini-page__line--mark"></span>
<span class="mini-page__line"></span>
<span class="mini-page__line"></span>
<span class="mini-page__line"></span>
<span class="mini-page__line"></span>
<span class="mini-page__line"></span>
</div>
</div>
<a href="#mp-top" class="totop" aria-label="맨 위로">
<svg class="totop__arrow" viewBox="0 0 24 24" aria-hidden="true"><polyline points="5,15 12,8 19,15"/></svg>
</a>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: ease-in-out; // 브라우저 scroll-behavior:smooth 의 기본 이징과 가장 가깝다
$color: $subject-ink; // 화살표 색
@include stage($stage-ink);
.stage { position: relative; }
// 실제 사용: html { scroll-behavior: smooth; } 한 줄 + <a href="#mp-top"> 뿐이라 여기엔 JS 가 없다.
// 이 박스는 480×300 안에서 실제 문서 스크롤을 흉내 내려고 translateY 트랙만 옮긴다(050 의 scrollLeft 교훈과 같은 계열).
.mini-page {
position: absolute;
inset: $sp-6 $sp-12 $sp-6 $sp-6;
overflow: hidden;
border-radius: $r-card;
background: rgba(#fff, .06);
}
.mini-page__track {
display: flex;
flex-direction: column;
gap: $sp-4;
padding: $sp-4;
transform: translateY(-140px);
}
.mini-page__track.is-demo { animation: mp-scroll $duration $easing infinite; }
@keyframes mp-scroll {
0%, 25% { transform: translateY(-140px); }
70%, 100% { transform: translateY(0); }
}
.mini-page__line {
display: block;
height: $sp-4;
border-radius: $r-xs;
background: rgba(#fff, .16);
}
.mini-page__line--mark { background: $stage-yellow; }
.totop {
position: absolute;
right: $sp-6; bottom: $sp-6;
width: 48px; height: 48px;
border-radius: $r-pill;
background: $stage-yellow;
display: grid;
place-items: center;
box-shadow: $shadow-raised;
text-decoration: none;
}
.totop__arrow {
width: 22px; height: 22px;
fill: none;
stroke: $color;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
}
@media (prefers-reduced-motion: reduce) {
.mini-page__track.is-demo { animation: none; transform: translateY(0); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.mini-page {
position: absolute;
inset: 24px 48px 24px 24px;
overflow: hidden;
border-radius: 16px;
background: rgba(255, 255, 255, 0.06);
}
.mini-page__track {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
transform: translateY(-140px);
}
.mini-page__track.is-demo {
animation: mp-scroll 2s ease-in-out infinite;
}
@keyframes mp-scroll {
0%, 25% {
transform: translateY(-140px);
}
70%, 100% {
transform: translateY(0);
}
}
.mini-page__line {
display: block;
height: 16px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.16);
}
.mini-page__line--mark {
background: #ffd23f;
}
.totop {
position: absolute;
right: 24px;
bottom: 24px;
width: 48px;
height: 48px;
border-radius: 999px;
background: #ffd23f;
display: grid;
place-items: center;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
text-decoration: none;
}
.totop__arrow {
width: 22px;
height: 22px;
fill: none;
stroke: #17141a;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
}
@media (prefers-reduced-motion: reduce) {
.mini-page__track.is-demo {
animation: none;
transform: translateY(0);
}
}import styles from "./SmoothAnchorButton.module.scss";
export type SmoothAnchorButtonProps = {
/** 스크롤 최상단에 둘 앵커 id (기본 "top") */
targetId?: string;
};
// 실제 사용: 전역 CSS 에 html { scroll-behavior: smooth; } 한 줄만 있으면 된다. JS 는 0줄이다
export default function SmoothAnchorButton({ targetId = "top" }: SmoothAnchorButtonProps) {
return (
<a href={`#${targetId}`} className={styles.totop} aria-label="맨 위로">
<svg className={styles.arrow} viewBox="0 0 24 24" aria-hidden="true">
<polyline points="5,15 12,8 19,15" />
</svg>
</a>
);
}09 · scroll-behavior 부드러운 이동 — 측정 누적면적 50.129% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
무엇이 핵심인가
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를 부모에서만 쓰면 자식이 못 읽는다
무엇이 문제인가
back to top button css 를 아홉 가지로 나눠 만들면서, 03 진행 링 표시를 처음 만들 때 --p를 inherits: false로 등록했다가 누적면적이 0.00%로 통째로 실패했습니다. 애니메이션은 부모 버튼(.totop)에서 도는데, 실제로 링을 그리는 stroke-dashoffset은 자식 SVG 원(.totop__fill)에 있었고, inherits: false라 자식이 부모의 바뀐 값을 물려받지 못해 링이 계속 0%로 멈춰 있었던 것입니다. inherits: true로 고치자 바로 움직였습니다.
어떻게 피하나
버튼이 나타나는 순간을 놓치면 위로가기 화살표가 사라진 채 남는데, 등장·퇴장 타이밍을 프레임 단위로 다루는 CSS 화살표 애니메이션이 그 지점을 짚습니다.
07 푸터에서 멈춤도 비슷한 함정이 있는데, 데모는 실제 스크롤 없이 footer-mock과 버튼을 같은 타이밍의 keyframes로 동시에 움직여야 하고, 하나라도 박자가 어긋나면 버튼이 푸터를 뚫고 지나가는 것처럼 보입니다. 압축 비밀번호 9sbsj52d로 여는 zip에는 아홉 개 모두 duration·easing을 맞춘 vanilla·React 파일이 들어 있습니다.
접근성
reduced-motion 에서 남는 것
운영체제에 동작 줄이기 설정이 켜져 있으면 아홉 개 모두 @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 판과 다릅니다. 맨 위로 돌아가는 길을 포함해 페이지 전체의 흐름을 꾸미려면 홈페이지 스크롤 효과가 그 큰 틀입니다.









