9 Back To Top Button CSS: No JS, Scroll-Driven
A back to top button css animation is what makes the small button that carries a visitor back up a long page fade in, spin, or slide into view — nine of them
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Fade in on scroll
- 02 Bouncing arrow
- 03 Circular progress ring
- 04 Rocket shape
- 05 Slide in from side
- 06 Rotating text
- 07 Stops above footer
- 08 Pill expands on hover
- 09 Smooth anchor via scroll-behavior
These nine aren't ranked by flashiness — they follow a button's actual lifecycle on a page. First come three ways a button can arrive once scrolling starts (01, 03, 05), then two that keep moving the whole time without any trigger (02, 06), then two playful reactions to a hovering mouse (04, 08), one that knows to duck out of the footer's way (07), and last is the one that skips animation entirely and hands the job to the browser (09). If the way down carries a story, the timeline animations hold that narrative, and paging one screen sideways is the alternative the CSS slider carousels provide.
01Fade in on scroll
What moves
Scroll past 240px and JavaScript adds a class that fades the button in over opacity and translateY together, so it's never sitting on screen, crowding a visitor before there's anywhere to scroll back to.

<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="Back to top">
<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 · Fade in on scroll — Measured area 1.764% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
Worth noting
The loop above repeats on a fixed two-second cycle just so it's visible without scrolling; the real transition underneath it runs in 260ms.
.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');
window.addEventListener('scroll', function () {
btn.classList.toggle('is-visible', window.scrollY > 240);
}, { passive: true });
02Bouncing arrow
The moment it fires
The button itself never moves — only the arrow icon inside it hops up and down forever, a quiet nudge that this is the button that moves you up.

<main class="stage">
<button type="button" class="totop" aria-label="Back to top">
<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 · Bouncing arrow — Measured area 0.17% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
The key property
It runs the moment the button is on screen, with no scroll listener and no hover needed to start it.
.totop__arrow {
animation: arrow-bounce 1.4s ease-out infinite;
}
@keyframes arrow-bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-5px); }
}
03Circular progress ring
What the user notices
An SVG ring wraps the button and fills as a visitor scrolls, mapped straight from how far down the page they are.

<main class="stage">
<button type="button" class="totop is-demo" role="progressbar" aria-label="Read progress" 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 · Circular progress ring — Measured area 0.454% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to read in the code
Seven lines of JavaScript write that percentage into a custom property, --p, each time the page scrolls, and the ring's stroke-dashoffset reads it back to decide how much of the circle to draw.
@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 });
04Rocket shape
Where this shows up
The button itself is a rocket icon, and hovering over or focusing on it plays a three-step keyframe once — the body tilts and lifts while a flame flickers behind it — then everything resets the instant the mouse leaves.

<main class="stage">
<button type="button" class="totop is-demo" aria-label="Launch to top">
<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 · Rocket shape — Measured area 0.289% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
How it is built
It only ever plays once per hover; it doesn't loop on its own.
.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); }
}
05Slide in from side
What happens on screen
The button starts hidden with display: none and glides in from off-screen on the right once scrolling passes the same threshold as item 01, giving a different arrival than fading straight up.

<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="Back to top">
<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 · Slide in from side — Measured area 2.232% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
How it works
@starting-style is what makes the first rendered frame already mid-transition — 40px off to the right and invisible — instead of the button just popping in unstyled the moment display switches to grid.
.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); }
}
06Rotating text
What it looks like
The words "SCROLL TO TOP" curve around the button's rim on an SVG textPath and spin slowly and continuously, while the arrow sits on its own fixed layer in the center and never turns with them.

<main class="stage">
<button type="button" class="totop" aria-label="Back to top">
<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 · Rotating text — Measured area 0.895% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to wire it up
.totop__ring {
animation: ring-spin 8s linear infinite;
}
@keyframes ring-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
07Stops above footer
When to use it
The button floats at a fixed spot until a nine-line IntersectionObserver reports the footer overlapping it, at which point a --lift custom property pushes the button up by exactly that overlap height so it never sits on top of footer links.

<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="Back to top">
<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 · Stops above footer — Measured area 24.521% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
The core of it
The loop above compresses page-and-footer motion into two seconds for the demo; the button's own transition, underneath that, is a plain 220ms 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);
08Pill expands on hover
What you see
A round 48px icon button stretches to 132px on hover or focus, and overflow: hidden is what keeps a "Back to top" label hidden inside it until the width transition reveals it.

<main class="stage">
<button type="button" class="totop" aria-label="Back to top">
<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">Back to top</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 · Pill expands on hover — Measured area 5.592% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
The idea behind it
The zip's real selector list is just :hover and :focus-visible — the demo above adds one extra class to trigger the same rule automatically, and that's the only line to delete when pasting the CSS in.
.totop {
width: 48px;
overflow: hidden;
transition: width 220ms ease-out;
}
.totop:hover,
.totop:focus-visible { width: 132px; }
09Smooth anchor via scroll-behavior
Where scroll to top button css only wins
Add one line, html { scroll-behavior: smooth; }, pair it with a plain <a href="#top">, and clicking it glides the whole page upward using the browser's own native scrolling — no @keyframes, no transition, and zero lines of JavaScript anywhere in this one.

<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="Back to top">
<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 · Smooth anchor via scroll-behavior — Measured area 50.129% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why it is done this way
The demo can't actually scroll a real document inside a small stage box, so the animation above fakes the same glide by moving an inner track instead; the CSS below is exactly what the real page uses.
html { scroll-behavior: smooth; }
.totop {
display: grid;
place-items: center;
text-decoration: none;
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
}
<a href="#top" aria-label="Back to top">
<svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="5,15 12,8 19,15"/></svg>
</a>
Where does this back to top button break?
What goes wrong
The real bug in this set — one a back to top button tailwind css port would hit just as easily — showed up while building item 03's progress ring. The animation lives on the parent button, .totop, which owns the --p custom property, but the ring itself is drawn by a child SVG element, .totop__fill, that only reads --p back through stroke-dashoffset. Registering --p with @property and leaving inherits: false — the setting that looked like it should be fine, since nothing else needed to inherit it — meant the child never received the parent's changing value at all, and the ring stayed frozen at 0% no matter how far the page scrolled. Switching that one line to inherits: true was the whole fix: a custom property animated on a parent and read on a child always needs to be inheritable, or the child just never sees it move.
How to avoid it
Miss the moment the button enters and the up arrow is simply gone — the CSS arrow animations treat that entrance-and-exit timing frame by frame.
Item 08 has a smaller version of the same kind of trap sitting in its selector list — the zip's real CSS only needs :hover and :focus-visible to expand the pill, and if a demo-only third class ever gets copied along with it by accident, the button would stay permanently expanded instead of reacting to the mouse. The same demo-only pattern shows up across the rest of the set, too: items 01, 03, 05, 07, and 09 each drive their looping preview above through a completely separate @keyframes block gated behind an .is-demo class, layered on top of each item's real trigger rules rather than reusing them, so the real behavior stays tied to its actual trigger (scroll position for four of them, a click for item 09) while the preview keeps looping on a fixed timer — deleting that .is-demo class and its keyframes is the only cleanup needed before the CSS below is production-ready. The zip below needs a password to open it, and rather than boxing that password off, it's sitting in this paragraph as an ordinary word: 9sbsj52d. Read the sentence instead of scanning it, and it's there.
Variants worth trying
| Variant | Changed value | Feel |
|---|---|---|
| Snappier fade-in | Item 01's transition cut from 260ms to 140ms | The button pops into place almost instantly instead of gently easing up |
| Earlier full ring | Item 03's scroll ratio capped so --p reaches 100 at 50% scrolled instead of 100% |
The ring reads "almost there" much sooner, useful on a shorter page |
| Wider pill | Item 08's expanded width widened from 132px to 168px | There's room for a longer label like "Back to the top" instead of the current "Back to top" sitting tight against the arrow |
Accessibility — what reduced motion leaves behind
What reduced motion leaves
Every item here turns off under prefers-reduced-motion: reduce, but what's left on screen isn't the same across all nine. Items 01, 03, 05, and 07 all lock to a fixed, usable end state rather than just freezing mid-motion: 01 and 05 land fully visible in their resting position instead of staying half-faded, 07 stays correctly lifted above the footer instead of possibly overlapping it, and 03's ring is forced to --p: 100 — a full ring — rather than sitting stuck wherever the last scroll position left it, which would otherwise misreport reading progress as empty. Items 02 and 06 simply stop looping and rest at the same neutral pose their own keyframes already use at 0% — the arrow at translateY(0), the text ring unrotated — so nothing about the button's shape changes, only the motion disappears. Item 04's rocket and flame both lose their hover animation entirely, so hovering or focusing still activates the button, it just no longer tilts or flickers. Item 08 keeps expanding on hover — only transition: none is added, so the pill snaps straight to 132px instead of easing there. Item 09 needs nothing extra at all, since scroll-behavior: smooth already falls back to auto under the same media query by design.
What else to check
For more on animating a custom property like item 03's --p, see MDN's @property reference; browser support for item 09's one-line trick is tracked on caniuse. The same "read scroll, drive a ring" idea shows up with a different technique — conic-gradient instead of SVG — in 9 CSS Scroll Progress Indicators, and item 08's width transition pairs with the same trigger pattern used in 9 CSS Input Search Focus Effects. More scroll-triggered pieces are collected under scroll.
FAQ
How far down the page should a back to top button appear?
There's no fixed rule, but showing it once a visitor has scrolled past roughly half a screen height is common. The demos above snap that threshold to a fixed 240px so items 01 and 05, the two that actually use it, are directly comparable; a real page usually measures against window.innerHeight instead, so the button appears at the same relative point no matter the screen size.
Do I need JavaScript for a back to top button html css build?
Not always. Item 09 needs none at all — scroll-behavior: smooth on a plain anchor link handles the entire scroll. The other eight items do need a small amount, though, because deciding when to show the button or how close the footer has crept both require reading the page's current scroll position, which CSS alone still can't do.
Does the React version in the zip behave differently from the plain CSS?
No, the motion itself is identical either way. Item 07's React component still creates the same IntersectionObserver inside a useEffect hook and disconnects it on unmount, and every component across the set takes its own settings — a scroll threshold, a duration, an easing curve — as props and forwards them into the same CSS rules the vanilla SCSS files already use. For the larger flow, the ride back up included, the homepage scroll effects hold the bigger frame.









