Skip to content

Instantly share code, notes, and snippets.

@NamesMT
Created November 1, 2025 09:18
Show Gist options
  • Select an option

  • Save NamesMT/3b254e302a9ebf1ad69834ee7ad5794f to your computer and use it in GitHub Desktop.

Select an option

Save NamesMT/3b254e302a9ebf1ad69834ee7ad5794f to your computer and use it in GitHub Desktop.
shadcn-vue Embla Carousel Scrollbar
<script setup lang="ts">
import type { EmblaCarouselType } from 'embla-carousel'
import { onBeforeUnmount, ref, watch } from 'vue'
import { useCarousel } from '@/lib/shadcn/components/ui/carousel/useCarousel'
const { carouselApi } = useCarousel()
const snapList = ref<number[]>([])
/**
* Refs / state
*/
const scrollbarRef = ref<HTMLDivElement | null>(null)
const scrollbarTrackRef = ref<HTMLDivElement | null>(null)
const isDragging = ref(false)
/**
* Helpers
*/
function findClosestIndex(snaps: number[], targetPercent: number): number {
let idx = 0
let min = Infinity
for (let i = 0; i < snaps.length; i++) {
const d = Math.abs(snaps[i]! - targetPercent)
if (d < min) {
min = d
idx = i
}
}
return idx
}
/**
* UI translation util
*/
function translateScrollbar(newPercent: number) {
const track = scrollbarTrackRef.value?.children[0]
const scrollbar = scrollbarRef.value
if (!track || !scrollbar)
return
const pct = Math.max(0, Math.min(100, newPercent))
const trackWidth = track.clientWidth
const scrollbarWidth = scrollbar.clientWidth
const maxTranslateX = trackWidth - scrollbarWidth
const translateX = (pct / 100) * maxTranslateX
scrollbar.style.transform = `translateX(${translateX}px)`
}
/**
* Handlers
*/
function handleTrackClick(event: MouseEvent) {
event.preventDefault()
event.stopPropagation()
const emblaApi = carouselApi.value
const scrollSnaps = snapList.value
if (!emblaApi || event.target === scrollbarRef.value || !scrollSnaps?.length)
return
const rect = scrollbarTrackRef.value?.getBoundingClientRect()
if (!rect)
return
// 0..1 progress across the track
const progress = (event.clientX - rect.left) / rect.width
const clamped = Math.max(0, Math.min(1, progress))
const closestIndex = findClosestIndex(scrollSnaps, clamped)
emblaApi.scrollTo(closestIndex)
}
function handleMouseMoveScrollbar(event: MouseEvent | TouchEvent) {
const emblaApi = carouselApi.value
const scrollSnaps = snapList.value
if (!emblaApi || !isDragging.value || !scrollSnaps?.length)
return
const track = scrollbarTrackRef.value?.children[0]
const scrollbar = scrollbarRef.value
if (!track || !scrollbar)
return
let clientX: number
if (event.type === 'touchmove') {
const t = (event as TouchEvent).touches[0]
if (!t)
return
clientX = t.clientX
}
else {
clientX = (event as MouseEvent).clientX
}
const rect = track.getBoundingClientRect()
const newTranslateX = Math.max(
0,
Math.min(
track.clientWidth - scrollbar.clientWidth,
clientX - rect.left - scrollbar.clientWidth / 2,
),
)
scrollbar.style.transform = `translateX(${newTranslateX}px)`
// Progress across the track in 0..1
const progress = newTranslateX / (track.clientWidth - scrollbar.clientWidth)
const closestIndex = findClosestIndex(scrollSnaps, progress)
// Drive Embla engine with normalized progress
const engine = emblaApi.internalEngine()
const maxWidthApiScrollProgressPx = engine.limit.length
const rangeValueForEmblaEngine = -progress * maxWidthApiScrollProgressPx
engine.animation.stop()
engine.translate.to(rangeValueForEmblaEngine)
engine.location.set(rangeValueForEmblaEngine)
engine.index.set(closestIndex)
}
function handleMouseUp(e: MouseEvent | TouchEvent) {
if (e.type !== 'touchmove') {
e.preventDefault?.()
e.stopPropagation?.()
}
isDragging.value = false
window.removeEventListener('mousemove', handleMouseMoveScrollbar)
window.removeEventListener('mouseup', handleMouseUp)
window.removeEventListener('touchmove', handleMouseMoveScrollbar)
window.removeEventListener('touchend', handleMouseUp)
}
function handleMouseDown(e: MouseEvent | TouchEvent) {
if ((e as any).type !== 'touchmove') {
e.preventDefault?.()
e.stopPropagation?.()
}
isDragging.value = true
window.addEventListener('mousemove', handleMouseMoveScrollbar, { passive: true })
window.addEventListener('mouseup', handleMouseUp, { passive: false })
window.addEventListener('touchmove', handleMouseMoveScrollbar, { passive: true })
window.addEventListener('touchend', handleMouseUp, { passive: true })
}
/**
* Embla scroll hook
*/
function onScroll(emblaApi: EmblaCarouselType | null | undefined) {
if (!emblaApi)
return
translateScrollbar(emblaApi.scrollProgress() * 100)
}
onBeforeUnmount(() => {
window.removeEventListener('mousemove', handleMouseMoveScrollbar)
window.removeEventListener('mouseup', handleMouseUp)
window.removeEventListener('touchmove', handleMouseMoveScrollbar)
window.removeEventListener('touchend', handleMouseUp)
})
watch(
() => carouselApi.value,
(emblaApi) => {
if (emblaApi) {
onScroll(emblaApi)
emblaApi.on('reInit', onScroll).on('scroll', onScroll)
snapList.value = emblaApi.scrollSnapList()
emblaApi.on('reInit', () => { snapList.value = emblaApi.scrollSnapList() })
emblaApi.on('slidesChanged', () => { snapList.value = emblaApi.scrollSnapList() })
}
},
{ immediate: false },
)
</script>
<template>
<div
ref="scrollbarTrackRef"
class="px-0.5 rounded-md bg-black/30 h-3 w-full cursor-pointer select-none touch-none"
@click="handleTrackClick"
>
<div
class="rounded-inherit size-full select-none relative"
>
<div
ref="scrollbarRef"
class="rounded-lg bg-surface-300/80 h-2 translate-y-0.5 [will-change:transform] left-0 top-0 absolute"
:style="{
width: `calc(100% / ${snapList.length || 1})`,
cursor: isDragging ? 'grabbing' : 'grab',
}"
@mousedown="handleMouseDown"
@touchstart.prevent.stop="handleMouseDown"
/>
</div>
</div>
</template>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment