09Hero Scrollbar Where Several Values React on One Thumb
A gloss-position value, an inertia-decay value, and a boundary-rebound value are layered onto one thumb, turning scrolling itself into a living scene.
Best forFull-screen product-intro section
GLOSS_SWEEP_MSSCROLLBAR_INERTIA_DECAYBOUNDARY_REBOUND_PX

<main class="stage">
<div class="sb is-demo" id="sb">
<div class="sb__viewport" id="sbViewport">
<div class="sb__content" id="sbContent"></div>
</div>
<div class="sb__track" id="sbTrack">
<div class="sb__thumb" id="sbThumb" role="scrollbar" aria-orientation="vertical" aria-controls="sbViewport" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" tabindex="0">
<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 = 'Scene ' + i;
var p = document.createElement('p');
p.className = 'sb__cardLine';
p.textContent = 'The gloss value, the inertia value, and the rebound value all react together on one thumb.';
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 Hero Scrollbar Where Several Values React on One Thumb — Measured area 36.332% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Custom Scrollbar CSS — 9 Patterns
The post this demo lives in
Custom Scrollbar CSS — 9 Patternscustom scrollbar css: For a half second the thumb overshoots the end and springs back under your fingertip. Item 3 coast See all demos in this post
If you like this effect - similar demos
Scroll08 Lag tracking — a trailing value doesn't match the real scroll value instantly, chasing it with a lag value insteadScroll Inertia CSS — 9 Value Patterns
Scroll02 A Pinned Panel Scene Where the Section Stays Fixed and Vertical Progress Splits Into Sideways Moves per PanelHorizontal Scroll Animation CSS: 9 Inertia Scenes
Scroll04 Hero shrink parallax9 CSS Parallax Scrolling — No JS, Scroll-Driven