mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-06 22:19:30 -07:00
Added <MagicBento /> component
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// Highlighted sidebar items
|
||||
export const NEW = ['Target Cursor', 'Ripple Grid'];
|
||||
export const NEW = ['Target Cursor', 'Ripple Grid', 'Magic Bento'];
|
||||
export const UPDATED = [];
|
||||
|
||||
// Used for main sidebar navigation
|
||||
@@ -59,6 +59,7 @@ export const CATEGORIES = [
|
||||
subcategories: [
|
||||
'Animated List',
|
||||
'Masonry',
|
||||
'Magic Bento',
|
||||
'Profile Card',
|
||||
'Dock',
|
||||
'Gooey Nav',
|
||||
|
||||
@@ -47,6 +47,7 @@ const textAnimations = {
|
||||
const components = {
|
||||
'animated-list': () => import('../demo/Components/AnimatedListDemo.vue'),
|
||||
'masonry': () => import('../demo/Components/MasonryDemo.vue'),
|
||||
'magic-bento': () => import('../demo/Components/MagicBentoDemo.vue'),
|
||||
'profile-card': () => import('../demo/Components/ProfileCardDemo.vue'),
|
||||
'dock': () => import('../demo/Components/DockDemo.vue'),
|
||||
'gooey-nav': () => import('../demo/Components/GooeyNavDemo.vue'),
|
||||
|
||||
26
src/constants/code/Components/magicBentoCode.ts
Normal file
26
src/constants/code/Components/magicBentoCode.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import code from '@content/Components/MagicBento/MagicBento.vue?raw';
|
||||
import type { CodeObject } from '../../../types/code';
|
||||
|
||||
export const magicBento: CodeObject = {
|
||||
cli: `npx jsrepo add https://vue-bits.dev/ui/Components/MagicBento`,
|
||||
installation: `npm install gsap`,
|
||||
usage: `<template>
|
||||
<MagicBento
|
||||
:text-auto-hide="true"
|
||||
:enable-stars="true"
|
||||
:enable-spotlight="true"
|
||||
:enable-border-glow="true"
|
||||
:enable-tilt="true"
|
||||
:enable-magnetism="true"
|
||||
:click-effect="true"
|
||||
:spotlight-radius="300"
|
||||
:particle-count="12"
|
||||
glow-color="132, 0, 255"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import MagicBento from "./MagicBento.vue";
|
||||
</script>`,
|
||||
code
|
||||
};
|
||||
918
src/content/Components/MagicBento/MagicBento.vue
Normal file
918
src/content/Components/MagicBento/MagicBento.vue
Normal file
@@ -0,0 +1,918 @@
|
||||
<script setup lang="ts">
|
||||
import { gsap } from 'gsap';
|
||||
import { computed, defineComponent, nextTick, onMounted, onUnmounted, ref, watch, type PropType } from 'vue';
|
||||
|
||||
interface BentoCardProps {
|
||||
color?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
label?: string;
|
||||
textAutoHide?: boolean;
|
||||
disableAnimations?: boolean;
|
||||
}
|
||||
|
||||
interface BentoProps {
|
||||
textAutoHide?: boolean;
|
||||
enableStars?: boolean;
|
||||
enableSpotlight?: boolean;
|
||||
enableBorderGlow?: boolean;
|
||||
disableAnimations?: boolean;
|
||||
spotlightRadius?: number;
|
||||
particleCount?: number;
|
||||
enableTilt?: boolean;
|
||||
glowColor?: string;
|
||||
clickEffect?: boolean;
|
||||
enableMagnetism?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_PARTICLE_COUNT = 12;
|
||||
const DEFAULT_SPOTLIGHT_RADIUS = 300;
|
||||
const DEFAULT_GLOW_COLOR = '0, 200, 83';
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
const cardData: BentoCardProps[] = [
|
||||
{
|
||||
color: '#000C00',
|
||||
title: 'Analytics',
|
||||
description: 'Track user behavior',
|
||||
label: 'Insights'
|
||||
},
|
||||
{
|
||||
color: '#000C00',
|
||||
title: 'Dashboard',
|
||||
description: 'Centralized data view',
|
||||
label: 'Overview'
|
||||
},
|
||||
{
|
||||
color: '#000C00',
|
||||
title: 'Collaboration',
|
||||
description: 'Work together seamlessly',
|
||||
label: 'Teamwork'
|
||||
},
|
||||
{
|
||||
color: '#000C00',
|
||||
title: 'Automation',
|
||||
description: 'Streamline workflows',
|
||||
label: 'Efficiency'
|
||||
},
|
||||
{
|
||||
color: '#000C00',
|
||||
title: 'Integration',
|
||||
description: 'Connect favorite tools',
|
||||
label: 'Connectivity'
|
||||
},
|
||||
{
|
||||
color: '#000C00',
|
||||
title: 'Security',
|
||||
description: 'Enterprise-grade protection',
|
||||
label: 'Protection'
|
||||
}
|
||||
];
|
||||
|
||||
const createParticleElement = (x: number, y: number, color: string = DEFAULT_GLOW_COLOR): HTMLDivElement => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'particle';
|
||||
el.style.cssText = `
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: rgba(${color}, 1);
|
||||
box-shadow: 0 0 6px rgba(${color}, 0.6);
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
left: ${x}px;
|
||||
top: ${y}px;
|
||||
`;
|
||||
return el;
|
||||
};
|
||||
|
||||
const calculateSpotlightValues = (radius: number) => ({
|
||||
proximity: radius * 0.5,
|
||||
fadeDistance: radius * 0.75
|
||||
});
|
||||
|
||||
const updateCardGlowProperties = (card: HTMLElement, mouseX: number, mouseY: number, glow: number, radius: number) => {
|
||||
const rect = card.getBoundingClientRect();
|
||||
const relativeX = ((mouseX - rect.left) / rect.width) * 100;
|
||||
const relativeY = ((mouseY - rect.top) / rect.height) * 100;
|
||||
|
||||
card.style.setProperty('--glow-x', `${relativeX}%`);
|
||||
card.style.setProperty('--glow-y', `${relativeY}%`);
|
||||
card.style.setProperty('--glow-intensity', glow.toString());
|
||||
card.style.setProperty('--glow-radius', `${radius}px`);
|
||||
};
|
||||
|
||||
const ParticleCard = defineComponent({
|
||||
name: 'ParticleCard',
|
||||
props: {
|
||||
disableAnimations: { type: Boolean, default: false },
|
||||
particleCount: { type: Number, default: DEFAULT_PARTICLE_COUNT },
|
||||
glowColor: { type: String, default: DEFAULT_GLOW_COLOR },
|
||||
enableTilt: { type: Boolean, default: true },
|
||||
clickEffect: { type: Boolean, default: false },
|
||||
enableMagnetism: { type: Boolean, default: false }
|
||||
},
|
||||
setup(props) {
|
||||
const cardRef = ref<HTMLDivElement | null>(null);
|
||||
const particlesRef = ref<HTMLDivElement[]>([]);
|
||||
const timeoutsRef = ref<number[]>([]);
|
||||
const isHoveredRef = ref(false);
|
||||
const memoizedParticles = ref<HTMLDivElement[]>([]);
|
||||
const particlesInitialized = ref(false);
|
||||
const magnetismAnimationRef = ref<gsap.core.Tween | null>(null);
|
||||
|
||||
const initializeParticles = () => {
|
||||
if (particlesInitialized.value || !cardRef.value) return;
|
||||
|
||||
const { width, height } = cardRef.value.getBoundingClientRect();
|
||||
memoizedParticles.value = Array.from({ length: props.particleCount }, () =>
|
||||
createParticleElement(Math.random() * width, Math.random() * height, props.glowColor)
|
||||
);
|
||||
particlesInitialized.value = true;
|
||||
};
|
||||
|
||||
const clearAllParticles = () => {
|
||||
timeoutsRef.value.forEach(clearTimeout);
|
||||
timeoutsRef.value = [];
|
||||
magnetismAnimationRef.value?.kill();
|
||||
|
||||
particlesRef.value.forEach(particle => {
|
||||
gsap.to(particle, {
|
||||
scale: 0,
|
||||
opacity: 0,
|
||||
duration: 0.3,
|
||||
ease: 'back.in(1.7)',
|
||||
onComplete: () => {
|
||||
particle.parentNode?.removeChild(particle);
|
||||
}
|
||||
});
|
||||
});
|
||||
particlesRef.value = [];
|
||||
};
|
||||
|
||||
const animateParticles = () => {
|
||||
if (!cardRef.value || !isHoveredRef.value) return;
|
||||
|
||||
if (!particlesInitialized.value) {
|
||||
initializeParticles();
|
||||
}
|
||||
|
||||
memoizedParticles.value.forEach((particle, index) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!isHoveredRef.value || !cardRef.value) return;
|
||||
|
||||
const clone = particle.cloneNode(true) as HTMLDivElement;
|
||||
cardRef.value.appendChild(clone);
|
||||
particlesRef.value.push(clone);
|
||||
|
||||
gsap.fromTo(clone, { scale: 0, opacity: 0 }, { scale: 1, opacity: 1, duration: 0.3, ease: 'back.out(1.7)' });
|
||||
|
||||
gsap.to(clone, {
|
||||
x: (Math.random() - 0.5) * 100,
|
||||
y: (Math.random() - 0.5) * 100,
|
||||
rotation: Math.random() * 360,
|
||||
duration: 2 + Math.random() * 2,
|
||||
ease: 'none',
|
||||
repeat: -1,
|
||||
yoyo: true
|
||||
});
|
||||
|
||||
gsap.to(clone, {
|
||||
opacity: 0.3,
|
||||
duration: 1.5,
|
||||
ease: 'power2.inOut',
|
||||
repeat: -1,
|
||||
yoyo: true
|
||||
});
|
||||
}, index * 100);
|
||||
|
||||
timeoutsRef.value.push(timeoutId);
|
||||
});
|
||||
};
|
||||
|
||||
let cleanupEventListeners: (() => void) | null = null;
|
||||
const setupEventListeners = () => {
|
||||
if (props.disableAnimations || !cardRef.value) return;
|
||||
|
||||
const element = cardRef.value;
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
isHoveredRef.value = true;
|
||||
animateParticles();
|
||||
|
||||
gsap.to(element, { y: -2, duration: 0.3, ease: 'power2.out' });
|
||||
|
||||
if (props.enableTilt) {
|
||||
gsap.to(element, {
|
||||
rotateX: 5,
|
||||
rotateY: 5,
|
||||
duration: 0.3,
|
||||
ease: 'power2.out',
|
||||
transformPerspective: 1000
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
isHoveredRef.value = false;
|
||||
clearAllParticles();
|
||||
|
||||
if (props.enableTilt) {
|
||||
gsap.to(element, {
|
||||
rotateX: 0,
|
||||
rotateY: 0,
|
||||
y: 0,
|
||||
duration: 0.3,
|
||||
ease: 'power2.out'
|
||||
});
|
||||
}
|
||||
|
||||
if (props.enableMagnetism) {
|
||||
gsap.to(element, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
duration: 0.3,
|
||||
ease: 'power2.out'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!props.enableTilt && !props.enableMagnetism) return;
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
const centerX = rect.width / 2;
|
||||
const centerY = rect.height / 2;
|
||||
|
||||
if (props.enableTilt) {
|
||||
const rotateX = ((y - centerY) / centerY) * -10;
|
||||
const rotateY = ((x - centerX) / centerX) * 10;
|
||||
|
||||
gsap.to(element, {
|
||||
rotateX,
|
||||
rotateY,
|
||||
duration: 0.1,
|
||||
ease: 'power2.out',
|
||||
transformPerspective: 1000
|
||||
});
|
||||
}
|
||||
|
||||
if (props.enableMagnetism) {
|
||||
const magnetX = (x - centerX) * 0.05;
|
||||
const magnetY = (y - centerY) * 0.05;
|
||||
|
||||
magnetismAnimationRef.value = gsap.to(element, {
|
||||
x: magnetX,
|
||||
y: magnetY,
|
||||
duration: 0.3,
|
||||
ease: 'power2.out'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (!props.clickEffect) return;
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
const maxDistance = Math.max(
|
||||
Math.hypot(x, y),
|
||||
Math.hypot(x - rect.width, y),
|
||||
Math.hypot(x, y - rect.height),
|
||||
Math.hypot(x - rect.width, y - rect.height)
|
||||
);
|
||||
|
||||
const ripple = document.createElement('div');
|
||||
ripple.style.cssText = `
|
||||
position: absolute;
|
||||
width: ${maxDistance * 2}px;
|
||||
height: ${maxDistance * 2}px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(${props.glowColor}, 0.4) 0%, rgba(${props.glowColor}, 0.2) 30%, transparent 70%);
|
||||
left: ${x - maxDistance}px;
|
||||
top: ${y - maxDistance}px;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
`;
|
||||
|
||||
element.appendChild(ripple);
|
||||
|
||||
gsap.fromTo(
|
||||
ripple,
|
||||
{
|
||||
scale: 0,
|
||||
opacity: 1
|
||||
},
|
||||
{
|
||||
scale: 1,
|
||||
opacity: 0,
|
||||
duration: 0.8,
|
||||
ease: 'power2.out',
|
||||
onComplete: () => ripple.remove()
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
element.addEventListener('mouseenter', handleMouseEnter);
|
||||
element.addEventListener('mouseleave', handleMouseLeave);
|
||||
element.addEventListener('mousemove', handleMouseMove);
|
||||
element.addEventListener('click', handleClick);
|
||||
|
||||
cleanupEventListeners = () => {
|
||||
isHoveredRef.value = false;
|
||||
element.removeEventListener('mouseenter', handleMouseEnter);
|
||||
element.removeEventListener('mouseleave', handleMouseLeave);
|
||||
element.removeEventListener('mousemove', handleMouseMove);
|
||||
element.removeEventListener('click', handleClick);
|
||||
clearAllParticles();
|
||||
};
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
setupEventListeners();
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanupEventListeners?.();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [
|
||||
animateParticles,
|
||||
clearAllParticles,
|
||||
props.disableAnimations,
|
||||
props.enableTilt,
|
||||
props.enableMagnetism,
|
||||
props.clickEffect,
|
||||
props.glowColor
|
||||
],
|
||||
() => {
|
||||
cleanupEventListeners?.();
|
||||
setupEventListeners();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
return {
|
||||
cardRef
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<div
|
||||
ref="cardRef"
|
||||
class="relative overflow-hidden"
|
||||
:style="{ position: 'relative', overflow: 'hidden' }"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
`
|
||||
});
|
||||
|
||||
const GlobalSpotlight = defineComponent({
|
||||
name: 'GlobalSpotlight',
|
||||
props: {
|
||||
gridRef: { type: Object as PropType<HTMLDivElement | null>, required: true },
|
||||
disableAnimations: { type: Boolean, default: false },
|
||||
enabled: { type: Boolean, default: true },
|
||||
spotlightRadius: { type: Number, default: DEFAULT_SPOTLIGHT_RADIUS },
|
||||
glowColor: { type: String, default: DEFAULT_GLOW_COLOR }
|
||||
},
|
||||
setup(props) {
|
||||
const spotlightRef = ref<HTMLDivElement | null>(null);
|
||||
const isInsideSection = ref(false);
|
||||
|
||||
let cleanupEventListeners: (() => void) | null = null;
|
||||
const setupEventListeners = () => {
|
||||
if (props.disableAnimations || !props.gridRef || !props.enabled) return;
|
||||
|
||||
const spotlight = document.createElement('div');
|
||||
spotlight.className = 'global-spotlight';
|
||||
spotlight.style.cssText = `
|
||||
position: fixed;
|
||||
width: 800px;
|
||||
height: 800px;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(circle,
|
||||
rgba(${props.glowColor}, 0.15) 0%,
|
||||
rgba(${props.glowColor}, 0.08) 15%,
|
||||
rgba(${props.glowColor}, 0.04) 25%,
|
||||
rgba(${props.glowColor}, 0.02) 40%,
|
||||
rgba(${props.glowColor}, 0.01) 65%,
|
||||
transparent 70%
|
||||
);
|
||||
z-index: 200;
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
mix-blend-mode: screen;
|
||||
`;
|
||||
document.body.appendChild(spotlight);
|
||||
spotlightRef.value = spotlight;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!spotlightRef.value || !props.gridRef) return;
|
||||
|
||||
const section = props.gridRef.closest('.bento-section');
|
||||
const rect = section?.getBoundingClientRect();
|
||||
const mouseInside =
|
||||
rect &&
|
||||
e.clientX >= rect.left &&
|
||||
e.clientX <= rect.right &&
|
||||
e.clientY >= rect.top &&
|
||||
e.clientY <= rect.bottom;
|
||||
|
||||
isInsideSection.value = mouseInside || false;
|
||||
const cards = props.gridRef.querySelectorAll('.card') as NodeListOf<HTMLDivElement>;
|
||||
|
||||
if (!mouseInside) {
|
||||
gsap.to(spotlightRef.value, {
|
||||
opacity: 0,
|
||||
duration: 0.3,
|
||||
ease: 'power2.out'
|
||||
});
|
||||
cards.forEach(card => {
|
||||
card.style.setProperty('--glow-intensity', '0');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { proximity, fadeDistance } = calculateSpotlightValues(props.spotlightRadius);
|
||||
let minDistance = Infinity;
|
||||
|
||||
cards.forEach(card => {
|
||||
const cardRect = card.getBoundingClientRect();
|
||||
const centerX = cardRect.left + cardRect.width / 2;
|
||||
const centerY = cardRect.top + cardRect.height / 2;
|
||||
const distance =
|
||||
Math.hypot(e.clientX - centerX, e.clientY - centerY) - Math.max(cardRect.width, cardRect.height) / 2;
|
||||
const effectiveDistance = Math.max(0, distance);
|
||||
|
||||
minDistance = Math.min(minDistance, effectiveDistance);
|
||||
|
||||
let glowIntensity = 0;
|
||||
if (effectiveDistance <= proximity) {
|
||||
glowIntensity = 1;
|
||||
} else if (effectiveDistance <= fadeDistance) {
|
||||
glowIntensity = (fadeDistance - effectiveDistance) / (fadeDistance - proximity);
|
||||
}
|
||||
|
||||
updateCardGlowProperties(card, e.clientX, e.clientY, glowIntensity, props.spotlightRadius);
|
||||
});
|
||||
|
||||
gsap.to(spotlightRef.value, {
|
||||
left: e.clientX,
|
||||
top: e.clientY,
|
||||
duration: 0.1,
|
||||
ease: 'power2.out'
|
||||
});
|
||||
|
||||
const targetOpacity =
|
||||
minDistance <= proximity
|
||||
? 0.8
|
||||
: minDistance <= fadeDistance
|
||||
? ((fadeDistance - minDistance) / (fadeDistance - proximity)) * 0.8
|
||||
: 0;
|
||||
|
||||
gsap.to(spotlightRef.value, {
|
||||
opacity: targetOpacity,
|
||||
duration: targetOpacity > 0 ? 0.2 : 0.5,
|
||||
ease: 'power2.out'
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
isInsideSection.value = false;
|
||||
(props.gridRef?.querySelectorAll('.card') as NodeListOf<HTMLDivElement>).forEach(card => {
|
||||
card.style.setProperty('--glow-intensity', '0');
|
||||
});
|
||||
if (spotlightRef.value) {
|
||||
gsap.to(spotlightRef.value, {
|
||||
opacity: 0,
|
||||
duration: 0.3,
|
||||
ease: 'power2.out'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseleave', handleMouseLeave);
|
||||
|
||||
cleanupEventListeners = () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseleave', handleMouseLeave);
|
||||
spotlightRef.value?.parentNode?.removeChild(spotlightRef.value);
|
||||
};
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
setupEventListeners();
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanupEventListeners?.();
|
||||
cleanupEventListeners = null;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props,
|
||||
() => {
|
||||
if (props.gridRef) {
|
||||
cleanupEventListeners?.();
|
||||
setupEventListeners();
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
},
|
||||
template: `<div></div>`
|
||||
});
|
||||
|
||||
const BentoCardGrid = defineComponent({
|
||||
name: 'BentoCardGrid',
|
||||
props: {
|
||||
gridRef: { type: Object }
|
||||
},
|
||||
template: `
|
||||
<div
|
||||
class="relative gap-2 grid p-3 select-none bento-section"
|
||||
:style="{ fontSize: 'clamp(1rem, 0.9rem + 0.5vw, 1.5rem)' }"
|
||||
:ref="gridRef"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
`
|
||||
});
|
||||
|
||||
const useMobileDetection = () => {
|
||||
const isMobile = ref(false);
|
||||
|
||||
const checkMobile = () => {
|
||||
isMobile.value = window.innerWidth <= MOBILE_BREAKPOINT;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', checkMobile);
|
||||
});
|
||||
});
|
||||
|
||||
return isMobile;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<BentoProps>(), {
|
||||
textAutoHide: true,
|
||||
enableStars: true,
|
||||
enableSpotlight: true,
|
||||
enableBorderGlow: true,
|
||||
disableAnimations: false,
|
||||
spotlightRadius: DEFAULT_SPOTLIGHT_RADIUS,
|
||||
particleCount: DEFAULT_PARTICLE_COUNT,
|
||||
enableTilt: false,
|
||||
glowColor: DEFAULT_GLOW_COLOR,
|
||||
clickEffect: true,
|
||||
enableMagnetism: true
|
||||
});
|
||||
|
||||
const gridRef = ref<HTMLDivElement | null>(null);
|
||||
const isMobile = useMobileDetection();
|
||||
const cardElements = ref<HTMLDivElement[]>([]);
|
||||
|
||||
const shouldDisableAnimations = computed(() => props.disableAnimations || isMobile.value);
|
||||
const baseClassName = computed(() => {
|
||||
return `card flex flex-col justify-between relative aspect-[4/3] min-h-[200px] w-full max-w-full p-5 rounded-[20px] border border-solid font-light overflow-hidden transition-[box-shadow] duration-300 ease-in-out hover:shadow-[0_8px_25px_rgba(0,0,0,0.15)] ${
|
||||
props.enableBorderGlow ? 'card--border-glow' : ''
|
||||
}`;
|
||||
});
|
||||
const getCardStyle = (card: BentoCardProps) => ({
|
||||
backgroundColor: card.color || 'var(--background-dark)',
|
||||
borderColor: 'var(--border-color)',
|
||||
color: 'var(--white)',
|
||||
'--glow-x': '50%',
|
||||
'--glow-y': '50%',
|
||||
'--glow-intensity': '0',
|
||||
'--glow-radius': '200px'
|
||||
});
|
||||
|
||||
const setupCardRef = (el: HTMLDivElement | null, index: number) => {
|
||||
if (!el) return;
|
||||
cardElements.value[index] = el;
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (shouldDisableAnimations.value) return;
|
||||
gsap.to(el, { y: -2, duration: 0.3, ease: 'power2.out' });
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (shouldDisableAnimations.value) return;
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
const centerX = rect.width / 2;
|
||||
const centerY = rect.height / 2;
|
||||
|
||||
if (props.enableTilt) {
|
||||
const rotateX = ((y - centerY) / centerY) * -10;
|
||||
const rotateY = ((x - centerX) / centerX) * 10;
|
||||
|
||||
gsap.to(el, {
|
||||
rotateX,
|
||||
rotateY,
|
||||
duration: 0.1,
|
||||
ease: 'power2.out',
|
||||
transformPerspective: 1000
|
||||
});
|
||||
}
|
||||
|
||||
if (props.enableMagnetism) {
|
||||
const magnetX = (x - centerX) * 0.05;
|
||||
const magnetY = (y - centerY) * 0.05;
|
||||
|
||||
gsap.to(el, {
|
||||
x: magnetX,
|
||||
y: magnetY,
|
||||
duration: 0.3,
|
||||
ease: 'power2.out'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (shouldDisableAnimations.value) return;
|
||||
|
||||
if (props.enableTilt) {
|
||||
gsap.to(el, {
|
||||
rotateX: 0,
|
||||
rotateY: 0,
|
||||
duration: 0.3,
|
||||
ease: 'power2.out'
|
||||
});
|
||||
}
|
||||
|
||||
if (props.enableMagnetism) {
|
||||
gsap.to(el, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
duration: 0.3,
|
||||
ease: 'power2.out'
|
||||
});
|
||||
} else {
|
||||
gsap.to(el, { y: 0, duration: 0.3, ease: 'power2.out' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (!props.clickEffect || shouldDisableAnimations.value) return;
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
const maxDistance = Math.max(
|
||||
Math.hypot(x, y),
|
||||
Math.hypot(x - rect.width, y),
|
||||
Math.hypot(x, y - rect.height),
|
||||
Math.hypot(x - rect.width, y - rect.height)
|
||||
);
|
||||
|
||||
const ripple = document.createElement('div');
|
||||
ripple.style.cssText = `
|
||||
position: absolute;
|
||||
width: ${maxDistance * 2}px;
|
||||
height: ${maxDistance * 2}px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(${props.glowColor}, 0.4) 0%, rgba(${props.glowColor}, 0.2) 30%, transparent 70%);
|
||||
left: ${x - maxDistance}px;
|
||||
top: ${y - maxDistance}px;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
`;
|
||||
|
||||
el.appendChild(ripple);
|
||||
|
||||
gsap.fromTo(
|
||||
ripple,
|
||||
{
|
||||
scale: 0,
|
||||
opacity: 1
|
||||
},
|
||||
{
|
||||
scale: 1,
|
||||
opacity: 0,
|
||||
duration: 0.8,
|
||||
ease: 'power2.out',
|
||||
onComplete: () => ripple.remove()
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
el.addEventListener('mouseenter', handleMouseEnter);
|
||||
el.addEventListener('mousemove', handleMouseMove);
|
||||
el.addEventListener('mouseleave', handleMouseLeave);
|
||||
el.addEventListener('click', handleClick);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<GlobalSpotlight
|
||||
v-if="enableSpotlight"
|
||||
:grid-ref="gridRef"
|
||||
:disable-animations="shouldDisableAnimations"
|
||||
:enabled="enableSpotlight"
|
||||
:spotlight-radius="spotlightRadius"
|
||||
:glow-color="glowColor"
|
||||
/>
|
||||
|
||||
<BentoCardGrid :grid-ref="(el: HTMLDivElement) => (gridRef = el)">
|
||||
<div class="gap-2 grid card-responsive">
|
||||
<template v-for="(card, index) in cardData" :key="index">
|
||||
<ParticleCard
|
||||
v-if="enableStars"
|
||||
:class="baseClassName"
|
||||
:style="getCardStyle(card)"
|
||||
:disable-animations="shouldDisableAnimations"
|
||||
:particle-count="particleCount"
|
||||
:glow-color="glowColor"
|
||||
:enable-tilt="enableTilt"
|
||||
:click-effect="clickEffect"
|
||||
:enable-magnetism="enableMagnetism"
|
||||
>
|
||||
<div class="relative flex justify-between gap-3 text-white card__header">
|
||||
<span class="text-base card__label">{{ card.label }}</span>
|
||||
</div>
|
||||
<div class="relative flex flex-col text-white card__content">
|
||||
<h3 :class="`card__title font-normal text-base m-0 mb-1 ${textAutoHide ? 'text-clamp-1' : ''}`">
|
||||
{{ card.title }}
|
||||
</h3>
|
||||
<p :class="`card__description text-xs leading-5 opacity-90 ${textAutoHide ? 'text-clamp-2' : ''}`">
|
||||
{{ card.description }}
|
||||
</p>
|
||||
</div>
|
||||
</ParticleCard>
|
||||
|
||||
<div
|
||||
v-else
|
||||
:class="baseClassName"
|
||||
:style="getCardStyle(card)"
|
||||
:ref="el => setupCardRef(el as HTMLDivElement, index)"
|
||||
>
|
||||
<div class="relative flex justify-between gap-3 text-white card__header">
|
||||
<span class="text-base card__label">{{ card.label }}</span>
|
||||
</div>
|
||||
<div class="relative flex flex-col text-white card__content">
|
||||
<h3 :class="`card__title font-normal text-base m-0 mb-1 ${textAutoHide ? 'text-clamp-1' : ''}`">
|
||||
{{ card.title }}
|
||||
</h3>
|
||||
<p :class="`card__description text-xs leading-5 opacity-90 ${textAutoHide ? 'text-clamp-2' : ''}`">
|
||||
{{ card.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</BentoCardGrid>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.bento-section {
|
||||
--glow-x: 50%;
|
||||
--glow-y: 50%;
|
||||
--glow-intensity: 0;
|
||||
--glow-radius: 200px;
|
||||
--glow-color: v-bind(glowColor);
|
||||
--border-color: #333;
|
||||
--background-dark: #060010;
|
||||
--white: hsl(0, 0%, 100%);
|
||||
}
|
||||
|
||||
.card-responsive {
|
||||
grid-template-columns: 1fr;
|
||||
width: 90%;
|
||||
margin: 0 auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.card-responsive {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.card-responsive {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
.card-responsive .card:nth-child(3) {
|
||||
grid-column: span 2;
|
||||
grid-row: span 2;
|
||||
}
|
||||
|
||||
.card-responsive .card:nth-child(4) {
|
||||
grid-column: 1 / span 2;
|
||||
grid-row: 2 / span 2;
|
||||
}
|
||||
|
||||
.card-responsive .card:nth-child(6) {
|
||||
grid-column: 4;
|
||||
grid-row: 3;
|
||||
}
|
||||
}
|
||||
|
||||
.card--border-glow::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 6px;
|
||||
background: radial-gradient(
|
||||
var(--glow-radius) circle at var(--glow-x) var(--glow-y),
|
||||
rgba(v-bind(glowColor), calc(var(--glow-intensity) * 0.8)) 0%,
|
||||
rgba(v-bind(glowColor), calc(var(--glow-intensity) * 0.4)) 30%,
|
||||
transparent 60%
|
||||
);
|
||||
border-radius: inherit;
|
||||
mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
mask-composite: subtract;
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.card--border-glow:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card--border-glow:hover {
|
||||
box-shadow:
|
||||
0 4px 20px rgba(46, 24, 78, 0.4),
|
||||
0 0 30px rgba(v-bind(glowColor), 0.2);
|
||||
}
|
||||
|
||||
.particle::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
background: rgba(v-bind(glowColor), 0.2);
|
||||
border-radius: 50%;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.particle-container:hover {
|
||||
box-shadow:
|
||||
0 4px 20px rgba(46, 24, 78, 0.2),
|
||||
0 0 30px rgba(v-bind(glowColor), 0.2);
|
||||
}
|
||||
|
||||
.text-clamp-1 {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
line-clamp: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.text-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media (max-width: 599px) {
|
||||
.card-responsive {
|
||||
grid-template-columns: 1fr;
|
||||
width: 90%;
|
||||
margin: 0 auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.card-responsive .card {
|
||||
width: 100%;
|
||||
min-height: 180px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
141
src/demo/Components/MagicBentoDemo.vue
Normal file
141
src/demo/Components/MagicBentoDemo.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<TabbedLayout>
|
||||
<template #preview>
|
||||
<div class="relative h-auto overflow-hidden demo-container">
|
||||
<MagicBento
|
||||
:enable-stars="enableStars"
|
||||
:enable-spotlight="enableSpotlight"
|
||||
:disable-animations="disableAnimations"
|
||||
:spotlight-radius="spotlightRadius"
|
||||
:enable-tilt="enableTilt"
|
||||
:click-effect="clickEffect"
|
||||
:enable-magnetism="enableMagnetism"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Customize>
|
||||
<PreviewSlider title="Spotlight Radius" v-model="spotlightRadius" :min="50" :max="800" :step="10" />
|
||||
|
||||
<PreviewSwitch title="Stars Effect" v-model="enableStars" />
|
||||
|
||||
<PreviewSwitch title="Spotlight Effect" v-model="enableSpotlight" />
|
||||
|
||||
<PreviewSwitch title="Tilt Effect" v-model="enableTilt" />
|
||||
|
||||
<PreviewSwitch title="Click Effect" v-model="clickEffect" />
|
||||
|
||||
<PreviewSwitch title="Magnetism" v-model="enableMagnetism" />
|
||||
|
||||
<PreviewSwitch title="Disable All Animations" v-model="disableAnimations" />
|
||||
</Customize>
|
||||
|
||||
<PropTable :data="propData" />
|
||||
<Dependencies :dependency-list="['gsap']" />
|
||||
</template>
|
||||
|
||||
<template #code>
|
||||
<CodeExample :code-object="magicBento" />
|
||||
</template>
|
||||
|
||||
<template #cli>
|
||||
<CliInstallation :command="magicBento.cli" />
|
||||
</template>
|
||||
</TabbedLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import CliInstallation from '../../components/code/CliInstallation.vue';
|
||||
import CodeExample from '../../components/code/CodeExample.vue';
|
||||
import Dependencies from '../../components/code/Dependencies.vue';
|
||||
import Customize from '../../components/common/Customize.vue';
|
||||
import PreviewSlider from '../../components/common/PreviewSlider.vue';
|
||||
import PreviewSwitch from '../../components/common/PreviewSwitch.vue';
|
||||
import PropTable from '../../components/common/PropTable.vue';
|
||||
import TabbedLayout from '../../components/common/TabbedLayout.vue';
|
||||
import { magicBento } from '../../constants/code/Components/magicBentoCode';
|
||||
import MagicBento from '../../content/Components/MagicBento/MagicBento.vue';
|
||||
|
||||
const enableStars = ref(true);
|
||||
const enableSpotlight = ref(true);
|
||||
const disableAnimations = ref(false);
|
||||
const spotlightRadius = ref(400);
|
||||
const enableTilt = ref(false);
|
||||
const clickEffect = ref(true);
|
||||
const enableMagnetism = ref(false);
|
||||
|
||||
const propData = [
|
||||
{
|
||||
name: 'textAutoHide',
|
||||
type: 'boolean',
|
||||
default: 'true',
|
||||
description: 'Whether text content should auto-hide on hover'
|
||||
},
|
||||
{
|
||||
name: 'enableStars',
|
||||
type: 'boolean',
|
||||
default: 'true',
|
||||
description: 'Enable particle star animation effect'
|
||||
},
|
||||
{
|
||||
name: 'enableSpotlight',
|
||||
type: 'boolean',
|
||||
default: 'true',
|
||||
description: 'Enable spotlight cursor following effect'
|
||||
},
|
||||
{
|
||||
name: 'enableBorderGlow',
|
||||
type: 'boolean',
|
||||
default: 'true',
|
||||
description: 'Enable border glow effect that follows cursor'
|
||||
},
|
||||
{
|
||||
name: 'disableAnimations',
|
||||
type: 'boolean',
|
||||
default: 'false',
|
||||
description: 'Disable all animations (automatically enabled on mobile)'
|
||||
},
|
||||
{
|
||||
name: 'spotlightRadius',
|
||||
type: 'number',
|
||||
default: '300',
|
||||
description: 'Radius of the spotlight effect in pixels'
|
||||
},
|
||||
{
|
||||
name: 'particleCount',
|
||||
type: 'number',
|
||||
default: '12',
|
||||
description: 'Number of particles in the star animation'
|
||||
},
|
||||
{
|
||||
name: 'enableTilt',
|
||||
type: 'boolean',
|
||||
default: 'false',
|
||||
description: 'Enable 3D tilt effect on card hover'
|
||||
},
|
||||
{
|
||||
name: 'glowColor',
|
||||
type: 'string',
|
||||
default: '"77, 254, 6"',
|
||||
description: 'RGB color values for glow effects (without rgba wrapper)'
|
||||
},
|
||||
{
|
||||
name: 'clickEffect',
|
||||
type: 'boolean',
|
||||
default: 'true',
|
||||
description: 'Enable ripple effect on card click'
|
||||
},
|
||||
{
|
||||
name: 'enableMagnetism',
|
||||
type: 'boolean',
|
||||
default: 'true',
|
||||
description: 'Enable subtle card attraction to cursor'
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.demo-container {
|
||||
padding: 0 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -12,7 +12,8 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
'@content': path.resolve(__dirname, 'src/content')
|
||||
'@content': path.resolve(__dirname, 'src/content'),
|
||||
vue: 'vue/dist/vue.esm-bundler.js'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user