css 스크롤바 디자인 9가지 — 관성과 반발로 움직인다
css 스크롤바 디자인은 손잡이 값 하나로 움직임이 갈리는 아홉 장면이다. 손끝이 트랙 끝에 닿는 그 짧은 순간, 손잡이가 한 번 밀려났다가 튕겨 돌아온다.
04 scrollbar-boundary-elastic-overshoot
06 scrollbar-horizontal-track-gallery
07 scrollbar-floating-overlay-autohide
09 scrollbar-hero-scene
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 굵기 값이 트랙을 채우는 기본 손잡이
- 02 커서가 올라오면 광택이 쓸고 지나가는 손잡이
- 03 손을 놓아도 관성 값만큼 더 미끄러지는 손잡이
- 04 끝에 닿으면 밀려났다 튕겨 돌아오는 손잡이
- 05 스크롤 위치 값에 따라 색이 옮겨가는 손잡이
- 06 카드 개수 값에 맞춰 늘어나는 가로 트랙
- 07 멈추면 투명도 값이 줄어 사라지는 오버레이 손잡이
- 08 손잡이 길이 값이 남은 분량을 그대로 보여주는 스크롤바
- 09 여러 값이 손잡이 하나 위에서 함께 반응하는 히어로 스크롤바
아홉 개는 값 하나짜리 손잡이에서 시작해 값이 겹치는 손잡이로 끝나도록 늘어놓았다. 01은 두께 값과 반경 값만으로 트랙을 채우고, 02는 그 위에 포인터가 올라올 때만 켜지는 광택 값을 얹는다. 03과 04는 손을 놓거나 끝에 닿는 순간의 물리 값(속도·반발)을 계산하고, 05·06·08은 위치·개수·비율 같은 상태 값을 손잡이 모양이나 색으로 그대로 옮긴다. 07은 시간 값으로 손잡이를 지웠다 되살리고, 09는 앞선 계산 세 개를 손잡이 하나 위에 포갠다.
01굵기 값이 트랙을 채우는 기본 손잡이
트랙 두께 값 하나와 모서리 반경 값 하나만으로 손잡이를 그리고, 스크롤 위치 비율을 손잡이의 세로 이동 값(transform)에 그대로 옮긴다. 최소 길이 값(THUMB_MIN_PX)을 넉넉히 잡아 두어 내용이 아무리 길어져도 손잡이가 손끝보다 얇아지지 않는다.
<main class="stage">
<div class="sb is-demo" id="sb">
<div class="sb__viewport" id="sbViewport">
<div class="sb__content" id="sbContent"></div>
</div>
<div class="sb__track" id="sbTrack">
<div class="sb__thumb" id="sbThumb" role="scrollbar" aria-orientation="vertical" aria-controls="sbViewport" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" tabindex="0"></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: thumbLen = track * visible/total 비율 값 하나로 손잡이 길이·위치가 정해진다.
// 네이티브 스크롤은 살아 있고(휠 그대로 동작), 화면에 보이는 막대만 자체 DOM 손잡이로 그린다.
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-ink);
.sb {
display: flex;
gap: $sp-2;
width: min(88vw, 300px);
height: min(56vw, 176px);
}
.sb__viewport {
flex: 1;
min-width: 0;
height: 100%;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
border-radius: $r-card;
background: rgba(255, 255, 255, .06);
}
.sb__viewport::-webkit-scrollbar { display: none; }
.sb__content {
display: flex;
flex-direction: column;
gap: $sp-2;
padding: $sp-2;
}
.sb__card {
border-radius: $r-sm;
padding: $sp-2 $sp-3;
background: rgba(255, 255, 255, .08);
}
.sb__cardTitle {
margin: 0 0 $sp-1;
color: $color;
font-weight: 700;
font-size: 12px;
}
.sb__cardLine {
margin: 0;
color: rgba(255, 247, 230, .68);
font-size: 10px;
line-height: 1.4;
}
.sb__track {
position: relative;
width: $sp-2;
border-radius: $r-pill;
background: rgba(255, 255, 255, .1);
}
.sb__thumb {
position: absolute;
top: 0;
left: 0;
width: 100%;
border-radius: $r-pill;
background: $subject-blue;
transition: transform $duration $easing;
cursor: grab;
}
.sb__thumb:focus-visible {
outline: 2px solid $subject-cream;
outline-offset: 2px;
}
@media (max-width: 340px) {
.sb { width: min(94vw, 260px); height: 148px; }
.sb__cardTitle { font-size: 11px; }
.sb__cardLine { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
.sb__thumb { transition: none; }
}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);
}
.sb {
display: flex;
gap: 8px;
width: min(88vw, 300px);
height: min(56vw, 176px);
}
.sb__viewport {
flex: 1;
min-width: 0;
height: 100%;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
border-radius: 16px;
background: rgba(255, 255, 255, 0.06);
}
.sb__viewport::-webkit-scrollbar {
display: none;
}
.sb__content {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
}
.sb__card {
border-radius: 8px;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.08);
}
.sb__cardTitle {
margin: 0 0 2px;
color: #fff7e6;
font-weight: 700;
font-size: 12px;
}
.sb__cardLine {
margin: 0;
color: rgba(255, 247, 230, 0.68);
font-size: 10px;
line-height: 1.4;
}
.sb__track {
position: relative;
width: 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
}
.sb__thumb {
position: absolute;
top: 0;
left: 0;
width: 100%;
border-radius: 999px;
background: #2f6df6;
transition: transform 220ms ease-out;
cursor: grab;
}
.sb__thumb:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
@media (max-width: 340px) {
.sb {
width: min(94vw, 260px);
height: 148px;
}
.sb__cardTitle {
font-size: 11px;
}
.sb__cardLine {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.sb__thumb {
transition: none;
}
}(function () {
var THUMB_MIN_PX = 26;
var KEY_STEP_PX = 32;
var root = document.getElementById('sb');
var viewport = document.getElementById('sbViewport');
var content = document.getElementById('sbContent');
var track = document.getElementById('sbTrack');
var thumb = document.getElementById('sbThumb');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
for (var i = 1; i <= 8; i++) {
var card = document.createElement('div');
card.className = 'sb__card';
var h = document.createElement('p');
h.className = 'sb__cardTitle';
h.textContent = '채널 ' + i;
var p = document.createElement('p');
p.className = 'sb__cardLine';
p.textContent = '스크롤 위치에 따라 손잡이 길이 값이 달라집니다.';
card.appendChild(h);
card.appendChild(p);
content.appendChild(card);
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function maxScroll() { return Math.max(0, viewport.scrollHeight - viewport.clientHeight); }
function syncThumb() {
var trackLen = track.clientHeight;
var total = viewport.scrollHeight;
var visible = viewport.clientHeight;
var thumbLen = clamp(trackLen * (visible / total), THUMB_MIN_PX, trackLen);
var ms = maxScroll();
var ratio = ms > 0 ? viewport.scrollTop / ms : 0;
var thumbPos = (trackLen - thumbLen) * ratio;
thumb.style.height = thumbLen.toFixed(1) + 'px';
thumb.style.transform = 'translateY(' + thumbPos.toFixed(1) + 'px)';
thumb.setAttribute('aria-valuenow', String(Math.round(ratio * 100)));
}
viewport.addEventListener('scroll', syncThumb);
var dragging = false;
var dragStartY = 0;
var dragStartScroll = 0;
thumb.addEventListener('pointerdown', function (e) {
dragging = true;
root.classList.remove('is-demo');
dragStartY = e.clientY;
dragStartScroll = viewport.scrollTop;
thumb.setPointerCapture(e.pointerId);
});
thumb.addEventListener('pointermove', function (e) {
if (!dragging) return;
var trackLen = track.clientHeight;
var thumbLen = thumb.clientHeight;
var deltaPx = e.clientY - dragStartY;
var moveRange = Math.max(1, trackLen - thumbLen);
viewport.scrollTop = clamp(dragStartScroll + deltaPx * (maxScroll() / moveRange), 0, maxScroll());
});
thumb.addEventListener('pointerup', function () { dragging = false; });
thumb.addEventListener('pointercancel', function () { dragging = false; });
track.addEventListener('pointerdown', function (e) {
if (e.target !== track) return;
root.classList.remove('is-demo');
var rect = track.getBoundingClientRect();
var trackLen = track.clientHeight;
var thumbLen = thumb.clientHeight;
var clickPos = clamp(e.clientY - rect.top - thumbLen / 2, 0, trackLen - thumbLen);
var ratio = (trackLen - thumbLen) > 0 ? clickPos / (trackLen - thumbLen) : 0;
viewport.scrollTop = ratio * maxScroll();
});
thumb.addEventListener('keydown', function (e) {
var ms = maxScroll();
if (e.key === 'ArrowDown') { viewport.scrollTop = clamp(viewport.scrollTop + KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === 'ArrowUp') { viewport.scrollTop = clamp(viewport.scrollTop - KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === 'PageDown') { viewport.scrollTop = clamp(viewport.scrollTop + viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === 'PageUp') { viewport.scrollTop = clamp(viewport.scrollTop - viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === 'Home') { viewport.scrollTop = 0; e.preventDefault(); }
else if (e.key === 'End') { viewport.scrollTop = ms; e.preventDefault(); }
if (e.key.indexOf('Arrow') === 0 || e.key === 'PageDown' || e.key === 'PageUp' || e.key === 'Home' || e.key === 'End') {
root.classList.remove('is-demo');
}
});
var startTime = null;
function demoTick(now) {
if (startTime === null) startTime = now;
var t = (now - startTime) / 1000;
if (root.classList.contains('is-demo')) {
var phase = (Math.sin(t * Math.PI) + 1) / 2;
viewport.scrollTop = phase * maxScroll();
}
requestAnimationFrame(demoTick);
}
syncThumb();
if (!reduce) { requestAnimationFrame(demoTick); }
})();// ScrollbarThumbTrackBasicRadius — vanilla 판(run/411/demo/01-scrollbar-thumb-track-basic-radius/index.html) 의 React 포트.
// 핵심: thumbLen = track * visible/total 비율 값 하나로 손잡이 길이·위치가 정해진다.
import { useEffect, useRef } from "react";
import styles from "./ScrollbarThumbTrackBasicRadius.module.scss";
type Box<T> = { current: T };
const THUMB_MIN_PX = 26;
const KEY_STEP_PX = 32;
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
export default function ScrollbarThumbTrackBasicRadius() {
const viewportRef = useRef<HTMLDivElement | null>(null);
const trackRef = useRef<HTMLDivElement | null>(null);
const thumbRef = useRef<HTMLDivElement | null>(null);
const demoRef = useRef<boolean>(true) as Box<boolean>;
const draggingRef = useRef<boolean>(false) as Box<boolean>;
const dragStartYRef = useRef<number>(0) as Box<number>;
const dragStartScrollRef = useRef<number>(0) as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const viewport = viewportRef.current!;
const track = trackRef.current!;
const thumb = thumbRef.current!;
if (!viewport || !track || !thumb) return;
function maxScroll(): number {
return Math.max(0, viewport.scrollHeight - viewport.clientHeight);
}
function syncThumb(): void {
const trackLen = track.clientHeight;
const total = viewport.scrollHeight;
const visible = viewport.clientHeight;
const thumbLen = clamp(trackLen * (visible / total), THUMB_MIN_PX, trackLen);
const ms = maxScroll();
const ratio = ms > 0 ? viewport.scrollTop / ms : 0;
const thumbPos = (trackLen - thumbLen) * ratio;
thumb.style.height = thumbLen.toFixed(1) + "px";
thumb.style.transform = "translateY(" + thumbPos.toFixed(1) + "px)";
thumb.setAttribute("aria-valuenow", String(Math.round(ratio * 100)));
}
function onScroll(): void { syncThumb(); }
function onThumbPointerDown(e: PointerEvent): void {
draggingRef.current = true;
demoRef.current = false;
dragStartYRef.current = e.clientY;
dragStartScrollRef.current = viewport.scrollTop;
thumb.setPointerCapture(e.pointerId);
}
function onThumbPointerMove(e: PointerEvent): void {
if (!draggingRef.current) return;
const trackLen = track.clientHeight;
const thumbLen = thumb.clientHeight;
const deltaPx = e.clientY - dragStartYRef.current;
const moveRange = Math.max(1, trackLen - thumbLen);
viewport.scrollTop = clamp(dragStartScrollRef.current + deltaPx * (maxScroll() / moveRange), 0, maxScroll());
}
function onThumbPointerUp(): void { draggingRef.current = false; }
function onThumbPointerCancel(): void { draggingRef.current = false; }
function onTrackPointerDown(e: PointerEvent): void {
if (e.target !== track) return;
demoRef.current = false;
const rect = track.getBoundingClientRect();
const trackLen = track.clientHeight;
const thumbLen = thumb.clientHeight;
const clickPos = clamp(e.clientY - rect.top - thumbLen / 2, 0, trackLen - thumbLen);
const ratio = (trackLen - thumbLen) > 0 ? clickPos / (trackLen - thumbLen) : 0;
viewport.scrollTop = ratio * maxScroll();
}
function onThumbKeyDown(e: KeyboardEvent): void {
const ms = maxScroll();
if (e.key === "ArrowDown") { viewport.scrollTop = clamp(viewport.scrollTop + KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === "ArrowUp") { viewport.scrollTop = clamp(viewport.scrollTop - KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === "PageDown") { viewport.scrollTop = clamp(viewport.scrollTop + viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === "PageUp") { viewport.scrollTop = clamp(viewport.scrollTop - viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === "Home") { viewport.scrollTop = 0; e.preventDefault(); }
else if (e.key === "End") { viewport.scrollTop = ms; e.preventDefault(); }
if (e.key.indexOf("Arrow") === 0 || e.key === "PageDown" || e.key === "PageUp" || e.key === "Home" || e.key === "End") {
demoRef.current = false;
}
}
let startTime: number | null = null;
let frame = 0;
function demoTick(now: number): void {
if (startTime === null) startTime = now;
const t = (now - startTime) / 1000;
if (demoRef.current) {
const phase = (Math.sin(t * Math.PI) + 1) / 2;
viewport.scrollTop = phase * maxScroll();
}
frame = requestAnimationFrame(demoTick);
}
viewport.addEventListener("scroll", onScroll);
thumb.addEventListener("pointerdown", onThumbPointerDown);
thumb.addEventListener("pointermove", onThumbPointerMove);
thumb.addEventListener("pointerup", onThumbPointerUp);
thumb.addEventListener("pointercancel", onThumbPointerCancel);
track.addEventListener("pointerdown", onTrackPointerDown);
thumb.addEventListener("keydown", onThumbKeyDown);
syncThumb();
if (!reduce) { frame = requestAnimationFrame(demoTick); }
return () => {
viewport.removeEventListener("scroll", onScroll);
thumb.removeEventListener("pointerdown", onThumbPointerDown);
thumb.removeEventListener("pointermove", onThumbPointerMove);
thumb.removeEventListener("pointerup", onThumbPointerUp);
thumb.removeEventListener("pointercancel", onThumbPointerCancel);
track.removeEventListener("pointerdown", onTrackPointerDown);
thumb.removeEventListener("keydown", onThumbKeyDown);
if (frame) cancelAnimationFrame(frame);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.sb}>
<div className={styles.sb__viewport} ref={viewportRef}>
<div className={styles.sb__content}>
{Array.from({ length: 8 }, (_, idx) => idx + 1).map((i) => (
<div className={styles.sb__card} key={i}>
<p className={styles.sb__cardTitle}>{"채널 " + i}</p>
<p className={styles.sb__cardLine}>스크롤 위치에 따라 손잡이 길이 값이 달라집니다.</p>
</div>
))}
</div>
</div>
<div className={styles.sb__track} ref={trackRef}>
<div
className={styles.sb__thumb}
ref={thumbRef}
role="scrollbar"
aria-orientation="vertical"
aria-valuenow={0}
aria-valuemin={0}
aria-valuemax={100}
tabIndex={0}
/>
</div>
</div>
</main>
);
}01 · 굵기 값이 트랙을 채우는 기본 손잡이 — 측정 누적면적 33.433% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.sb__track {
position: relative;
width: $sp-2;
border-radius: $r-pill;
background: rgba(255, 255, 255, .1);
}
.sb__thumb {
position: absolute;
top: 0;
left: 0;
width: 100%;
border-radius: $r-pill;
background: $subject-blue;
transition: transform $duration $easing;
cursor: grab;
}
측정: 누적면적 33.4% · 강도 평균 56.0 · 23/23 프레임에서 움직였다.
02커서가 올라오면 광택이 쓸고 지나가는 손잡이
포인터가 손잡이 위로 들어오는 순간에만 광택 띠 하나가 위에서 아래로 한 번 쓸고 지나간다. 지속시간 값(GLOSS_SWEEP_MS) 520이 그대로 keyframes 시간이 되므로 값을 줄이면 스치듯 짧게, 늘리면 천천히 흐르듯 지나간다.
<main class="stage">
<div class="sb is-demo" id="sb">
<div class="sb__viewport" id="sbViewport">
<div class="sb__content" id="sbContent"></div>
</div>
<div class="sb__track" id="sbTrack">
<div class="sb__thumb" id="sbThumb" role="scrollbar" aria-orientation="vertical" aria-controls="sbViewport" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" tabindex="0">
<span class="sb__thumbGloss" id="sbGloss"></span>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 포인터가 손잡이에 오르는 순간 광택 위치 값(::after 를 감싼 span)이
// 손잡이 길이를 따라 한 번 이동한다 — GLOSS_SWEEP_MS 로 길이를 잰다.
$duration: 520ms;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-yellow);
.sb {
display: flex;
gap: $sp-2;
width: min(88vw, 300px);
height: min(56vw, 176px);
}
.sb__viewport {
flex: 1;
min-width: 0;
height: 100%;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
border-radius: $r-card;
background: rgba(23, 20, 26, .06);
}
.sb__viewport::-webkit-scrollbar { display: none; }
.sb__content {
display: flex;
flex-direction: column;
gap: $sp-2;
padding: $sp-2;
}
.sb__card {
border-radius: $r-sm;
padding: $sp-2 $sp-3;
background: rgba(23, 20, 26, .07);
}
.sb__cardTitle {
margin: 0 0 $sp-1;
color: $color;
font-weight: 700;
font-size: 12px;
}
.sb__cardLine {
margin: 0;
color: rgba(23, 20, 26, .62);
font-size: 10px;
line-height: 1.4;
}
.sb__track {
position: relative;
width: $sp-2;
border-radius: $r-pill;
background: rgba(23, 20, 26, .12);
}
.sb__thumb {
position: absolute;
top: 0;
left: 0;
width: 100%;
overflow: hidden;
border-radius: $r-pill;
background: $subject-blue-deep;
transition: transform $dur-quick $easing;
cursor: grab;
}
.sb__thumb:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 2px;
}
.sb__thumbGloss {
position: absolute;
top: -20%;
left: -60%;
width: 40%;
height: 140%;
background: linear-gradient(115deg, transparent, rgba(255, 255, 255, .85), transparent);
transform: translateY(-120%);
pointer-events: none;
}
.sb__thumbGloss.is-sweeping {
animation: glossSweep $duration linear;
}
@keyframes glossSweep {
0% { transform: translateY(-120%); }
100% { transform: translateY(220%); }
}
@media (max-width: 340px) {
.sb { width: min(94vw, 260px); height: 148px; }
.sb__cardTitle { font-size: 11px; }
.sb__cardLine { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
.sb__thumb { transition: none; }
.sb__thumbGloss.is-sweeping { 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);
}
.sb {
display: flex;
gap: 8px;
width: min(88vw, 300px);
height: min(56vw, 176px);
}
.sb__viewport {
flex: 1;
min-width: 0;
height: 100%;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
border-radius: 16px;
background: rgba(23, 20, 26, 0.06);
}
.sb__viewport::-webkit-scrollbar {
display: none;
}
.sb__content {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
}
.sb__card {
border-radius: 8px;
padding: 8px 12px;
background: rgba(23, 20, 26, 0.07);
}
.sb__cardTitle {
margin: 0 0 2px;
color: #17141a;
font-weight: 700;
font-size: 12px;
}
.sb__cardLine {
margin: 0;
color: rgba(23, 20, 26, 0.62);
font-size: 10px;
line-height: 1.4;
}
.sb__track {
position: relative;
width: 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.12);
}
.sb__thumb {
position: absolute;
top: 0;
left: 0;
width: 100%;
overflow: hidden;
border-radius: 999px;
background: #0a4fe8;
transition: transform 220ms ease-out;
cursor: grab;
}
.sb__thumb:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.sb__thumbGloss {
position: absolute;
top: -20%;
left: -60%;
width: 40%;
height: 140%;
background: linear-gradient(115deg, transparent, rgba(255, 255, 255, 0.85), transparent);
transform: translateY(-120%);
pointer-events: none;
}
.sb__thumbGloss.is-sweeping {
animation: glossSweep 520ms linear;
}
@keyframes glossSweep {
0% {
transform: translateY(-120%);
}
100% {
transform: translateY(220%);
}
}
@media (max-width: 340px) {
.sb {
width: min(94vw, 260px);
height: 148px;
}
.sb__cardTitle {
font-size: 11px;
}
.sb__cardLine {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.sb__thumb {
transition: none;
}
.sb__thumbGloss.is-sweeping {
animation: none;
}
}(function () {
var THUMB_MIN_PX = 26;
var KEY_STEP_PX = 32;
var GLOSS_SWEEP_MS = 520;
var root = document.getElementById('sb');
var viewport = document.getElementById('sbViewport');
var content = document.getElementById('sbContent');
var track = document.getElementById('sbTrack');
var thumb = document.getElementById('sbThumb');
var gloss = document.getElementById('sbGloss');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
for (var i = 1; i <= 8; i++) {
var card = document.createElement('div');
card.className = 'sb__card';
var h = document.createElement('p');
h.className = 'sb__cardTitle';
h.textContent = '메뉴 ' + i;
var p = document.createElement('p');
p.className = 'sb__cardLine';
p.textContent = '포인터가 손잡이에 오르면 광택 위치 값이 한 번 이동합니다.';
card.appendChild(h);
card.appendChild(p);
content.appendChild(card);
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function maxScroll() { return Math.max(0, viewport.scrollHeight - viewport.clientHeight); }
function syncThumb() {
var trackLen = track.clientHeight;
var total = viewport.scrollHeight;
var visible = viewport.clientHeight;
var thumbLen = clamp(trackLen * (visible / total), THUMB_MIN_PX, trackLen);
var ms = maxScroll();
var ratio = ms > 0 ? viewport.scrollTop / ms : 0;
var thumbPos = (trackLen - thumbLen) * ratio;
thumb.style.height = thumbLen.toFixed(1) + 'px';
thumb.style.transform = 'translateY(' + thumbPos.toFixed(1) + 'px)';
thumb.setAttribute('aria-valuenow', String(Math.round(ratio * 100)));
}
var sweeping = false;
function triggerGloss() {
if (reduce) return;
if (sweeping) return;
sweeping = true;
gloss.classList.add('is-sweeping');
}
gloss.addEventListener('animationend', function () {
gloss.classList.remove('is-sweeping');
sweeping = false;
});
viewport.addEventListener('scroll', syncThumb);
var dragging = false;
var dragStartY = 0;
var dragStartScroll = 0;
thumb.addEventListener('pointerenter', function () { triggerGloss(); });
thumb.addEventListener('focus', function () { triggerGloss(); });
thumb.addEventListener('pointerdown', function (e) {
dragging = true;
root.classList.remove('is-demo');
dragStartY = e.clientY;
dragStartScroll = viewport.scrollTop;
thumb.setPointerCapture(e.pointerId);
});
thumb.addEventListener('pointermove', function (e) {
if (!dragging) return;
var trackLen = track.clientHeight;
var thumbLen = thumb.clientHeight;
var deltaPx = e.clientY - dragStartY;
var moveRange = Math.max(1, trackLen - thumbLen);
viewport.scrollTop = clamp(dragStartScroll + deltaPx * (maxScroll() / moveRange), 0, maxScroll());
});
thumb.addEventListener('pointerup', function () { dragging = false; });
thumb.addEventListener('pointercancel', function () { dragging = false; });
track.addEventListener('pointerdown', function (e) {
if (e.target !== track) return;
root.classList.remove('is-demo');
var rect = track.getBoundingClientRect();
var trackLen = track.clientHeight;
var thumbLen = thumb.clientHeight;
var clickPos = clamp(e.clientY - rect.top - thumbLen / 2, 0, trackLen - thumbLen);
var ratio = (trackLen - thumbLen) > 0 ? clickPos / (trackLen - thumbLen) : 0;
viewport.scrollTop = ratio * maxScroll();
});
thumb.addEventListener('keydown', function (e) {
var ms = maxScroll();
if (e.key === 'ArrowDown') { viewport.scrollTop = clamp(viewport.scrollTop + KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === 'ArrowUp') { viewport.scrollTop = clamp(viewport.scrollTop - KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === 'PageDown') { viewport.scrollTop = clamp(viewport.scrollTop + viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === 'PageUp') { viewport.scrollTop = clamp(viewport.scrollTop - viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === 'Home') { viewport.scrollTop = 0; e.preventDefault(); }
else if (e.key === 'End') { viewport.scrollTop = ms; e.preventDefault(); }
if (e.key.indexOf('Arrow') === 0 || e.key === 'PageDown' || e.key === 'PageUp' || e.key === 'Home' || e.key === 'End') {
root.classList.remove('is-demo');
}
});
var startTime = null;
var lastSweepAt = -10;
function demoTick(now) {
if (startTime === null) startTime = now;
var t = (now - startTime) / 1000;
if (root.classList.contains('is-demo')) {
var phase = (Math.sin(t * Math.PI) + 1) / 2;
viewport.scrollTop = phase * maxScroll();
if (t - lastSweepAt >= 0.85) { triggerGloss(); lastSweepAt = t; }
}
requestAnimationFrame(demoTick);
}
syncThumb();
if (!reduce) { requestAnimationFrame(demoTick); }
})();// ScrollbarThumbHoverGlossSweep — vanilla 판(run/411/demo/02-scrollbar-thumb-hover-gloss-sweep/index.html) 의 React 포트.
// 핵심: 포인터가 손잡이에 오르는 순간 광택 위치 값(span)이 GLOSS_SWEEP_MS 동안 한 번 이동한다.
import { useEffect, useRef } from "react";
import styles from "./ScrollbarThumbHoverGlossSweep.module.scss";
type Box<T> = { current: T };
const THUMB_MIN_PX = 26;
const KEY_STEP_PX = 32;
const GLOSS_SWEEP_MS = 520;
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
export default function ScrollbarThumbHoverGlossSweep() {
const viewportRef = useRef<HTMLDivElement | null>(null);
const trackRef = useRef<HTMLDivElement | null>(null);
const thumbRef = useRef<HTMLDivElement | null>(null);
const glossRef = useRef<HTMLSpanElement | null>(null);
const demoRef = useRef<boolean>(true) as Box<boolean>;
const draggingRef = useRef<boolean>(false) as Box<boolean>;
const dragStartYRef = useRef<number>(0) as Box<number>;
const dragStartScrollRef = useRef<number>(0) as Box<number>;
const sweepingRef = useRef<boolean>(false) as Box<boolean>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const viewport = viewportRef.current!;
const track = trackRef.current!;
const thumb = thumbRef.current!;
const gloss = glossRef.current!;
if (!viewport || !track || !thumb || !gloss) return;
function maxScroll(): number {
return Math.max(0, viewport.scrollHeight - viewport.clientHeight);
}
function syncThumb(): void {
const trackLen = track.clientHeight;
const total = viewport.scrollHeight;
const visible = viewport.clientHeight;
const thumbLen = clamp(trackLen * (visible / total), THUMB_MIN_PX, trackLen);
const ms = maxScroll();
const ratio = ms > 0 ? viewport.scrollTop / ms : 0;
const thumbPos = (trackLen - thumbLen) * ratio;
thumb.style.height = thumbLen.toFixed(1) + "px";
thumb.style.transform = "translateY(" + thumbPos.toFixed(1) + "px)";
thumb.setAttribute("aria-valuenow", String(Math.round(ratio * 100)));
}
function triggerGloss(): void {
if (reduce || sweepingRef.current) return;
sweepingRef.current = true;
gloss.classList.add("is-sweeping");
}
function onGlossAnimationEnd(): void {
gloss.classList.remove("is-sweeping");
sweepingRef.current = false;
}
function onScroll(): void { syncThumb(); }
function onThumbPointerEnter(): void { triggerGloss(); }
function onThumbFocus(): void { triggerGloss(); }
function onThumbPointerDown(e: PointerEvent): void {
draggingRef.current = true;
demoRef.current = false;
dragStartYRef.current = e.clientY;
dragStartScrollRef.current = viewport.scrollTop;
thumb.setPointerCapture(e.pointerId);
}
function onThumbPointerMove(e: PointerEvent): void {
if (!draggingRef.current) return;
const trackLen = track.clientHeight;
const thumbLen = thumb.clientHeight;
const deltaPx = e.clientY - dragStartYRef.current;
const moveRange = Math.max(1, trackLen - thumbLen);
viewport.scrollTop = clamp(dragStartScrollRef.current + deltaPx * (maxScroll() / moveRange), 0, maxScroll());
}
function onThumbPointerUp(): void { draggingRef.current = false; }
function onThumbPointerCancel(): void { draggingRef.current = false; }
function onTrackPointerDown(e: PointerEvent): void {
if (e.target !== track) return;
demoRef.current = false;
const rect = track.getBoundingClientRect();
const trackLen = track.clientHeight;
const thumbLen = thumb.clientHeight;
const clickPos = clamp(e.clientY - rect.top - thumbLen / 2, 0, trackLen - thumbLen);
const ratio = (trackLen - thumbLen) > 0 ? clickPos / (trackLen - thumbLen) : 0;
viewport.scrollTop = ratio * maxScroll();
}
function onThumbKeyDown(e: KeyboardEvent): void {
const ms = maxScroll();
if (e.key === "ArrowDown") { viewport.scrollTop = clamp(viewport.scrollTop + KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === "ArrowUp") { viewport.scrollTop = clamp(viewport.scrollTop - KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === "PageDown") { viewport.scrollTop = clamp(viewport.scrollTop + viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === "PageUp") { viewport.scrollTop = clamp(viewport.scrollTop - viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === "Home") { viewport.scrollTop = 0; e.preventDefault(); }
else if (e.key === "End") { viewport.scrollTop = ms; e.preventDefault(); }
if (e.key.indexOf("Arrow") === 0 || e.key === "PageDown" || e.key === "PageUp" || e.key === "Home" || e.key === "End") {
demoRef.current = false;
}
}
let startTime: number | null = null;
let lastSweepAt = -10;
let frame = 0;
function demoTick(now: number): void {
if (startTime === null) startTime = now;
const t = (now - startTime) / 1000;
if (demoRef.current) {
const phase = (Math.sin(t * Math.PI) + 1) / 2;
viewport.scrollTop = phase * maxScroll();
if (t - lastSweepAt >= 0.85) { triggerGloss(); lastSweepAt = t; }
}
frame = requestAnimationFrame(demoTick);
}
viewport.addEventListener("scroll", onScroll);
thumb.addEventListener("pointerenter", onThumbPointerEnter);
thumb.addEventListener("focus", onThumbFocus);
gloss.addEventListener("animationend", onGlossAnimationEnd);
thumb.addEventListener("pointerdown", onThumbPointerDown);
thumb.addEventListener("pointermove", onThumbPointerMove);
thumb.addEventListener("pointerup", onThumbPointerUp);
thumb.addEventListener("pointercancel", onThumbPointerCancel);
track.addEventListener("pointerdown", onTrackPointerDown);
thumb.addEventListener("keydown", onThumbKeyDown);
syncThumb();
if (!reduce) { frame = requestAnimationFrame(demoTick); }
return () => {
viewport.removeEventListener("scroll", onScroll);
thumb.removeEventListener("pointerenter", onThumbPointerEnter);
thumb.removeEventListener("focus", onThumbFocus);
gloss.removeEventListener("animationend", onGlossAnimationEnd);
thumb.removeEventListener("pointerdown", onThumbPointerDown);
thumb.removeEventListener("pointermove", onThumbPointerMove);
thumb.removeEventListener("pointerup", onThumbPointerUp);
thumb.removeEventListener("pointercancel", onThumbPointerCancel);
track.removeEventListener("pointerdown", onTrackPointerDown);
thumb.removeEventListener("keydown", onThumbKeyDown);
if (frame) cancelAnimationFrame(frame);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.sb}>
<div className={styles.sb__viewport} ref={viewportRef}>
<div className={styles.sb__content}>
{Array.from({ length: 8 }, (_, idx) => idx + 1).map((i) => (
<div className={styles.sb__card} key={i}>
<p className={styles.sb__cardTitle}>{"메뉴 " + i}</p>
<p className={styles.sb__cardLine}>포인터가 손잡이에 오르면 광택 위치 값이 한 번 이동합니다.</p>
</div>
))}
</div>
</div>
<div className={styles.sb__track} ref={trackRef}>
<div
className={styles.sb__thumb}
ref={thumbRef}
role="scrollbar"
aria-orientation="vertical"
aria-valuenow={0}
aria-valuemin={0}
aria-valuemax={100}
tabIndex={0}
>
<span className={styles.sb__thumbGloss} ref={glossRef} />
</div>
</div>
</div>
</main>
);
}02 · 커서가 올라오면 광택이 쓸고 지나가는 손잡이 — 측정 누적면적 33.401% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.sb__thumbGloss {
position: absolute;
top: -20%;
left: -60%;
width: 40%;
height: 140%;
background: linear-gradient(115deg, transparent, rgba(255, 255, 255, .85), transparent);
transform: translateY(-120%);
pointer-events: none;
}
.sb__thumbGloss.is-sweeping {
animation: glossSweep $duration linear;
}
@keyframes glossSweep {
0% { transform: translateY(-120%); }
100% { transform: translateY(220%); }
}
측정: 누적면적 33.4% · 강도 평균 43.9 · 23/23 프레임에서 움직였다.
03손을 놓아도 관성 값만큼 더 미끄러지는 손잡이
드래그하다 손을 놓는 순간의 프레임당 이동 값이 속도 값에 저장되고, 매 프레임 감쇠 값(SCROLLBAR_INERTIA_DECAY, 0.92)을 곱해 조금씩 줄어들며 몇 프레임 더 미끄러진 뒤 멈춘다. 속도의 절대값이 문턱값(VEL_EPS, 0.4) 아래로 떨어지면 그 자리에서 완전히 멈춘다.
<main class="stage">
<div class="sb is-demo" id="sb">
<div class="sb__viewport" id="sbViewport">
<div class="sb__content" id="sbContent"></div>
</div>
<div class="sb__track" id="sbTrack">
<div class="sb__thumb" id="sbThumb" role="scrollbar" aria-orientation="vertical" aria-controls="sbViewport" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" tabindex="0"></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 드래그를 놓는 순간의 속도 값이 SCROLLBAR_INERTIA_DECAY 만큼 매 프레임 줄며
// coastStep() 이 몇 프레임 더 스크롤을 미끄러뜨린 뒤 멈춘다.
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-orange);
.sb {
display: flex;
gap: $sp-2;
width: min(88vw, 300px);
height: min(56vw, 176px);
}
.sb__viewport {
flex: 1;
min-width: 0;
height: 100%;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
border-radius: $r-card;
background: rgba(255, 255, 255, .16);
}
.sb__viewport::-webkit-scrollbar { display: none; }
.sb__content {
display: flex;
flex-direction: column;
gap: $sp-2;
padding: $sp-2;
}
.sb__card {
border-radius: $r-sm;
padding: $sp-2 $sp-3;
background: rgba(255, 255, 255, .22);
}
.sb__cardTitle {
margin: 0 0 $sp-1;
color: $color;
font-weight: 700;
font-size: 12px;
}
.sb__cardLine {
margin: 0;
color: rgba(23, 20, 26, .68);
font-size: 10px;
line-height: 1.4;
}
.sb__track {
position: relative;
width: $sp-2;
border-radius: $r-pill;
background: rgba(255, 255, 255, .28);
}
.sb__thumb {
position: absolute;
top: 0;
left: 0;
width: 100%;
border-radius: $r-pill;
background: $subject-ink;
transition: none;
cursor: grab;
}
.sb__thumb:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 2px;
}
@media (max-width: 340px) {
.sb { width: min(94vw, 260px); height: 148px; }
.sb__cardTitle { font-size: 11px; }
.sb__cardLine { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
.sb__thumb { 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);
}
.sb {
display: flex;
gap: 8px;
width: min(88vw, 300px);
height: min(56vw, 176px);
}
.sb__viewport {
flex: 1;
min-width: 0;
height: 100%;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
border-radius: 16px;
background: rgba(255, 255, 255, 0.16);
}
.sb__viewport::-webkit-scrollbar {
display: none;
}
.sb__content {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
}
.sb__card {
border-radius: 8px;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.22);
}
.sb__cardTitle {
margin: 0 0 2px;
color: #17141a;
font-weight: 700;
font-size: 12px;
}
.sb__cardLine {
margin: 0;
color: rgba(23, 20, 26, 0.68);
font-size: 10px;
line-height: 1.4;
}
.sb__track {
position: relative;
width: 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.28);
}
.sb__thumb {
position: absolute;
top: 0;
left: 0;
width: 100%;
border-radius: 999px;
background: #17141a;
transition: none;
cursor: grab;
}
.sb__thumb:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
@media (max-width: 340px) {
.sb {
width: min(94vw, 260px);
height: 148px;
}
.sb__cardTitle {
font-size: 11px;
}
.sb__cardLine {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.sb__thumb {
transition: none;
}
}(function () {
var THUMB_MIN_PX = 26;
var KEY_STEP_PX = 32;
var SCROLLBAR_INERTIA_DECAY = 0.92;
var VEL_EPS = 0.4;
var SIM_KICK_PX = 30;
var root = document.getElementById('sb');
var viewport = document.getElementById('sbViewport');
var content = document.getElementById('sbContent');
var track = document.getElementById('sbTrack');
var thumb = document.getElementById('sbThumb');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
for (var i = 1; i <= 8; i++) {
var card = document.createElement('div');
card.className = 'sb__card';
var h = document.createElement('p');
h.className = 'sb__cardTitle';
h.textContent = '카드 ' + i;
var p = document.createElement('p');
p.className = 'sb__cardLine';
p.textContent = '놓는 순간의 속도 값이 감쇠 계수만큼 매 프레임 줄어듭니다.';
card.appendChild(h);
card.appendChild(p);
content.appendChild(card);
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function maxScroll() { return Math.max(0, viewport.scrollHeight - viewport.clientHeight); }
function syncThumb() {
var trackLen = track.clientHeight;
var total = viewport.scrollHeight;
var visible = viewport.clientHeight;
var thumbLen = clamp(trackLen * (visible / total), THUMB_MIN_PX, trackLen);
var ms = maxScroll();
var ratio = ms > 0 ? viewport.scrollTop / ms : 0;
var thumbPos = (trackLen - thumbLen) * ratio;
thumb.style.height = thumbLen.toFixed(1) + 'px';
thumb.style.transform = 'translateY(' + thumbPos.toFixed(1) + 'px)';
thumb.setAttribute('aria-valuenow', String(Math.round(ratio * 100)));
}
viewport.addEventListener('scroll', syncThumb);
var dragging = false;
var dragStartY = 0;
var dragStartScroll = 0;
var lastMoveY = 0;
var vel = 0;
thumb.addEventListener('pointerdown', function (e) {
dragging = true;
vel = 0;
root.classList.remove('is-demo');
dragStartY = e.clientY;
lastMoveY = e.clientY;
dragStartScroll = viewport.scrollTop;
thumb.setPointerCapture(e.pointerId);
});
thumb.addEventListener('pointermove', function (e) {
if (!dragging) return;
var trackLen = track.clientHeight;
var thumbLen = thumb.clientHeight;
var moveRange = Math.max(1, trackLen - thumbLen);
var scale = maxScroll() / moveRange;
var frameDeltaPx = (e.clientY - lastMoveY) * scale;
vel = frameDeltaPx;
lastMoveY = e.clientY;
var totalDeltaPx = (e.clientY - dragStartY) * scale;
viewport.scrollTop = clamp(dragStartScroll + totalDeltaPx, 0, maxScroll());
});
function coastStep() {
if (Math.abs(vel) <= VEL_EPS) { vel = 0; return; }
vel = vel * SCROLLBAR_INERTIA_DECAY;
viewport.scrollTop = clamp(viewport.scrollTop + vel, 0, maxScroll());
}
thumb.addEventListener('pointerup', function () { dragging = false; });
thumb.addEventListener('pointercancel', function () { dragging = false; vel = 0; });
track.addEventListener('pointerdown', function (e) {
if (e.target !== track) return;
root.classList.remove('is-demo');
var rect = track.getBoundingClientRect();
var trackLen = track.clientHeight;
var thumbLen = thumb.clientHeight;
var clickPos = clamp(e.clientY - rect.top - thumbLen / 2, 0, trackLen - thumbLen);
var ratio = (trackLen - thumbLen) > 0 ? clickPos / (trackLen - thumbLen) : 0;
viewport.scrollTop = ratio * maxScroll();
});
thumb.addEventListener('keydown', function (e) {
var ms = maxScroll();
if (e.key === 'ArrowDown') { viewport.scrollTop = clamp(viewport.scrollTop + KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === 'ArrowUp') { viewport.scrollTop = clamp(viewport.scrollTop - KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === 'PageDown') { viewport.scrollTop = clamp(viewport.scrollTop + viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === 'PageUp') { viewport.scrollTop = clamp(viewport.scrollTop - viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === 'Home') { viewport.scrollTop = 0; e.preventDefault(); }
else if (e.key === 'End') { viewport.scrollTop = ms; e.preventDefault(); }
if (e.key.indexOf('Arrow') === 0 || e.key === 'PageDown' || e.key === 'PageUp' || e.key === 'Home' || e.key === 'End') {
root.classList.remove('is-demo');
vel = 0;
}
});
var startTime = null;
var nextKickAt = 0;
var kickSign = 1;
function demoTick(now) {
if (startTime === null) { startTime = now; }
var t = (now - startTime) / 1000;
if (root.classList.contains('is-demo') && !dragging) {
if (t >= nextKickAt) {
vel = SIM_KICK_PX * kickSign;
kickSign = -kickSign;
nextKickAt = t + 0.9;
}
}
if (!dragging && vel !== 0) { coastStep(); }
requestAnimationFrame(demoTick);
}
syncThumb();
if (!reduce) { requestAnimationFrame(demoTick); }
})();// ScrollbarThumbDragInertiaCoast — vanilla 판(run/411/demo/03-scrollbar-thumb-drag-inertia-coast/index.html) 의 React 포트.
// 핵심: 드래그를 놓는 순간의 속도 값이 SCROLLBAR_INERTIA_DECAY 만큼 매 프레임 줄며 coastStep() 이 더 미끄러뜨린다.
import { useEffect, useRef } from "react";
import styles from "./ScrollbarThumbDragInertiaCoast.module.scss";
type Box<T> = { current: T };
const THUMB_MIN_PX = 26;
const KEY_STEP_PX = 32;
const SCROLLBAR_INERTIA_DECAY = 0.92;
const VEL_EPS = 0.4;
const SIM_KICK_PX = 30;
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
export default function ScrollbarThumbDragInertiaCoast() {
const viewportRef = useRef<HTMLDivElement | null>(null);
const trackRef = useRef<HTMLDivElement | null>(null);
const thumbRef = useRef<HTMLDivElement | null>(null);
const demoRef = useRef<boolean>(true) as Box<boolean>;
const draggingRef = useRef<boolean>(false) as Box<boolean>;
const dragStartYRef = useRef<number>(0) as Box<number>;
const dragStartScrollRef = useRef<number>(0) as Box<number>;
const lastMoveYRef = useRef<number>(0) as Box<number>;
const velRef = useRef<number>(0) as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const viewport = viewportRef.current!;
const track = trackRef.current!;
const thumb = thumbRef.current!;
if (!viewport || !track || !thumb) return;
function maxScroll(): number {
return Math.max(0, viewport.scrollHeight - viewport.clientHeight);
}
function syncThumb(): void {
const trackLen = track.clientHeight;
const total = viewport.scrollHeight;
const visible = viewport.clientHeight;
const thumbLen = clamp(trackLen * (visible / total), THUMB_MIN_PX, trackLen);
const ms = maxScroll();
const ratio = ms > 0 ? viewport.scrollTop / ms : 0;
const thumbPos = (trackLen - thumbLen) * ratio;
thumb.style.height = thumbLen.toFixed(1) + "px";
thumb.style.transform = "translateY(" + thumbPos.toFixed(1) + "px)";
thumb.setAttribute("aria-valuenow", String(Math.round(ratio * 100)));
}
function onScroll(): void { syncThumb(); }
function onThumbPointerDown(e: PointerEvent): void {
draggingRef.current = true;
velRef.current = 0;
demoRef.current = false;
dragStartYRef.current = e.clientY;
lastMoveYRef.current = e.clientY;
dragStartScrollRef.current = viewport.scrollTop;
thumb.setPointerCapture(e.pointerId);
}
function onThumbPointerMove(e: PointerEvent): void {
if (!draggingRef.current) return;
const trackLen = track.clientHeight;
const thumbLen = thumb.clientHeight;
const moveRange = Math.max(1, trackLen - thumbLen);
const scale = maxScroll() / moveRange;
const frameDeltaPx = (e.clientY - lastMoveYRef.current) * scale;
velRef.current = frameDeltaPx;
lastMoveYRef.current = e.clientY;
const totalDeltaPx = (e.clientY - dragStartYRef.current) * scale;
viewport.scrollTop = clamp(dragStartScrollRef.current + totalDeltaPx, 0, maxScroll());
}
function coastStep(): void {
if (Math.abs(velRef.current) <= VEL_EPS) { velRef.current = 0; return; }
velRef.current = velRef.current * SCROLLBAR_INERTIA_DECAY;
viewport.scrollTop = clamp(viewport.scrollTop + velRef.current, 0, maxScroll());
}
function onThumbPointerUp(): void { draggingRef.current = false; }
function onThumbPointerCancel(): void { draggingRef.current = false; velRef.current = 0; }
function onTrackPointerDown(e: PointerEvent): void {
if (e.target !== track) return;
demoRef.current = false;
const rect = track.getBoundingClientRect();
const trackLen = track.clientHeight;
const thumbLen = thumb.clientHeight;
const clickPos = clamp(e.clientY - rect.top - thumbLen / 2, 0, trackLen - thumbLen);
const ratio = (trackLen - thumbLen) > 0 ? clickPos / (trackLen - thumbLen) : 0;
viewport.scrollTop = ratio * maxScroll();
}
function onThumbKeyDown(e: KeyboardEvent): void {
const ms = maxScroll();
if (e.key === "ArrowDown") { viewport.scrollTop = clamp(viewport.scrollTop + KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === "ArrowUp") { viewport.scrollTop = clamp(viewport.scrollTop - KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === "PageDown") { viewport.scrollTop = clamp(viewport.scrollTop + viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === "PageUp") { viewport.scrollTop = clamp(viewport.scrollTop - viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === "Home") { viewport.scrollTop = 0; e.preventDefault(); }
else if (e.key === "End") { viewport.scrollTop = ms; e.preventDefault(); }
if (e.key.indexOf("Arrow") === 0 || e.key === "PageDown" || e.key === "PageUp" || e.key === "Home" || e.key === "End") {
demoRef.current = false;
velRef.current = 0;
}
}
let startTime: number | null = null;
let nextKickAt = 0;
let kickSign = 1;
let frame = 0;
function demoTick(now: number): void {
if (startTime === null) startTime = now;
const t = (now - startTime) / 1000;
if (demoRef.current && !draggingRef.current) {
if (t >= nextKickAt) {
velRef.current = SIM_KICK_PX * kickSign;
kickSign = -kickSign;
nextKickAt = t + 0.9;
}
}
if (!draggingRef.current && velRef.current !== 0) { coastStep(); }
frame = requestAnimationFrame(demoTick);
}
viewport.addEventListener("scroll", onScroll);
thumb.addEventListener("pointerdown", onThumbPointerDown);
thumb.addEventListener("pointermove", onThumbPointerMove);
thumb.addEventListener("pointerup", onThumbPointerUp);
thumb.addEventListener("pointercancel", onThumbPointerCancel);
track.addEventListener("pointerdown", onTrackPointerDown);
thumb.addEventListener("keydown", onThumbKeyDown);
syncThumb();
if (!reduce) { frame = requestAnimationFrame(demoTick); }
return () => {
viewport.removeEventListener("scroll", onScroll);
thumb.removeEventListener("pointerdown", onThumbPointerDown);
thumb.removeEventListener("pointermove", onThumbPointerMove);
thumb.removeEventListener("pointerup", onThumbPointerUp);
thumb.removeEventListener("pointercancel", onThumbPointerCancel);
track.removeEventListener("pointerdown", onTrackPointerDown);
thumb.removeEventListener("keydown", onThumbKeyDown);
if (frame) cancelAnimationFrame(frame);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.sb}>
<div className={styles.sb__viewport} ref={viewportRef}>
<div className={styles.sb__content}>
{Array.from({ length: 8 }, (_, idx) => idx + 1).map((i) => (
<div className={styles.sb__card} key={i}>
<p className={styles.sb__cardTitle}>{"카드 " + i}</p>
<p className={styles.sb__cardLine}>놓는 순간의 속도 값이 감쇠 계수만큼 매 프레임 줄어듭니다.</p>
</div>
))}
</div>
</div>
<div className={styles.sb__track} ref={trackRef}>
<div
className={styles.sb__thumb}
ref={thumbRef}
role="scrollbar"
aria-orientation="vertical"
aria-valuenow={0}
aria-valuemin={0}
aria-valuemax={100}
tabIndex={0}
/>
</div>
</div>
</main>
);
}03 · 손을 놓아도 관성 값만큼 더 미끄러지는 손잡이 — 측정 누적면적 33.617% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
thumb.addEventListener('pointermove', function (e) {
if (!dragging) return;
var trackLen = track.clientHeight;
var thumbLen = thumb.clientHeight;
var moveRange = Math.max(1, trackLen - thumbLen);
var scale = maxScroll() / moveRange;
var frameDeltaPx = (e.clientY - lastMoveY) * scale;
vel = frameDeltaPx;
lastMoveY = e.clientY;
var totalDeltaPx = (e.clientY - dragStartY) * scale;
viewport.scrollTop = clamp(dragStartScroll + totalDeltaPx, 0, maxScroll());
});
function coastStep() {
if (Math.abs(vel) <= VEL_EPS) { vel = 0; return; }
vel = vel * SCROLLBAR_INERTIA_DECAY;
viewport.scrollTop = clamp(viewport.scrollTop + vel, 0, maxScroll());
}
측정: 누적면적 33.6% · 강도 평균 57.3 · 23/23 프레임에서 움직였다.
04끝에 닿으면 밀려났다 튕겨 돌아오는 손잡이
트랙 끝에 닿는 순간 손잡이가 반발 거리 값(BOUNDARY_REBOUND_PX, 12)만큼 더 밀려났다가, 감쇠 값(SPRING_DECAY, 0.7)을 매 프레임 곱한 오차 값을 따라 제자리로 튕겨 돌아온다. 오차의 절대값이 문턱값(OVERSHOOT_EPS, 0.3) 아래로 떨어지면 스프링 계산을 멈춘다.

<main class="stage">
<div class="sb is-demo" id="sb">
<div class="sb__viewport" id="sbViewport">
<div class="sb__content" id="sbContent"></div>
</div>
<div class="sb__track" id="sbTrack">
<div class="sb__thumb" id="sbThumb" role="scrollbar" aria-orientation="vertical" aria-controls="sbViewport" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" tabindex="0"></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 트랙 끝에 닿는 순간 BOUNDARY_REBOUND_PX 만큼 손잡이가 더 밀려났다가
// springBack() 의 SPRING_DECAY 값으로 매 프레임 0 을 향해 튕겨 돌아온다.
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-paper);
.sb {
display: flex;
gap: $sp-2;
width: min(88vw, 300px);
height: min(56vw, 176px);
}
.sb__viewport {
flex: 1;
min-width: 0;
height: 100%;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
border-radius: $r-card;
background: rgba(23, 20, 26, .04);
}
.sb__viewport::-webkit-scrollbar { display: none; }
.sb__content {
display: flex;
flex-direction: column;
gap: $sp-2;
padding: $sp-2;
}
.sb__card {
border-radius: $r-sm;
padding: $sp-2 $sp-3;
background: $subject-cream;
}
.sb__cardTitle {
margin: 0 0 $sp-1;
color: $color;
font-weight: 700;
font-size: 12px;
}
.sb__cardLine {
margin: 0;
color: rgba(23, 20, 26, .6);
font-size: 10px;
line-height: 1.4;
}
.sb__track {
position: relative;
width: $sp-2;
border-radius: $r-pill;
background: rgba(23, 20, 26, .08);
}
.sb__thumb {
position: absolute;
top: 0;
left: 0;
width: 100%;
border-radius: $r-pill;
background: $subject-blue-deep;
transition: none;
cursor: grab;
}
.sb__thumb:focus-visible {
outline: 2px solid $subject-blue-deep;
outline-offset: 2px;
}
@media (max-width: 340px) {
.sb { width: min(94vw, 260px); height: 148px; }
.sb__cardTitle { font-size: 11px; }
.sb__cardLine { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
.sb__thumb { 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);
}
.sb {
display: flex;
gap: 8px;
width: min(88vw, 300px);
height: min(56vw, 176px);
}
.sb__viewport {
flex: 1;
min-width: 0;
height: 100%;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
border-radius: 16px;
background: rgba(23, 20, 26, 0.04);
}
.sb__viewport::-webkit-scrollbar {
display: none;
}
.sb__content {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
}
.sb__card {
border-radius: 8px;
padding: 8px 12px;
background: #fff7e6;
}
.sb__cardTitle {
margin: 0 0 2px;
color: #17141a;
font-weight: 700;
font-size: 12px;
}
.sb__cardLine {
margin: 0;
color: rgba(23, 20, 26, 0.6);
font-size: 10px;
line-height: 1.4;
}
.sb__track {
position: relative;
width: 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
}
.sb__thumb {
position: absolute;
top: 0;
left: 0;
width: 100%;
border-radius: 999px;
background: #0a4fe8;
transition: none;
cursor: grab;
}
.sb__thumb:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.sb {
width: min(94vw, 260px);
height: 148px;
}
.sb__cardTitle {
font-size: 11px;
}
.sb__cardLine {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.sb__thumb {
transition: none;
}
}(function () {
var THUMB_MIN_PX = 26;
var KEY_STEP_PX = 32;
var BOUNDARY_REBOUND_PX = 12;
var SPRING_DECAY = 0.7;
var OVERSHOOT_EPS = 0.3;
var root = document.getElementById('sb');
var viewport = document.getElementById('sbViewport');
var content = document.getElementById('sbContent');
var track = document.getElementById('sbTrack');
var thumb = document.getElementById('sbThumb');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
for (var i = 1; i <= 8; i++) {
var card = document.createElement('div');
card.className = 'sb__card';
var h = document.createElement('p');
h.className = 'sb__cardTitle';
h.textContent = '카드 ' + i;
var p = document.createElement('p');
p.className = 'sb__cardLine';
p.textContent = '트랙 끝에 닿으면 반발 거리 값만큼 밀려났다 튕겨 돌아옵니다.';
card.appendChild(h);
card.appendChild(p);
content.appendChild(card);
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function maxScroll() { return Math.max(0, viewport.scrollHeight - viewport.clientHeight); }
var overshoot = 0;
function paintThumb() {
var trackLen = track.clientHeight;
var total = viewport.scrollHeight;
var visible = viewport.clientHeight;
var thumbLen = clamp(trackLen * (visible / total), THUMB_MIN_PX, trackLen);
var ms = maxScroll();
var ratio = ms > 0 ? viewport.scrollTop / ms : 0;
var thumbPos = (trackLen - thumbLen) * ratio + overshoot;
thumb.style.height = thumbLen.toFixed(1) + 'px';
thumb.style.transform = 'translateY(' + thumbPos.toFixed(1) + 'px)';
thumb.setAttribute('aria-valuenow', String(Math.round(ratio * 100)));
}
viewport.addEventListener('scroll', paintThumb);
var dragging = false;
var dragStartY = 0;
var dragStartScroll = 0;
thumb.addEventListener('pointerdown', function (e) {
dragging = true;
root.classList.remove('is-demo');
dragStartY = e.clientY;
dragStartScroll = viewport.scrollTop;
thumb.setPointerCapture(e.pointerId);
});
thumb.addEventListener('pointermove', function (e) {
if (!dragging) return;
var trackLen = track.clientHeight;
var thumbLen = thumb.clientHeight;
var moveRange = Math.max(1, trackLen - thumbLen);
var scale = maxScroll() / moveRange;
var rawTarget = dragStartScroll + (e.clientY - dragStartY) * scale;
var ms = maxScroll();
viewport.scrollTop = clamp(rawTarget, 0, ms);
if (reduce) {
overshoot = 0;
} else if (rawTarget < 0) { overshoot = clamp(rawTarget, -BOUNDARY_REBOUND_PX, 0); }
else if (rawTarget > ms) { overshoot = clamp(rawTarget - ms, 0, BOUNDARY_REBOUND_PX); }
else { overshoot = 0; }
paintThumb();
});
thumb.addEventListener('pointerup', function () { dragging = false; });
thumb.addEventListener('pointercancel', function () { dragging = false; });
track.addEventListener('pointerdown', function (e) {
if (e.target !== track) return;
root.classList.remove('is-demo');
var rect = track.getBoundingClientRect();
var trackLen = track.clientHeight;
var thumbLen = thumb.clientHeight;
var clickPos = clamp(e.clientY - rect.top - thumbLen / 2, 0, trackLen - thumbLen);
var ratio = (trackLen - thumbLen) > 0 ? clickPos / (trackLen - thumbLen) : 0;
viewport.scrollTop = ratio * maxScroll();
});
thumb.addEventListener('keydown', function (e) {
var ms = maxScroll();
if (e.key === 'ArrowDown') { viewport.scrollTop = clamp(viewport.scrollTop + KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === 'ArrowUp') { viewport.scrollTop = clamp(viewport.scrollTop - KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === 'PageDown') { viewport.scrollTop = clamp(viewport.scrollTop + viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === 'PageUp') { viewport.scrollTop = clamp(viewport.scrollTop - viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === 'Home') { viewport.scrollTop = 0; e.preventDefault(); }
else if (e.key === 'End') { viewport.scrollTop = ms; e.preventDefault(); }
if (e.key.indexOf('Arrow') === 0 || e.key === 'PageDown' || e.key === 'PageUp' || e.key === 'Home' || e.key === 'End') {
root.classList.remove('is-demo');
}
});
function springBack() {
if (Math.abs(overshoot) <= OVERSHOOT_EPS) { overshoot = 0; return false; }
overshoot = overshoot * SPRING_DECAY;
return true;
}
var startTime = null;
var phaseAt = 0;
var target = 'bottom';
function demoTick(now) {
if (startTime === null) { startTime = now; }
var t = (now - startTime) / 1000;
if (root.classList.contains('is-demo') && !dragging) {
var cycle = t - phaseAt;
if (cycle < 0.35) {
var ms = maxScroll();
var toward = target === 'bottom' ? ms : 0;
viewport.scrollTop = toward;
if (cycle > 0.28) {
overshoot = target === 'bottom' ? BOUNDARY_REBOUND_PX : -BOUNDARY_REBOUND_PX;
}
} else {
springBack();
}
if (cycle >= 1.0) {
phaseAt = t;
target = target === 'bottom' ? 'top' : 'bottom';
}
} else if (!dragging) {
springBack();
}
paintThumb();
requestAnimationFrame(demoTick);
}
paintThumb();
if (!reduce) { requestAnimationFrame(demoTick); }
})();// ScrollbarBoundaryElasticOvershoot — vanilla 판(run/411/demo/04-scrollbar-boundary-elastic-overshoot/index.html) 의 React 포트.
// 핵심: 트랙 끝에 닿는 순간 BOUNDARY_REBOUND_PX 만큼 더 밀려났다가 springBack() 의 SPRING_DECAY 값으로 튕겨 돌아온다.
import { useEffect, useRef } from "react";
import styles from "./ScrollbarBoundaryElasticOvershoot.module.scss";
type Box<T> = { current: T };
const THUMB_MIN_PX = 26;
const KEY_STEP_PX = 32;
const BOUNDARY_REBOUND_PX = 12;
const SPRING_DECAY = 0.7;
const OVERSHOOT_EPS = 0.3;
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
export default function ScrollbarBoundaryElasticOvershoot() {
const viewportRef = useRef<HTMLDivElement | null>(null);
const trackRef = useRef<HTMLDivElement | null>(null);
const thumbRef = useRef<HTMLDivElement | null>(null);
const demoRef = useRef<boolean>(true) as Box<boolean>;
const draggingRef = useRef<boolean>(false) as Box<boolean>;
const dragStartYRef = useRef<number>(0) as Box<number>;
const dragStartScrollRef = useRef<number>(0) as Box<number>;
const overshootRef = useRef<number>(0) as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const viewport = viewportRef.current!;
const track = trackRef.current!;
const thumb = thumbRef.current!;
if (!viewport || !track || !thumb) return;
function maxScroll(): number {
return Math.max(0, viewport.scrollHeight - viewport.clientHeight);
}
function paintThumb(): void {
const trackLen = track.clientHeight;
const total = viewport.scrollHeight;
const visible = viewport.clientHeight;
const thumbLen = clamp(trackLen * (visible / total), THUMB_MIN_PX, trackLen);
const ms = maxScroll();
const ratio = ms > 0 ? viewport.scrollTop / ms : 0;
const thumbPos = (trackLen - thumbLen) * ratio + overshootRef.current;
thumb.style.height = thumbLen.toFixed(1) + "px";
thumb.style.transform = "translateY(" + thumbPos.toFixed(1) + "px)";
thumb.setAttribute("aria-valuenow", String(Math.round(ratio * 100)));
}
function onScroll(): void { paintThumb(); }
function onThumbPointerDown(e: PointerEvent): void {
draggingRef.current = true;
demoRef.current = false;
dragStartYRef.current = e.clientY;
dragStartScrollRef.current = viewport.scrollTop;
thumb.setPointerCapture(e.pointerId);
}
function onThumbPointerMove(e: PointerEvent): void {
if (!draggingRef.current) return;
const trackLen = track.clientHeight;
const thumbLen = thumb.clientHeight;
const moveRange = Math.max(1, trackLen - thumbLen);
const scale = maxScroll() / moveRange;
const rawTarget = dragStartScrollRef.current + (e.clientY - dragStartYRef.current) * scale;
const ms = maxScroll();
viewport.scrollTop = clamp(rawTarget, 0, ms);
if (reduce) { overshootRef.current = 0; }
else if (rawTarget < 0) { overshootRef.current = clamp(rawTarget, -BOUNDARY_REBOUND_PX, 0); }
else if (rawTarget > ms) { overshootRef.current = clamp(rawTarget - ms, 0, BOUNDARY_REBOUND_PX); }
else { overshootRef.current = 0; }
paintThumb();
}
function onThumbPointerUp(): void { draggingRef.current = false; }
function onThumbPointerCancel(): void { draggingRef.current = false; }
function onTrackPointerDown(e: PointerEvent): void {
if (e.target !== track) return;
demoRef.current = false;
const rect = track.getBoundingClientRect();
const trackLen = track.clientHeight;
const thumbLen = thumb.clientHeight;
const clickPos = clamp(e.clientY - rect.top - thumbLen / 2, 0, trackLen - thumbLen);
const ratio = (trackLen - thumbLen) > 0 ? clickPos / (trackLen - thumbLen) : 0;
viewport.scrollTop = ratio * maxScroll();
}
function onThumbKeyDown(e: KeyboardEvent): void {
const ms = maxScroll();
if (e.key === "ArrowDown") { viewport.scrollTop = clamp(viewport.scrollTop + KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === "ArrowUp") { viewport.scrollTop = clamp(viewport.scrollTop - KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === "PageDown") { viewport.scrollTop = clamp(viewport.scrollTop + viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === "PageUp") { viewport.scrollTop = clamp(viewport.scrollTop - viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === "Home") { viewport.scrollTop = 0; e.preventDefault(); }
else if (e.key === "End") { viewport.scrollTop = ms; e.preventDefault(); }
if (e.key.indexOf("Arrow") === 0 || e.key === "PageDown" || e.key === "PageUp" || e.key === "Home" || e.key === "End") {
demoRef.current = false;
}
}
function springBack(): boolean {
if (Math.abs(overshootRef.current) <= OVERSHOOT_EPS) { overshootRef.current = 0; return false; }
overshootRef.current = overshootRef.current * SPRING_DECAY;
return true;
}
let startTime: number | null = null;
let phaseAt = 0;
let target: "top" | "bottom" = "bottom";
let frame = 0;
function demoTick(now: number): void {
if (startTime === null) startTime = now;
const t = (now - startTime) / 1000;
if (demoRef.current && !draggingRef.current) {
const cycle = t - phaseAt;
if (cycle < 0.35) {
const ms = maxScroll();
const toward = target === "bottom" ? ms : 0;
viewport.scrollTop = toward;
if (cycle > 0.28) {
overshootRef.current = target === "bottom" ? BOUNDARY_REBOUND_PX : -BOUNDARY_REBOUND_PX;
}
} else {
springBack();
}
if (cycle >= 1.0) {
phaseAt = t;
target = target === "bottom" ? "top" : "bottom";
}
} else if (!draggingRef.current) {
springBack();
}
paintThumb();
frame = requestAnimationFrame(demoTick);
}
viewport.addEventListener("scroll", onScroll);
thumb.addEventListener("pointerdown", onThumbPointerDown);
thumb.addEventListener("pointermove", onThumbPointerMove);
thumb.addEventListener("pointerup", onThumbPointerUp);
thumb.addEventListener("pointercancel", onThumbPointerCancel);
track.addEventListener("pointerdown", onTrackPointerDown);
thumb.addEventListener("keydown", onThumbKeyDown);
paintThumb();
if (!reduce) { frame = requestAnimationFrame(demoTick); }
return () => {
viewport.removeEventListener("scroll", onScroll);
thumb.removeEventListener("pointerdown", onThumbPointerDown);
thumb.removeEventListener("pointermove", onThumbPointerMove);
thumb.removeEventListener("pointerup", onThumbPointerUp);
thumb.removeEventListener("pointercancel", onThumbPointerCancel);
track.removeEventListener("pointerdown", onTrackPointerDown);
thumb.removeEventListener("keydown", onThumbKeyDown);
if (frame) cancelAnimationFrame(frame);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.sb}>
<div className={styles.sb__viewport} ref={viewportRef}>
<div className={styles.sb__content}>
{Array.from({ length: 8 }, (_, idx) => idx + 1).map((i) => (
<div className={styles.sb__card} key={i}>
<p className={styles.sb__cardTitle}>{"카드 " + i}</p>
<p className={styles.sb__cardLine}>트랙 끝에 닿으면 반발 거리 값만큼 밀려났다 튕겨 돌아옵니다.</p>
</div>
))}
</div>
</div>
<div className={styles.sb__track} ref={trackRef}>
<div
className={styles.sb__thumb}
ref={thumbRef}
role="scrollbar"
aria-orientation="vertical"
aria-valuenow={0}
aria-valuemin={0}
aria-valuemax={100}
tabIndex={0}
/>
</div>
</div>
</main>
);
}04 · 끝에 닿으면 밀려났다 튕겨 돌아오는 손잡이 — 측정 누적면적 13.787% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function springBack() {
if (Math.abs(overshoot) <= OVERSHOOT_EPS) { overshoot = 0; return false; }
overshoot = overshoot * SPRING_DECAY;
return true;
}
var startTime = null;
var phaseAt = 0;
var target = 'bottom';
function demoTick(now) {
if (startTime === null) { startTime = now; }
var t = (now - startTime) / 1000;
if (root.classList.contains('is-demo') && !dragging) {
var cycle = t - phaseAt;
if (cycle < 0.35) {
var ms = maxScroll();
var toward = target === 'bottom' ? ms : 0;
viewport.scrollTop = toward;
if (cycle > 0.28) {
overshoot = target === 'bottom' ? BOUNDARY_REBOUND_PX : -BOUNDARY_REBOUND_PX;
}
} else {
springBack();
}
측정: 누적면적 13.8% · 강도 평균 144.1 · 12/23 프레임에서 움직였다.
05스크롤 위치 값에 따라 색이 옮겨가는 손잡이
스크롤 위치 비율 값을 CSS 사용자 정의 속성(--pos)에 그대로 써서, 손잡이와 트랙의 그라디언트 색 구간이 같은 값을 따라 함께 이동한다. 계산은 한 함수(applyPositionColor)에서만 일어나고 손잡이 크기·위치 계산(syncThumb)은 그 값을 그대로 넘겨받아 쓰기만 한다.
<main class="stage">
<div class="sb is-demo" id="sb">
<div class="sb__viewport" id="sbViewport">
<div class="sb__content" id="sbContent"></div>
</div>
<div class="sb__track" id="sbTrack">
<div class="sb__thumb" id="sbThumb" role="scrollbar" aria-orientation="vertical" aria-controls="sbViewport" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" tabindex="0"></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 스크롤 위치 비율 값(--pos) 하나를 손잡이 그라디언트 색 구간과 트랙 발광 톤에
// 같은 값으로 전파한다 — applyPositionColor() 가 그 계산 레이어다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-ink);
.sb {
display: flex;
gap: $sp-2;
width: min(88vw, 300px);
height: min(56vw, 176px);
}
.sb__viewport {
flex: 1;
min-width: 0;
height: 100%;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
border-radius: $r-card;
background: rgba(255, 255, 255, .06);
}
.sb__viewport::-webkit-scrollbar { display: none; }
.sb__content {
display: flex;
flex-direction: column;
gap: $sp-2;
padding: $sp-2;
}
.sb__card {
border-radius: $r-sm;
padding: $sp-2 $sp-3;
background: rgba(255, 255, 255, .08);
}
.sb__cardTitle {
margin: 0 0 $sp-1;
color: $color;
font-weight: 700;
font-size: 12px;
}
.sb__cardLine {
margin: 0;
color: rgba(255, 247, 230, .68);
font-size: 10px;
line-height: 1.4;
}
.sb__track {
position: relative;
width: $sp-2;
border-radius: $r-pill;
--pos: 0;
background: linear-gradient(180deg, rgba($subject-pink, .28), rgba($subject-sky, .28) calc(var(--pos) * 100%), rgba(255, 255, 255, .1));
}
.sb__thumb {
position: absolute;
top: 0;
left: 0;
width: 100%;
border-radius: $r-pill;
--pos: 0;
background: linear-gradient($subject-pink, $subject-lilac calc(var(--pos) * 100%), $subject-sky);
transition: transform $duration $easing;
cursor: grab;
}
.sb__thumb:focus-visible {
outline: 2px solid $subject-cream;
outline-offset: 2px;
}
@media (max-width: 340px) {
.sb { width: min(94vw, 260px); height: 148px; }
.sb__cardTitle { font-size: 11px; }
.sb__cardLine { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
.sb__thumb { transition: none; }
}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);
}
.sb {
display: flex;
gap: 8px;
width: min(88vw, 300px);
height: min(56vw, 176px);
}
.sb__viewport {
flex: 1;
min-width: 0;
height: 100%;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
border-radius: 16px;
background: rgba(255, 255, 255, 0.06);
}
.sb__viewport::-webkit-scrollbar {
display: none;
}
.sb__content {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
}
.sb__card {
border-radius: 8px;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.08);
}
.sb__cardTitle {
margin: 0 0 2px;
color: #fff7e6;
font-weight: 700;
font-size: 12px;
}
.sb__cardLine {
margin: 0;
color: rgba(255, 247, 230, 0.68);
font-size: 10px;
line-height: 1.4;
}
.sb__track {
position: relative;
width: 8px;
border-radius: 999px;
--pos: 0;
background: linear-gradient(180deg, rgba(255, 158, 187, 0.28), rgba(110, 200, 255, 0.28) calc(var(--pos) * 100%), rgba(255, 255, 255, 0.1));
}
.sb__thumb {
position: absolute;
top: 0;
left: 0;
width: 100%;
border-radius: 999px;
--pos: 0;
background: linear-gradient(#ff9ebb, #b9a0ff calc(var(--pos) * 100%), #6ec8ff);
transition: transform 2s ease-out;
cursor: grab;
}
.sb__thumb:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
@media (max-width: 340px) {
.sb {
width: min(94vw, 260px);
height: 148px;
}
.sb__cardTitle {
font-size: 11px;
}
.sb__cardLine {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.sb__thumb {
transition: none;
}
}(function () {
var THUMB_MIN_PX = 26;
var KEY_STEP_PX = 32;
var root = document.getElementById('sb');
var viewport = document.getElementById('sbViewport');
var content = document.getElementById('sbContent');
var track = document.getElementById('sbTrack');
var thumb = document.getElementById('sbThumb');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
for (var i = 1; i <= 8; i++) {
var card = document.createElement('div');
card.className = 'sb__card';
var h = document.createElement('p');
h.className = 'sb__cardTitle';
h.textContent = '문단 ' + i;
var p = document.createElement('p');
p.className = 'sb__cardLine';
p.textContent = '스크롤 위치 값이 커질수록 손잡이 그라디언트 색 구간 값도 함께 이동합니다.';
card.appendChild(h);
card.appendChild(p);
content.appendChild(card);
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function maxScroll() { return Math.max(0, viewport.scrollHeight - viewport.clientHeight); }
function applyPositionColor(ratio) {
// 계산 레이어: 위치 비율 값을 손잡이 그라디언트 구간과 트랙 발광 톤에 같은 값으로 전파한다.
thumb.style.setProperty('--pos', ratio.toFixed(3));
track.style.setProperty('--pos', ratio.toFixed(3));
}
function syncThumb() {
var trackLen = track.clientHeight;
var total = viewport.scrollHeight;
var visible = viewport.clientHeight;
var thumbLen = clamp(trackLen * (visible / total), THUMB_MIN_PX, trackLen);
var ms = maxScroll();
var ratio = ms > 0 ? viewport.scrollTop / ms : 0;
var thumbPos = (trackLen - thumbLen) * ratio;
thumb.style.height = thumbLen.toFixed(1) + 'px';
thumb.style.transform = 'translateY(' + thumbPos.toFixed(1) + 'px)';
thumb.setAttribute('aria-valuenow', String(Math.round(ratio * 100)));
applyPositionColor(ratio);
}
viewport.addEventListener('scroll', syncThumb);
var dragging = false;
var dragStartY = 0;
var dragStartScroll = 0;
thumb.addEventListener('pointerdown', function (e) {
dragging = true;
root.classList.remove('is-demo');
dragStartY = e.clientY;
dragStartScroll = viewport.scrollTop;
thumb.setPointerCapture(e.pointerId);
});
thumb.addEventListener('pointermove', function (e) {
if (!dragging) return;
var trackLen = track.clientHeight;
var thumbLen = thumb.clientHeight;
var deltaPx = e.clientY - dragStartY;
var moveRange = Math.max(1, trackLen - thumbLen);
viewport.scrollTop = clamp(dragStartScroll + deltaPx * (maxScroll() / moveRange), 0, maxScroll());
});
thumb.addEventListener('pointerup', function () { dragging = false; });
thumb.addEventListener('pointercancel', function () { dragging = false; });
track.addEventListener('pointerdown', function (e) {
if (e.target !== track) return;
root.classList.remove('is-demo');
var rect = track.getBoundingClientRect();
var trackLen = track.clientHeight;
var thumbLen = thumb.clientHeight;
var clickPos = clamp(e.clientY - rect.top - thumbLen / 2, 0, trackLen - thumbLen);
var ratio = (trackLen - thumbLen) > 0 ? clickPos / (trackLen - thumbLen) : 0;
viewport.scrollTop = ratio * maxScroll();
});
thumb.addEventListener('keydown', function (e) {
var ms = maxScroll();
if (e.key === 'ArrowDown') { viewport.scrollTop = clamp(viewport.scrollTop + KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === 'ArrowUp') { viewport.scrollTop = clamp(viewport.scrollTop - KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === 'PageDown') { viewport.scrollTop = clamp(viewport.scrollTop + viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === 'PageUp') { viewport.scrollTop = clamp(viewport.scrollTop - viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === 'Home') { viewport.scrollTop = 0; e.preventDefault(); }
else if (e.key === 'End') { viewport.scrollTop = ms; e.preventDefault(); }
if (e.key.indexOf('Arrow') === 0 || e.key === 'PageDown' || e.key === 'PageUp' || e.key === 'Home' || e.key === 'End') {
root.classList.remove('is-demo');
}
});
var startTime = null;
function demoTick(now) {
if (startTime === null) startTime = now;
var t = (now - startTime) / 1000;
if (root.classList.contains('is-demo')) {
var phase = (Math.sin(t * Math.PI) + 1) / 2;
viewport.scrollTop = phase * maxScroll();
}
requestAnimationFrame(demoTick);
}
syncThumb();
if (!reduce) { requestAnimationFrame(demoTick); }
})();// ScrollbarThumbGradientPositionShift — vanilla 판(run/411/demo/05-scrollbar-thumb-gradient-position-shift/index.html) 의 React 포트.
// 핵심: 스크롤 위치 비율 값(--pos) 하나를 손잡이 그라디언트 색 구간과 트랙 발광 톤에 같은 값으로 전파한다.
import { useEffect, useRef } from "react";
import styles from "./ScrollbarThumbGradientPositionShift.module.scss";
type Box<T> = { current: T };
const THUMB_MIN_PX = 26;
const KEY_STEP_PX = 32;
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
export default function ScrollbarThumbGradientPositionShift() {
const viewportRef = useRef<HTMLDivElement | null>(null);
const trackRef = useRef<HTMLDivElement | null>(null);
const thumbRef = useRef<HTMLDivElement | null>(null);
const demoRef = useRef<boolean>(true) as Box<boolean>;
const draggingRef = useRef<boolean>(false) as Box<boolean>;
const dragStartYRef = useRef<number>(0) as Box<number>;
const dragStartScrollRef = useRef<number>(0) as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const viewport = viewportRef.current!;
const track = trackRef.current!;
const thumb = thumbRef.current!;
if (!viewport || !track || !thumb) return;
function maxScroll(): number {
return Math.max(0, viewport.scrollHeight - viewport.clientHeight);
}
function applyPositionColor(ratio: number): void {
// 계산 레이어: 위치 비율 값을 손잡이 그라디언트 구간과 트랙 발광 톤에 같은 값으로 전파한다.
thumb.style.setProperty("--pos", ratio.toFixed(3));
track.style.setProperty("--pos", ratio.toFixed(3));
}
function syncThumb(): void {
const trackLen = track.clientHeight;
const total = viewport.scrollHeight;
const visible = viewport.clientHeight;
const thumbLen = clamp(trackLen * (visible / total), THUMB_MIN_PX, trackLen);
const ms = maxScroll();
const ratio = ms > 0 ? viewport.scrollTop / ms : 0;
const thumbPos = (trackLen - thumbLen) * ratio;
thumb.style.height = thumbLen.toFixed(1) + "px";
thumb.style.transform = "translateY(" + thumbPos.toFixed(1) + "px)";
thumb.setAttribute("aria-valuenow", String(Math.round(ratio * 100)));
applyPositionColor(ratio);
}
function onScroll(): void { syncThumb(); }
function onThumbPointerDown(e: PointerEvent): void {
draggingRef.current = true;
demoRef.current = false;
dragStartYRef.current = e.clientY;
dragStartScrollRef.current = viewport.scrollTop;
thumb.setPointerCapture(e.pointerId);
}
function onThumbPointerMove(e: PointerEvent): void {
if (!draggingRef.current) return;
const trackLen = track.clientHeight;
const thumbLen = thumb.clientHeight;
const deltaPx = e.clientY - dragStartYRef.current;
const moveRange = Math.max(1, trackLen - thumbLen);
viewport.scrollTop = clamp(dragStartScrollRef.current + deltaPx * (maxScroll() / moveRange), 0, maxScroll());
}
function onThumbPointerUp(): void { draggingRef.current = false; }
function onThumbPointerCancel(): void { draggingRef.current = false; }
function onTrackPointerDown(e: PointerEvent): void {
if (e.target !== track) return;
demoRef.current = false;
const rect = track.getBoundingClientRect();
const trackLen = track.clientHeight;
const thumbLen = thumb.clientHeight;
const clickPos = clamp(e.clientY - rect.top - thumbLen / 2, 0, trackLen - thumbLen);
const ratio = (trackLen - thumbLen) > 0 ? clickPos / (trackLen - thumbLen) : 0;
viewport.scrollTop = ratio * maxScroll();
}
function onThumbKeyDown(e: KeyboardEvent): void {
const ms = maxScroll();
if (e.key === "ArrowDown") { viewport.scrollTop = clamp(viewport.scrollTop + KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === "ArrowUp") { viewport.scrollTop = clamp(viewport.scrollTop - KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === "PageDown") { viewport.scrollTop = clamp(viewport.scrollTop + viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === "PageUp") { viewport.scrollTop = clamp(viewport.scrollTop - viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === "Home") { viewport.scrollTop = 0; e.preventDefault(); }
else if (e.key === "End") { viewport.scrollTop = ms; e.preventDefault(); }
if (e.key.indexOf("Arrow") === 0 || e.key === "PageDown" || e.key === "PageUp" || e.key === "Home" || e.key === "End") {
demoRef.current = false;
}
}
let startTime: number | null = null;
let frame = 0;
function demoTick(now: number): void {
if (startTime === null) startTime = now;
const t = (now - startTime) / 1000;
if (demoRef.current) {
const phase = (Math.sin(t * Math.PI) + 1) / 2;
viewport.scrollTop = phase * maxScroll();
}
frame = requestAnimationFrame(demoTick);
}
viewport.addEventListener("scroll", onScroll);
thumb.addEventListener("pointerdown", onThumbPointerDown);
thumb.addEventListener("pointermove", onThumbPointerMove);
thumb.addEventListener("pointerup", onThumbPointerUp);
thumb.addEventListener("pointercancel", onThumbPointerCancel);
track.addEventListener("pointerdown", onTrackPointerDown);
thumb.addEventListener("keydown", onThumbKeyDown);
syncThumb();
if (!reduce) { frame = requestAnimationFrame(demoTick); }
return () => {
viewport.removeEventListener("scroll", onScroll);
thumb.removeEventListener("pointerdown", onThumbPointerDown);
thumb.removeEventListener("pointermove", onThumbPointerMove);
thumb.removeEventListener("pointerup", onThumbPointerUp);
thumb.removeEventListener("pointercancel", onThumbPointerCancel);
track.removeEventListener("pointerdown", onTrackPointerDown);
thumb.removeEventListener("keydown", onThumbKeyDown);
if (frame) cancelAnimationFrame(frame);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.sb}>
<div className={styles.sb__viewport} ref={viewportRef}>
<div className={styles.sb__content}>
{Array.from({ length: 8 }, (_, idx) => idx + 1).map((i) => (
<div className={styles.sb__card} key={i}>
<p className={styles.sb__cardTitle}>{"문단 " + i}</p>
<p className={styles.sb__cardLine}>스크롤 위치 값이 커질수록 손잡이 그라디언트 색 구간 값도 함께 이동합니다.</p>
</div>
))}
</div>
</div>
<div className={styles.sb__track} ref={trackRef}>
<div
className={styles.sb__thumb}
ref={thumbRef}
role="scrollbar"
aria-orientation="vertical"
aria-valuenow={0}
aria-valuemin={0}
aria-valuemax={100}
tabIndex={0}
/>
</div>
</div>
</main>
);
}05 · 스크롤 위치 값에 따라 색이 옮겨가는 손잡이 — 측정 누적면적 33.326% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function applyPositionColor(ratio) {
// 계산 레이어: 위치 비율 값을 손잡이 그라디언트 구간과 트랙 발광 톤에 같은 값으로 전파한다.
thumb.style.setProperty('--pos', ratio.toFixed(3));
track.style.setProperty('--pos', ratio.toFixed(3));
}
function syncThumb() {
var trackLen = track.clientHeight;
var total = viewport.scrollHeight;
var visible = viewport.clientHeight;
var thumbLen = clamp(trackLen * (visible / total), THUMB_MIN_PX, trackLen);
var ms = maxScroll();
var ratio = ms > 0 ? viewport.scrollTop / ms : 0;
var thumbPos = (trackLen - thumbLen) * ratio;
thumb.style.height = thumbLen.toFixed(1) + 'px';
thumb.style.transform = 'translateY(' + thumbPos.toFixed(1) + 'px)';
thumb.setAttribute('aria-valuenow', String(Math.round(ratio * 100)));
applyPositionColor(ratio);
}
측정: 누적면적 33.3% · 강도 평균 59.8 · 23/23 프레임에서 움직였다.
06카드 개수 값에 맞춰 늘어나는 가로 트랙
가로로 나열된 카드 개수 값이 전체 스크롤 너비를 정하고, 보이는 너비와의 비율 값이 손잡이 너비(thumbLen)로 그대로 바뀐다. 세로용 계산(syncThumb)과 거의 같은 식을 가로 축(scrollWidth·scrollLeft)으로만 바꿔 쓴 짝이다.

<main class="stage">
<div class="sb is-demo" id="sb">
<div class="sb__viewport" id="sbViewport">
<div class="sb__content" id="sbContent"></div>
</div>
<div class="sb__track" id="sbTrack">
<div class="sb__thumb" id="sbThumb" role="scrollbar" aria-orientation="horizontal" aria-controls="sbViewport" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" tabindex="0"></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 가로로 나열된 카드 개수 값(CARD_COUNT)에서 나온 scrollWidth 로
// 트랙 대비 손잡이 길이 비율이 계산되어 갤러리 아래 얇게 깔린다.
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-yellow);
.sb {
display: flex;
flex-direction: column;
gap: $sp-2;
width: min(90vw, 320px);
}
.sb__viewport {
width: 100%;
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.sb__viewport::-webkit-scrollbar { display: none; }
.sb__content {
display: flex;
gap: $sp-2;
padding: $sp-1;
}
.sb__card {
flex: 0 0 auto;
width: 86px;
height: 110px;
border-radius: $r-card;
padding: $sp-2;
display: flex;
flex-direction: column;
justify-content: flex-end;
background: linear-gradient(160deg, rgba(23, 20, 26, .1), rgba(23, 20, 26, .22));
}
.sb__cardTitle {
margin: 0 0 $sp-1;
color: $subject-cream;
font-weight: 700;
font-size: 12px;
}
.sb__cardLine {
margin: 0;
color: rgba(255, 247, 230, .78);
font-size: 9px;
}
.sb__track {
position: relative;
width: 100%;
height: $sp-2;
border-radius: $r-pill;
background: rgba(23, 20, 26, .12);
}
.sb__thumb {
position: absolute;
top: 0;
left: 0;
height: 100%;
border-radius: $r-pill;
background: $color;
transition: transform $duration $easing;
cursor: grab;
}
.sb__thumb:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 2px;
}
@media (max-width: 340px) {
.sb { width: min(96vw, 280px); }
.sb__card { width: 74px; height: 96px; }
.sb__cardTitle { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.sb__thumb { transition: 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);
}
.sb {
display: flex;
flex-direction: column;
gap: 8px;
width: min(90vw, 320px);
}
.sb__viewport {
width: 100%;
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.sb__viewport::-webkit-scrollbar {
display: none;
}
.sb__content {
display: flex;
gap: 8px;
padding: 4px;
}
.sb__card {
flex: 0 0 auto;
width: 86px;
height: 110px;
border-radius: 16px;
padding: 8px;
display: flex;
flex-direction: column;
justify-content: flex-end;
background: linear-gradient(160deg, rgba(23, 20, 26, 0.1), rgba(23, 20, 26, 0.22));
}
.sb__cardTitle {
margin: 0 0 2px;
color: #fff7e6;
font-weight: 700;
font-size: 12px;
}
.sb__cardLine {
margin: 0;
color: rgba(255, 247, 230, 0.78);
font-size: 9px;
}
.sb__track {
position: relative;
width: 100%;
height: 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.12);
}
.sb__thumb {
position: absolute;
top: 0;
left: 0;
height: 100%;
border-radius: 999px;
background: #17141a;
transition: transform 220ms ease-out;
cursor: grab;
}
.sb__thumb:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
@media (max-width: 340px) {
.sb {
width: min(96vw, 280px);
}
.sb__card {
width: 74px;
height: 96px;
}
.sb__cardTitle {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.sb__thumb {
transition: none;
}
}(function () {
var THUMB_MIN_PX = 30;
var KEY_STEP_PX = 60;
var CARD_COUNT = 6;
var root = document.getElementById('sb');
var viewport = document.getElementById('sbViewport');
var content = document.getElementById('sbContent');
var track = document.getElementById('sbTrack');
var thumb = document.getElementById('sbThumb');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
for (var i = 1; i <= CARD_COUNT; i++) {
var card = document.createElement('div');
card.className = 'sb__card';
var h = document.createElement('p');
h.className = 'sb__cardTitle';
h.textContent = '상품 ' + i;
var p = document.createElement('p');
p.className = 'sb__cardLine';
p.textContent = '카드 개수 값';
card.appendChild(h);
card.appendChild(p);
content.appendChild(card);
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function maxScrollX() { return Math.max(0, viewport.scrollWidth - viewport.clientWidth); }
function syncThumbX() {
var trackLen = track.clientWidth;
var total = viewport.scrollWidth;
var visible = viewport.clientWidth;
var thumbLen = clamp(trackLen * (visible / total), THUMB_MIN_PX, trackLen);
var ms = maxScrollX();
var ratio = ms > 0 ? viewport.scrollLeft / ms : 0;
var thumbPos = (trackLen - thumbLen) * ratio;
thumb.style.width = thumbLen.toFixed(1) + 'px';
thumb.style.transform = 'translateX(' + thumbPos.toFixed(1) + 'px)';
thumb.setAttribute('aria-valuenow', String(Math.round(ratio * 100)));
}
viewport.addEventListener('scroll', syncThumbX);
var dragging = false;
var dragStartX = 0;
var dragStartScroll = 0;
thumb.addEventListener('pointerdown', function (e) {
dragging = true;
root.classList.remove('is-demo');
dragStartX = e.clientX;
dragStartScroll = viewport.scrollLeft;
thumb.setPointerCapture(e.pointerId);
});
thumb.addEventListener('pointermove', function (e) {
if (!dragging) return;
var trackLen = track.clientWidth;
var thumbLen = thumb.clientWidth;
var deltaPx = e.clientX - dragStartX;
var moveRange = Math.max(1, trackLen - thumbLen);
viewport.scrollLeft = clamp(dragStartScroll + deltaPx * (maxScrollX() / moveRange), 0, maxScrollX());
});
thumb.addEventListener('pointerup', function () { dragging = false; });
thumb.addEventListener('pointercancel', function () { dragging = false; });
track.addEventListener('pointerdown', function (e) {
if (e.target !== track) return;
root.classList.remove('is-demo');
var rect = track.getBoundingClientRect();
var trackLen = track.clientWidth;
var thumbLen = thumb.clientWidth;
var clickPos = clamp(e.clientX - rect.left - thumbLen / 2, 0, trackLen - thumbLen);
var ratio = (trackLen - thumbLen) > 0 ? clickPos / (trackLen - thumbLen) : 0;
viewport.scrollLeft = ratio * maxScrollX();
});
thumb.addEventListener('keydown', function (e) {
var ms = maxScrollX();
if (e.key === 'ArrowRight') { viewport.scrollLeft = clamp(viewport.scrollLeft + KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === 'ArrowLeft') { viewport.scrollLeft = clamp(viewport.scrollLeft - KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === 'PageDown') { viewport.scrollLeft = clamp(viewport.scrollLeft + viewport.clientWidth, 0, ms); e.preventDefault(); }
else if (e.key === 'PageUp') { viewport.scrollLeft = clamp(viewport.scrollLeft - viewport.clientWidth, 0, ms); e.preventDefault(); }
else if (e.key === 'Home') { viewport.scrollLeft = 0; e.preventDefault(); }
else if (e.key === 'End') { viewport.scrollLeft = ms; e.preventDefault(); }
if (e.key.indexOf('Arrow') === 0 || e.key === 'PageDown' || e.key === 'PageUp' || e.key === 'Home' || e.key === 'End') {
root.classList.remove('is-demo');
}
});
var startTime = null;
function demoTick(now) {
if (startTime === null) startTime = now;
var t = (now - startTime) / 1000;
if (root.classList.contains('is-demo')) {
var phase = (Math.sin(t * Math.PI) + 1) / 2;
viewport.scrollLeft = phase * maxScrollX();
}
requestAnimationFrame(demoTick);
}
syncThumbX();
if (!reduce) { requestAnimationFrame(demoTick); }
})();// ScrollbarHorizontalTrackGallery — vanilla 판(run/411/demo/06-scrollbar-horizontal-track-gallery/index.html) 의 React 포트.
// 핵심: 가로로 나열된 카드 개수 값(CARD_COUNT)에서 나온 scrollWidth 로 트랙 대비 손잡이 길이 비율이 계산된다.
import { useEffect, useRef } from "react";
import styles from "./ScrollbarHorizontalTrackGallery.module.scss";
type Box<T> = { current: T };
const THUMB_MIN_PX = 30;
const KEY_STEP_PX = 60;
const CARD_COUNT = 6;
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
export default function ScrollbarHorizontalTrackGallery() {
const viewportRef = useRef<HTMLDivElement | null>(null);
const trackRef = useRef<HTMLDivElement | null>(null);
const thumbRef = useRef<HTMLDivElement | null>(null);
const demoRef = useRef<boolean>(true) as Box<boolean>;
const draggingRef = useRef<boolean>(false) as Box<boolean>;
const dragStartXRef = useRef<number>(0) as Box<number>;
const dragStartScrollRef = useRef<number>(0) as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const viewport = viewportRef.current!;
const track = trackRef.current!;
const thumb = thumbRef.current!;
if (!viewport || !track || !thumb) return;
function maxScrollX(): number {
return Math.max(0, viewport.scrollWidth - viewport.clientWidth);
}
function syncThumbX(): void {
const trackLen = track.clientWidth;
const total = viewport.scrollWidth;
const visible = viewport.clientWidth;
const thumbLen = clamp(trackLen * (visible / total), THUMB_MIN_PX, trackLen);
const ms = maxScrollX();
const ratio = ms > 0 ? viewport.scrollLeft / ms : 0;
const thumbPos = (trackLen - thumbLen) * ratio;
thumb.style.width = thumbLen.toFixed(1) + "px";
thumb.style.transform = "translateX(" + thumbPos.toFixed(1) + "px)";
thumb.setAttribute("aria-valuenow", String(Math.round(ratio * 100)));
}
function onScroll(): void { syncThumbX(); }
function onThumbPointerDown(e: PointerEvent): void {
draggingRef.current = true;
demoRef.current = false;
dragStartXRef.current = e.clientX;
dragStartScrollRef.current = viewport.scrollLeft;
thumb.setPointerCapture(e.pointerId);
}
function onThumbPointerMove(e: PointerEvent): void {
if (!draggingRef.current) return;
const trackLen = track.clientWidth;
const thumbLen = thumb.clientWidth;
const deltaPx = e.clientX - dragStartXRef.current;
const moveRange = Math.max(1, trackLen - thumbLen);
viewport.scrollLeft = clamp(dragStartScrollRef.current + deltaPx * (maxScrollX() / moveRange), 0, maxScrollX());
}
function onThumbPointerUp(): void { draggingRef.current = false; }
function onThumbPointerCancel(): void { draggingRef.current = false; }
function onTrackPointerDown(e: PointerEvent): void {
if (e.target !== track) return;
demoRef.current = false;
const rect = track.getBoundingClientRect();
const trackLen = track.clientWidth;
const thumbLen = thumb.clientWidth;
const clickPos = clamp(e.clientX - rect.left - thumbLen / 2, 0, trackLen - thumbLen);
const ratio = (trackLen - thumbLen) > 0 ? clickPos / (trackLen - thumbLen) : 0;
viewport.scrollLeft = ratio * maxScrollX();
}
function onThumbKeyDown(e: KeyboardEvent): void {
const ms = maxScrollX();
if (e.key === "ArrowRight") { viewport.scrollLeft = clamp(viewport.scrollLeft + KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === "ArrowLeft") { viewport.scrollLeft = clamp(viewport.scrollLeft - KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === "PageDown") { viewport.scrollLeft = clamp(viewport.scrollLeft + viewport.clientWidth, 0, ms); e.preventDefault(); }
else if (e.key === "PageUp") { viewport.scrollLeft = clamp(viewport.scrollLeft - viewport.clientWidth, 0, ms); e.preventDefault(); }
else if (e.key === "Home") { viewport.scrollLeft = 0; e.preventDefault(); }
else if (e.key === "End") { viewport.scrollLeft = ms; e.preventDefault(); }
if (e.key.indexOf("Arrow") === 0 || e.key === "PageDown" || e.key === "PageUp" || e.key === "Home" || e.key === "End") {
demoRef.current = false;
}
}
let startTime: number | null = null;
let frame = 0;
function demoTick(now: number): void {
if (startTime === null) startTime = now;
const t = (now - startTime) / 1000;
if (demoRef.current) {
const phase = (Math.sin(t * Math.PI) + 1) / 2;
viewport.scrollLeft = phase * maxScrollX();
}
frame = requestAnimationFrame(demoTick);
}
viewport.addEventListener("scroll", onScroll);
thumb.addEventListener("pointerdown", onThumbPointerDown);
thumb.addEventListener("pointermove", onThumbPointerMove);
thumb.addEventListener("pointerup", onThumbPointerUp);
thumb.addEventListener("pointercancel", onThumbPointerCancel);
track.addEventListener("pointerdown", onTrackPointerDown);
thumb.addEventListener("keydown", onThumbKeyDown);
syncThumbX();
if (!reduce) { frame = requestAnimationFrame(demoTick); }
return () => {
viewport.removeEventListener("scroll", onScroll);
thumb.removeEventListener("pointerdown", onThumbPointerDown);
thumb.removeEventListener("pointermove", onThumbPointerMove);
thumb.removeEventListener("pointerup", onThumbPointerUp);
thumb.removeEventListener("pointercancel", onThumbPointerCancel);
track.removeEventListener("pointerdown", onTrackPointerDown);
thumb.removeEventListener("keydown", onThumbKeyDown);
if (frame) cancelAnimationFrame(frame);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.sb}>
<div className={styles.sb__viewport} ref={viewportRef}>
<div className={styles.sb__content}>
{Array.from({ length: CARD_COUNT }, (_, idx) => idx + 1).map((i) => (
<div className={styles.sb__card} key={i}>
<p className={styles.sb__cardTitle}>{"상품 " + i}</p>
<p className={styles.sb__cardLine}>카드 개수 값</p>
</div>
))}
</div>
</div>
<div className={styles.sb__track} ref={trackRef}>
<div
className={styles.sb__thumb}
ref={thumbRef}
role="scrollbar"
aria-orientation="horizontal"
aria-valuenow={0}
aria-valuemin={0}
aria-valuemax={100}
tabIndex={0}
/>
</div>
</div>
</main>
);
}06 · 카드 개수 값에 맞춰 늘어나는 가로 트랙 — 측정 누적면적 26.656% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function maxScrollX() { return Math.max(0, viewport.scrollWidth - viewport.clientWidth); }
function syncThumbX() {
var trackLen = track.clientWidth;
var total = viewport.scrollWidth;
var visible = viewport.clientWidth;
var thumbLen = clamp(trackLen * (visible / total), THUMB_MIN_PX, trackLen);
var ms = maxScrollX();
var ratio = ms > 0 ? viewport.scrollLeft / ms : 0;
var thumbPos = (trackLen - thumbLen) * ratio;
thumb.style.width = thumbLen.toFixed(1) + 'px';
thumb.style.transform = 'translateX(' + thumbPos.toFixed(1) + 'px)';
thumb.setAttribute('aria-valuenow', String(Math.round(ratio * 100)));
}
측정: 누적면적 26.7% · 강도 평균 55.1 · 23/23 프레임에서 움직였다.
07멈추면 투명도 값이 줄어 사라지는 오버레이 손잡이
스크롤이 멈춘 뒤 흐른 시간 값이 지연 값(OVERLAY_HIDE_DELAY_MS, 600)을 넘으면 투명도 값이 줄기 시작해 서서히 옅어지고, 다시 스크롤하면 투명도 값이 즉시 1로 되돌아온다. 콘텐츠 위에 겹쳐 떠 있는 오버레이형이라 평소엔 화면을 가리지 않다가 손이 필요할 때만 나타난다.

<main class="stage">
<div class="sb is-demo" id="sb">
<div class="sb__viewport" id="sbViewport">
<div class="sb__content" id="sbContent"></div>
</div>
<div class="sb__track sb__track--overlay" id="sbTrack">
<div class="sb__thumb" id="sbThumb" role="scrollbar" aria-orientation="vertical" aria-controls="sbViewport" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" tabindex="0"></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 스크롤이 멈춘 뒤 경과한 시간 값(rAf now 기준)이 OVERLAY_HIDE_DELAY_MS 를 넘으면
// 오버레이 트랙의 투명도 값이 줄어들고, 다시 스크롤하면 즉시 1 로 되돌아간다.
$duration: 380ms;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-orange);
.sb {
position: relative;
width: min(88vw, 300px);
height: min(56vw, 176px);
}
.sb__viewport {
width: 100%;
height: 100%;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
border-radius: $r-card;
background: rgba(255, 255, 255, .18);
}
.sb__viewport::-webkit-scrollbar { display: none; }
.sb__content {
display: flex;
flex-direction: column;
gap: $sp-2;
padding: $sp-2 $sp-6 $sp-2 $sp-2;
}
.sb__card {
border-radius: $r-sm;
padding: $sp-2 $sp-3;
background: rgba(255, 255, 255, .24);
}
.sb__cardTitle {
margin: 0 0 $sp-1;
color: $color;
font-weight: 700;
font-size: 12px;
}
.sb__cardLine {
margin: 0;
color: rgba(23, 20, 26, .68);
font-size: 10px;
line-height: 1.4;
}
.sb__track--overlay {
position: absolute;
top: $sp-2;
right: $sp-2;
bottom: $sp-2;
width: $sp-2;
border-radius: $r-pill;
background: rgba(23, 20, 26, .1);
}
.sb__thumb {
position: absolute;
top: 0;
left: 0;
width: 100%;
border-radius: $r-pill;
background: $subject-ink;
transition: transform $dur-quick $easing;
cursor: grab;
}
.sb__thumb:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 2px;
}
@media (max-width: 340px) {
.sb { width: min(94vw, 260px); height: 148px; }
.sb__cardTitle { font-size: 11px; }
.sb__cardLine { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
.sb__thumb { transition: none; }
.sb__track--overlay { 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);
}
.sb {
position: relative;
width: min(88vw, 300px);
height: min(56vw, 176px);
}
.sb__viewport {
width: 100%;
height: 100%;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
border-radius: 16px;
background: rgba(255, 255, 255, 0.18);
}
.sb__viewport::-webkit-scrollbar {
display: none;
}
.sb__content {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 24px 8px 8px;
}
.sb__card {
border-radius: 8px;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.24);
}
.sb__cardTitle {
margin: 0 0 2px;
color: #17141a;
font-weight: 700;
font-size: 12px;
}
.sb__cardLine {
margin: 0;
color: rgba(23, 20, 26, 0.68);
font-size: 10px;
line-height: 1.4;
}
.sb__track--overlay {
position: absolute;
top: 8px;
right: 8px;
bottom: 8px;
width: 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.1);
}
.sb__thumb {
position: absolute;
top: 0;
left: 0;
width: 100%;
border-radius: 999px;
background: #17141a;
transition: transform 220ms ease-out;
cursor: grab;
}
.sb__thumb:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
@media (max-width: 340px) {
.sb {
width: min(94vw, 260px);
height: 148px;
}
.sb__cardTitle {
font-size: 11px;
}
.sb__cardLine {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.sb__thumb {
transition: none;
}
.sb__track--overlay {
transition: none;
}
}(function () {
var THUMB_MIN_PX = 26;
var KEY_STEP_PX = 32;
var OVERLAY_HIDE_DELAY_MS = 600;
var OVERLAY_FADE_MS = 380;
var root = document.getElementById('sb');
var viewport = document.getElementById('sbViewport');
var content = document.getElementById('sbContent');
var track = document.getElementById('sbTrack');
var thumb = document.getElementById('sbThumb');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
for (var i = 1; i <= 8; i++) {
var card = document.createElement('div');
card.className = 'sb__card';
var h = document.createElement('p');
h.className = 'sb__cardTitle';
h.textContent = '섹션 ' + i;
var p = document.createElement('p');
p.className = 'sb__cardLine';
p.textContent = '스크롤을 멈춘 뒤 경과한 시간 값이 늘수록 투명도 값이 줄어듭니다.';
card.appendChild(h);
card.appendChild(p);
content.appendChild(card);
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function maxScroll() { return Math.max(0, viewport.scrollHeight - viewport.clientHeight); }
function syncThumb() {
var trackLen = track.clientHeight;
var total = viewport.scrollHeight;
var visible = viewport.clientHeight;
var thumbLen = clamp(trackLen * (visible / total), THUMB_MIN_PX, trackLen);
var ms = maxScroll();
var ratio = ms > 0 ? viewport.scrollTop / ms : 0;
var thumbPos = (trackLen - thumbLen) * ratio;
thumb.style.height = thumbLen.toFixed(1) + 'px';
thumb.style.transform = 'translateY(' + thumbPos.toFixed(1) + 'px)';
thumb.setAttribute('aria-valuenow', String(Math.round(ratio * 100)));
}
var scrolledFlag = true;
viewport.addEventListener('scroll', function () { scrolledFlag = true; syncThumb(); });
var dragging = false;
var dragStartY = 0;
var dragStartScroll = 0;
thumb.addEventListener('pointerdown', function (e) {
dragging = true;
scrolledFlag = true;
root.classList.remove('is-demo');
dragStartY = e.clientY;
dragStartScroll = viewport.scrollTop;
thumb.setPointerCapture(e.pointerId);
});
thumb.addEventListener('pointermove', function (e) {
if (!dragging) return;
scrolledFlag = true;
var trackLen = track.clientHeight;
var thumbLen = thumb.clientHeight;
var deltaPx = e.clientY - dragStartY;
var moveRange = Math.max(1, trackLen - thumbLen);
viewport.scrollTop = clamp(dragStartScroll + deltaPx * (maxScroll() / moveRange), 0, maxScroll());
});
thumb.addEventListener('pointerup', function () { dragging = false; });
thumb.addEventListener('pointercancel', function () { dragging = false; });
track.addEventListener('pointerdown', function (e) {
if (e.target !== track) return;
scrolledFlag = true;
root.classList.remove('is-demo');
var rect = track.getBoundingClientRect();
var trackLen = track.clientHeight;
var thumbLen = thumb.clientHeight;
var clickPos = clamp(e.clientY - rect.top - thumbLen / 2, 0, trackLen - thumbLen);
var ratio = (trackLen - thumbLen) > 0 ? clickPos / (trackLen - thumbLen) : 0;
viewport.scrollTop = ratio * maxScroll();
});
thumb.addEventListener('keydown', function (e) {
var ms = maxScroll();
if (e.key === 'ArrowDown') { viewport.scrollTop = clamp(viewport.scrollTop + KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === 'ArrowUp') { viewport.scrollTop = clamp(viewport.scrollTop - KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === 'PageDown') { viewport.scrollTop = clamp(viewport.scrollTop + viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === 'PageUp') { viewport.scrollTop = clamp(viewport.scrollTop - viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === 'Home') { viewport.scrollTop = 0; e.preventDefault(); }
else if (e.key === 'End') { viewport.scrollTop = ms; e.preventDefault(); }
if (e.key.indexOf('Arrow') === 0 || e.key === 'PageDown' || e.key === 'PageUp' || e.key === 'Home' || e.key === 'End') {
scrolledFlag = true;
root.classList.remove('is-demo');
}
});
var startTime = null;
var lastActiveT = 0;
var cycleAt = 0;
function demoTick(now) {
if (startTime === null) { startTime = now; lastActiveT = 0; }
var t = (now - startTime) / 1000;
if (root.classList.contains('is-demo') && !reduce) {
var cycle = t - cycleAt;
if (cycle >= 1.0) { cycleAt = t; cycle = 0; }
if (cycle < 0.15) {
var phase = cycle / 0.15;
var cycleIndex = Math.round(cycleAt);
var goingDown = cycleIndex % 2 === 0;
var from = goingDown ? 0 : maxScroll();
var to = goingDown ? maxScroll() : 0;
viewport.scrollTop = from + (to - from) * phase;
scrolledFlag = true;
}
}
if (scrolledFlag) { lastActiveT = t; scrolledFlag = false; }
var elapsedMs = (t - lastActiveT) * 1000;
var opacity;
if (elapsedMs <= OVERLAY_HIDE_DELAY_MS) {
opacity = 1;
} else if (reduce) {
opacity = 0;
} else {
opacity = clamp(1 - (elapsedMs - OVERLAY_HIDE_DELAY_MS) / OVERLAY_FADE_MS, 0, 1);
}
track.style.opacity = opacity.toFixed(3);
requestAnimationFrame(demoTick);
}
syncThumb();
requestAnimationFrame(demoTick);
})();// ScrollbarFloatingOverlayAutohide — vanilla 판(run/411/demo/07-scrollbar-floating-overlay-autohide/index.html) 의 React 포트.
// 핵심: 스크롤이 멈춘 뒤 경과한 시간 값이 OVERLAY_HIDE_DELAY_MS 를 넘으면 오버레이 트랙의 투명도 값이 OVERLAY_FADE_MS 동안 줄어든다.
import { useEffect, useRef } from "react";
import styles from "./ScrollbarFloatingOverlayAutohide.module.scss";
type Box<T> = { current: T };
const THUMB_MIN_PX = 26;
const KEY_STEP_PX = 32;
const OVERLAY_HIDE_DELAY_MS = 600;
const OVERLAY_FADE_MS = 380;
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
export default function ScrollbarFloatingOverlayAutohide() {
const viewportRef = useRef<HTMLDivElement | null>(null);
const trackRef = useRef<HTMLDivElement | null>(null);
const thumbRef = useRef<HTMLDivElement | null>(null);
const demoRef = useRef<boolean>(true) as Box<boolean>;
const draggingRef = useRef<boolean>(false) as Box<boolean>;
const dragStartYRef = useRef<number>(0) as Box<number>;
const dragStartScrollRef = useRef<number>(0) as Box<number>;
const scrolledFlagRef = useRef<boolean>(true) as Box<boolean>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const viewport = viewportRef.current!;
const track = trackRef.current!;
const thumb = thumbRef.current!;
if (!viewport || !track || !thumb) return;
function maxScroll(): number {
return Math.max(0, viewport.scrollHeight - viewport.clientHeight);
}
function syncThumb(): void {
const trackLen = track.clientHeight;
const total = viewport.scrollHeight;
const visible = viewport.clientHeight;
const thumbLen = clamp(trackLen * (visible / total), THUMB_MIN_PX, trackLen);
const ms = maxScroll();
const ratio = ms > 0 ? viewport.scrollTop / ms : 0;
const thumbPos = (trackLen - thumbLen) * ratio;
thumb.style.height = thumbLen.toFixed(1) + "px";
thumb.style.transform = "translateY(" + thumbPos.toFixed(1) + "px)";
thumb.setAttribute("aria-valuenow", String(Math.round(ratio * 100)));
}
function onScroll(): void { scrolledFlagRef.current = true; syncThumb(); }
function onThumbPointerDown(e: PointerEvent): void {
draggingRef.current = true;
scrolledFlagRef.current = true;
demoRef.current = false;
dragStartYRef.current = e.clientY;
dragStartScrollRef.current = viewport.scrollTop;
thumb.setPointerCapture(e.pointerId);
}
function onThumbPointerMove(e: PointerEvent): void {
if (!draggingRef.current) return;
scrolledFlagRef.current = true;
const trackLen = track.clientHeight;
const thumbLen = thumb.clientHeight;
const deltaPx = e.clientY - dragStartYRef.current;
const moveRange = Math.max(1, trackLen - thumbLen);
viewport.scrollTop = clamp(dragStartScrollRef.current + deltaPx * (maxScroll() / moveRange), 0, maxScroll());
}
function onThumbPointerUp(): void { draggingRef.current = false; }
function onThumbPointerCancel(): void { draggingRef.current = false; }
function onTrackPointerDown(e: PointerEvent): void {
if (e.target !== track) return;
scrolledFlagRef.current = true;
demoRef.current = false;
const rect = track.getBoundingClientRect();
const trackLen = track.clientHeight;
const thumbLen = thumb.clientHeight;
const clickPos = clamp(e.clientY - rect.top - thumbLen / 2, 0, trackLen - thumbLen);
const ratio = (trackLen - thumbLen) > 0 ? clickPos / (trackLen - thumbLen) : 0;
viewport.scrollTop = ratio * maxScroll();
}
function onThumbKeyDown(e: KeyboardEvent): void {
const ms = maxScroll();
if (e.key === "ArrowDown") { viewport.scrollTop = clamp(viewport.scrollTop + KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === "ArrowUp") { viewport.scrollTop = clamp(viewport.scrollTop - KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === "PageDown") { viewport.scrollTop = clamp(viewport.scrollTop + viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === "PageUp") { viewport.scrollTop = clamp(viewport.scrollTop - viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === "Home") { viewport.scrollTop = 0; e.preventDefault(); }
else if (e.key === "End") { viewport.scrollTop = ms; e.preventDefault(); }
if (e.key.indexOf("Arrow") === 0 || e.key === "PageDown" || e.key === "PageUp" || e.key === "Home" || e.key === "End") {
scrolledFlagRef.current = true;
demoRef.current = false;
}
}
let startTime: number | null = null;
let lastActiveT = 0;
let cycleAt = 0;
let frame = 0;
function demoTick(now: number): void {
if (startTime === null) { startTime = now; lastActiveT = 0; }
const t = (now - startTime) / 1000;
if (demoRef.current && !reduce) {
let cycle = t - cycleAt;
if (cycle >= 1.0) { cycleAt = t; cycle = 0; }
if (cycle < 0.15) {
const phase = cycle / 0.15;
const cycleIndex = Math.round(cycleAt);
const goingDown = cycleIndex % 2 === 0;
const from = goingDown ? 0 : maxScroll();
const to = goingDown ? maxScroll() : 0;
viewport.scrollTop = from + (to - from) * phase;
scrolledFlagRef.current = true;
}
}
if (scrolledFlagRef.current) { lastActiveT = t; scrolledFlagRef.current = false; }
const elapsedMs = (t - lastActiveT) * 1000;
let opacity: number;
if (elapsedMs <= OVERLAY_HIDE_DELAY_MS) {
opacity = 1;
} else if (reduce) {
opacity = 0;
} else {
opacity = clamp(1 - (elapsedMs - OVERLAY_HIDE_DELAY_MS) / OVERLAY_FADE_MS, 0, 1);
}
track.style.opacity = opacity.toFixed(3);
frame = requestAnimationFrame(demoTick);
}
viewport.addEventListener("scroll", onScroll);
thumb.addEventListener("pointerdown", onThumbPointerDown);
thumb.addEventListener("pointermove", onThumbPointerMove);
thumb.addEventListener("pointerup", onThumbPointerUp);
thumb.addEventListener("pointercancel", onThumbPointerCancel);
track.addEventListener("pointerdown", onTrackPointerDown);
thumb.addEventListener("keydown", onThumbKeyDown);
syncThumb();
frame = requestAnimationFrame(demoTick);
return () => {
viewport.removeEventListener("scroll", onScroll);
thumb.removeEventListener("pointerdown", onThumbPointerDown);
thumb.removeEventListener("pointermove", onThumbPointerMove);
thumb.removeEventListener("pointerup", onThumbPointerUp);
thumb.removeEventListener("pointercancel", onThumbPointerCancel);
track.removeEventListener("pointerdown", onTrackPointerDown);
thumb.removeEventListener("keydown", onThumbKeyDown);
if (frame) cancelAnimationFrame(frame);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.sb}>
<div className={styles.sb__viewport} ref={viewportRef}>
<div className={styles.sb__content}>
{Array.from({ length: 8 }, (_, idx) => idx + 1).map((i) => (
<div className={styles.sb__card} key={i}>
<p className={styles.sb__cardTitle}>{"섹션 " + i}</p>
<p className={styles.sb__cardLine}>스크롤을 멈춘 뒤 경과한 시간 값이 늘수록 투명도 값이 줄어듭니다.</p>
</div>
))}
</div>
</div>
<div className={styles["sb__track--overlay"]} ref={trackRef}>
<div
className={styles.sb__thumb}
ref={thumbRef}
role="scrollbar"
aria-orientation="vertical"
aria-valuenow={0}
aria-valuemin={0}
aria-valuemax={100}
tabIndex={0}
/>
</div>
</div>
</main>
);
}07 · 멈추면 투명도 값이 줄어 사라지는 오버레이 손잡이 — 측정 누적면적 21.918% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
if (scrolledFlag) { lastActiveT = t; scrolledFlag = false; }
var elapsedMs = (t - lastActiveT) * 1000;
var opacity;
if (elapsedMs <= OVERLAY_HIDE_DELAY_MS) {
opacity = 1;
} else if (reduce) {
opacity = 0;
} else {
opacity = clamp(1 - (elapsedMs - OVERLAY_HIDE_DELAY_MS) / OVERLAY_FADE_MS, 0, 1);
}
track.style.opacity = opacity.toFixed(3);
requestAnimationFrame(demoTick);
}
syncThumb();
측정: 누적면적 21.9% · 강도 평균 78.6 · 13/23 프레임에서 움직였다.
08손잡이 길이 값이 남은 분량을 그대로 보여주는 스크롤바
전체 콘텐츠 높이와 보이는 영역의 비율 값을 손잡이 길이 값에 그대로 반영하고, 같은 비율 값에서 100을 뺀 나머지를 퍼센트 배지 글자로 옮긴다. 손잡이 길이와 배지 숫자가 같은 값 하나에서 나오므로 둘이 어긋날 일이 없다.
<main class="stage">
<div class="sb is-demo" id="sb">
<p class="sb__badge" id="sbBadge">남은 분량 100%</p>
<div class="sb__row">
<div class="sb__viewport" id="sbViewport">
<div class="sb__content" id="sbContent"></div>
</div>
<div class="sb__track" id="sbTrack">
<div class="sb__thumb" id="sbThumb" role="scrollbar" aria-orientation="vertical" aria-controls="sbViewport" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" tabindex="0"></div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: lengthRatio(visible/total) 값이 손잡이 길이 값에 그대로 반영되고,
// scrollRatio 값이 배지의 "남은 분량" 문구로 다시 표현된다 — 같은 비율 값의 두 표현.
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-paper);
.sb {
display: flex;
flex-direction: column;
gap: $sp-2;
width: min(88vw, 300px);
}
.sb__badge {
margin: 0;
align-self: flex-start;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .08);
color: $color;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.sb__row {
display: flex;
gap: $sp-2;
height: min(48vw, 148px);
}
.sb__viewport {
flex: 1;
min-width: 0;
height: 100%;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
border-radius: $r-card;
background: rgba(23, 20, 26, .04);
}
.sb__viewport::-webkit-scrollbar { display: none; }
.sb__content {
display: flex;
flex-direction: column;
gap: $sp-2;
padding: $sp-2;
}
.sb__card {
border-radius: $r-sm;
padding: $sp-2 $sp-3;
background: $subject-cream;
}
.sb__cardTitle {
margin: 0 0 $sp-1;
color: $color;
font-weight: 700;
font-size: 12px;
}
.sb__cardLine {
margin: 0;
color: rgba(23, 20, 26, .6);
font-size: 10px;
line-height: 1.4;
}
.sb__track {
position: relative;
width: $sp-2;
border-radius: $r-pill;
background: rgba(23, 20, 26, .08);
}
.sb__thumb {
position: absolute;
top: 0;
left: 0;
width: 100%;
border-radius: $r-pill;
background: $subject-blue-deep;
transition: transform $duration $easing, height $duration $easing;
cursor: grab;
}
.sb__thumb:focus-visible {
outline: 2px solid $subject-blue-deep;
outline-offset: 2px;
}
@media (max-width: 340px) {
.sb { width: min(94vw, 260px); }
.sb__row { height: 130px; }
.sb__cardTitle { font-size: 11px; }
.sb__cardLine { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
.sb__thumb { 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);
}
.sb {
display: flex;
flex-direction: column;
gap: 8px;
width: min(88vw, 300px);
}
.sb__badge {
margin: 0;
align-self: flex-start;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.sb__row {
display: flex;
gap: 8px;
height: min(48vw, 148px);
}
.sb__viewport {
flex: 1;
min-width: 0;
height: 100%;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
border-radius: 16px;
background: rgba(23, 20, 26, 0.04);
}
.sb__viewport::-webkit-scrollbar {
display: none;
}
.sb__content {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
}
.sb__card {
border-radius: 8px;
padding: 8px 12px;
background: #fff7e6;
}
.sb__cardTitle {
margin: 0 0 2px;
color: #17141a;
font-weight: 700;
font-size: 12px;
}
.sb__cardLine {
margin: 0;
color: rgba(23, 20, 26, 0.6);
font-size: 10px;
line-height: 1.4;
}
.sb__track {
position: relative;
width: 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
}
.sb__thumb {
position: absolute;
top: 0;
left: 0;
width: 100%;
border-radius: 999px;
background: #0a4fe8;
transition: transform 220ms ease-out, height 220ms ease-out;
cursor: grab;
}
.sb__thumb:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.sb {
width: min(94vw, 260px);
}
.sb__row {
height: 130px;
}
.sb__cardTitle {
font-size: 11px;
}
.sb__cardLine {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.sb__thumb {
transition: none;
}
}(function () {
var THUMB_MIN_PX = 26;
var KEY_STEP_PX = 32;
var root = document.getElementById('sb');
var viewport = document.getElementById('sbViewport');
var content = document.getElementById('sbContent');
var track = document.getElementById('sbTrack');
var thumb = document.getElementById('sbThumb');
var badge = document.getElementById('sbBadge');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
for (var i = 1; i <= 8; i++) {
var card = document.createElement('div');
card.className = 'sb__card';
var h = document.createElement('p');
h.className = 'sb__cardTitle';
h.textContent = '설정 ' + i;
var p = document.createElement('p');
p.className = 'sb__cardLine';
p.textContent = '전체 높이와 보이는 영역의 비율 값이 손잡이 길이 값 그대로다.';
card.appendChild(h);
card.appendChild(p);
content.appendChild(card);
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function maxScroll() { return Math.max(0, viewport.scrollHeight - viewport.clientHeight); }
function syncThumb() {
var trackLen = track.clientHeight;
var total = viewport.scrollHeight;
var visible = viewport.clientHeight;
var lengthRatio = clamp(visible / total, 0, 1);
var thumbLen = clamp(trackLen * lengthRatio, THUMB_MIN_PX, trackLen);
var ms = maxScroll();
var scrollRatio = ms > 0 ? viewport.scrollTop / ms : 0;
var thumbPos = (trackLen - thumbLen) * scrollRatio;
thumb.style.height = thumbLen.toFixed(1) + 'px';
thumb.style.transform = 'translateY(' + thumbPos.toFixed(1) + 'px)';
thumb.setAttribute('aria-valuenow', String(Math.round(scrollRatio * 100)));
var remaining = Math.round((1 - scrollRatio) * 100);
badge.textContent = '남은 분량 ' + clamp(remaining, 0, 100) + '%';
}
viewport.addEventListener('scroll', syncThumb);
var dragging = false;
var dragStartY = 0;
var dragStartScroll = 0;
thumb.addEventListener('pointerdown', function (e) {
dragging = true;
root.classList.remove('is-demo');
dragStartY = e.clientY;
dragStartScroll = viewport.scrollTop;
thumb.setPointerCapture(e.pointerId);
});
thumb.addEventListener('pointermove', function (e) {
if (!dragging) return;
var trackLen = track.clientHeight;
var thumbLen = thumb.clientHeight;
var deltaPx = e.clientY - dragStartY;
var moveRange = Math.max(1, trackLen - thumbLen);
viewport.scrollTop = clamp(dragStartScroll + deltaPx * (maxScroll() / moveRange), 0, maxScroll());
});
thumb.addEventListener('pointerup', function () { dragging = false; });
thumb.addEventListener('pointercancel', function () { dragging = false; });
track.addEventListener('pointerdown', function (e) {
if (e.target !== track) return;
root.classList.remove('is-demo');
var rect = track.getBoundingClientRect();
var trackLen = track.clientHeight;
var thumbLen = thumb.clientHeight;
var clickPos = clamp(e.clientY - rect.top - thumbLen / 2, 0, trackLen - thumbLen);
var ratio = (trackLen - thumbLen) > 0 ? clickPos / (trackLen - thumbLen) : 0;
viewport.scrollTop = ratio * maxScroll();
});
thumb.addEventListener('keydown', function (e) {
var ms = maxScroll();
if (e.key === 'ArrowDown') { viewport.scrollTop = clamp(viewport.scrollTop + KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === 'ArrowUp') { viewport.scrollTop = clamp(viewport.scrollTop - KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === 'PageDown') { viewport.scrollTop = clamp(viewport.scrollTop + viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === 'PageUp') { viewport.scrollTop = clamp(viewport.scrollTop - viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === 'Home') { viewport.scrollTop = 0; e.preventDefault(); }
else if (e.key === 'End') { viewport.scrollTop = ms; e.preventDefault(); }
if (e.key.indexOf('Arrow') === 0 || e.key === 'PageDown' || e.key === 'PageUp' || e.key === 'Home' || e.key === 'End') {
root.classList.remove('is-demo');
}
});
var startTime = null;
function demoTick(now) {
if (startTime === null) startTime = now;
var t = (now - startTime) / 1000;
if (root.classList.contains('is-demo')) {
var phase = (Math.sin(t * Math.PI) + 1) / 2;
viewport.scrollTop = phase * maxScroll();
}
requestAnimationFrame(demoTick);
}
syncThumb();
if (!reduce) { requestAnimationFrame(demoTick); }
})();// ScrollbarThumbRatioProgressBadge — vanilla 판(run/411/demo/08-scrollbar-thumb-ratio-progress-badge/index.html) 의 React 포트.
// 핵심: lengthRatio(visible/total) 값이 손잡이 길이 값에, scrollRatio 값이 배지의 "남은 분량" 문구로 같은 비율의 두 표현이 된다.
import { useEffect, useRef } from "react";
import styles from "./ScrollbarThumbRatioProgressBadge.module.scss";
type Box<T> = { current: T };
const THUMB_MIN_PX = 26;
const KEY_STEP_PX = 32;
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
export default function ScrollbarThumbRatioProgressBadge() {
const viewportRef = useRef<HTMLDivElement | null>(null);
const trackRef = useRef<HTMLDivElement | null>(null);
const thumbRef = useRef<HTMLDivElement | null>(null);
const badgeRef = useRef<HTMLParagraphElement | null>(null);
const demoRef = useRef<boolean>(true) as Box<boolean>;
const draggingRef = useRef<boolean>(false) as Box<boolean>;
const dragStartYRef = useRef<number>(0) as Box<number>;
const dragStartScrollRef = useRef<number>(0) as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const viewport = viewportRef.current!;
const track = trackRef.current!;
const thumb = thumbRef.current!;
const badge = badgeRef.current!;
if (!viewport || !track || !thumb || !badge) return;
function maxScroll(): number {
return Math.max(0, viewport.scrollHeight - viewport.clientHeight);
}
function syncThumb(): void {
const trackLen = track.clientHeight;
const total = viewport.scrollHeight;
const visible = viewport.clientHeight;
const lengthRatio = clamp(visible / total, 0, 1);
const thumbLen = clamp(trackLen * lengthRatio, THUMB_MIN_PX, trackLen);
const ms = maxScroll();
const scrollRatio = ms > 0 ? viewport.scrollTop / ms : 0;
const thumbPos = (trackLen - thumbLen) * scrollRatio;
thumb.style.height = thumbLen.toFixed(1) + "px";
thumb.style.transform = "translateY(" + thumbPos.toFixed(1) + "px)";
thumb.setAttribute("aria-valuenow", String(Math.round(scrollRatio * 100)));
const remaining = Math.round((1 - scrollRatio) * 100);
badge.textContent = "남은 분량 " + clamp(remaining, 0, 100) + "%";
}
function onScroll(): void { syncThumb(); }
function onThumbPointerDown(e: PointerEvent): void {
draggingRef.current = true;
demoRef.current = false;
dragStartYRef.current = e.clientY;
dragStartScrollRef.current = viewport.scrollTop;
thumb.setPointerCapture(e.pointerId);
}
function onThumbPointerMove(e: PointerEvent): void {
if (!draggingRef.current) return;
const trackLen = track.clientHeight;
const thumbLen = thumb.clientHeight;
const deltaPx = e.clientY - dragStartYRef.current;
const moveRange = Math.max(1, trackLen - thumbLen);
viewport.scrollTop = clamp(dragStartScrollRef.current + deltaPx * (maxScroll() / moveRange), 0, maxScroll());
}
function onThumbPointerUp(): void { draggingRef.current = false; }
function onThumbPointerCancel(): void { draggingRef.current = false; }
function onTrackPointerDown(e: PointerEvent): void {
if (e.target !== track) return;
demoRef.current = false;
const rect = track.getBoundingClientRect();
const trackLen = track.clientHeight;
const thumbLen = thumb.clientHeight;
const clickPos = clamp(e.clientY - rect.top - thumbLen / 2, 0, trackLen - thumbLen);
const ratio = (trackLen - thumbLen) > 0 ? clickPos / (trackLen - thumbLen) : 0;
viewport.scrollTop = ratio * maxScroll();
}
function onThumbKeyDown(e: KeyboardEvent): void {
const ms = maxScroll();
if (e.key === "ArrowDown") { viewport.scrollTop = clamp(viewport.scrollTop + KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === "ArrowUp") { viewport.scrollTop = clamp(viewport.scrollTop - KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === "PageDown") { viewport.scrollTop = clamp(viewport.scrollTop + viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === "PageUp") { viewport.scrollTop = clamp(viewport.scrollTop - viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === "Home") { viewport.scrollTop = 0; e.preventDefault(); }
else if (e.key === "End") { viewport.scrollTop = ms; e.preventDefault(); }
if (e.key.indexOf("Arrow") === 0 || e.key === "PageDown" || e.key === "PageUp" || e.key === "Home" || e.key === "End") {
demoRef.current = false;
}
}
let startTime: number | null = null;
let frame = 0;
function demoTick(now: number): void {
if (startTime === null) startTime = now;
const t = (now - startTime) / 1000;
if (demoRef.current) {
const phase = (Math.sin(t * Math.PI) + 1) / 2;
viewport.scrollTop = phase * maxScroll();
}
frame = requestAnimationFrame(demoTick);
}
viewport.addEventListener("scroll", onScroll);
thumb.addEventListener("pointerdown", onThumbPointerDown);
thumb.addEventListener("pointermove", onThumbPointerMove);
thumb.addEventListener("pointerup", onThumbPointerUp);
thumb.addEventListener("pointercancel", onThumbPointerCancel);
track.addEventListener("pointerdown", onTrackPointerDown);
thumb.addEventListener("keydown", onThumbKeyDown);
syncThumb();
if (!reduce) { frame = requestAnimationFrame(demoTick); }
return () => {
viewport.removeEventListener("scroll", onScroll);
thumb.removeEventListener("pointerdown", onThumbPointerDown);
thumb.removeEventListener("pointermove", onThumbPointerMove);
thumb.removeEventListener("pointerup", onThumbPointerUp);
thumb.removeEventListener("pointercancel", onThumbPointerCancel);
track.removeEventListener("pointerdown", onTrackPointerDown);
thumb.removeEventListener("keydown", onThumbKeyDown);
if (frame) cancelAnimationFrame(frame);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.sb}>
<p className={styles.sb__badge} ref={badgeRef}>남은 분량 100%</p>
<div className={styles.sb__row}>
<div className={styles.sb__viewport} ref={viewportRef}>
<div className={styles.sb__content}>
{Array.from({ length: 8 }, (_, idx) => idx + 1).map((i) => (
<div className={styles.sb__card} key={i}>
<p className={styles.sb__cardTitle}>{"설정 " + i}</p>
<p className={styles.sb__cardLine}>전체 높이와 보이는 영역의 비율 값이 손잡이 길이 값 그대로다.</p>
</div>
))}
</div>
</div>
<div className={styles.sb__track} ref={trackRef}>
<div
className={styles.sb__thumb}
ref={thumbRef}
role="scrollbar"
aria-orientation="vertical"
aria-valuenow={0}
aria-valuemin={0}
aria-valuemax={100}
tabIndex={0}
/>
</div>
</div>
</div>
</main>
);
}08 · 손잡이 길이 값이 남은 분량을 그대로 보여주는 스크롤바 — 측정 누적면적 27.411% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function syncThumb() {
var trackLen = track.clientHeight;
var total = viewport.scrollHeight;
var visible = viewport.clientHeight;
var lengthRatio = clamp(visible / total, 0, 1);
var thumbLen = clamp(trackLen * lengthRatio, THUMB_MIN_PX, trackLen);
var ms = maxScroll();
var scrollRatio = ms > 0 ? viewport.scrollTop / ms : 0;
var thumbPos = (trackLen - thumbLen) * scrollRatio;
thumb.style.height = thumbLen.toFixed(1) + 'px';
thumb.style.transform = 'translateY(' + thumbPos.toFixed(1) + 'px)';
thumb.setAttribute('aria-valuenow', String(Math.round(scrollRatio * 100)));
var remaining = Math.round((1 - scrollRatio) * 100);
badge.textContent = '남은 분량 ' + clamp(remaining, 0, 100) + '%';
}
측정: 누적면적 27.4% · 강도 평균 48.0 · 23/23 프레임에서 움직였다.
09여러 값이 손잡이 하나 위에서 함께 반응하는 히어로 스크롤바
광택 값(02)·관성 감쇠 값(03)·경계 반발 값(04) 세 계산을 같은 손잡이 위에 그대로 포갰을 뿐, 새 계산은 하나도 더하지 않았다. 손을 놓으면 관성으로 미끄러지다 끝에서 반발로 튕기고, 호버하면 그 와중에도 광택이 따로 한 번 지나간다.

<main class="stage">
<div class="sb is-demo" id="sb">
<div class="sb__viewport" id="sbViewport">
<div class="sb__content" id="sbContent"></div>
</div>
<div class="sb__track" id="sbTrack">
<div class="sb__thumb" id="sbThumb" role="scrollbar" aria-orientation="vertical" aria-controls="sbViewport" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" tabindex="0">
<span class="sb__thumbGloss" id="sbGloss"></span>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 02 의 광택 위치 값 · 03 의 관성 감쇠 값(SCROLLBAR_INERTIA_DECAY) · 04 의
// 경계 반발 값(BOUNDARY_REBOUND_PX) 을 손잡이 하나에 겹쳐 스크롤 자체를 장면으로 만든다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-ink);
.sb {
display: flex;
gap: $sp-2;
width: min(90vw, 320px);
height: min(58vw, 186px);
}
.sb__viewport {
flex: 1;
min-width: 0;
height: 100%;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
border-radius: $r-card;
background: radial-gradient(140% 120% at 20% 0%, rgba($subject-blue, .16), transparent 60%), rgba(255, 255, 255, .06);
}
.sb__viewport::-webkit-scrollbar { display: none; }
.sb__content {
display: flex;
flex-direction: column;
gap: $sp-2;
padding: $sp-2;
}
.sb__card {
border-radius: $r-sm;
padding: $sp-2 $sp-3;
background: rgba(255, 255, 255, .08);
}
.sb__cardTitle {
margin: 0 0 $sp-1;
color: $color;
font-weight: 700;
font-size: 12px;
}
.sb__cardLine {
margin: 0;
color: rgba(255, 247, 230, .68);
font-size: 10px;
line-height: 1.4;
}
.sb__track {
position: relative;
width: $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .1);
}
.sb__thumb {
position: absolute;
top: 0;
left: 0;
width: 100%;
overflow: hidden;
border-radius: $r-pill;
background: linear-gradient(180deg, $subject-blue, $subject-lilac);
box-shadow: 0 0 14px rgba($subject-blue, .5);
transition: none;
cursor: grab;
}
.sb__thumb:focus-visible {
outline: 2px solid $subject-cream;
outline-offset: 2px;
}
.sb__thumbGloss {
position: absolute;
top: -20%;
left: -60%;
width: 40%;
height: 140%;
background: linear-gradient(115deg, transparent, rgba(255, 255, 255, .85), transparent);
transform: translateY(-120%);
pointer-events: none;
}
.sb__thumbGloss.is-sweeping {
animation: heroGlossSweep 520ms linear;
}
@keyframes heroGlossSweep {
0% { transform: translateY(-120%); }
100% { transform: translateY(220%); }
}
@media (max-width: 340px) {
.sb { width: min(96vw, 270px); height: 152px; }
.sb__cardTitle { font-size: 11px; }
.sb__cardLine { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
.sb__thumb { transition: none; }
.sb__thumbGloss.is-sweeping { animation: none; }
}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);
}
.sb {
display: flex;
gap: 8px;
width: min(90vw, 320px);
height: min(58vw, 186px);
}
.sb__viewport {
flex: 1;
min-width: 0;
height: 100%;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
border-radius: 16px;
background: radial-gradient(140% 120% at 20% 0%, rgba(47, 109, 246, 0.16), transparent 60%), rgba(255, 255, 255, 0.06);
}
.sb__viewport::-webkit-scrollbar {
display: none;
}
.sb__content {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
}
.sb__card {
border-radius: 8px;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.08);
}
.sb__cardTitle {
margin: 0 0 2px;
color: #fff7e6;
font-weight: 700;
font-size: 12px;
}
.sb__cardLine {
margin: 0;
color: rgba(255, 247, 230, 0.68);
font-size: 10px;
line-height: 1.4;
}
.sb__track {
position: relative;
width: 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
}
.sb__thumb {
position: absolute;
top: 0;
left: 0;
width: 100%;
overflow: hidden;
border-radius: 999px;
background: linear-gradient(180deg, #2f6df6, #b9a0ff);
box-shadow: 0 0 14px rgba(47, 109, 246, 0.5);
transition: none;
cursor: grab;
}
.sb__thumb:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.sb__thumbGloss {
position: absolute;
top: -20%;
left: -60%;
width: 40%;
height: 140%;
background: linear-gradient(115deg, transparent, rgba(255, 255, 255, 0.85), transparent);
transform: translateY(-120%);
pointer-events: none;
}
.sb__thumbGloss.is-sweeping {
animation: heroGlossSweep 520ms linear;
}
@keyframes heroGlossSweep {
0% {
transform: translateY(-120%);
}
100% {
transform: translateY(220%);
}
}
@media (max-width: 340px) {
.sb {
width: min(96vw, 270px);
height: 152px;
}
.sb__cardTitle {
font-size: 11px;
}
.sb__cardLine {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.sb__thumb {
transition: none;
}
.sb__thumbGloss.is-sweeping {
animation: none;
}
}(function () {
var THUMB_MIN_PX = 26;
var KEY_STEP_PX = 32;
var SCROLLBAR_INERTIA_DECAY = 0.92;
var VEL_EPS = 0.4;
var SIM_KICK_PX = 34;
var BOUNDARY_REBOUND_PX = 12;
var SPRING_DECAY = 0.7;
var OVERSHOOT_EPS = 0.3;
var GLOSS_SWEEP_MS = 520;
var root = document.getElementById('sb');
var viewport = document.getElementById('sbViewport');
var content = document.getElementById('sbContent');
var track = document.getElementById('sbTrack');
var thumb = document.getElementById('sbThumb');
var gloss = document.getElementById('sbGloss');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
for (var i = 1; i <= 8; i++) {
var card = document.createElement('div');
card.className = 'sb__card';
var h = document.createElement('p');
h.className = 'sb__cardTitle';
h.textContent = '장면 ' + i;
var p = document.createElement('p');
p.className = 'sb__cardLine';
p.textContent = '광택 값·관성 값·반발 값이 손잡이 하나 위에서 함께 반응합니다.';
card.appendChild(h);
card.appendChild(p);
content.appendChild(card);
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function maxScroll() { return Math.max(0, viewport.scrollHeight - viewport.clientHeight); }
var overshoot = 0;
function paintThumb() {
var trackLen = track.clientHeight;
var total = viewport.scrollHeight;
var visible = viewport.clientHeight;
var thumbLen = clamp(trackLen * (visible / total), THUMB_MIN_PX, trackLen);
var ms = maxScroll();
var ratio = ms > 0 ? viewport.scrollTop / ms : 0;
var thumbPos = (trackLen - thumbLen) * ratio + overshoot;
thumb.style.height = thumbLen.toFixed(1) + 'px';
thumb.style.transform = 'translateY(' + thumbPos.toFixed(1) + 'px)';
thumb.setAttribute('aria-valuenow', String(Math.round(ratio * 100)));
}
var sweeping = false;
function triggerGloss() {
if (reduce || sweeping) return;
sweeping = true;
gloss.classList.add('is-sweeping');
}
gloss.addEventListener('animationend', function () {
gloss.classList.remove('is-sweeping');
sweeping = false;
});
viewport.addEventListener('scroll', paintThumb);
thumb.addEventListener('pointerenter', function () { triggerGloss(); });
thumb.addEventListener('focus', function () { triggerGloss(); });
var dragging = false;
var dragStartY = 0;
var dragStartScroll = 0;
var lastMoveY = 0;
var vel = 0;
thumb.addEventListener('pointerdown', function (e) {
dragging = true;
vel = 0;
root.classList.remove('is-demo');
dragStartY = e.clientY;
lastMoveY = e.clientY;
dragStartScroll = viewport.scrollTop;
thumb.setPointerCapture(e.pointerId);
});
thumb.addEventListener('pointermove', function (e) {
if (!dragging) return;
var trackLen = track.clientHeight;
var thumbLen = thumb.clientHeight;
var moveRange = Math.max(1, trackLen - thumbLen);
var scale = maxScroll() / moveRange;
var frameDeltaPx = (e.clientY - lastMoveY) * scale;
vel = frameDeltaPx;
lastMoveY = e.clientY;
var rawTarget = dragStartScroll + (e.clientY - dragStartY) * scale;
var ms = maxScroll();
viewport.scrollTop = clamp(rawTarget, 0, ms);
if (reduce) { overshoot = 0; }
else if (rawTarget < 0) { overshoot = clamp(rawTarget, -BOUNDARY_REBOUND_PX, 0); }
else if (rawTarget > ms) { overshoot = clamp(rawTarget - ms, 0, BOUNDARY_REBOUND_PX); }
else { overshoot = 0; }
paintThumb();
});
function coastStep() {
if (Math.abs(vel) <= VEL_EPS) { vel = 0; return; }
vel = vel * SCROLLBAR_INERTIA_DECAY;
var ms = maxScroll();
var next = viewport.scrollTop + vel;
if (!reduce && (next < 0 || next > ms)) {
overshoot = next < 0 ? clamp(next, -BOUNDARY_REBOUND_PX, 0) : clamp(next - ms, 0, BOUNDARY_REBOUND_PX);
}
viewport.scrollTop = clamp(next, 0, ms);
}
function springBack() {
if (Math.abs(overshoot) <= OVERSHOOT_EPS) { overshoot = 0; return; }
overshoot = overshoot * SPRING_DECAY;
}
thumb.addEventListener('pointerup', function () { dragging = false; });
thumb.addEventListener('pointercancel', function () { dragging = false; vel = 0; });
track.addEventListener('pointerdown', function (e) {
if (e.target !== track) return;
root.classList.remove('is-demo');
var rect = track.getBoundingClientRect();
var trackLen = track.clientHeight;
var thumbLen = thumb.clientHeight;
var clickPos = clamp(e.clientY - rect.top - thumbLen / 2, 0, trackLen - thumbLen);
var ratio = (trackLen - thumbLen) > 0 ? clickPos / (trackLen - thumbLen) : 0;
viewport.scrollTop = ratio * maxScroll();
});
thumb.addEventListener('keydown', function (e) {
var ms = maxScroll();
if (e.key === 'ArrowDown') { viewport.scrollTop = clamp(viewport.scrollTop + KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === 'ArrowUp') { viewport.scrollTop = clamp(viewport.scrollTop - KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === 'PageDown') { viewport.scrollTop = clamp(viewport.scrollTop + viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === 'PageUp') { viewport.scrollTop = clamp(viewport.scrollTop - viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === 'Home') { viewport.scrollTop = 0; e.preventDefault(); }
else if (e.key === 'End') { viewport.scrollTop = ms; e.preventDefault(); }
if (e.key.indexOf('Arrow') === 0 || e.key === 'PageDown' || e.key === 'PageUp' || e.key === 'Home' || e.key === 'End') {
root.classList.remove('is-demo');
vel = 0;
}
});
var startTime = null;
var nextKickAt = 0;
var kickSign = 1;
var lastSweepAt = -10;
function demoTick(now) {
if (startTime === null) { startTime = now; }
var t = (now - startTime) / 1000;
if (root.classList.contains('is-demo') && !dragging) {
if (t >= nextKickAt) {
vel = SIM_KICK_PX * kickSign;
kickSign = -kickSign;
nextKickAt = t + 0.85;
triggerGloss();
}
if (t - lastSweepAt >= 0.85) { lastSweepAt = t; }
}
if (!dragging) {
if (vel !== 0) { coastStep(); }
else if (overshoot !== 0) { springBack(); }
}
paintThumb();
requestAnimationFrame(demoTick);
}
paintThumb();
if (!reduce) { requestAnimationFrame(demoTick); }
})();// ScrollbarHeroScene — vanilla 판(run/411/demo/09-scrollbar-hero-scene/index.html) 의 React 포트.
// 핵심: 02 의 광택 위치 값 · 03 의 관성 감쇠 값(SCROLLBAR_INERTIA_DECAY) · 04 의 경계 반발 값(BOUNDARY_REBOUND_PX) 을 손잡이 하나에 겹친다.
import { useEffect, useRef } from "react";
import styles from "./ScrollbarHeroScene.module.scss";
type Box<T> = { current: T };
const THUMB_MIN_PX = 26;
const KEY_STEP_PX = 32;
const SCROLLBAR_INERTIA_DECAY = 0.92;
const VEL_EPS = 0.4;
const SIM_KICK_PX = 34;
const BOUNDARY_REBOUND_PX = 12;
const SPRING_DECAY = 0.7;
const OVERSHOOT_EPS = 0.3;
const GLOSS_SWEEP_MS = 520;
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
export default function ScrollbarHeroScene() {
const viewportRef = useRef<HTMLDivElement | null>(null);
const trackRef = useRef<HTMLDivElement | null>(null);
const thumbRef = useRef<HTMLDivElement | null>(null);
const glossRef = useRef<HTMLSpanElement | null>(null);
const demoRef = useRef<boolean>(true) as Box<boolean>;
const draggingRef = useRef<boolean>(false) as Box<boolean>;
const dragStartYRef = useRef<number>(0) as Box<number>;
const dragStartScrollRef = useRef<number>(0) as Box<number>;
const lastMoveYRef = useRef<number>(0) as Box<number>;
const velRef = useRef<number>(0) as Box<number>;
const overshootRef = useRef<number>(0) as Box<number>;
const sweepingRef = useRef<boolean>(false) as Box<boolean>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const viewport = viewportRef.current!;
const track = trackRef.current!;
const thumb = thumbRef.current!;
const gloss = glossRef.current!;
if (!viewport || !track || !thumb || !gloss) return;
function maxScroll(): number {
return Math.max(0, viewport.scrollHeight - viewport.clientHeight);
}
function paintThumb(): void {
const trackLen = track.clientHeight;
const total = viewport.scrollHeight;
const visible = viewport.clientHeight;
const thumbLen = clamp(trackLen * (visible / total), THUMB_MIN_PX, trackLen);
const ms = maxScroll();
const ratio = ms > 0 ? viewport.scrollTop / ms : 0;
const thumbPos = (trackLen - thumbLen) * ratio + overshootRef.current;
thumb.style.height = thumbLen.toFixed(1) + "px";
thumb.style.transform = "translateY(" + thumbPos.toFixed(1) + "px)";
thumb.setAttribute("aria-valuenow", String(Math.round(ratio * 100)));
}
function triggerGloss(): void {
if (reduce || sweepingRef.current) return;
sweepingRef.current = true;
gloss.classList.add("is-sweeping");
}
function onGlossAnimationEnd(): void {
gloss.classList.remove("is-sweeping");
sweepingRef.current = false;
}
function onScroll(): void { paintThumb(); }
function onThumbPointerEnter(): void { triggerGloss(); }
function onThumbFocus(): void { triggerGloss(); }
function onThumbPointerDown(e: PointerEvent): void {
draggingRef.current = true;
velRef.current = 0;
demoRef.current = false;
dragStartYRef.current = e.clientY;
lastMoveYRef.current = e.clientY;
dragStartScrollRef.current = viewport.scrollTop;
thumb.setPointerCapture(e.pointerId);
}
function onThumbPointerMove(e: PointerEvent): void {
if (!draggingRef.current) return;
const trackLen = track.clientHeight;
const thumbLen = thumb.clientHeight;
const moveRange = Math.max(1, trackLen - thumbLen);
const scale = maxScroll() / moveRange;
const frameDeltaPx = (e.clientY - lastMoveYRef.current) * scale;
velRef.current = frameDeltaPx;
lastMoveYRef.current = e.clientY;
const rawTarget = dragStartScrollRef.current + (e.clientY - dragStartYRef.current) * scale;
const ms = maxScroll();
viewport.scrollTop = clamp(rawTarget, 0, ms);
if (reduce) { overshootRef.current = 0; }
else if (rawTarget < 0) { overshootRef.current = clamp(rawTarget, -BOUNDARY_REBOUND_PX, 0); }
else if (rawTarget > ms) { overshootRef.current = clamp(rawTarget - ms, 0, BOUNDARY_REBOUND_PX); }
else { overshootRef.current = 0; }
paintThumb();
}
function coastStep(): void {
if (Math.abs(velRef.current) <= VEL_EPS) { velRef.current = 0; return; }
velRef.current = velRef.current * SCROLLBAR_INERTIA_DECAY;
const ms = maxScroll();
const next = viewport.scrollTop + velRef.current;
if (!reduce && (next < 0 || next > ms)) {
overshootRef.current = next < 0 ? clamp(next, -BOUNDARY_REBOUND_PX, 0) : clamp(next - ms, 0, BOUNDARY_REBOUND_PX);
}
viewport.scrollTop = clamp(next, 0, ms);
}
function springBack(): void {
if (Math.abs(overshootRef.current) <= OVERSHOOT_EPS) { overshootRef.current = 0; return; }
overshootRef.current = overshootRef.current * SPRING_DECAY;
}
function onThumbPointerUp(): void { draggingRef.current = false; }
function onThumbPointerCancel(): void { draggingRef.current = false; velRef.current = 0; }
function onTrackPointerDown(e: PointerEvent): void {
if (e.target !== track) return;
demoRef.current = false;
const rect = track.getBoundingClientRect();
const trackLen = track.clientHeight;
const thumbLen = thumb.clientHeight;
const clickPos = clamp(e.clientY - rect.top - thumbLen / 2, 0, trackLen - thumbLen);
const ratio = (trackLen - thumbLen) > 0 ? clickPos / (trackLen - thumbLen) : 0;
viewport.scrollTop = ratio * maxScroll();
}
function onThumbKeyDown(e: KeyboardEvent): void {
const ms = maxScroll();
if (e.key === "ArrowDown") { viewport.scrollTop = clamp(viewport.scrollTop + KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === "ArrowUp") { viewport.scrollTop = clamp(viewport.scrollTop - KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === "PageDown") { viewport.scrollTop = clamp(viewport.scrollTop + viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === "PageUp") { viewport.scrollTop = clamp(viewport.scrollTop - viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === "Home") { viewport.scrollTop = 0; e.preventDefault(); }
else if (e.key === "End") { viewport.scrollTop = ms; e.preventDefault(); }
if (e.key.indexOf("Arrow") === 0 || e.key === "PageDown" || e.key === "PageUp" || e.key === "Home" || e.key === "End") {
demoRef.current = false;
velRef.current = 0;
}
}
let startTime: number | null = null;
let nextKickAt = 0;
let kickSign = 1;
let lastSweepAt = -10;
let frame = 0;
function demoTick(now: number): void {
if (startTime === null) { startTime = now; }
const t = (now - startTime) / 1000;
if (demoRef.current && !draggingRef.current) {
if (t >= nextKickAt) {
velRef.current = SIM_KICK_PX * kickSign;
kickSign = -kickSign;
nextKickAt = t + 0.85;
triggerGloss();
}
if (t - lastSweepAt >= 0.85) { lastSweepAt = t; }
}
if (!draggingRef.current) {
if (velRef.current !== 0) { coastStep(); }
else if (overshootRef.current !== 0) { springBack(); }
}
paintThumb();
frame = requestAnimationFrame(demoTick);
}
viewport.addEventListener("scroll", onScroll);
thumb.addEventListener("pointerenter", onThumbPointerEnter);
thumb.addEventListener("focus", onThumbFocus);
gloss.addEventListener("animationend", onGlossAnimationEnd);
thumb.addEventListener("pointerdown", onThumbPointerDown);
thumb.addEventListener("pointermove", onThumbPointerMove);
thumb.addEventListener("pointerup", onThumbPointerUp);
thumb.addEventListener("pointercancel", onThumbPointerCancel);
track.addEventListener("pointerdown", onTrackPointerDown);
thumb.addEventListener("keydown", onThumbKeyDown);
paintThumb();
if (!reduce) { frame = requestAnimationFrame(demoTick); }
return () => {
viewport.removeEventListener("scroll", onScroll);
thumb.removeEventListener("pointerenter", onThumbPointerEnter);
thumb.removeEventListener("focus", onThumbFocus);
gloss.removeEventListener("animationend", onGlossAnimationEnd);
thumb.removeEventListener("pointerdown", onThumbPointerDown);
thumb.removeEventListener("pointermove", onThumbPointerMove);
thumb.removeEventListener("pointerup", onThumbPointerUp);
thumb.removeEventListener("pointercancel", onThumbPointerCancel);
track.removeEventListener("pointerdown", onTrackPointerDown);
thumb.removeEventListener("keydown", onThumbKeyDown);
if (frame) cancelAnimationFrame(frame);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.sb}>
<div className={styles.sb__viewport} ref={viewportRef}>
<div className={styles.sb__content}>
{Array.from({ length: 8 }, (_, idx) => idx + 1).map((i) => (
<div className={styles.sb__card} key={i}>
<p className={styles.sb__cardTitle}>{"장면 " + i}</p>
<p className={styles.sb__cardLine}>광택 값·관성 값·반발 값이 손잡이 하나 위에서 함께 반응합니다.</p>
</div>
))}
</div>
</div>
<div className={styles.sb__track} ref={trackRef}>
<div
className={styles.sb__thumb}
ref={thumbRef}
role="scrollbar"
aria-orientation="vertical"
aria-valuenow={0}
aria-valuemin={0}
aria-valuemax={100}
tabIndex={0}
>
<span className={styles.sb__thumbGloss} ref={glossRef} />
</div>
</div>
</div>
</main>
);
}09 · 여러 값이 손잡이 하나 위에서 함께 반응하는 히어로 스크롤바 — 측정 누적면적 36.332% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function coastStep() {
if (Math.abs(vel) <= VEL_EPS) { vel = 0; return; }
vel = vel * SCROLLBAR_INERTIA_DECAY;
var ms = maxScroll();
var next = viewport.scrollTop + vel;
if (!reduce && (next < 0 || next > ms)) {
overshoot = next < 0 ? clamp(next, -BOUNDARY_REBOUND_PX, 0) : clamp(next - ms, 0, BOUNDARY_REBOUND_PX);
}
viewport.scrollTop = clamp(next, 0, ms);
}
function springBack() {
if (Math.abs(overshoot) <= OVERSHOOT_EPS) { overshoot = 0; return; }
overshoot = overshoot * SPRING_DECAY;
}
측정: 누적면적 36.3% · 강도 평균 66.4 · 23/23 프레임에서 움직였다.
아홉 손잡이를 숫자로 겹쳐 보면 무엇이 드러나는가
| NN 이름 | 누적면적 % | 강도 평균 | x/23 |
|---|---|---|---|
| 01 굵기 값이 트랙을 채우는 기본 손잡이 | 33.4 | 56.0 | 23/23 |
| 02 커서가 올라오면 광택이 쓸고 지나가는 손잡이 | 33.4 | 43.9 | 23/23 |
| 03 손을 놓아도 관성 값만큼 더 미끄러지는 손잡이 | 33.6 | 57.3 | 23/23 |
| 04 끝에 닿으면 밀려났다 튕겨 돌아오는 손잡이 | 13.8 | 144.1 | 12/23 |
| 05 스크롤 위치 값에 따라 색이 옮겨가는 손잡이 | 33.3 | 59.8 | 23/23 |
| 06 카드 개수 값에 맞춰 늘어나는 가로 트랙 | 26.7 | 55.1 | 23/23 |
| 07 멈추면 투명도 값이 줄어 사라지는 오버레이 손잡이 | 21.9 | 78.6 | 13/23 |
| 08 손잡이 길이 값이 남은 분량을 그대로 보여주는 스크롤바 | 27.4 | 48.0 | 23/23 |
| 09 여러 값이 손잡이 하나 위에서 함께 반응하는 히어로 스크롤바 | 36.3 | 66.4 | 23/23 |
04와 07만 23프레임을 다 채우지 못한다. 04는 끝에 닿아야만 반발 계산이 켜지니 화면 가운데를 오가는 프레임엔 값이 비어 있고, 07은 반대로 스크롤이 멈춘 뒤에야 투명도가 움직이니 계속 스크롤 중인 프레임엔 값이 없다. 강도 평균은 그런데도 04가 가장 높다 — 반발 거리 12px가 12프레임이라는 짧은 구간 안에서만 몰아치기 때문이다.
값 하나만 바꾸면 반응이 어떻게 갈라지는가
같은 자리 값 하나만 옮겨 보면 각 항목이 무엇으로 움직이는지 더 또렷하게 드러난다.
| 항목 | 바꾸는 값 | 줄이면 | 늘리면 |
|---|---|---|---|
| 03 | SCROLLBAR_INERTIA_DECAY 0.92 → 0.98 | 손을 놓자마자 손잡이가 곧바로 멎어 관성이 거의 안 느껴진다 | 놓은 뒤에도 한참을 더 흘러가듯 미끄러진다 |
| 04 | BOUNDARY_REBOUND_PX 12 → 32 | 끝에 닿아도 손잡이가 살짝만 물러났다 돌아온다 | 끝에서 크게 튀어나갔다가 요란하게 튕겨 돌아온다 |
| 07 | OVERLAY_HIDE_DELAY_MS 600 → 1800 | 스크롤을 멈추자마자 손잡이가 서둘러 옅어진다 | 한참 그대로 떠 있다가 뒤늦게 옅어진다 |
어디서 깨지는가 — 클릭이 진짜 손잡이를 빗나가는 함정
네이티브 스크롤바를 지우려면 표준 속성 하나(scrollbar-width)와 엔진별 접두사 선택자를 함께 써야 하는데, 지우는 순간부터 드래그·키보드·포커스 링까지 전부 직접 만든 값으로 대신 책임져야 한다. 흔한 함정은 장식으로 얹은 요소가 진짜 손잡이 위를 가리는 경우다 — 02의 광택 띠는 pointer-events 값을 none 으로 꺼 두지 않으면 광택이 지나가는 순간 클릭이 손잡이가 아니라 광택 레이어에 먼저 맞는다. 직접 만든 아홉 개와 압축 파일 안 소스가 같은지 궁금하면, 압축을 열 때 wn4b2gqq 을 넣어 scss·js 를 그대로 대조해 보면 된다.
손잡이 크기 계산(syncThumb·syncThumbX)은 scroll 이벤트에만 물려 있다 — 이미지가 늦게 채워지는 등 스크롤 없이 콘텐츠 높이만 바뀌면 손잡이 길이가 그대로 남고 다음 스크롤이 일어나야 갱신된다. 콘텐츠가 늦게 채워지는 화면에 붙일 땐 준비가 끝난 시점에 같은 계산 함수를 한 번 더 불러 주는 값을 직접 추가해야 한다.
실무에서는 이 아홉 개를 어디에 붙이나
css 스크롤바 만들기를 검색해 처음 여기까지 왔다면 01의 두 값(두께·반경)만 손대도 감이 잡힌다. 사이드 내비게이션이나 긴 설정 패널처럼 세로로만 길어지는 화면에는 01이나 03이면 충분하고, 이미지·카드가 가로로 넘치는 갤러리에는 06을 그대로 깐다. 문서형 페이지처럼 어디까지 읽었는지가 중요한 화면에는 05나 08처럼 위치·비율 값을 손잡이 자체로 드러내는 쪽이 정보를 더 준다. 지도나 캔버스처럼 화면을 가리면 안 되는 곳엔 07의 오버레이형을 얹고, css 스크롤바 커스텀 요구가 인터랙션까지 포함한다면 09처럼 값을 겹쳐 쓰면 된다.
다른 스크롤 편과 어디서 갈라지는가
관성 스크롤 9종은 스크롤 위치 값 자체에 관성을 먹여 콘텐츠가 화면 안에서 흔들리거나 밀리는 편이고, 여기 03·04·09는 그 관성·반발 계산을 콘텐츠가 아니라 손잡이 하나에만 건다. 뷰 타임라인 전환은 스크롤 구간 값으로 요소 전환을 만드는 편이라 손잡이가 아예 등장하지 않는다. 스크롤 인디케이터 9종은 읽기 진행률을 눈에만 보여줄 뿐 손으로 잡아 끌 수 없는데, 여기 아홉 개는 모두 pointer 이벤트로 드래그하고 role="scrollbar"로 키보드까지 받는다는 점이 다르다. css 스크롤바 항상 보이게 두고 싶다면 07의 지연 값(OVERLAY_HIDE_DELAY_MS)을 아주 크게 잡아 오버레이가 거의 사라지지 않게 하면 된다. css 스크롤바 위치 변경까지 필요하다면 트랙 요소를 반대쪽 절대 위치로 옮기기만 하면 되고, 움직이는 값 자체는 01과 같다.
접근성 — 아홉 개가 reduced-motion 에서 남기는 것
01·03·04·09는 자동 재생 루프(requestAnimationFrame(demoTick))를 reduced-motion 이면 아예 시작하지 않는다 — 속도를 늦추는 게 아니라 처음부터 돌리지 않는 쪽이다. 03은 그래서 손을 놓은 뒤의 관성 미끄러짐까지 함께 사라지고 드래그 중 직접 반응만 남으며, 09도 같은 이유로 광택·관성·반발 세 계산이 전부 멈춘 채 손끝이 움직인 만큼만 반응한다. 07은 애니메이션 대신 값 자체를 바꾼다 — 경과 시간이 지연 값을 넘으면 서서히 옅어지는 대신 투명도가 곧장 0으로 떨어진다. 아홉 개 모두 손잡이에 role="scrollbar" aria-orientation aria-controls aria-valuenow·valuemin·valuemax 를 달고 tabindex="0" 으로 포커스를 받아, 방향키·Page Up·Page Down·Home·End 만으로 마우스 없이 스크롤할 수 있다. 탭으로 손잡이에 포커스가 오면 :focus-visible 규칙이 윤곽선(outline)을 그려 지금 키보드가 어느 손잡이를 쥐고 있는지 놓치지 않게 한다(MDN, prefers-reduced-motion).
손잡이를 직접 만들면 정말 느려질까 — 자주 나오는 질문 셋
손잡이를 직접 만들면 네이티브보다 느려지지 않나?
아니다. 손잡이는 트랙 위에 올려 둔 요소 하나를 transform 으로만 옮기므로 레이아웃을 다시 계산하지 않는다. 아홉 개 모두 위치·크기를 style.transform·style.height(또는 width)로만 바꾸고 나머지 값은 CSS 트랜지션에 맡긴다.
마우스 휠로 스크롤해도 손잡이가 따라오나?
그렇다. 손잡이 위치는 스크롤 이벤트 종류를 따지지 않고 viewport 의 실제 scrollTop·scrollLeft 값에서 매번 다시 계산(syncThumb·syncThumbX)하므로 휠·터치·키보드 어느 입력으로 스크롤해도 같은 값을 따라간다.
터치 기기에서도 같은 손잡이를 그대로 쓸 수 있나?
쓸 수 있다. pointerdown·pointermove·pointerup 이벤트로 짰기 때문에 마우스든 터치든 같은 코드가 반응한다. 다만 손가락은 커서보다 굵으니 트랙 두께 값을 조금 키워 주는 편이 잡기 쉽다 — 값 하나만 바꾸면 되는 부분이다. 가로형(06)도 마찬가지로 손가락 하나로 가로 스크롤과 손잡이 드래그를 같은 pointer 이벤트가 함께 처리하므로 별도의 터치 전용 코드를 더 짤 필요는 없다. 노트북 트랙패드처럼 휠 이벤트가 아주 잘게 쪼개져 들어오는 입력 장치에서도 손잡이는 scroll 이벤트가 일어난 뒤의 값만 읽으므로 입력 장치 종류를 가리지 않고 같은 계산을 반복한다.









