02カーソルが乗ると光沢が通り過ぎるつまみ
ポインターがつまみに乗った瞬間、光の反射位置の値がつまみの長さに沿って一度移動し、光沢がすっと通り過ぎる。
使いどころプレミアムダッシュボードのサイドバー
GLOSS_SWEEP_MStriggerGlosssyncThumb
<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 に同梱 · css スクロールバー デザイン 9パターン — 慣性と反発で動く
このデモが属する記事
css スクロールバー デザイン 9パターン — 慣性と反発で動くcss スクロールバー デザインでは、指先がトラックの端に触れた一瞬、つまみが少し押し出されて跳ね返る。3番はその瞬間、慣性の値で数フレーム余分に滑らせ、4番は反発の値で跳ね返らせ、9番はその二つの値を一つのつまみの上で重ねる。 この記事のデモをすべて見る

