Advanced
Custom Scrollbar
Add a custom scrollbar to Blossom Carousel with svg, offset-path and scroll-timeline.
1
2
3
4
5
6
7
8
9
10
<div class="example">
<BlossomCarousel class="carousel">
<div class="slide"></div>
<div class="slide"></div>
<div class="slide"></div>
...
</BlossomCarousel>
<div class="scrollbar">
<svg class="track" viewBox="0 0 300 16" fill="none">
<!-- The faded path is the full track. -->
<path
d="cosinePath"
stroke="currentColor"
opacity="0.2"
stroke-width="4"
stroke-linecap="round"
/>
<!-- The progress path is also referenced by offset-path. -->
<path
id="custom-scrollbar-path"
class="progress"
d="cosinePath"
stroke="currentColor"
stroke-width="4"
stroke-linecap="round"
pathLength="1"
/>
</svg>
<button class="handle"></button>
</div>
</div>
.example {
/* Gives the carousel and scrollbar a shared named scroll timeline. */
timeline-scope: --custom-scrollbar;
}
.carousel {
/* The carousel's horizontal scroll position drives the animations below. */
scroll-timeline: --custom-scrollbar inline;
}
.scrollbar {
position: relative;
width: 300px;
height: 16px;
}
.track .progress {
/* pathLength="1" lets us animate the whole path with values from 1 to 0. */
animation: progress 1s linear both;
animation-timeline: --custom-scrollbar;
stroke-dasharray: 1;
}
.handle {
position: absolute;
top: 0;
left: 0;
/* The handle follows the same SVG path as the progress stroke. */
offset-path: url("#custom-scrollbar-path");
offset-anchor: auto;
animation: handle 1s linear both;
animation-timeline: --custom-scrollbar;
}
@keyframes progress {
from {
stroke-dashoffset: 1;
}
to {
stroke-dashoffset: 0;
}
}
@keyframes handle {
from {
offset-distance: 0%;
}
to {
offset-distance: 100%;
}
}
let svgRect: DOMRect
let offsetX: number
let activeHandle: HTMLButtonElement | null = null
const carousel = document.querySelector<HTMLElement>('.carousel')!
const svg = document.querySelector<SVGSVGElement>('.track')!
const handle = document.querySelector<HTMLButtonElement>('.handle')!
handle.addEventListener('pointerdown', onPointerDown)
function onPointerDown(event: PointerEvent) {
const handleRect = handle.getBoundingClientRect()
// Keep receiving pointer events while dragging outside the handle.
activeHandle = handle
handle.setPointerCapture(event.pointerId)
// Measure once at the start so pointer movement maps to track progress.
svgRect = svg.getBoundingClientRect()
offsetX = event.clientX - handleRect.left
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
}
function onPointerMove(event: PointerEvent) {
const newX = event.clientX - offsetX
const percentageX = ((newX - svgRect.left) / svgRect.width) * 100
// Convert track progress to the carousel's scroll range.
carousel.scrollTo({
left: (percentageX / 100) * (carousel.scrollWidth - carousel.clientWidth),
behavior: 'instant',
})
}
function onPointerUp(event: PointerEvent) {
activeHandle?.releasePointerCapture(event.pointerId)
activeHandle = null
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
}
<div class="group relative flex w-full flex-col items-center gap-6">
<BlossomCarousel class="carousel grid! auto-cols-[200px] grid-flow-col gap-4">
<div class="aspect-3/4 w-full"></div>
<div class="aspect-3/4 w-full"></div>
<div class="aspect-3/4 w-full"></div>
...
</BlossomCarousel>
<div class="relative h-4 w-[300px]">
<svg class="track h-full w-full" viewBox="0 0 300 16" fill="none">
<!-- The faded path is the full track. -->
<path
d="cosinePath"
stroke="currentColor"
opacity="0.2"
stroke-width="4"
stroke-linecap="round"
/>
<!-- The progress path is also referenced by offset-path. -->
<path
id="custom-scrollbar-path"
d="cosinePath"
stroke="currentColor"
stroke-width="4"
stroke-linecap="round"
pathLength="1"
/>
</svg>
<button class="handle absolute top-0 left-0 size-3 rounded-full"></button>
</div>
</div>
.group {
/* Gives the carousel and scrollbar a shared named scroll timeline. */
timeline-scope: --custom-scrollbar;
}
.carousel {
/* The carousel's horizontal scroll position drives the animations below. */
scroll-timeline: --custom-scrollbar inline;
}
svg path:last-child {
/* pathLength="1" lets us animate the whole path with values from 1 to 0. */
animation: progress 1s linear both;
animation-timeline: --custom-scrollbar;
stroke-dasharray: 1;
}
.handle {
/* The handle follows the same SVG path as the progress stroke. */
offset-path: url("#custom-scrollbar-path");
offset-anchor: auto;
animation: handle 1s linear both;
animation-timeline: --custom-scrollbar;
}
@keyframes progress {
from {
stroke-dashoffset: 1;
}
to {
stroke-dashoffset: 0;
}
}
@keyframes handle {
from {
offset-distance: 0%;
}
to {
offset-distance: 99%;
}
}
let svgRect: DOMRect
let offsetX: number
let activeHandle: HTMLButtonElement | null = null
const carousel = document.querySelector<HTMLElement>('.carousel')!
const svg = document.querySelector<SVGSVGElement>('.track')!
const handle = document.querySelector<HTMLButtonElement>('.handle')!
handle.addEventListener('pointerdown', onPointerDown)
function onPointerDown(event: PointerEvent) {
const handleRect = handle.getBoundingClientRect()
// Keep receiving pointer events while dragging outside the handle.
activeHandle = handle
handle.setPointerCapture(event.pointerId)
// Measure once at the start so pointer movement maps to track progress.
svgRect = svg.getBoundingClientRect()
offsetX = event.clientX - handleRect.left
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
}
function onPointerMove(event: PointerEvent) {
const newX = event.clientX - offsetX
const percentageX = ((newX - svgRect.left) / svgRect.width) * 100
// Convert track progress to the carousel's scroll range.
carousel.scrollTo({
left: (percentageX / 100) * (carousel.scrollWidth - carousel.clientWidth),
behavior: 'instant',
})
}
function onPointerUp(event: PointerEvent) {
activeHandle?.releasePointerCapture(event.pointerId)
activeHandle = null
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
}