05스크롤 위치 값에 따라 색이 옮겨가는 손잡이
스크롤 위치 값이 커질수록 손잡이 그라디언트의 색 구간 값이 함께 이동해, 손잡이 색만 보고도 대략의 위치를 가늠한다.
쓰임긴 문서 리더 페이지
applyPositionColorsyncThumbTHUMB_MIN_PX
<main class="stage">
<div class="sb is-demo" id="sb">
<div class="sb__viewport" id="sbViewport">
<div class="sb__content" id="sbContent"></div>
</div>
<div class="sb__track" id="sbTrack">
<div class="sb__thumb" id="sbThumb" role="scrollbar" aria-orientation="vertical" aria-controls="sbViewport" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" tabindex="0"></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 스크롤 위치 비율 값(--pos) 하나를 손잡이 그라디언트 색 구간과 트랙 발광 톤에
// 같은 값으로 전파한다 — applyPositionColor() 가 그 계산 레이어다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-ink);
.sb {
display: flex;
gap: $sp-2;
width: min(88vw, 300px);
height: min(56vw, 176px);
}
.sb__viewport {
flex: 1;
min-width: 0;
height: 100%;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
border-radius: $r-card;
background: rgba(255, 255, 255, .06);
}
.sb__viewport::-webkit-scrollbar { display: none; }
.sb__content {
display: flex;
flex-direction: column;
gap: $sp-2;
padding: $sp-2;
}
.sb__card {
border-radius: $r-sm;
padding: $sp-2 $sp-3;
background: rgba(255, 255, 255, .08);
}
.sb__cardTitle {
margin: 0 0 $sp-1;
color: $color;
font-weight: 700;
font-size: 12px;
}
.sb__cardLine {
margin: 0;
color: rgba(255, 247, 230, .68);
font-size: 10px;
line-height: 1.4;
}
.sb__track {
position: relative;
width: $sp-2;
border-radius: $r-pill;
--pos: 0;
background: linear-gradient(180deg, rgba($subject-pink, .28), rgba($subject-sky, .28) calc(var(--pos) * 100%), rgba(255, 255, 255, .1));
}
.sb__thumb {
position: absolute;
top: 0;
left: 0;
width: 100%;
border-radius: $r-pill;
--pos: 0;
background: linear-gradient($subject-pink, $subject-lilac calc(var(--pos) * 100%), $subject-sky);
transition: transform $duration $easing;
cursor: grab;
}
.sb__thumb:focus-visible {
outline: 2px solid $subject-cream;
outline-offset: 2px;
}
@media (max-width: 340px) {
.sb { width: min(94vw, 260px); height: 148px; }
.sb__cardTitle { font-size: 11px; }
.sb__cardLine { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
.sb__thumb { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sb {
display: flex;
gap: 8px;
width: min(88vw, 300px);
height: min(56vw, 176px);
}
.sb__viewport {
flex: 1;
min-width: 0;
height: 100%;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
border-radius: 16px;
background: rgba(255, 255, 255, 0.06);
}
.sb__viewport::-webkit-scrollbar {
display: none;
}
.sb__content {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
}
.sb__card {
border-radius: 8px;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.08);
}
.sb__cardTitle {
margin: 0 0 2px;
color: #fff7e6;
font-weight: 700;
font-size: 12px;
}
.sb__cardLine {
margin: 0;
color: rgba(255, 247, 230, 0.68);
font-size: 10px;
line-height: 1.4;
}
.sb__track {
position: relative;
width: 8px;
border-radius: 999px;
--pos: 0;
background: linear-gradient(180deg, rgba(255, 158, 187, 0.28), rgba(110, 200, 255, 0.28) calc(var(--pos) * 100%), rgba(255, 255, 255, 0.1));
}
.sb__thumb {
position: absolute;
top: 0;
left: 0;
width: 100%;
border-radius: 999px;
--pos: 0;
background: linear-gradient(#ff9ebb, #b9a0ff calc(var(--pos) * 100%), #6ec8ff);
transition: transform 2s ease-out;
cursor: grab;
}
.sb__thumb:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
@media (max-width: 340px) {
.sb {
width: min(94vw, 260px);
height: 148px;
}
.sb__cardTitle {
font-size: 11px;
}
.sb__cardLine {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.sb__thumb {
transition: none;
}
}(function () {
var THUMB_MIN_PX = 26;
var KEY_STEP_PX = 32;
var root = document.getElementById('sb');
var viewport = document.getElementById('sbViewport');
var content = document.getElementById('sbContent');
var track = document.getElementById('sbTrack');
var thumb = document.getElementById('sbThumb');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
for (var i = 1; i <= 8; i++) {
var card = document.createElement('div');
card.className = 'sb__card';
var h = document.createElement('p');
h.className = 'sb__cardTitle';
h.textContent = '문단 ' + i;
var p = document.createElement('p');
p.className = 'sb__cardLine';
p.textContent = '스크롤 위치 값이 커질수록 손잡이 그라디언트 색 구간 값도 함께 이동합니다.';
card.appendChild(h);
card.appendChild(p);
content.appendChild(card);
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function maxScroll() { return Math.max(0, viewport.scrollHeight - viewport.clientHeight); }
function applyPositionColor(ratio) {
// 계산 레이어: 위치 비율 값을 손잡이 그라디언트 구간과 트랙 발광 톤에 같은 값으로 전파한다.
thumb.style.setProperty('--pos', ratio.toFixed(3));
track.style.setProperty('--pos', ratio.toFixed(3));
}
function syncThumb() {
var trackLen = track.clientHeight;
var total = viewport.scrollHeight;
var visible = viewport.clientHeight;
var thumbLen = clamp(trackLen * (visible / total), THUMB_MIN_PX, trackLen);
var ms = maxScroll();
var ratio = ms > 0 ? viewport.scrollTop / ms : 0;
var thumbPos = (trackLen - thumbLen) * ratio;
thumb.style.height = thumbLen.toFixed(1) + 'px';
thumb.style.transform = 'translateY(' + thumbPos.toFixed(1) + 'px)';
thumb.setAttribute('aria-valuenow', String(Math.round(ratio * 100)));
applyPositionColor(ratio);
}
viewport.addEventListener('scroll', syncThumb);
var dragging = false;
var dragStartY = 0;
var dragStartScroll = 0;
thumb.addEventListener('pointerdown', function (e) {
dragging = true;
root.classList.remove('is-demo');
dragStartY = e.clientY;
dragStartScroll = viewport.scrollTop;
thumb.setPointerCapture(e.pointerId);
});
thumb.addEventListener('pointermove', function (e) {
if (!dragging) return;
var trackLen = track.clientHeight;
var thumbLen = thumb.clientHeight;
var deltaPx = e.clientY - dragStartY;
var moveRange = Math.max(1, trackLen - thumbLen);
viewport.scrollTop = clamp(dragStartScroll + deltaPx * (maxScroll() / moveRange), 0, maxScroll());
});
thumb.addEventListener('pointerup', function () { dragging = false; });
thumb.addEventListener('pointercancel', function () { dragging = false; });
track.addEventListener('pointerdown', function (e) {
if (e.target !== track) return;
root.classList.remove('is-demo');
var rect = track.getBoundingClientRect();
var trackLen = track.clientHeight;
var thumbLen = thumb.clientHeight;
var clickPos = clamp(e.clientY - rect.top - thumbLen / 2, 0, trackLen - thumbLen);
var ratio = (trackLen - thumbLen) > 0 ? clickPos / (trackLen - thumbLen) : 0;
viewport.scrollTop = ratio * maxScroll();
});
thumb.addEventListener('keydown', function (e) {
var ms = maxScroll();
if (e.key === 'ArrowDown') { viewport.scrollTop = clamp(viewport.scrollTop + KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === 'ArrowUp') { viewport.scrollTop = clamp(viewport.scrollTop - KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === 'PageDown') { viewport.scrollTop = clamp(viewport.scrollTop + viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === 'PageUp') { viewport.scrollTop = clamp(viewport.scrollTop - viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === 'Home') { viewport.scrollTop = 0; e.preventDefault(); }
else if (e.key === 'End') { viewport.scrollTop = ms; e.preventDefault(); }
if (e.key.indexOf('Arrow') === 0 || e.key === 'PageDown' || e.key === 'PageUp' || e.key === 'Home' || e.key === 'End') {
root.classList.remove('is-demo');
}
});
var startTime = null;
function demoTick(now) {
if (startTime === null) startTime = now;
var t = (now - startTime) / 1000;
if (root.classList.contains('is-demo')) {
var phase = (Math.sin(t * Math.PI) + 1) / 2;
viewport.scrollTop = phase * maxScroll();
}
requestAnimationFrame(demoTick);
}
syncThumb();
if (!reduce) { requestAnimationFrame(demoTick); }
})();// ScrollbarThumbGradientPositionShift — vanilla 판(run/411/demo/05-scrollbar-thumb-gradient-position-shift/index.html) 의 React 포트.
// 핵심: 스크롤 위치 비율 값(--pos) 하나를 손잡이 그라디언트 색 구간과 트랙 발광 톤에 같은 값으로 전파한다.
import { useEffect, useRef } from "react";
import styles from "./ScrollbarThumbGradientPositionShift.module.scss";
type Box<T> = { current: T };
const THUMB_MIN_PX = 26;
const KEY_STEP_PX = 32;
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
export default function ScrollbarThumbGradientPositionShift() {
const viewportRef = useRef<HTMLDivElement | null>(null);
const trackRef = useRef<HTMLDivElement | null>(null);
const thumbRef = useRef<HTMLDivElement | null>(null);
const demoRef = useRef<boolean>(true) as Box<boolean>;
const draggingRef = useRef<boolean>(false) as Box<boolean>;
const dragStartYRef = useRef<number>(0) as Box<number>;
const dragStartScrollRef = useRef<number>(0) as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const viewport = viewportRef.current!;
const track = trackRef.current!;
const thumb = thumbRef.current!;
if (!viewport || !track || !thumb) return;
function maxScroll(): number {
return Math.max(0, viewport.scrollHeight - viewport.clientHeight);
}
function applyPositionColor(ratio: number): void {
// 계산 레이어: 위치 비율 값을 손잡이 그라디언트 구간과 트랙 발광 톤에 같은 값으로 전파한다.
thumb.style.setProperty("--pos", ratio.toFixed(3));
track.style.setProperty("--pos", ratio.toFixed(3));
}
function syncThumb(): void {
const trackLen = track.clientHeight;
const total = viewport.scrollHeight;
const visible = viewport.clientHeight;
const thumbLen = clamp(trackLen * (visible / total), THUMB_MIN_PX, trackLen);
const ms = maxScroll();
const ratio = ms > 0 ? viewport.scrollTop / ms : 0;
const thumbPos = (trackLen - thumbLen) * ratio;
thumb.style.height = thumbLen.toFixed(1) + "px";
thumb.style.transform = "translateY(" + thumbPos.toFixed(1) + "px)";
thumb.setAttribute("aria-valuenow", String(Math.round(ratio * 100)));
applyPositionColor(ratio);
}
function onScroll(): void { syncThumb(); }
function onThumbPointerDown(e: PointerEvent): void {
draggingRef.current = true;
demoRef.current = false;
dragStartYRef.current = e.clientY;
dragStartScrollRef.current = viewport.scrollTop;
thumb.setPointerCapture(e.pointerId);
}
function onThumbPointerMove(e: PointerEvent): void {
if (!draggingRef.current) return;
const trackLen = track.clientHeight;
const thumbLen = thumb.clientHeight;
const deltaPx = e.clientY - dragStartYRef.current;
const moveRange = Math.max(1, trackLen - thumbLen);
viewport.scrollTop = clamp(dragStartScrollRef.current + deltaPx * (maxScroll() / moveRange), 0, maxScroll());
}
function onThumbPointerUp(): void { draggingRef.current = false; }
function onThumbPointerCancel(): void { draggingRef.current = false; }
function onTrackPointerDown(e: PointerEvent): void {
if (e.target !== track) return;
demoRef.current = false;
const rect = track.getBoundingClientRect();
const trackLen = track.clientHeight;
const thumbLen = thumb.clientHeight;
const clickPos = clamp(e.clientY - rect.top - thumbLen / 2, 0, trackLen - thumbLen);
const ratio = (trackLen - thumbLen) > 0 ? clickPos / (trackLen - thumbLen) : 0;
viewport.scrollTop = ratio * maxScroll();
}
function onThumbKeyDown(e: KeyboardEvent): void {
const ms = maxScroll();
if (e.key === "ArrowDown") { viewport.scrollTop = clamp(viewport.scrollTop + KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === "ArrowUp") { viewport.scrollTop = clamp(viewport.scrollTop - KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === "PageDown") { viewport.scrollTop = clamp(viewport.scrollTop + viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === "PageUp") { viewport.scrollTop = clamp(viewport.scrollTop - viewport.clientHeight, 0, ms); e.preventDefault(); }
else if (e.key === "Home") { viewport.scrollTop = 0; e.preventDefault(); }
else if (e.key === "End") { viewport.scrollTop = ms; e.preventDefault(); }
if (e.key.indexOf("Arrow") === 0 || e.key === "PageDown" || e.key === "PageUp" || e.key === "Home" || e.key === "End") {
demoRef.current = false;
}
}
let startTime: number | null = null;
let frame = 0;
function demoTick(now: number): void {
if (startTime === null) startTime = now;
const t = (now - startTime) / 1000;
if (demoRef.current) {
const phase = (Math.sin(t * Math.PI) + 1) / 2;
viewport.scrollTop = phase * maxScroll();
}
frame = requestAnimationFrame(demoTick);
}
viewport.addEventListener("scroll", onScroll);
thumb.addEventListener("pointerdown", onThumbPointerDown);
thumb.addEventListener("pointermove", onThumbPointerMove);
thumb.addEventListener("pointerup", onThumbPointerUp);
thumb.addEventListener("pointercancel", onThumbPointerCancel);
track.addEventListener("pointerdown", onTrackPointerDown);
thumb.addEventListener("keydown", onThumbKeyDown);
syncThumb();
if (!reduce) { frame = requestAnimationFrame(demoTick); }
return () => {
viewport.removeEventListener("scroll", onScroll);
thumb.removeEventListener("pointerdown", onThumbPointerDown);
thumb.removeEventListener("pointermove", onThumbPointerMove);
thumb.removeEventListener("pointerup", onThumbPointerUp);
thumb.removeEventListener("pointercancel", onThumbPointerCancel);
track.removeEventListener("pointerdown", onTrackPointerDown);
thumb.removeEventListener("keydown", onThumbKeyDown);
if (frame) cancelAnimationFrame(frame);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.sb}>
<div className={styles.sb__viewport} ref={viewportRef}>
<div className={styles.sb__content}>
{Array.from({ length: 8 }, (_, idx) => idx + 1).map((i) => (
<div className={styles.sb__card} key={i}>
<p className={styles.sb__cardTitle}>{"문단 " + i}</p>
<p className={styles.sb__cardLine}>스크롤 위치 값이 커질수록 손잡이 그라디언트 색 구간 값도 함께 이동합니다.</p>
</div>
))}
</div>
</div>
<div className={styles.sb__track} ref={trackRef}>
<div
className={styles.sb__thumb}
ref={thumbRef}
role="scrollbar"
aria-orientation="vertical"
aria-valuenow={0}
aria-valuemin={0}
aria-valuemax={100}
tabIndex={0}
/>
</div>
</div>
</main>
);
}05 스크롤 위치 값에 따라 색이 옮겨가는 손잡이 — 측정 누적면적 33.326% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · css 스크롤바 디자인 9가지 — 관성과 반발로 움직인다
이 데모가 속한 글
css 스크롤바 디자인 9가지 — 관성과 반발로 움직인다css 스크롤바 디자인은 손잡이 값 하나로 움직임이 갈리는 아홉 장면이다. 손끝이 트랙 끝에 닿는 그 짧은 순간, 손잡이가 한 번 밀려났다가 튕겨 돌아온다. 03번은 그 순간 관성 값이 몇 프레임 더 미끄러지게 하고 이 글의 데모 전부 보기

