Created
November 1, 2025 09:18
-
-
Save NamesMT/3b254e302a9ebf1ad69834ee7ad5794f to your computer and use it in GitHub Desktop.
shadcn-vue Embla Carousel Scrollbar
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <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