css スクロールバー デザイン 9パターン — 慣性と反発で動く
css スクロールバー デザインでは、指先がトラックの端に触れた一瞬、つまみが少し押し出されて跳ね返る。3番はその瞬間、慣性の値で数フレーム余分に滑らせ、4番は反発の値で跳ね返らせ、9番はその二つの値を一つのつまみの上で重ねる。
04 scrollbar-boundary-elastic-overshoot
06 scrollbar-horizontal-track-gallery
07 scrollbar-floating-overlay-autohide
09 scrollbar-hero-scene
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 太さ値がトラックを満たす基本のつまみ
- 02 カーソルが乗ると光沢が通り過ぎるつまみ
- 03 手を離しても慣性値の分だけ滑り続けるつまみ
- 04 端に当たると押し出されて跳ね返るつまみ
- 05 スクロール位置値で色が移り変わるつまみ
- 06 カード数値に合わせて伸びる横トラック
- 07 止まると透明度値が下がり消えるオーバーレイつまみ
- 08 つまみ長さ値が残り分量をそのまま示すスクロールバー
- 09 複数の値が一つのつまみの上で共に反応するヒーロースクロールバー
9つのカスタムスクロールバーは、どれだけつまみが動くかで並んでいます
以下の9つのデモはすべて、480×300の8枚カードリストの上で同じ自作ハンドル——transform: translateY() で位置を、height で長さを決める <div> で、ブラウザ既定のスクロールバーの箱ではありません——を動かします。並び順には理由があります。1番は他の8つが拡張していく基本のつまみ、2番から5番まではそれぞれ一つずつ反応する値(光沢位置・離脱速度・押し出し距離・グラデーション停止点)を足していき、6番は同じ計算を横トラックに向け、7番と8番は位置ではなく時間と比率を読み、9番はここまでの値を3つ重ねて一つのハンドルに乗せます。
01太さ値がトラックを満たす基本のつまみ
このパターンに必要な数値は太さ値と角丸値の二つだけです——THUMB_MIN_PX はリストが長くなってもつまみがドラッグできない大きさまで縮まないよう下限を保証し、syncThumb() はスクロールのたびにトラック自身の clientHeight からつまみの高さと位置を計算し直します。
<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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);
実測: 累積面積33.4%、平均強度56.0、23/23 フレームで動いた。
02カーソルが乗ると光沢が通り過ぎるつまみ
GLOSS_SWEEP_MS は光の反射レイヤーがつまみの長さを一度だけ通り過ぎるのにかかる時間を決め、triggerGloss() はそのスイープを発火させていいかどうかを判定するゲートです——スイープの途中では再始動を拒み、prefers-reduced-motion が設定されていれば最初から拒みます。これはまさに、さりげないホバー演出と目障りな演出を分ける css スクロールバー カスタマイズ の細部です。
<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function triggerGloss() {
if (reduce) return;
if (sweeping) return;
sweeping = true;
gloss.classList.add('is-sweeping');
}
実測: 累積面積33.4%、平均強度43.9、23/23 フレームで動いた。
03手を離しても慣性値の分だけ滑り続けるつまみ
このパターンが動かす数値は SCROLLBAR_INERTIA_DECAY ひとつです。ドラッグしていたつまみを離した瞬間の速度値が、アニメーションフレームごとにこの減衰係数だけ掛け合わされていくため、ポインターが離れた瞬間にぴたりと止まるのではなく、あと数フレームだけ滑り続けます。
<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 はつまみがトラックの端をどれだけ押し出すかを決める値で、その押し出し値は springBack() によって SPRING_DECAY ずつ毎フレーム縮んでいき、小さな閾値を下回った時点で「もう動かすものがない」と関数自身が報告します。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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; }
実測: 累積面積13.8%、平均強度144.1、12/23 フレームで動いた。
05スクロール位置値で色が移り変わるつまみ
applyPositionColor() は生のスクロール位置と描画の間に挟まる計算レイヤーです。syncThumb() がつまみの高さのために計算している0から1の同じ比率値をそのまま受け取り、つまみのグラデーションとトラックの発光が両方参照するCSSカスタムプロパティに渡すので、読み手は css スクロールバー 色 だけでおおよその現在地を推測できます。
<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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カード数値に合わせて伸びる横トラック
CARD_COUNT がスクロール可能な行の幅そのものを決め、syncThumbX() は1番と同じ比率計算をX軸に回転させただけの関数です——clientWidth・scrollWidth・scrollLeft が縦方向の対になる値を置き換え、つまみは translateY() の代わりに translateX() で動きます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 は最後のスクロールイベントからつまみが完全に見えたままでいる時間で、その時間を過ぎると OVERLAY_FADE_MS が透明度を1から0まで一気にではなく一定の幅で下げていきます。新しいスクロールイベントが来れば、経過時間値も透明度値もその場でゼロに戻ります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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);
実測: 累積面積21.9%、平均強度78.6、13/23 フレームで動いた。
08つまみ長さ値が残り分量をそのまま示すスクロールバー
他の全パターンがつまみの長さを決めるのに使っているのと同じ高さの比率値が、ここではそのまま平易な数字も動かします。remaining がスクロール比率を反転させてパーセント表示に変え、バッジに書き込むので、縮んでいくつまみの長さと減っていくバッジの数字が同じ話を二通りに語ります。
<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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複数の値が一つのつまみの上で共に反応するヒーロースクロールバー
paintThumb() は1番の syncThumb() に一行だけ足したものです——4番のばね計算が生み出す押し出し値を、スクロール比率と一緒にそのままつまみの位置へ折り込みます——同じファイルの中では2番の光沢スイープのゲートと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">
<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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)));
}
実測: 累積面積36.3%、平均強度66.4、23/23 フレームで動いた。
実際にどれだけ動いたか — 9つのつまみの測定
| 項目 | 累積面積 | 平均強度 | 動いたフレーム |
|---|---|---|---|
| 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 ヒーローシーン3値重ね | 36.3% | 66.4 | 23/23 |
9つの中で平均強度が最も高いのは4番の144.1、動いたフレーム数が最も少ないのも4番の12/23です——反発はスクロール比率が0か1に触れた直後の短い区間でしか跳ねないので、変化する画素はサイクルの大半に薄く広がる1・3・5・6・8・9番とは違い、短く強く動きます。
値がつまみの反応を変える場所 — 慣性減衰・境界反発・オーバーレイ遅延
9つのパターンのうち、重みの大半を負っている定数は3つです。SCROLLBAR_INERTIA_DECAY(3番と9番)は速度に掛かるフレームごとの乗数なので、動いているつまみを減速させることしかできず、止まっているつまみを押し出すことはできません。BOUNDARY_REBOUND_PX(4番と9番)はスクロール比率が0か1に達したちょうどそのフレームに一度だけ加わる固定ピクセル距離で、その後 SPRING_DECAY によって次のフレームから巻き戻されます。OVERLAY_HIDE_DELAY_MS(7番)だけは毛色が違い、位置ではなく透明度を動かし、1へ向かって減衰するのではなくスクロールのたびにゼロへ戻ります。9つのどれも scrollbar-width や scrollbar-color には触れていません——ここにあるつまみはすべてスクリプトが位置を決める本物の <div> で、その理由はこの二つのネイティブプロパティが既定の箱の色と太さを変えるだけで、光沢・反発・フェードタイマーを付け足す仕組みを持たないからです。9つ全部が共有するもう一つの定数 THUMB_MIN_PX は、これら3つとは違う狭い理由で存在します。リストが十分長くなると syncThumb() の中の visible / total がポインターでは確実につかめないほど小さな比率まで縮んでいくため、THUMB_MIN_PX は真の比率がどれだけ小さくなろうとつまみの高さに下限をかけ、長さの正確さを少し犠牲にしてでもドラッグできる大きさを守ります。
どこで壊れるか — モーション低減設定・低スペック端末・タッチ入力
zipを開く前に知っておく落とし穴は .is-demo です。9つすべてが既定でシード固定の擬似スクロールサイクルをループさせているのは、まだ誰も触れていないページでもつまみが生きているように見せるためで、つまみへの最初の本物の pointerdown・keydown・ドラッグがそのクラスを外し、実際のスクロール位置へ制御を戻します——操作した瞬間、デモはリハーサルされた動きを読者自身の動きに明け渡します。この切り替えはテストの仕方にも関わります——固定した読み込み状態だけで9つを判断すると必ずリハーサル済みのサイクルしか見えず、本物のドラッグ・コースト・反発の計算を見る唯一の方法はつまみをクリックかタブで操作して実際に動かすことで、これはこのページの下の測定表が1つの数値を記録する前に必ず通った手順と同じです。prefers-reduced-motion: reduce はさらに深く切り込みます——2番と9番は光沢スイープをそもそも発火させず、7番はフェードせずに透明度をいきなりゼロへ落とし、3番・4番・9番のコーストと反発の計算そのものは動き続けます(到達性が壊れるわけではありません)が、動きとして目に見える余分なフレームを足すのをやめます——スクリーンリーダーやキーボードだけの読者にとってこの違いはそもそも意味を持ちません、role="scrollbar" と aria-valuenow は見た目に関係なく同じ頻度で更新され続けるからです。低スペック端末が最も重く感じるのは9番で、光沢・慣性・反発の3つを同じつまみの上で毎フレーム計算し直しているのに対し、1番や8番のように定数一つで済むつまみはネイティブなスクロールバーがもともと払っているスクロールリスナーの分以外、ほとんど追加コストがありません——1つより3つの反応値を毎フレーム計算し直すほうが単純に処理量が多いだけで、端末の速さそのものとは別の話です。タッチ入力はマウスと同じ pointerdown/pointermove/pointerup のリスナーを通っているため、指でつまみをドラッグする動きはカーソルでの操作と変わらず、タッチ画面での短いドラッグでも vel と overshoot にはマウスドラッグと同じ数値が渡るので、3番・4番・9番のコーストと反発はどちらの入力でも同じように起こります。9つの index.html/style.scss すべてを開けるwn4b2gqqがこの節にあるのは意図的なことで、ここまで読めば上の定数はページの上の数字ではなく、自分で実際に変えられる値に変わります。
3つのバリエーションとアクセシビリティに残る課題
| バリエーション | 変更した値 | 変わること |
|---|---|---|
| つまみの慣性減衰値を調整する | SCROLLBAR_INERTIA_DECAY 0.92 → 0.98 |
値を小さくすると手を離した直後につまみはすぐ止まり、値を大きくすると離した後もしばらく滑り続けるはずだ |
| 境界反発距離の値を調整する | BOUNDARY_REBOUND_PX 12 → 32 |
値を小さくすると端に当たってもわずかに押し出されて戻り、値を大きくすると大きく押し出されて弾むように戻るはずだ |
| オーバーレイの消失遅延値を調整する | OVERLAY_HIDE_DELAY_MS 600 → 1800 |
値を小さくするとスクロールが止まるとすぐにつまみが薄れ、値を大きくするとしばらく残ってからゆっくり薄れるはずだ |
3つのバリエーションのどれも、上のアクセシビリティ経路には触れません——role="scrollbar"・aria-valuenow・矢印キーからPage Up/Down・Home・Endまでのキーボード一式は、SCROLLBAR_INERTIA_DECAY が0.92でも0.98でも同じ配線のままです。ポインターに反応する css スクロールバー カスタマイズ であっても、最終的にはキーボードにも答えなければならないからで、9つのつまみはどれも矢印キーの操作一つで .is-demo を外し、実際のスクロール位置に制御を渡します。
css スクロールバー デザインは既定のスクロールバーと何が違うか?
既定のスクロールバーはレンダリングエンジンが描いて更新してくれる箱で、スタイルシートが触れる面は scrollbar-width と scrollbar-color の2つのプロパティだけです。同じ箱を狙ったエンジン接頭辞付きセレクターを使っても、できるのは色と太さの入れ替えまでです。上の9つはそのネイティブな箱を隠し(scrollbar-width: none と対になるエンジン接頭辞付き擬似セレクター)、本物のスクリプト付き <div> に置き換えるので、光沢・反発・グラデーションの色・フェードタイマーがどれも値で動かせるものになります——ネイティブな箱にはそのどれにもフックがありません。代わりに払う代償も本物で、syncThumb() はスクロールのたびに自分で走らなければならず、role="scrollbar" と aria-valuenow も、置き換えた箱と同じだけの到達性を保つために手で付ける必要があります。
使う前によくある質問にまとめて答えます
ネイティブなスクロールバーを隠すとキーボードでのスクロールが壊れますか?
いいえ——scrollbar-width: none と対になるエンジン接頭辞付き擬似セレクターで隠しても、消えるのは描かれた箱だけで、要素そのものはスクロール可能なままです。それぞれのデモは独自の keydown リスナーも持っていて、矢印キー・Page Up/Down・Home・Endでつまみを直接動かせます。これはブラウザがスクロールコンテナ自体に対してもともと行っていることに上乗せされます。
なぜ4番のつまみはトラックの実際の端を越えて動くのですか?
BOUNDARY_REBOUND_PX は、スクロール比率が0か1に達したちょうど1フレームだけ、つまみの位置に自分自身を足します——これは意図された行き過ぎであってバグではなく、springBack() が次のフレームからその余分な距離をゼロへ引き戻し始めます。
この9つは1ページにまとめて組み込めますか、それとも独立していますか?
各パターンは自分専用の定数と自分専用の demoTick() ループを持つ独立した index.html/style.scss の組で、9つのどれか1つだけを既存のページに落とし込んでも残り8つを引き込むことはありません。9番だけが光沢・慣性・反発という3つの値の連鎖をあらかじめ組み合わせていますが、それも他の8つから読み込むのではなく1つの独立したファイルの中で完結しています。
MDNのscrollbar-colorガイドは、この9つが置き換えているネイティブな2つのプロパティを解説しており、prefers-reduced-motionは上のすべてのデモが最初のフレームの前に確認しているメディアクエリを扱っています。同じ慣性減衰の考え方を1つのつまみではなくページ全体の運動量に当てはめた編としてはスクロール 慣性 css 9選 — 値が自ら収まっていく9つの計算パターンが構造的にいちばん近い兄弟編で、css スクロール アニメーション 9選 — 区間値で切り替えるは scroll リスナーの代わりに宣言的なタイムラインでスクロール位置を読んでいて、ここの5番の手動での比率計算と見比べる価値があります。そしてスクロール インジケーター 9選 — 現在地がすぐわかるは目的がいちばん近い従兄弟編です——どちらの系列もスクロール比率を目に見えるものに変えますが、読書進捗バーはスクロール可能な要素の外に置かれるのに対し、上のつまみはどれも要素自身のスクロールバーを置き換えています。









