
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
carouseluiHorizontal or vertical scroller with previous/next controls. Hand-rolled `useCarousel` composable backed by native CSS scroll-snap — no external carousel library, just the browser. Drop in images, cards, or any custom slide content.
Also available for React ->$pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/carousel.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/carousel.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/carousel.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/carousel.jsonnpx shadcn-vue@latest add @uipkge/carouselInstalls to:app/components/ui/carousel/| Name | Type / Values | Default | Required |
|---|---|---|---|
modelValue | number | 0 | optional |
orientation | 'horizontal''vertical' | 'horizontal' | optional |
loop | boolean | false | optional |
class | HTMLAttributes['class'] | — | optional |
Type aliases from this item's source — use them to shape the data you pass in.
CarouselOptionsinterface CarouselOptions {
loop?: boolean
orientation?: 'horizontal' | 'vertical'
}UseCarouselReturninterface UseCarouselReturn {
activeIndex: Ref<number>
scrollSnaps: Ref<number[]>
canScrollPrev: Ref<boolean>
canScrollNext: Ref<boolean>
scrollTo: (index: number, smooth?: boolean) => void
scrollToPrev: (smooth?: boolean) => void
scrollToNext: (smooth?: boolean) => void
rootRef: Ref<HTMLElement | null>
orientation: Ref<'horizontal' | 'vertical'>
}<!--
Carousel Component
A flexible carousel/slider for cycling through content.
@example - Basic carousel with images
<Carousel>
<CarouselContent>
<CarouselItem v-for="img in images" :key="img.id">
<img :src="img.src" :alt="img.alt" class="w-full h-full object-cover" />
</CarouselItem>
</CarouselContent>
</Carousel>
@example - Carousel with navigation controls
<Carousel v-model:active-index="activeIndex" :loop="true">
<CarouselContent>
<CarouselItem v-for="slide in slides" :key="slide.id">
<SlideContent :content="slide" />
</CarouselItem>
</CarouselContent>
<template #footer>
<CarouselPrevious />
<CarouselIndicators />
<CarouselNext />
</template>
</Carousel>
@example - Vertical carousel
<Carousel orientation="vertical" :loop="false">
<CarouselContent>
<CarouselItem v-for="item in items" :key="item.id">
{{ item }}
</CarouselItem>
</CarouselContent>
</Carousel>
-->
<script setup lang="ts">
import { provide, computed, watch, onMounted, onUnmounted, type HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { useCarousel } from './useCarousel'
import { carouselVariants } from './carousel.variants'
export interface CarouselProps {
modelValue?: number
orientation?: 'horizontal' | 'vertical'
loop?: boolean
class?: HTMLAttributes['class']
}
const props = withDefaults(defineProps<CarouselProps>(), {
modelValue: 0,
orientation: 'horizontal',
loop: false,
})
const emit = defineEmits<{
'update:modelValue': [value: number]
}>()
const carousel = useCarousel({
loop: props.loop,
orientation: props.orientation,
})
// Sync modelValue → scroll when controlled externally
watch(
() => props.modelValue,
(newVal) => {
if (newVal !== carousel.activeIndex.value) {
carousel.scrollTo(newVal, false)
}
},
)
// Emit scroll position changes back to parent
function onScroll() {
const newIndex = carousel.activeIndex.value
if (newIndex !== props.modelValue) {
emit('update:modelValue', newIndex)
}
}
onMounted(() => {
if (carousel.rootRef.value) {
carousel.rootRef.value.addEventListener('scroll', onScroll, { passive: true })
}
})
onUnmounted(() => {
if (carousel.rootRef.value) {
carousel.rootRef.value.removeEventListener('scroll', onScroll)
}
})
// Provide to children
provide('carousel', {
...carousel,
orientation: computed(() => props.orientation),
loop: computed(() => props.loop),
})
</script>
<template>
<div
data-uipkge
data-slot="carousel"
:class="cn(carouselVariants({ orientation: props.orientation }), props.class)"
role="region"
aria-roledescription="carousel"
aria-label="Carousel"
>
<slot />
</div>
</template>
<!--
CarouselContent
The scrollable container for carousel items.
Uses CSS scroll-snap for smooth sliding behavior.
@example
<CarouselContent>
<CarouselItem v-for="slide in slides" :key="slide.id">
<img :src="slide.src" :alt="slide.alt" />
</CarouselItem>
</CarouselContent>
-->
<script setup lang="ts">
import { inject, computed, type HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
const carousel = inject<{
rootRef: { value: HTMLElement | null }
orientation: { value: 'horizontal' | 'vertical' }
} | null>('carousel', null)
const isHorizontal = computed(() => carousel?.orientation?.value !== 'vertical')
const scrollClass = computed(() => {
return isHorizontal.value
? 'flex overflow-x-auto scroll-smooth snap-x snap-mandatory'
: 'flex flex-col overflow-y-auto snap-y snap-mandatory'
})
</script>
<template>
<div
:ref="
(el) => {
if (carousel) carousel.rootRef.value = el as HTMLElement
}
"
data-uipkge
data-slot="carousel-content"
:class="
cn(
scrollClass,
'relative h-full w-full',
'[scrollbar-width:none] [-ms-overflow-style:none]',
'[&::-webkit-scrollbar]:hidden',
props.class,
)
"
:aria-orientation="carousel?.orientation?.value"
role="group"
>
<slot />
</div>
</template>
<!--
CarouselFooter
Wrapper for carousel footer content (pagination, indicators).
@example
<CarouselFooter>
<CarouselIndicators />
</CarouselFooter>
-->
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<div data-uipkge data-slot="carousel-footer" :class="cn('flex items-center justify-between px-1 pt-2', props.class)">
<slot />
</div>
</template>
<!--
CarouselHeader
Wrapper for carousel header content (title, controls).
@example
<CarouselHeader>
<h3>Featured Products</h3>
</CarouselHeader>
-->
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<div data-uipkge data-slot="carousel-header" :class="cn('flex items-center justify-between px-1 pb-2', props.class)">
<slot />
</div>
</template>
<!--
CarouselIndicators
Pagination dots showing current slide position.
Clicking a dot navigates to that slide.
@example
<CarouselIndicators />
-->
<script setup lang="ts">
import { inject, type HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
}
defineProps<Props>()
const carousel = inject<{
activeIndex: { value: number }
scrollSnaps: { value: number[] }
scrollTo: (index: number, smooth?: boolean) => void
} | null>('carousel', null)
</script>
<template>
<div
v-if="carousel"
data-uipkge
data-slot="carousel-indicators"
class="flex items-center justify-center gap-1.5 py-2"
role="tablist"
aria-label="Carousel navigation"
>
<button
v-for="(_, index) in carousel.scrollSnaps.value"
:key="index"
type="button"
role="tab"
:aria-label="`Go to slide ${index + 1}`"
:aria-selected="index === carousel.activeIndex.value"
:class="
cn(
'h-2 w-2 rounded-full transition-colors duration-200',
index === carousel.activeIndex.value
? 'bg-primary w-6'
: 'bg-muted-foreground/30 hover:bg-muted-foreground/50',
)
"
@click="carousel.scrollTo(index, true)"
/>
</div>
</template>
<!--
CarouselItem
Individual slide within the carousel.
Each item snaps into view when scrolled.
@example
<CarouselItem>
<img src="/img1.jpg" alt="Slide 1" />
</CarouselItem>
-->
<script setup lang="ts">
import { inject, computed, type HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { carouselItemVariants } from './index'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
const carousel = inject<{
orientation: { value: 'horizontal' | 'vertical' }
} | null>('carousel', null)
const itemClass = computed(() => {
const orientation = carousel?.orientation?.value ?? 'horizontal'
return carouselItemVariants({ orientation })
})
</script>
<template>
<div
data-uipkge
data-slot="carousel-item"
:class="cn(itemClass, 'shrink-0 grow-0 basis-full', 'snap-start', 'relative', props.class)"
role="group"
aria-roledescription="slide"
>
<slot />
</div>
</template>
<!--
CarouselNext
Navigation button to go to the next slide.
@example
<CarouselNext label="Next slide" />
-->
<script setup lang="ts">
import { inject, computed, type HTMLAttributes } from 'vue'
import { ChevronRight } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
interface Props {
class?: HTMLAttributes['class']
label?: string
}
const props = withDefaults(defineProps<Props>(), {
label: 'Next slide',
})
const carousel = inject<{
canScrollNext: { value: boolean }
scrollToNext: (smooth?: boolean) => void
orientation: { value: 'horizontal' | 'vertical' }
} | null>('carousel', null)
const isHorizontal = computed(() => carousel?.orientation?.value !== 'vertical')
</script>
<template>
<Button
type="button"
variant="outline"
size="icon"
:class="
cn(
'absolute z-10 size-8 shrink-0 rounded-full',
'bg-background/80 border shadow-md backdrop-blur-sm',
'hover:bg-accent hover:text-accent-foreground',
'disabled:pointer-events-none disabled:opacity-50',
isHorizontal ? 'top-1/2 -right-3 -translate-y-1/2' : '-bottom-3 left-1/2 -translate-x-1/2 rotate-90',
props.class,
)
"
:disabled="!carousel?.canScrollNext?.value"
:aria-label="props.label"
@click="carousel?.scrollToNext(true)"
>
<slot>
<ChevronRight class="size-4" aria-hidden="true" />
</slot>
</Button>
</template>
<!--
CarouselPrevious
Navigation button to go to the previous slide.
@example
<CarouselPrevious label="Previous slide" />
-->
<script setup lang="ts">
import { inject, computed, type HTMLAttributes } from 'vue'
import { ChevronLeft } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
interface Props {
class?: HTMLAttributes['class']
label?: string
}
const props = withDefaults(defineProps<Props>(), {
label: 'Previous slide',
})
const carousel = inject<{
canScrollPrev: { value: boolean }
scrollToPrev: (smooth?: boolean) => void
orientation: { value: 'horizontal' | 'vertical' }
} | null>('carousel', null)
const isHorizontal = computed(() => carousel?.orientation?.value !== 'vertical')
</script>
<template>
<Button
type="button"
variant="outline"
size="icon"
:class="
cn(
'absolute z-10 size-8 shrink-0 rounded-full',
'bg-background/80 border shadow-md backdrop-blur-sm',
'hover:bg-accent hover:text-accent-foreground',
'disabled:pointer-events-none disabled:opacity-50',
isHorizontal ? 'top-1/2 -left-3 -translate-y-1/2' : '-top-3 left-1/2 -translate-x-1/2 rotate-90',
props.class,
)
"
:disabled="!carousel?.canScrollPrev?.value"
:aria-label="props.label"
@click="carousel?.scrollToPrev(true)"
>
<slot>
<ChevronLeft class="size-4" aria-hidden="true" />
</slot>
</Button>
</template>
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
/**
* Variant definitions live in their own file (rather than the package
* `index.ts` or inline in the SFC) so `Carousel.vue` / `CarouselItem.vue`
* can import them without creating a circular dependency back through
* the index. The circular form caused intermittent
* `$setup.carouselVariants is not a function` errors during dev SSR.
*/
export const carouselVariants = cva('relative overflow-hidden', {
variants: {
orientation: {
horizontal: 'w-full',
vertical: 'h-full flex-col',
},
},
defaultVariants: {
orientation: 'horizontal',
},
})
export const carouselItemVariants = cva('flex shrink-0 grow-0 basis-full flex-col', {
variants: {
orientation: {
horizontal: 'w-full',
vertical: 'h-full',
},
},
defaultVariants: {
orientation: 'horizontal',
},
})
export type CarouselVariants = VariantProps<typeof carouselVariants>
export type CarouselItemVariants = VariantProps<typeof carouselItemVariants>
/**
* useCarousel
*
* Lightweight carousel composable using native CSS scroll-snap.
* No external dependencies required.
*/
import { ref, computed, onMounted, onUnmounted, type Ref } from 'vue'
export interface CarouselOptions {
loop?: boolean
orientation?: 'horizontal' | 'vertical'
}
export interface UseCarouselReturn {
activeIndex: Ref<number>
scrollSnaps: Ref<number[]>
canScrollPrev: Ref<boolean>
canScrollNext: Ref<boolean>
scrollTo: (index: number, smooth?: boolean) => void
scrollToPrev: (smooth?: boolean) => void
scrollToNext: (smooth?: boolean) => void
rootRef: Ref<HTMLElement | null>
orientation: Ref<'horizontal' | 'vertical'>
}
export function useCarousel(options: CarouselOptions = {}): UseCarouselReturn {
const { loop = false, orientation = 'horizontal' } = options
const rootRef = ref<HTMLElement | null>(null)
const activeIndex = ref(0)
const scrollSnaps = ref<number[]>([])
const canScrollPrev = ref(false)
const canScrollNext = ref(false)
const isHorizontal = computed(() => orientation === 'horizontal')
function getScrollPosition(): number {
if (!rootRef.value) return 0
return isHorizontal.value ? rootRef.value.scrollLeft : rootRef.value.scrollTop
}
function getItemSize(): number {
if (!rootRef.value) return 0
return isHorizontal.value ? rootRef.value.offsetWidth : rootRef.value.offsetHeight
}
function updateScrollState() {
if (!rootRef.value) return
const el = rootRef.value
const itemSize = getItemSize()
if (itemSize === 0) return
const scrollPos = getScrollPosition()
const scrollWidth = isHorizontal.value ? el.scrollWidth : el.scrollHeight
const viewportSize = isHorizontal.value ? el.offsetWidth : el.offsetHeight
// Calculate active index
activeIndex.value = Math.round(scrollPos / itemSize)
// Update scroll snaps
const newSnaps: number[] = []
const itemCount = Math.ceil(scrollWidth / itemSize)
for (let i = 0; i < itemCount; i++) {
newSnaps.push(i * itemSize)
}
scrollSnaps.value = newSnaps
// Update navigation state
canScrollPrev.value = loop || scrollPos > 0
canScrollNext.value = loop || scrollPos < scrollWidth - viewportSize - 1
}
function scrollTo(index: number, smooth = true) {
if (!rootRef.value || index < 0) return
const el = rootRef.value
const itemSize = getItemSize()
if (itemSize === 0) return
const targetScroll = index * itemSize
el.scrollTo({
left: isHorizontal.value ? targetScroll : 0,
top: isHorizontal.value ? 0 : targetScroll,
behavior: smooth ? 'smooth' : 'auto',
})
}
function scrollToPrev(smooth = true) {
const maxIndex = scrollSnaps.value.length - 1
if (maxIndex < 0) return
if (loop) {
const nextIndex = activeIndex.value <= 0 ? maxIndex : activeIndex.value - 1
scrollTo(nextIndex, smooth)
return
}
scrollTo(Math.max(0, activeIndex.value - 1), smooth)
}
function scrollToNext(smooth = true) {
const maxIndex = scrollSnaps.value.length - 1
if (maxIndex < 0) return
const nextIndex = loop ? (activeIndex.value + 1) % (maxIndex + 1) : Math.min(maxIndex, activeIndex.value + 1)
scrollTo(nextIndex, smooth)
}
let scrollHandler: (() => void) | null = null
let resizeHandler: (() => void) | null = null
onMounted(() => {
if (rootRef.value) {
updateScrollState()
scrollHandler = () => updateScrollState()
rootRef.value.addEventListener('scroll', scrollHandler, { passive: true })
resizeHandler = () => updateScrollState()
window.addEventListener('resize', resizeHandler, { passive: true })
}
})
onUnmounted(() => {
if (rootRef.value && scrollHandler) {
rootRef.value.removeEventListener('scroll', scrollHandler)
}
if (resizeHandler) {
window.removeEventListener('resize', resizeHandler)
}
})
return {
activeIndex,
scrollSnaps,
canScrollPrev,
canScrollNext,
scrollTo,
scrollToPrev,
scrollToNext,
rootRef,
orientation: ref(orientation),
}
}
export { default as Carousel } from './Carousel.vue'
export { default as CarouselContent } from './CarouselContent.vue'
export { default as CarouselItem } from './CarouselItem.vue'
export { default as CarouselPrevious } from './CarouselPrevious.vue'
export { default as CarouselNext } from './CarouselNext.vue'
export { default as CarouselIndicators } from './CarouselIndicators.vue'
export { default as CarouselHeader } from './CarouselHeader.vue'
export { default as CarouselFooter } from './CarouselFooter.vue'
// Re-export variant API from the sibling file (kept separate to avoid the
// Carousel.vue <-> index.ts circular import that broke dev SSR).
export {
carouselVariants,
carouselItemVariants,
type CarouselVariants,
type CarouselItemVariants,
} from './carousel.variants'
Raw manifest:https://uipkge.dev/r/vue/carousel.json