mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-09 16:39:31 -06:00
Component Boom
This commit is contained in:
277
src/content/Components/Carousel/Carousel.vue
Normal file
277
src/content/Components/Carousel/Carousel.vue
Normal file
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<div ref="containerRef" :class="[
|
||||
'relative overflow-hidden p-4',
|
||||
round
|
||||
? 'rounded-full border border-[#333]'
|
||||
: 'rounded-[24px] border border-[#333]'
|
||||
]" :style="{
|
||||
width: `${baseWidth}px`,
|
||||
...(round && { height: `${baseWidth}px` }),
|
||||
}">
|
||||
<Motion tag="div" class="flex" drag="x" :dragConstraints="dragConstraints" :style="{
|
||||
width: itemWidth + 'px',
|
||||
gap: `${GAP}px`,
|
||||
perspective: 1000,
|
||||
perspectiveOrigin: `${currentIndex * trackItemOffset + itemWidth / 2}px 50%`,
|
||||
x: motionX,
|
||||
}" @dragEnd="handleDragEnd" :animate="{ x: -(currentIndex * trackItemOffset) }" :transition="effectiveTransition"
|
||||
@animationComplete="handleAnimationComplete">
|
||||
<Motion v-for="(item, index) in carouselItems" :key="index" tag="div" :class="[
|
||||
'relative shrink-0 flex flex-col overflow-hidden cursor-grab active:cursor-grabbing',
|
||||
round
|
||||
? 'items-center justify-center text-center bg-[#111] border border-[#333] rounded-full'
|
||||
: 'items-start justify-between bg-[#111] border border-[#333] rounded-[12px]'
|
||||
]" :style="{
|
||||
width: itemWidth + 'px',
|
||||
height: round ? itemWidth + 'px' : '100%',
|
||||
rotateY: getRotateY(index),
|
||||
...(round && { borderRadius: '50%' }),
|
||||
}" :transition="effectiveTransition">
|
||||
<div :class="round ? 'p-0 m-0' : 'mb-4 p-5'">
|
||||
<span class="flex h-[28px] w-[28px] items-center justify-center rounded-full bg-[#060010]">
|
||||
<i :class="item.icon" class="text-white text-base"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div class="mb-1 font-black text-lg text-white">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<p class="text-sm text-white">{{ item.description }}</p>
|
||||
</div>
|
||||
</Motion>
|
||||
</Motion>
|
||||
<div :class="[
|
||||
'flex w-full justify-center',
|
||||
round ? 'absolute z-20 bottom-12 left-1/2 -translate-x-1/2' : ''
|
||||
]">
|
||||
<div class="mt-4 flex w-[150px] justify-between px-8">
|
||||
<Motion v-for="(_, index) in items" :key="index" tag="div" :class="[
|
||||
'h-2 w-2 rounded-full cursor-pointer transition-colors duration-150',
|
||||
currentIndex % items.length === index
|
||||
? round
|
||||
? 'bg-white'
|
||||
: 'bg-[#333333]'
|
||||
: round
|
||||
? 'bg-[#555]'
|
||||
: 'bg-[rgba(51,51,51,0.4)]'
|
||||
]" :animate="{
|
||||
scale: currentIndex % items.length === index ? 1.2 : 1,
|
||||
}" @click="() => setCurrentIndex(index)" :transition="{ duration: 0.15 }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export interface CarouselItem {
|
||||
title: string
|
||||
description: string
|
||||
id: number
|
||||
icon: string
|
||||
}
|
||||
|
||||
export interface CarouselProps {
|
||||
items?: CarouselItem[]
|
||||
baseWidth?: number
|
||||
autoplay?: boolean
|
||||
autoplayDelay?: number
|
||||
pauseOnHover?: boolean
|
||||
loop?: boolean
|
||||
round?: boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_ITEMS: CarouselItem[] = [
|
||||
{
|
||||
title: "Text Animations",
|
||||
description: "Cool text animations for your projects.",
|
||||
id: 1,
|
||||
icon: "pi pi-file",
|
||||
},
|
||||
{
|
||||
title: "Animations",
|
||||
description: "Smooth animations for your projects.",
|
||||
id: 2,
|
||||
icon: "pi pi-circle",
|
||||
},
|
||||
{
|
||||
title: "Components",
|
||||
description: "Reusable components for your projects.",
|
||||
id: 3,
|
||||
icon: "pi pi-objects-column",
|
||||
},
|
||||
{
|
||||
title: "Backgrounds",
|
||||
description: "Beautiful backgrounds and patterns for your projects.",
|
||||
id: 4,
|
||||
icon: "pi pi-table",
|
||||
},
|
||||
{
|
||||
title: "Common UI",
|
||||
description: "Common UI components are coming soon!",
|
||||
id: 5,
|
||||
icon: "pi pi-code",
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { Motion, useMotionValue, useTransform } from 'motion-v'
|
||||
|
||||
const DRAG_BUFFER = 0
|
||||
const VELOCITY_THRESHOLD = 500
|
||||
const GAP = 16
|
||||
const SPRING_OPTIONS = { type: "spring" as const, stiffness: 300, damping: 30 }
|
||||
|
||||
const props = withDefaults(defineProps<CarouselProps>(), {
|
||||
items: () => DEFAULT_ITEMS,
|
||||
baseWidth: 300,
|
||||
autoplay: false,
|
||||
autoplayDelay: 3000,
|
||||
pauseOnHover: false,
|
||||
loop: false,
|
||||
round: false,
|
||||
})
|
||||
|
||||
const containerPadding = 16
|
||||
const itemWidth = computed(() => props.baseWidth - containerPadding * 2)
|
||||
const trackItemOffset = computed(() => itemWidth.value + GAP)
|
||||
|
||||
const carouselItems = computed(() => props.loop ? [...props.items, props.items[0]] : props.items)
|
||||
const currentIndex = ref<number>(0)
|
||||
const motionX = useMotionValue(0)
|
||||
const isHovered = ref<boolean>(false)
|
||||
const isResetting = ref<boolean>(false)
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
let autoplayTimer: number | null = null
|
||||
|
||||
const dragConstraints = computed(() => {
|
||||
return props.loop
|
||||
? {}
|
||||
: {
|
||||
left: -trackItemOffset.value * (carouselItems.value.length - 1),
|
||||
right: 0,
|
||||
}
|
||||
})
|
||||
|
||||
const effectiveTransition = computed(() =>
|
||||
isResetting.value ? { duration: 0 } : SPRING_OPTIONS
|
||||
)
|
||||
|
||||
const maxItems = Math.max(props.items.length + 1, 10)
|
||||
const rotateYTransforms = Array.from({ length: maxItems }, (_, index) => {
|
||||
const range = computed(() => [
|
||||
-(index + 1) * trackItemOffset.value,
|
||||
-index * trackItemOffset.value,
|
||||
-(index - 1) * trackItemOffset.value,
|
||||
])
|
||||
const outputRange = [90, 0, -90]
|
||||
return useTransform(motionX, range, outputRange, { clamp: false })
|
||||
})
|
||||
|
||||
const getRotateY = (index: number) => {
|
||||
return rotateYTransforms[index] || rotateYTransforms[0]
|
||||
}
|
||||
|
||||
const setCurrentIndex = (index: number) => {
|
||||
currentIndex.value = index
|
||||
}
|
||||
|
||||
const handleAnimationComplete = () => {
|
||||
if (props.loop && currentIndex.value === carouselItems.value.length - 1) {
|
||||
isResetting.value = true
|
||||
motionX.set(0)
|
||||
currentIndex.value = 0
|
||||
setTimeout(() => {
|
||||
isResetting.value = false
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
|
||||
interface DragInfo {
|
||||
offset: { x: number; y: number }
|
||||
velocity: { x: number; y: number }
|
||||
}
|
||||
|
||||
const handleDragEnd = (event: Event, info: DragInfo) => {
|
||||
const offset = info.offset.x
|
||||
const velocity = info.velocity.x
|
||||
|
||||
if (offset < -DRAG_BUFFER || velocity < -VELOCITY_THRESHOLD) {
|
||||
if (props.loop && currentIndex.value === props.items.length - 1) {
|
||||
currentIndex.value = currentIndex.value + 1
|
||||
} else {
|
||||
currentIndex.value = Math.min(currentIndex.value + 1, carouselItems.value.length - 1)
|
||||
}
|
||||
} else if (offset > DRAG_BUFFER || velocity > VELOCITY_THRESHOLD) {
|
||||
if (props.loop && currentIndex.value === 0) {
|
||||
currentIndex.value = props.items.length - 1
|
||||
} else {
|
||||
currentIndex.value = Math.max(currentIndex.value - 1, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const startAutoplay = () => {
|
||||
if (props.autoplay && (!props.pauseOnHover || !isHovered.value)) {
|
||||
autoplayTimer = window.setInterval(() => {
|
||||
currentIndex.value = (() => {
|
||||
const prev = currentIndex.value
|
||||
if (prev === props.items.length - 1 && props.loop) {
|
||||
return prev + 1
|
||||
}
|
||||
if (prev === carouselItems.value.length - 1) {
|
||||
return props.loop ? 0 : prev
|
||||
}
|
||||
return prev + 1
|
||||
})()
|
||||
}, props.autoplayDelay)
|
||||
}
|
||||
}
|
||||
|
||||
const stopAutoplay = () => {
|
||||
if (autoplayTimer) {
|
||||
clearInterval(autoplayTimer)
|
||||
autoplayTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
isHovered.value = true
|
||||
if (props.pauseOnHover) {
|
||||
stopAutoplay()
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
isHovered.value = false
|
||||
if (props.pauseOnHover) {
|
||||
startAutoplay()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => props.autoplay, () => props.autoplayDelay, isHovered, () => props.loop, () => props.items.length, () => carouselItems.value.length, () => props.pauseOnHover],
|
||||
() => {
|
||||
stopAutoplay()
|
||||
startAutoplay()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (props.pauseOnHover && containerRef.value) {
|
||||
containerRef.value.addEventListener('mouseenter', handleMouseEnter)
|
||||
containerRef.value.addEventListener('mouseleave', handleMouseLeave)
|
||||
}
|
||||
startAutoplay()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (containerRef.value) {
|
||||
containerRef.value.removeEventListener('mouseenter', handleMouseEnter)
|
||||
containerRef.value.removeEventListener('mouseleave', handleMouseLeave)
|
||||
}
|
||||
stopAutoplay()
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user