mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 06:29:30 -07:00
Merge pull request #59 from Gazoon007/feat/bounce-cards
Create <BounceCards /> component
This commit is contained in:
@@ -82,6 +82,7 @@ export const CATEGORIES = [
|
|||||||
'Elastic Slider',
|
'Elastic Slider',
|
||||||
'Stack',
|
'Stack',
|
||||||
'Chroma Grid',
|
'Chroma Grid',
|
||||||
|
'Bounce Cards',
|
||||||
'Counter',
|
'Counter',
|
||||||
'Rolling Gallery'
|
'Rolling Gallery'
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ const components = {
|
|||||||
'tilted-card': () => import('../demo/Components/TiltedCardDemo.vue'),
|
'tilted-card': () => import('../demo/Components/TiltedCardDemo.vue'),
|
||||||
'stack': () => import('../demo/Components/StackDemo.vue'),
|
'stack': () => import('../demo/Components/StackDemo.vue'),
|
||||||
'chroma-grid': () => import('../demo/Components/ChromaGridDemo.vue'),
|
'chroma-grid': () => import('../demo/Components/ChromaGridDemo.vue'),
|
||||||
|
'bounce-cards': () => import('../demo/Components/BounceCardsDemo.vue'),
|
||||||
'counter': () => import('../demo/Components/CounterDemo.vue'),
|
'counter': () => import('../demo/Components/CounterDemo.vue'),
|
||||||
'rolling-gallery': () => import('../demo/Components/RollingGalleryDemo.vue'),
|
'rolling-gallery': () => import('../demo/Components/RollingGalleryDemo.vue'),
|
||||||
'scroll-stack': () => import('../demo/Components/ScrollStackDemo.vue'),
|
'scroll-stack': () => import('../demo/Components/ScrollStackDemo.vue'),
|
||||||
|
|||||||
39
src/constants/code/Components/bounceCardsCode.ts
Normal file
39
src/constants/code/Components/bounceCardsCode.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import code from '@content/Components/BounceCards/BounceCards.vue?raw';
|
||||||
|
import { createCodeObject } from '@/types/code';
|
||||||
|
|
||||||
|
export const bounceCards = createCodeObject(code, 'Components/BounceCards', {
|
||||||
|
installation: `npm install gsap`,
|
||||||
|
usage: `<template>
|
||||||
|
<BounceCards
|
||||||
|
:images="images"
|
||||||
|
:container-width="500"
|
||||||
|
:container-height="250"
|
||||||
|
:animation-delay="0.5"
|
||||||
|
:animation-stagger="0.06"
|
||||||
|
ease-type="elastic.out(1, 0.8)"
|
||||||
|
:transform-styles="transformStyles"
|
||||||
|
:enable-hover="true"
|
||||||
|
class="custom-bounce-cards"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import BounceCards from "./BounceCards.vue";
|
||||||
|
|
||||||
|
const images = [
|
||||||
|
'https://picsum.photos/400/400?grayscale',
|
||||||
|
'https://picsum.photos/500/500?grayscale',
|
||||||
|
'https://picsum.photos/600/600?grayscale',
|
||||||
|
'https://picsum.photos/700/700?grayscale',
|
||||||
|
'https://picsum.photos/300/300?grayscale'
|
||||||
|
];
|
||||||
|
|
||||||
|
const transformStyles = [
|
||||||
|
'rotate(5deg) translate(-150px)',
|
||||||
|
'rotate(0deg) translate(-70px)',
|
||||||
|
'rotate(-5deg)',
|
||||||
|
'rotate(5deg) translate(70px)',
|
||||||
|
'rotate(-5deg) translate(150px)'
|
||||||
|
];
|
||||||
|
</script>`
|
||||||
|
});
|
||||||
177
src/content/Components/BounceCards/BounceCards.vue
Normal file
177
src/content/Components/BounceCards/BounceCards.vue
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="['relative flex items-center justify-center', className]"
|
||||||
|
:style="{
|
||||||
|
width: typeof containerWidth === 'number' ? `${containerWidth}px` : containerWidth,
|
||||||
|
height: typeof containerHeight === 'number' ? `${containerHeight}px` : containerHeight
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(src, idx) in images"
|
||||||
|
:key="idx"
|
||||||
|
ref="cardRefs"
|
||||||
|
class="absolute w-[200px] aspect-square border-[5px] border-white rounded-[25px] overflow-hidden shadow-[0_4px_10px_rgba(0,0,0,0.2)] bg-[#f8f9fa] opacity-0"
|
||||||
|
:style="{ transform: transformStyles[idx] ?? 'none' }"
|
||||||
|
@mouseenter="() => pushSiblings(idx)"
|
||||||
|
@mouseleave="resetSiblings"
|
||||||
|
>
|
||||||
|
<div v-if="!imageLoaded[idx]" class="absolute inset-0 z-[1] flex items-center justify-center bg-black/80">
|
||||||
|
<div class="w-[75px] h-[75px] border-[3px] border-gray-400 border-t-[#27FF64] rounded-full animate-spin"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<img
|
||||||
|
class="absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ease-in-out z-[2]"
|
||||||
|
:src="src"
|
||||||
|
:alt="`card-${idx}`"
|
||||||
|
:style="{ opacity: imageLoaded[idx] ? 1 : 0 }"
|
||||||
|
@load="() => onImageLoad(idx)"
|
||||||
|
@error="() => onImageError(idx)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, ref, watch, nextTick } from 'vue';
|
||||||
|
import { gsap } from 'gsap';
|
||||||
|
|
||||||
|
export interface BounceCardsProps {
|
||||||
|
className?: string;
|
||||||
|
images?: string[];
|
||||||
|
containerWidth?: number | string;
|
||||||
|
containerHeight?: number | string;
|
||||||
|
animationDelay?: number;
|
||||||
|
animationStagger?: number;
|
||||||
|
easeType?: string;
|
||||||
|
transformStyles?: string[];
|
||||||
|
enableHover?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<BounceCardsProps>(), {
|
||||||
|
className: '',
|
||||||
|
images: () => [],
|
||||||
|
containerWidth: 400,
|
||||||
|
containerHeight: 400,
|
||||||
|
animationDelay: 0.5,
|
||||||
|
animationStagger: 0.06,
|
||||||
|
easeType: 'elastic.out(1, 0.8)',
|
||||||
|
transformStyles: () => [
|
||||||
|
'rotate(10deg) translate(-170px)',
|
||||||
|
'rotate(5deg) translate(-85px)',
|
||||||
|
'rotate(-3deg)',
|
||||||
|
'rotate(-10deg) translate(85px)',
|
||||||
|
'rotate(2deg) translate(170px)'
|
||||||
|
],
|
||||||
|
enableHover: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageLoaded = ref(new Array(props.images.length).fill(false));
|
||||||
|
const cardRefs = ref<HTMLElement[]>([]);
|
||||||
|
|
||||||
|
const getNoRotationTransform = (transformStr: string): string => {
|
||||||
|
const hasRotate = /rotate\([\s\S]*?\)/.test(transformStr);
|
||||||
|
if (hasRotate) {
|
||||||
|
return transformStr.replace(/rotate\([\s\S]*?\)/, 'rotate(0deg)');
|
||||||
|
} else if (transformStr === 'none') {
|
||||||
|
return 'rotate(0deg)';
|
||||||
|
} else {
|
||||||
|
return `${transformStr} rotate(0deg)`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPushedTransform = (baseTransform: string, offsetX: number): string => {
|
||||||
|
const translateRegex = /translate\(([-0-9.]+)px\)/;
|
||||||
|
const match = baseTransform.match(translateRegex);
|
||||||
|
if (match) {
|
||||||
|
const currentX = parseFloat(match[1]);
|
||||||
|
const newX = currentX + offsetX;
|
||||||
|
return baseTransform.replace(translateRegex, `translate(${newX}px)`);
|
||||||
|
} else {
|
||||||
|
return baseTransform === 'none' ? `translate(${offsetX}px)` : `${baseTransform} translate(${offsetX}px)`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pushSiblings = (hoveredIdx: number) => {
|
||||||
|
if (!props.enableHover) return;
|
||||||
|
|
||||||
|
props.images.forEach((_, i) => {
|
||||||
|
gsap.killTweensOf(cardRefs.value[i]);
|
||||||
|
|
||||||
|
const baseTransform = props.transformStyles[i] || 'none';
|
||||||
|
|
||||||
|
if (i === hoveredIdx) {
|
||||||
|
const noRotationTransform = getNoRotationTransform(baseTransform);
|
||||||
|
gsap.to(cardRefs.value[i], {
|
||||||
|
transform: noRotationTransform,
|
||||||
|
duration: 0.4,
|
||||||
|
ease: 'back.out(1.4)',
|
||||||
|
overwrite: 'auto'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const offsetX = i < hoveredIdx ? -160 : 160;
|
||||||
|
const pushedTransform = getPushedTransform(baseTransform, offsetX);
|
||||||
|
const distance = Math.abs(hoveredIdx - i);
|
||||||
|
const delay = distance * 0.05;
|
||||||
|
|
||||||
|
gsap.to(cardRefs.value[i], {
|
||||||
|
transform: pushedTransform,
|
||||||
|
duration: 0.4,
|
||||||
|
ease: 'back.out(1.4)',
|
||||||
|
delay,
|
||||||
|
overwrite: 'auto'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetSiblings = () => {
|
||||||
|
if (!props.enableHover) return;
|
||||||
|
|
||||||
|
props.images.forEach((_, i) => {
|
||||||
|
gsap.killTweensOf(cardRefs.value[i]);
|
||||||
|
const baseTransform = props.transformStyles[i] || 'none';
|
||||||
|
gsap.to(cardRefs.value[i], {
|
||||||
|
transform: baseTransform,
|
||||||
|
duration: 0.4,
|
||||||
|
ease: 'back.out(1.4)',
|
||||||
|
overwrite: 'auto'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onImageLoad = (idx: number) => {
|
||||||
|
imageLoaded.value[idx] = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onImageError = (idx: number) => {
|
||||||
|
imageLoaded.value[idx] = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const playEntranceAnimation = () => {
|
||||||
|
gsap.killTweensOf(cardRefs.value);
|
||||||
|
gsap.set(cardRefs.value, { opacity: 0, scale: 0 });
|
||||||
|
|
||||||
|
gsap.fromTo(
|
||||||
|
cardRefs.value,
|
||||||
|
{ scale: 0, opacity: 0 },
|
||||||
|
{
|
||||||
|
scale: 1,
|
||||||
|
opacity: 1,
|
||||||
|
stagger: props.animationStagger,
|
||||||
|
ease: props.easeType,
|
||||||
|
delay: props.animationDelay
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(playEntranceAnimation);
|
||||||
|
watch(() => props.images, async () => {
|
||||||
|
await nextTick();
|
||||||
|
gsap.set(cardRefs.value, { opacity: 0, scale: 0 });
|
||||||
|
playEntranceAnimation();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
gsap.killTweensOf(cardRefs.value);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
162
src/demo/Components/BounceCardsDemo.vue
Normal file
162
src/demo/Components/BounceCardsDemo.vue
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
|
||||||
|
<template>
|
||||||
|
<TabbedLayout>
|
||||||
|
<template #preview>
|
||||||
|
<div class="demo-container bounce-cards-demo">
|
||||||
|
<RefreshButton @refresh="forceRerender" />
|
||||||
|
|
||||||
|
<BounceCards
|
||||||
|
:key="rerenderKey"
|
||||||
|
class="custom-bounceCards"
|
||||||
|
:images="images"
|
||||||
|
:animation-delay="animationDelay"
|
||||||
|
:animation-stagger="animationStagger"
|
||||||
|
ease-type="elastic.out(1, 0.5)"
|
||||||
|
:transform-styles="transformStyles"
|
||||||
|
:enable-hover="enableHover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Customize>
|
||||||
|
<PreviewSwitch
|
||||||
|
title="Enable Hover Effect"
|
||||||
|
v-model="enableHover"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PreviewSlider
|
||||||
|
title="Animation Delay"
|
||||||
|
v-model="animationDelay"
|
||||||
|
:min="0.1"
|
||||||
|
:max="2"
|
||||||
|
:step="0.1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PreviewSlider
|
||||||
|
title="Animation Stagger"
|
||||||
|
v-model="animationStagger"
|
||||||
|
:min="0"
|
||||||
|
:max="0.3"
|
||||||
|
:step="0.01"
|
||||||
|
/>
|
||||||
|
</Customize>
|
||||||
|
|
||||||
|
<PropTable :data="propData" />
|
||||||
|
|
||||||
|
<Dependencies :dependency-list="['gsap']" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #code>
|
||||||
|
<CodeExample :code-object="bounceCards" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cli>
|
||||||
|
<CliInstallation :command="bounceCards.cli" />
|
||||||
|
</template>
|
||||||
|
</TabbedLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import TabbedLayout from '@/components/common/TabbedLayout.vue';
|
||||||
|
import RefreshButton from '@/components/common/RefreshButton.vue';
|
||||||
|
import PropTable from '@/components/common/PropTable.vue';
|
||||||
|
import Dependencies from '@/components/code/Dependencies.vue';
|
||||||
|
import CliInstallation from '@/components/code/CliInstallation.vue';
|
||||||
|
import CodeExample from '@/components/code/CodeExample.vue';
|
||||||
|
import Customize from '@/components/common/Customize.vue';
|
||||||
|
import PreviewSwitch from '@/components/common/PreviewSwitch.vue';
|
||||||
|
import PreviewSlider from '@/components/common/PreviewSlider.vue';
|
||||||
|
import BounceCards from '@/content/Components/BounceCards/BounceCards.vue';
|
||||||
|
import { bounceCards } from '@/constants/code/Components/bounceCardsCode';
|
||||||
|
import { useForceRerender } from '@/composables/useForceRerender';
|
||||||
|
|
||||||
|
const enableHover = ref(false);
|
||||||
|
const animationDelay = ref(1);
|
||||||
|
const animationStagger = ref(0.08);
|
||||||
|
const { rerenderKey, forceRerender } = useForceRerender();
|
||||||
|
|
||||||
|
const images = ref([
|
||||||
|
'https://picsum.photos/400/400?grayscale',
|
||||||
|
'https://picsum.photos/500/500?grayscale',
|
||||||
|
'https://picsum.photos/600/600?grayscale',
|
||||||
|
'https://picsum.photos/700/700?grayscale',
|
||||||
|
'https://picsum.photos/300/300?grayscale'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const transformStyles = ref([
|
||||||
|
'rotate(5deg) translate(-150px)',
|
||||||
|
'rotate(0deg) translate(-70px)',
|
||||||
|
'rotate(-5deg)',
|
||||||
|
'rotate(5deg) translate(70px)',
|
||||||
|
'rotate(-5deg) translate(150px)'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const propData = [
|
||||||
|
{
|
||||||
|
name: 'className',
|
||||||
|
type: 'string',
|
||||||
|
default: '-',
|
||||||
|
description: 'Additional CSS classes for the container.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'images',
|
||||||
|
type: 'string[]',
|
||||||
|
default: '[]',
|
||||||
|
description: 'Array of image URLs to display.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'containerWidth',
|
||||||
|
type: 'number',
|
||||||
|
default: '400',
|
||||||
|
description: 'Width of the container (px).'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'containerHeight',
|
||||||
|
type: 'number',
|
||||||
|
default: '400',
|
||||||
|
description: 'Height of the container (px).'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'animationDelay',
|
||||||
|
type: 'number',
|
||||||
|
default: '-',
|
||||||
|
description: 'Delay (in seconds) before the animation starts.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'animationStagger',
|
||||||
|
type: 'number',
|
||||||
|
default: '-',
|
||||||
|
description: 'Time (in seconds) between each card\'s animation.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'easeType',
|
||||||
|
type: 'string',
|
||||||
|
default: 'elastic.out(1, 0.8)',
|
||||||
|
description: 'Easing function for the bounce.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'transformStyles',
|
||||||
|
type: 'string[]',
|
||||||
|
default: 'various rotations/translations',
|
||||||
|
description: 'Custom transforms for each card position.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'enableHover',
|
||||||
|
type: 'boolean',
|
||||||
|
default: 'false',
|
||||||
|
description: 'If true, hovering pushes siblings aside and flattens the hovered card\'s rotation.'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bounce-cards-demo {
|
||||||
|
min-height: 400px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user