07止まると透明度値が下がり消えるオーバーレイつまみ
スクロールが止まってからの経過時間値が増えるほど透明度値が下がり、再びスクロールすると透明度値は即座に戻る。
使いどころ読み取り専用の本文ページ
OVERLAY_HIDE_DELAY_MSOVERLAY_FADE_MSsyncThumb

<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 に同梱 · css スクロールバー デザイン 9パターン — 慣性と反発で動く
このデモが属する記事
css スクロールバー デザイン 9パターン — 慣性と反発で動くcss スクロールバー デザインでは、指先がトラックの端に触れた一瞬、つまみが少し押し出されて跳ね返る。3番はその瞬間、慣性の値で数フレーム余分に滑らせ、4番は反発の値で跳ね返らせ、9番はその二つの値を一つのつまみの上で重ねる。 この記事のデモをすべて見る

