06Horizontal Track Sized by the Card-count Value
A track-to-thumb ratio value is computed from the count value of the horizontally laid-out cards, then laid thinly under the gallery.
Best forHorizontal product-card gallery
maxScrollXsyncThumbXCARD_COUNT

<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="horizontal" aria-controls="sbViewport" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" tabindex="0"></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 가로로 나열된 카드 개수 값(CARD_COUNT)에서 나온 scrollWidth 로
// 트랙 대비 손잡이 길이 비율이 계산되어 갤러리 아래 얇게 깔린다.
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-yellow);
.sb {
display: flex;
flex-direction: column;
gap: $sp-2;
width: min(90vw, 320px);
}
.sb__viewport {
width: 100%;
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.sb__viewport::-webkit-scrollbar { display: none; }
.sb__content {
display: flex;
gap: $sp-2;
padding: $sp-1;
}
.sb__card {
flex: 0 0 auto;
width: 86px;
height: 110px;
border-radius: $r-card;
padding: $sp-2;
display: flex;
flex-direction: column;
justify-content: flex-end;
background: linear-gradient(160deg, rgba(23, 20, 26, .1), rgba(23, 20, 26, .22));
}
.sb__cardTitle {
margin: 0 0 $sp-1;
color: $subject-cream;
font-weight: 700;
font-size: 12px;
}
.sb__cardLine {
margin: 0;
color: rgba(255, 247, 230, .78);
font-size: 9px;
}
.sb__track {
position: relative;
width: 100%;
height: $sp-2;
border-radius: $r-pill;
background: rgba(23, 20, 26, .12);
}
.sb__thumb {
position: absolute;
top: 0;
left: 0;
height: 100%;
border-radius: $r-pill;
background: $color;
transition: transform $duration $easing;
cursor: grab;
}
.sb__thumb:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 2px;
}
@media (max-width: 340px) {
.sb { width: min(96vw, 280px); }
.sb__card { width: 74px; height: 96px; }
.sb__cardTitle { font-size: 11px; }
}
@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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sb {
display: flex;
flex-direction: column;
gap: 8px;
width: min(90vw, 320px);
}
.sb__viewport {
width: 100%;
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.sb__viewport::-webkit-scrollbar {
display: none;
}
.sb__content {
display: flex;
gap: 8px;
padding: 4px;
}
.sb__card {
flex: 0 0 auto;
width: 86px;
height: 110px;
border-radius: 16px;
padding: 8px;
display: flex;
flex-direction: column;
justify-content: flex-end;
background: linear-gradient(160deg, rgba(23, 20, 26, 0.1), rgba(23, 20, 26, 0.22));
}
.sb__cardTitle {
margin: 0 0 2px;
color: #fff7e6;
font-weight: 700;
font-size: 12px;
}
.sb__cardLine {
margin: 0;
color: rgba(255, 247, 230, 0.78);
font-size: 9px;
}
.sb__track {
position: relative;
width: 100%;
height: 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.12);
}
.sb__thumb {
position: absolute;
top: 0;
left: 0;
height: 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(96vw, 280px);
}
.sb__card {
width: 74px;
height: 96px;
}
.sb__cardTitle {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.sb__thumb {
transition: none;
}
}(function () {
var THUMB_MIN_PX = 30;
var KEY_STEP_PX = 60;
var CARD_COUNT = 6;
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 <= CARD_COUNT; i++) {
var card = document.createElement('div');
card.className = 'sb__card';
var h = document.createElement('p');
h.className = 'sb__cardTitle';
h.textContent = 'Item ' + i;
var p = document.createElement('p');
p.className = 'sb__cardLine';
p.textContent = 'Card-count value';
card.appendChild(h);
card.appendChild(p);
content.appendChild(card);
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function maxScrollX() { return Math.max(0, viewport.scrollWidth - viewport.clientWidth); }
function syncThumbX() {
var trackLen = track.clientWidth;
var total = viewport.scrollWidth;
var visible = viewport.clientWidth;
var thumbLen = clamp(trackLen * (visible / total), THUMB_MIN_PX, trackLen);
var ms = maxScrollX();
var ratio = ms > 0 ? viewport.scrollLeft / ms : 0;
var thumbPos = (trackLen - thumbLen) * ratio;
thumb.style.width = thumbLen.toFixed(1) + 'px';
thumb.style.transform = 'translateX(' + thumbPos.toFixed(1) + 'px)';
thumb.setAttribute('aria-valuenow', String(Math.round(ratio * 100)));
}
viewport.addEventListener('scroll', syncThumbX);
var dragging = false;
var dragStartX = 0;
var dragStartScroll = 0;
thumb.addEventListener('pointerdown', function (e) {
dragging = true;
root.classList.remove('is-demo');
dragStartX = e.clientX;
dragStartScroll = viewport.scrollLeft;
thumb.setPointerCapture(e.pointerId);
});
thumb.addEventListener('pointermove', function (e) {
if (!dragging) return;
var trackLen = track.clientWidth;
var thumbLen = thumb.clientWidth;
var deltaPx = e.clientX - dragStartX;
var moveRange = Math.max(1, trackLen - thumbLen);
viewport.scrollLeft = clamp(dragStartScroll + deltaPx * (maxScrollX() / moveRange), 0, maxScrollX());
});
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.clientWidth;
var thumbLen = thumb.clientWidth;
var clickPos = clamp(e.clientX - rect.left - thumbLen / 2, 0, trackLen - thumbLen);
var ratio = (trackLen - thumbLen) > 0 ? clickPos / (trackLen - thumbLen) : 0;
viewport.scrollLeft = ratio * maxScrollX();
});
thumb.addEventListener('keydown', function (e) {
var ms = maxScrollX();
if (e.key === 'ArrowRight') { viewport.scrollLeft = clamp(viewport.scrollLeft + KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === 'ArrowLeft') { viewport.scrollLeft = clamp(viewport.scrollLeft - KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === 'PageDown') { viewport.scrollLeft = clamp(viewport.scrollLeft + viewport.clientWidth, 0, ms); e.preventDefault(); }
else if (e.key === 'PageUp') { viewport.scrollLeft = clamp(viewport.scrollLeft - viewport.clientWidth, 0, ms); e.preventDefault(); }
else if (e.key === 'Home') { viewport.scrollLeft = 0; e.preventDefault(); }
else if (e.key === 'End') { viewport.scrollLeft = 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.scrollLeft = phase * maxScrollX();
}
requestAnimationFrame(demoTick);
}
syncThumbX();
if (!reduce) { requestAnimationFrame(demoTick); }
})();// ScrollbarHorizontalTrackGallery — vanilla 판(run/411/demo/06-scrollbar-horizontal-track-gallery/index.html) 의 React 포트.
// 핵심: 가로로 나열된 카드 개수 값(CARD_COUNT)에서 나온 scrollWidth 로 트랙 대비 손잡이 길이 비율이 계산된다.
import { useEffect, useRef } from "react";
import styles from "./ScrollbarHorizontalTrackGallery.module.scss";
type Box<T> = { current: T };
const THUMB_MIN_PX = 30;
const KEY_STEP_PX = 60;
const CARD_COUNT = 6;
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
export default function ScrollbarHorizontalTrackGallery() {
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 dragStartXRef = 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 maxScrollX(): number {
return Math.max(0, viewport.scrollWidth - viewport.clientWidth);
}
function syncThumbX(): void {
const trackLen = track.clientWidth;
const total = viewport.scrollWidth;
const visible = viewport.clientWidth;
const thumbLen = clamp(trackLen * (visible / total), THUMB_MIN_PX, trackLen);
const ms = maxScrollX();
const ratio = ms > 0 ? viewport.scrollLeft / ms : 0;
const thumbPos = (trackLen - thumbLen) * ratio;
thumb.style.width = thumbLen.toFixed(1) + "px";
thumb.style.transform = "translateX(" + thumbPos.toFixed(1) + "px)";
thumb.setAttribute("aria-valuenow", String(Math.round(ratio * 100)));
}
function onScroll(): void { syncThumbX(); }
function onThumbPointerDown(e: PointerEvent): void {
draggingRef.current = true;
demoRef.current = false;
dragStartXRef.current = e.clientX;
dragStartScrollRef.current = viewport.scrollLeft;
thumb.setPointerCapture(e.pointerId);
}
function onThumbPointerMove(e: PointerEvent): void {
if (!draggingRef.current) return;
const trackLen = track.clientWidth;
const thumbLen = thumb.clientWidth;
const deltaPx = e.clientX - dragStartXRef.current;
const moveRange = Math.max(1, trackLen - thumbLen);
viewport.scrollLeft = clamp(dragStartScrollRef.current + deltaPx * (maxScrollX() / moveRange), 0, maxScrollX());
}
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.clientWidth;
const thumbLen = thumb.clientWidth;
const clickPos = clamp(e.clientX - rect.left - thumbLen / 2, 0, trackLen - thumbLen);
const ratio = (trackLen - thumbLen) > 0 ? clickPos / (trackLen - thumbLen) : 0;
viewport.scrollLeft = ratio * maxScrollX();
}
function onThumbKeyDown(e: KeyboardEvent): void {
const ms = maxScrollX();
if (e.key === "ArrowRight") { viewport.scrollLeft = clamp(viewport.scrollLeft + KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === "ArrowLeft") { viewport.scrollLeft = clamp(viewport.scrollLeft - KEY_STEP_PX, 0, ms); e.preventDefault(); }
else if (e.key === "PageDown") { viewport.scrollLeft = clamp(viewport.scrollLeft + viewport.clientWidth, 0, ms); e.preventDefault(); }
else if (e.key === "PageUp") { viewport.scrollLeft = clamp(viewport.scrollLeft - viewport.clientWidth, 0, ms); e.preventDefault(); }
else if (e.key === "Home") { viewport.scrollLeft = 0; e.preventDefault(); }
else if (e.key === "End") { viewport.scrollLeft = 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.scrollLeft = phase * maxScrollX();
}
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);
syncThumbX();
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: CARD_COUNT }, (_, 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="horizontal"
aria-valuenow={0}
aria-valuemin={0}
aria-valuemax={100}
tabIndex={0}
/>
</div>
</div>
</main>
);
}06 Horizontal Track Sized by the Card-count Value — Measured area 26.656% · 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
Scroll01 Card That Grows and Sharpens With the Range ValueCSS Scroll Animation: 9 View-Timeline Examples
Scroll01 Horizontal story panels9 Website Scroll Effects — Apple-Style, CSS Only