01カスタムスクロールバー — 太さ・色・角丸を自分で決めるスクロールバー
コンテンツがあふれたときに現れるスクロールバーの太さ・色・角丸を、背景に合わせて自分で指定します。
使いどころサイドパネルの一覧、コードプレビューウィンドウ
scrollbar-widthscrollbar-color::-webkit-scrollbar

<main class="stage">
<div class="wrap is-demo" id="wrap">
<div class="head">
<span class="modeLabel" id="modeLabel">標準サポート版</span>
<button class="simToggle" id="simToggle" type="button" aria-pressed="false">フォールバックを再現</button>
</div>
<div class="body">
<ol class="itemList" id="list">
<li>設計ドキュメント</li>
<li>カラートークン</li>
<li>余白の規則</li>
<li>ボタンの状態</li>
<li>入力バリデーション</li>
<li>アクセシビリティ点検</li>
<li>パフォーマンス計測</li>
<li>デプロイノート</li>
</ol>
<div class="track" id="track"><div class="trackDot" id="trackDot"></div></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 target(실제 scrollTop 비율) → shown(lerp 추적) 에 있다 — 여기는 두 스크롤바
// 표기법(표준 속성 · 옛 의사요소)과 트랙 점의 모양만 맡는다.
// 표준: scrollbar-width·scrollbar-color. 옛 폴백: ::-webkit-scrollbar 3형제. 두 선언은 같은 목록에 둬도
// 충돌하지 않는다 — 표준을 아는 환경은 표준을, 모르는 환경은 옛 의사요소만 읽는다.
@include stage($stage-ink);
.wrap {
width: 260px;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
}
.modeLabel {
color: $subject-cream;
font-size: 11px;
font-weight: 600;
letter-spacing: .02em;
}
.simToggle {
appearance: none;
border: none;
border-radius: $r-pill;
background: rgba($subject-cream, .14);
color: $subject-cream;
padding: $sp-1 $sp-3;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: background $dur-instant $ease-out, transform $dur-instant $ease-out;
&:hover { background: rgba($subject-cream, .22); }
&:active { transform: scale(.96); }
&[aria-pressed="true"] { background: $subject-orange-deep; }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
.body {
display: flex;
align-items: stretch;
gap: $sp-2;
}
.itemList {
margin: 0;
padding: $sp-2 $sp-3;
list-style: none;
width: 220px;
height: 116px;
overflow-y: scroll;
border-radius: $r-sm;
background: rgba($subject-cream, .06);
// 폴백: 옛 브라우저용 의사요소 스크롤바 — 항상 선언돼 있고, 표준 속성을 아는 환경은 아래 @supports 가 덮는다
&::-webkit-scrollbar { width: 10px; }
&::-webkit-scrollbar-track { background: transparent; }
&::-webkit-scrollbar-thumb { background: $subject-blue; border-radius: $r-pill; }
}
@supports (scrollbar-width: thin) {
.itemList:not(.force-fallback) {
scrollbar-width: thin;
scrollbar-color: $subject-blue-light transparent;
}
}
// 시뮬레이션 토글 — 실제 UA 감지가 아니라 표준 속성을 강제로 꺼서 옛 폴백 경로가 눈에 보이게 한다
.force-fallback .itemList {
scrollbar-width: auto;
scrollbar-color: unset;
}
.itemList li {
padding: $sp-1 0;
color: $subject-cream;
font-size: 12px;
border-bottom: 1px solid rgba($subject-cream, .1);
&:last-child { border-bottom: none; }
}
.track {
position: relative;
width: 6px;
border-radius: $r-pill;
background: rgba($subject-cream, .1);
}
.trackDot {
position: absolute;
top: 0;
left: 0;
width: 6px;
height: 28px;
border-radius: $r-pill;
background: $subject-blue-light;
transform: translateY(0);
}
.force-fallback .trackDot { background: $subject-orange-deep; }
@media (max-width: 340px) {
.wrap { width: 210px; }
.itemList { width: 174px; height: 96px; font-size: 11px; }
.modeLabel { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.trackDot { transition: none; }
.simToggle { 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);
}
.wrap {
width: 260px;
display: flex;
flex-direction: column;
gap: 8px;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.modeLabel {
color: #fff7e6;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
}
.simToggle {
appearance: none;
border: none;
border-radius: 999px;
background: rgba(255, 247, 230, 0.14);
color: #fff7e6;
padding: 4px 12px;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: background 140ms ease-out, transform 140ms ease-out;
}
.simToggle:hover {
background: rgba(255, 247, 230, 0.22);
}
.simToggle:active {
transform: scale(0.96);
}
.simToggle[aria-pressed=true] {
background: #c93000;
}
.simToggle:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.body {
display: flex;
align-items: stretch;
gap: 8px;
}
.itemList {
margin: 0;
padding: 8px 12px;
list-style: none;
width: 220px;
height: 116px;
overflow-y: scroll;
border-radius: 8px;
background: rgba(255, 247, 230, 0.06);
}
.itemList::-webkit-scrollbar {
width: 10px;
}
.itemList::-webkit-scrollbar-track {
background: transparent;
}
.itemList::-webkit-scrollbar-thumb {
background: #2f6df6;
border-radius: 999px;
}
@supports (scrollbar-width: thin) {
.itemList:not(.force-fallback) {
scrollbar-width: thin;
scrollbar-color: #5b8cf8 transparent;
}
}
.force-fallback .itemList {
scrollbar-width: auto;
scrollbar-color: unset;
}
.itemList li {
padding: 4px 0;
color: #fff7e6;
font-size: 12px;
border-bottom: 1px solid rgba(255, 247, 230, 0.1);
}
.itemList li:last-child {
border-bottom: none;
}
.track {
position: relative;
width: 6px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.1);
}
.trackDot {
position: absolute;
top: 0;
left: 0;
width: 6px;
height: 28px;
border-radius: 999px;
background: #5b8cf8;
transform: translateY(0);
}
.force-fallback .trackDot {
background: #c93000;
}
@media (max-width: 340px) {
.wrap {
width: 210px;
}
.itemList {
width: 174px;
height: 96px;
font-size: 11px;
}
.modeLabel {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.trackDot {
transition: none;
}
.simToggle {
transition: none;
}
}(function () {
var LERP_RATE = 0.22; // 트랙 점이 목표 위치를 따라붙는 비율 — 작을수록 관성이 크게 느껴진다
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var list = document.getElementById('list');
var track = document.getElementById('track');
var dot = document.getElementById('trackDot');
var simToggle = document.getElementById('simToggle');
var modeLabel = document.getElementById('modeLabel');
var isDemo = true;
var target = 0; // 목표 스크롤 비율 0~1 — 실제 scrollTop 에서 나온다
var shown = 0; // 화면에 그려지는 비율 — target 을 lerp 로 뒤따른다
function ratio() {
var max = list.scrollHeight - list.clientHeight;
return max > 0 ? list.scrollTop / max : 0;
}
function onScroll() { target = ratio(); }
list.addEventListener('scroll', onScroll);
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
list.addEventListener('pointerdown', human);
list.addEventListener('keydown', human);
function setSim(on) {
wrap.classList.toggle('force-fallback', on);
simToggle.setAttribute('aria-pressed', String(on));
modeLabel.textContent = on ? 'フォールバックを再現' : '標準サポート版';
}
var simOn = false;
simToggle.addEventListener('click', function () {
human();
simOn = !simOn;
setSim(simOn);
});
var dir = 1;
var last = 0;
var STEP_MS = 520;
function autoAdvance(ts) {
if (ts - last < STEP_MS) return;
last = ts;
var max = list.scrollHeight - list.clientHeight;
var next = list.scrollTop + dir * (max * 0.42);
if (next >= max - 1) { next = max; dir = -1; }
else if (next <= 1) { next = 0; dir = 1; }
list.scrollTop = next; // 진짜 스크롤 — 위 scroll 리스너가 target 을 갱신한다
}
function loop(ts) {
if (!last) last = ts;
if (isDemo && !reduce) autoAdvance(ts);
var trackH = track.clientHeight - dot.offsetHeight;
if (reduce) { shown = target; } else { shown += (target - shown) * LERP_RATE; }
dot.style.transform = 'translateY(' + Math.max(0, Math.min(trackH, shown * trackH)) + 'px)';
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// ScrollbarStyling — vanilla 판(run/375/demo/01-compat-scrollbar-styling/index.html) 의 React 포트.
// 표준 scrollbar-width/scrollbar-color 와 옛 ::-webkit-scrollbar 의사요소를 함께 선언하고,
// 트랙 점은 실제 scroll 이벤트에서 나온 비율(target)을 lerp 로 뒤따른다.
import { useEffect, useRef } from "react";
import styles from "./ScrollbarStyling.module.scss";
type Box<T> = { current: T };
const LERP_RATE = 0.22; // 트랙 점이 목표 위치를 따라붙는 비율 — 작을수록 관성이 크게 느껴진다
const STEP_MS = 520;
const ITEMS = ["설계 문서", "색상 토큰", "여백 규칙", "버튼 상태", "입력 검증", "접근성 점검", "성능 측정", "배포 노트"];
export default function ScrollbarStyling() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const listRef = useRef(null) as unknown as Box<HTMLOListElement>;
const trackRef = useRef(null) as unknown as Box<HTMLDivElement>;
const dotRef = useRef(null) as unknown as Box<HTMLDivElement>;
const simToggleRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const modeLabelRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const wrap = rootRef.current;
const list = listRef.current;
const track = trackRef.current;
const dot = dotRef.current;
const simToggle = simToggleRef.current;
const modeLabel = modeLabelRef.current;
if (!wrap || !list || !track || !dot || !simToggle || !modeLabel) return;
let isDemo = true;
let target = 0;
let shown = 0;
function ratio() {
const max = list!.scrollHeight - list!.clientHeight;
return max > 0 ? list!.scrollTop / max : 0;
}
function onScroll() { target = ratio(); }
list.addEventListener("scroll", onScroll);
function human() { if (isDemo) { wrap!.classList.remove(styles.isDemo); isDemo = false; } }
function onPointerDown() { human(); }
function onKeyDown() { human(); }
list.addEventListener("pointerdown", onPointerDown);
list.addEventListener("keydown", onKeyDown);
let simOn = false;
function setSim(on: boolean) {
simOn = on;
wrap!.classList.toggle(styles.forceFallback, on);
simToggle!.setAttribute("aria-pressed", String(on));
modeLabel!.textContent = on ? "폴백 흉내내기" : "표준 지원판";
}
function onSimClick() { human(); simOn = !simOn; setSim(simOn); }
simToggle.addEventListener("click", onSimClick);
let dir = 1;
let last = 0;
function autoAdvance(ts: number) {
if (ts - last < STEP_MS) return;
last = ts;
const max = list!.scrollHeight - list!.clientHeight;
let next = list!.scrollTop + dir * (max * 0.42);
if (next >= max - 1) { next = max; dir = -1; }
else if (next <= 1) { next = 0; dir = 1; }
list!.scrollTop = next;
}
function loop(ts: number) {
if (!last) last = ts;
if (isDemo && !reduce) autoAdvance(ts);
const trackH = track!.clientHeight - dot!.offsetHeight;
if (reduce) { shown = target; } else { shown += (target - shown) * LERP_RATE; }
dot!.style.transform = "translateY(" + Math.max(0, Math.min(trackH, shown * trackH)) + "px)";
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
list!.removeEventListener("scroll", onScroll);
list!.removeEventListener("pointerdown", onPointerDown);
list!.removeEventListener("keydown", onKeyDown);
simToggle!.removeEventListener("click", onSimClick);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<div className={styles.head}>
<span className={styles.modeLabel} ref={modeLabelRef}>표준 지원판</span>
<button className={styles.simToggle} ref={simToggleRef} type="button" aria-pressed="false">폴백 흉내내기</button>
</div>
<div className={styles.body}>
<ol className={styles.itemList} ref={listRef}>
{ITEMS.map(function (s) { return <li key={s}>{s}</li>; })}
</ol>
<div className={styles.track} ref={trackRef}>
<div className={styles.trackDot} ref={dotRef}></div>
</div>
</div>
</div>
</main>
);
}01 カスタムスクロールバー — 太さ・色・角丸を自分で決めるスクロールバー — 計測 累積面積 7.615% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · css ブラウザ対応 9選 — 実務で使うフォールバック集
このデモが属する記事
css ブラウザ対応 9選 — 実務で使うフォールバック集9つのcss ブラウザ対応トリックのうち、1つはアドレスバーの開閉のたびに画面全体の高さを再計算し、1つは入力欄にフォーカスしても画面が拡大されないようロックし、1つはリストを端まで引っ張っても背景が一緒に揺れないよう止めます。 この記事のデモをすべて見る

