mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-09 08:29:30 -06:00
308 lines
8.2 KiB
Vue
308 lines
8.2 KiB
Vue
<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>
|