トップへ戻るボタン css アニメーション9選【CSSだけ・JS不要】
トップへ戻るボタンとは、ページを下まで読んだあとにワンクリックで一番上へ戻してくれる小さなボタンのことです。この記事では「トップへ戻るボタン css」でよく探される9つの動きを、AOS・GSAPなしでそのまま使えるコードとしてまとめました。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 スクロールで出現
- 02 矢印バウンス
- 03 進捗リング表示
- 04 ロケット型
- 05 横からスライド
- 06 回るテキスト
- 07 フッターで停止
- 08 ホバーでピル拡張
- 09 scroll-behaviorで滑らか移動
9つは名前順ではなく、ボタンが画面で経験する順番に並べています。まず現れ方(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スクロールで出現
どこで使うか
ボタンは普段は透明で隠れていて、一定の高さ(240px)を超えてスクロールすると、JavaScript 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
押さえておく点
長いリストやブログ記事の下部でよく使われる出方です。
$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');
var threshold = 240;
function sync() { btn.classList.toggle('is-visible', window.scrollY > threshold); }
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どう作るか
JavaScriptなしで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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
@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 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 });
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの要点
遊び心が欲しいゲーム・スタートアップのランディングページに向いています。
.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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
動く仕組み
display: noneから切り替わる瞬間の開始値を指定する@starting-styleを使っているので、JavaScriptはスクロール位置を確認するだけで、現れ方そのものは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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
組み立て方
ブランドの個性を出したいサイトやポートフォリオの下部に合います。
.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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
なぜこう書くか
IntersectionObserver9行がフッターと重なった高さをCSS変数--liftに代入するだけで、動き自体はtransformひとつのままです。
.totop {
transform: translateY(calc(-1 * var(--lift, 0px)));
transition: transform 220ms ease-out;
}
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);
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
実装の核
widthの切り替えなのでtopやleftより周りのレイアウトへの影響はやや大きいものの、動くのはボタン1つだけなので負荷は小さく済みます。
.totop {
width: 48px;
overflow: hidden;
transition: width 220ms ease-out;
}
.totop:hover,
.totop:focus-visible { width: 132px; }
09scroll-behaviorでなめらか移動
何が動くか
これまでの8つと違い、この項目にはアニメーションもJavaScriptも登場しません。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
何が肝心か
htmlにscroll-behavior: smoothを1行足すだけで、アンカーリンクを押した画面はブラウザが自動でなめらかに上まで動かしてくれます。
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に直すと、その場ですぐ動き出しました。
どう避けるか
ボタンが現れる瞬間を逃すと上向き矢印は消えたまま残ります。出現と退場のタイミングをフレーム単位で扱うCSS矢印アニメーションがその地点を指します。
07のフッターで停止にも似た落とし穴があり、デモは実際のスクロールを使わずにfooter-mockとボタンを同じ2秒周期の@keyframesで同時に動かす必要があるため、片方でもタイミングがずれるとボタンがフッターを突き抜けて見えてしまいます。こうしたつまずきを直したあと、9個をまとめた圧縮ファイルを開く鍵は9sbsj52dという場所に、太字にも枠にもせずそっと置いてあるので、流し読みせずゆっくり読むと見つかります。
アクセシビリティ — 9個がreduced-motionで残すもの
reduced-motion で残るもの
OSの動作を減らす設定が入っている場合、9個すべてで@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
トップへ戻るボタンデザイン css は何pxスクロールしたら表示すべきですか?
決まった値はありませんが、画面の高さ(ビューポート)の半分ほどでよく使われます。このデモは240pxで固定していますが、実際のページではwindow.innerHeightを基準に計算しておくと、画面サイズが変わっても同じ比率で表示されるようになります。
09のようにscroll-behaviorだけあれば、ほかの項目は不要ですか?
いいえ。scroll-behavior: smoothが決めるのは「どう上へ戻るか」だけで、「ボタンがいつ現れるか」「フッターと重ならないか」といった問題はそのまま残ります。09は01〜08と競合するものではなく、01〜08のどれかで現れ方を決めたうえで、実際のスクロール移動に重ねて使うことが多い組み合わせです。
zipの中のvanillaとreactのコードは違いますか?
画面に見える動きは同じで、値の受け渡し方だけが異なります。vanilla側のSCSS変数3つ($duration・$easing・$color)を、React版ではコンポーネントのpropsとして受け取り、内部でCSSカスタムプロパティ(--duration・--easing・--color)に変換して渡しています。一番上へ戻る道を含めページ全体の流れを飾るなら、ホームページのスクロール効果がその大きな枠です。









