Add prettier config, format codebase

This commit is contained in:
David Haz
2025-07-12 11:59:33 +03:00
parent ac8b2c04d8
commit f4d97ee94e
211 changed files with 10586 additions and 8810 deletions

View File

@@ -2,14 +2,20 @@
<div
ref="containerRef"
class="card-swap-container absolute bottom-0 right-0 transform translate-x-[5%] translate-y-[20%] origin-bottom-right perspective-[900px] overflow-visible max-[768px]:translate-x-[25%] max-[768px]:translate-y-[25%] max-[768px]:scale-[0.75] max-[480px]:translate-x-[25%] max-[480px]:translate-y-[25%] max-[480px]:scale-[0.55]"
:style="{ width: typeof width === 'number' ? `${width}px` : width, height: typeof height === 'number' ? `${height}px` : height }"
:style="{
width: typeof width === 'number' ? `${width}px` : width,
height: typeof height === 'number' ? `${height}px` : height
}"
>
<div
v-for="(_, index) in 3"
:key="index"
ref="cardRefs"
class="card-swap-card absolute top-1/2 left-1/2 rounded-xl border border-white bg-black [transform-style:preserve-3d] [will-change:transform] [backface-visibility:hidden]"
:style="{ width: typeof width === 'number' ? `${width}px` : width, height: typeof height === 'number' ? `${height}px` : height }"
:style="{
width: typeof width === 'number' ? `${width}px` : width,
height: typeof height === 'number' ? `${height}px` : height
}"
@click="handleCardClick(index)"
>
<slot :name="`card-${index}`" :index="index" />
@@ -18,38 +24,33 @@
</template>
<script lang="ts">
import gsap from 'gsap'
import gsap from 'gsap';
export interface CardSwapProps {
width?: number | string
height?: number | string
cardDistance?: number
verticalDistance?: number
delay?: number
pauseOnHover?: boolean
onCardClick?: (idx: number) => void
skewAmount?: number
easing?: 'linear' | 'elastic'
width?: number | string;
height?: number | string;
cardDistance?: number;
verticalDistance?: number;
delay?: number;
pauseOnHover?: boolean;
onCardClick?: (idx: number) => void;
skewAmount?: number;
easing?: 'linear' | 'elastic';
}
interface Slot {
x: number
y: number
z: number
zIndex: number
x: number;
y: number;
z: number;
zIndex: number;
}
const makeSlot = (
i: number,
distX: number,
distY: number,
total: number
): Slot => ({
const makeSlot = (i: number, distX: number, distY: number, total: number): Slot => ({
x: i * distX,
y: -i * distY,
z: -i * distX * 1.5,
zIndex: total - i,
})
zIndex: total - i
});
const placeNow = (el: HTMLElement, slot: Slot, skew: number) => {
gsap.set(el, {
@@ -61,15 +62,15 @@ const placeNow = (el: HTMLElement, slot: Slot, skew: number) => {
skewY: skew,
transformOrigin: 'center center',
zIndex: slot.zIndex,
force3D: true,
})
}
force3D: true
});
};
export { makeSlot, placeNow }
export { makeSlot, placeNow };
</script>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue';
const props = withDefaults(defineProps<CardSwapProps>(), {
width: 500,
@@ -79,23 +80,23 @@ const props = withDefaults(defineProps<CardSwapProps>(), {
delay: 5000,
pauseOnHover: false,
skewAmount: 6,
easing: 'elastic',
})
easing: 'elastic'
});
const emit = defineEmits<{
'card-click': [index: number]
}>()
'card-click': [index: number];
}>();
const containerRef = ref<HTMLDivElement>()
const cardRefs = ref<HTMLElement[]>([])
const order = ref<number[]>([0, 1, 2])
const tlRef = ref<gsap.core.Timeline | null>(null)
const intervalRef = ref<number>()
const containerRef = ref<HTMLDivElement>();
const cardRefs = ref<HTMLElement[]>([]);
const order = ref<number[]>([0, 1, 2]);
const tlRef = ref<gsap.core.Timeline | null>(null);
const intervalRef = ref<number>();
const handleCardClick = (index: number) => {
emit('card-click', index)
props.onCardClick?.(index)
}
emit('card-click', index);
props.onCardClick?.(index);
};
const config = computed(() => {
return props.easing === 'elastic'
@@ -105,7 +106,7 @@ const config = computed(() => {
durMove: 2,
durReturn: 2,
promoteOverlap: 0.9,
returnDelay: 0.05,
returnDelay: 0.05
}
: {
ease: 'power1.inOut',
@@ -113,63 +114,63 @@ const config = computed(() => {
durMove: 0.8,
durReturn: 0.8,
promoteOverlap: 0.45,
returnDelay: 0.2,
}
})
returnDelay: 0.2
};
});
const initializeCards = () => {
if (!cardRefs.value.length) return
const total = cardRefs.value.length
if (!cardRefs.value.length) return;
const total = cardRefs.value.length;
cardRefs.value.forEach((el, i) => {
if (el) {
placeNow(el, makeSlot(i, props.cardDistance, props.verticalDistance, total), props.skewAmount)
placeNow(el, makeSlot(i, props.cardDistance, props.verticalDistance, total), props.skewAmount);
}
})
}
});
};
const updateCardPositions = () => {
if (!cardRefs.value.length) return
const total = cardRefs.value.length
if (!cardRefs.value.length) return;
const total = cardRefs.value.length;
cardRefs.value.forEach((el, i) => {
if (el) {
const slot = makeSlot(i, props.cardDistance, props.verticalDistance, total)
const slot = makeSlot(i, props.cardDistance, props.verticalDistance, total);
gsap.set(el, {
x: slot.x,
y: slot.y,
z: slot.z,
skewY: props.skewAmount,
})
skewY: props.skewAmount
});
}
})
}
});
};
const swap = () => {
if (order.value.length < 2) return
if (order.value.length < 2) return;
const [front, ...rest] = order.value
const elFront = cardRefs.value[front]
if (!elFront) return
const [front, ...rest] = order.value;
const elFront = cardRefs.value[front];
if (!elFront) return;
const tl = gsap.timeline()
tlRef.value = tl
const tl = gsap.timeline();
tlRef.value = tl;
tl.to(elFront, {
y: '+=500',
duration: config.value.durDrop,
ease: config.value.ease,
})
ease: config.value.ease
});
tl.addLabel('promote', `-=${config.value.durDrop * config.value.promoteOverlap}`)
tl.addLabel('promote', `-=${config.value.durDrop * config.value.promoteOverlap}`);
rest.forEach((idx, i) => {
const el = cardRefs.value[idx]
if (!el) return
const slot = makeSlot(i, props.cardDistance, props.verticalDistance, cardRefs.value.length)
tl.set(el, { zIndex: slot.zIndex }, 'promote')
const el = cardRefs.value[idx];
if (!el) return;
const slot = makeSlot(i, props.cardDistance, props.verticalDistance, cardRefs.value.length);
tl.set(el, { zIndex: slot.zIndex }, 'promote');
tl.to(
el,
{
@@ -177,114 +178,114 @@ const swap = () => {
y: slot.y,
z: slot.z,
duration: config.value.durMove,
ease: config.value.ease,
ease: config.value.ease
},
`promote+=${i * 0.15}`
)
})
);
});
const backSlot = makeSlot(
cardRefs.value.length - 1,
props.cardDistance,
props.verticalDistance,
cardRefs.value.length
)
tl.addLabel('return', `promote+=${config.value.durMove * config.value.returnDelay}`)
);
tl.addLabel('return', `promote+=${config.value.durMove * config.value.returnDelay}`);
tl.call(
() => {
gsap.set(elFront, { zIndex: backSlot.zIndex })
gsap.set(elFront, { zIndex: backSlot.zIndex });
},
undefined,
'return'
)
tl.set(elFront, { x: backSlot.x, z: backSlot.z }, 'return')
);
tl.set(elFront, { x: backSlot.x, z: backSlot.z }, 'return');
tl.to(
elFront,
{
y: backSlot.y,
duration: config.value.durReturn,
ease: config.value.ease,
ease: config.value.ease
},
'return'
)
);
tl.call(() => {
order.value = [...rest, front]
})
}
order.value = [...rest, front];
});
};
const startAnimation = () => {
stopAnimation()
swap()
intervalRef.value = window.setInterval(swap, props.delay)
}
stopAnimation();
swap();
intervalRef.value = window.setInterval(swap, props.delay);
};
const stopAnimation = () => {
tlRef.value?.kill()
tlRef.value?.kill();
if (intervalRef.value) {
clearInterval(intervalRef.value)
clearInterval(intervalRef.value);
}
}
};
const resumeAnimation = () => {
tlRef.value?.play()
intervalRef.value = window.setInterval(swap, props.delay)
}
tlRef.value?.play();
intervalRef.value = window.setInterval(swap, props.delay);
};
const setupHoverListeners = () => {
if (props.pauseOnHover && containerRef.value) {
containerRef.value.addEventListener('mouseenter', stopAnimation)
containerRef.value.addEventListener('mouseleave', resumeAnimation)
containerRef.value.addEventListener('mouseenter', stopAnimation);
containerRef.value.addEventListener('mouseleave', resumeAnimation);
}
}
};
const removeHoverListeners = () => {
if (containerRef.value) {
containerRef.value.removeEventListener('mouseenter', stopAnimation)
containerRef.value.removeEventListener('mouseleave', resumeAnimation)
containerRef.value.removeEventListener('mouseenter', stopAnimation);
containerRef.value.removeEventListener('mouseleave', resumeAnimation);
}
}
};
watch(
() => [props.cardDistance, props.verticalDistance, props.skewAmount],
() => {
updateCardPositions()
updateCardPositions();
}
)
);
watch(
() => props.delay,
() => {
if (intervalRef.value) {
clearInterval(intervalRef.value)
intervalRef.value = window.setInterval(swap, props.delay)
clearInterval(intervalRef.value);
intervalRef.value = window.setInterval(swap, props.delay);
}
}
)
);
watch(
() => props.pauseOnHover,
() => {
removeHoverListeners()
setupHoverListeners()
removeHoverListeners();
setupHoverListeners();
}
)
);
watch(
() => props.easing,
() => {}
)
);
onMounted(() => {
nextTick(() => {
initializeCards()
startAnimation()
setupHoverListeners()
})
})
initializeCards();
startAnimation();
setupHoverListeners();
});
});
onUnmounted(() => {
stopAnimation()
removeHoverListeners()
})
stopAnimation();
removeHoverListeners();
});
</script>

View File

@@ -1,62 +1,86 @@
<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="{
<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',
height: round ? itemWidth + 'px' : '100%',
rotateY: getRotateY(index),
...(round && { borderRadius: '50%' }),
}" :transition="effectiveTransition">
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>
<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="['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 }" />
<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>
@@ -64,64 +88,64 @@
<script lang="ts">
export interface CarouselItem {
title: string
description: string
id: number
icon: string
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
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.",
title: 'Text Animations',
description: 'Cool text animations for your projects.',
id: 1,
icon: "pi pi-file",
icon: 'pi pi-file'
},
{
title: "Animations",
description: "Smooth animations for your projects.",
title: 'Animations',
description: 'Smooth animations for your projects.',
id: 2,
icon: "pi pi-circle",
icon: 'pi pi-circle'
},
{
title: "Components",
description: "Reusable components for your projects.",
title: 'Components',
description: 'Reusable components for your projects.',
id: 3,
icon: "pi pi-objects-column",
icon: 'pi pi-objects-column'
},
{
title: "Backgrounds",
description: "Beautiful backgrounds and patterns for your projects.",
title: 'Backgrounds',
description: 'Beautiful backgrounds and patterns for your projects.',
id: 4,
icon: "pi pi-table",
icon: 'pi pi-table'
},
{
title: "Common UI",
description: "Common UI components are coming soon!",
title: 'Common UI',
description: 'Common UI components are coming soon!',
id: 5,
icon: "pi pi-code",
},
]
icon: 'pi pi-code'
}
];
</script>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { Motion, useMotionValue, useTransform } from 'motion-v'
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 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,
@@ -130,148 +154,154 @@ const props = withDefaults(defineProps<CarouselProps>(), {
autoplayDelay: 3000,
pauseOnHover: false,
loop: false,
round: false,
})
round: false
});
const containerPadding = 16
const itemWidth = computed(() => props.baseWidth - containerPadding * 2)
const trackItemOffset = computed(() => itemWidth.value + GAP)
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 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 containerRef = ref<HTMLDivElement>();
let autoplayTimer: number | null = null;
const dragConstraints = computed(() => {
return props.loop
? {}
: {
left: -trackItemOffset.value * (carouselItems.value.length - 1),
right: 0,
}
})
left: -trackItemOffset.value * (carouselItems.value.length - 1),
right: 0
};
});
const effectiveTransition = computed(() =>
isResetting.value ? { duration: 0 } : SPRING_OPTIONS
)
const effectiveTransition = computed(() => (isResetting.value ? { duration: 0 } : SPRING_OPTIONS));
const maxItems = Math.max(props.items.length + 1, 10)
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 })
})
-(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]
}
return rotateYTransforms[index] || rotateYTransforms[0];
};
const setCurrentIndex = (index: number) => {
currentIndex.value = index
}
currentIndex.value = index;
};
const handleAnimationComplete = () => {
if (props.loop && currentIndex.value === carouselItems.value.length - 1) {
isResetting.value = true
motionX.set(0)
currentIndex.value = 0
isResetting.value = true;
motionX.set(0);
currentIndex.value = 0;
setTimeout(() => {
isResetting.value = false
}, 50)
isResetting.value = false;
}, 50);
}
}
};
interface DragInfo {
offset: { x: number; y: number }
velocity: { x: number; y: number }
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
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
currentIndex.value = currentIndex.value + 1;
} else {
currentIndex.value = Math.min(currentIndex.value + 1, carouselItems.value.length - 1)
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
currentIndex.value = props.items.length - 1;
} else {
currentIndex.value = Math.max(currentIndex.value - 1, 0)
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
const prev = currentIndex.value;
if (prev === props.items.length - 1 && props.loop) {
return prev + 1
return prev + 1;
}
if (prev === carouselItems.value.length - 1) {
return props.loop ? 0 : prev
return props.loop ? 0 : prev;
}
return prev + 1
})()
}, props.autoplayDelay)
return prev + 1;
})();
}, props.autoplayDelay);
}
}
};
const stopAutoplay = () => {
if (autoplayTimer) {
clearInterval(autoplayTimer)
autoplayTimer = null
clearInterval(autoplayTimer);
autoplayTimer = null;
}
}
};
const handleMouseEnter = () => {
isHovered.value = true
isHovered.value = true;
if (props.pauseOnHover) {
stopAutoplay()
stopAutoplay();
}
}
};
const handleMouseLeave = () => {
isHovered.value = false
isHovered.value = false;
if (props.pauseOnHover) {
startAutoplay()
startAutoplay();
}
}
};
watch(
[() => props.autoplay, () => props.autoplayDelay, isHovered, () => props.loop, () => props.items.length, () => carouselItems.value.length, () => props.pauseOnHover],
[
() => props.autoplay,
() => props.autoplayDelay,
isHovered,
() => props.loop,
() => props.items.length,
() => carouselItems.value.length,
() => props.pauseOnHover
],
() => {
stopAutoplay()
startAutoplay()
stopAutoplay();
startAutoplay();
}
)
);
onMounted(() => {
if (props.pauseOnHover && containerRef.value) {
containerRef.value.addEventListener('mouseenter', handleMouseEnter)
containerRef.value.addEventListener('mouseleave', handleMouseLeave)
containerRef.value.addEventListener('mouseenter', handleMouseEnter);
containerRef.value.addEventListener('mouseleave', handleMouseLeave);
}
startAutoplay()
})
startAutoplay();
});
onUnmounted(() => {
if (containerRef.value) {
containerRef.value.removeEventListener('mouseenter', handleMouseEnter)
containerRef.value.removeEventListener('mouseleave', handleMouseLeave)
containerRef.value.removeEventListener('mouseenter', handleMouseEnter);
containerRef.value.removeEventListener('mouseleave', handleMouseLeave);
}
stopAutoplay()
})
</script>
stopAutoplay();
});
</script>

View File

@@ -3,17 +3,17 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { Camera, Mesh, Plane, Program, Renderer, Texture, Transform } from 'ogl'
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { Camera, Mesh, Plane, Program, Renderer, Texture, Transform } from 'ogl';
interface CircularGalleryProps {
items?: { image: string; text: string }[]
bend?: number
textColor?: string
borderRadius?: number
font?: string
scrollSpeed?: number
scrollEase?: number
items?: { image: string; text: string }[];
bend?: number;
textColor?: string;
borderRadius?: number;
font?: string;
scrollSpeed?: number;
scrollEase?: number;
}
const props = withDefaults(defineProps<CircularGalleryProps>(), {
@@ -23,38 +23,38 @@ const props = withDefaults(defineProps<CircularGalleryProps>(), {
font: 'bold 30px Figtree',
scrollSpeed: 2,
scrollEase: 0.05
})
});
const containerRef = ref<HTMLDivElement>()
let app: App | null = null
const containerRef = ref<HTMLDivElement>();
let app: App | null = null;
type GL = Renderer['gl']
type GL = Renderer['gl'];
function debounce<T extends (...args: unknown[]) => void>(func: T, wait: number) {
let timeout: number
let timeout: number;
return function (this: unknown, ...args: Parameters<T>) {
window.clearTimeout(timeout)
timeout = window.setTimeout(() => func.apply(this, args), wait)
}
window.clearTimeout(timeout);
timeout = window.setTimeout(() => func.apply(this, args), wait);
};
}
function lerp(p1: number, p2: number, t: number): number {
return p1 + (p2 - p1) * t
return p1 + (p2 - p1) * t;
}
function autoBind(instance: Record<string, unknown>): void {
const proto = Object.getPrototypeOf(instance)
Object.getOwnPropertyNames(proto).forEach((key) => {
const proto = Object.getPrototypeOf(instance);
Object.getOwnPropertyNames(proto).forEach(key => {
if (key !== 'constructor' && typeof instance[key] === 'function') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
instance[key] = (instance[key] as any).bind(instance)
instance[key] = (instance[key] as any).bind(instance);
}
})
});
}
function getFontSize(font: string): number {
const match = font.match(/(\d+)px/)
return match ? parseInt(match[1], 10) : 30
const match = font.match(/(\d+)px/);
return match ? parseInt(match[1], 10) : 30;
}
function createTextTexture(
@@ -63,64 +63,64 @@ function createTextTexture(
font: string = 'bold 30px monospace',
color: string = 'black'
): { texture: Texture; width: number; height: number } {
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
if (!context) throw new Error('Could not get 2d context')
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) throw new Error('Could not get 2d context');
context.font = font
const metrics = context.measureText(text)
const textWidth = Math.ceil(metrics.width)
const fontSize = getFontSize(font)
const textHeight = Math.ceil(fontSize * 1.2)
context.font = font;
const metrics = context.measureText(text);
const textWidth = Math.ceil(metrics.width);
const fontSize = getFontSize(font);
const textHeight = Math.ceil(fontSize * 1.2);
canvas.width = textWidth + 20
canvas.height = textHeight + 20
canvas.width = textWidth + 20;
canvas.height = textHeight + 20;
context.font = font
context.fillStyle = color
context.textBaseline = 'middle'
context.textAlign = 'center'
context.clearRect(0, 0, canvas.width, canvas.height)
context.fillText(text, canvas.width / 2, canvas.height / 2)
context.font = font;
context.fillStyle = color;
context.textBaseline = 'middle';
context.textAlign = 'center';
context.clearRect(0, 0, canvas.width, canvas.height);
context.fillText(text, canvas.width / 2, canvas.height / 2);
const texture = new Texture(gl, { generateMipmaps: false })
texture.image = canvas
return { texture, width: canvas.width, height: canvas.height }
const texture = new Texture(gl, { generateMipmaps: false });
texture.image = canvas;
return { texture, width: canvas.width, height: canvas.height };
}
interface TitleProps {
gl: GL
plane: Mesh
renderer: Renderer
text: string
textColor?: string
font?: string
gl: GL;
plane: Mesh;
renderer: Renderer;
text: string;
textColor?: string;
font?: string;
}
class Title {
gl: GL
plane: Mesh
renderer: Renderer
text: string
textColor: string
font: string
mesh!: Mesh
gl: GL;
plane: Mesh;
renderer: Renderer;
text: string;
textColor: string;
font: string;
mesh!: Mesh;
constructor({ gl, plane, renderer, text, textColor = '#545050', font = '30px sans-serif' }: TitleProps) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
autoBind(this as any)
this.gl = gl
this.plane = plane
this.renderer = renderer
this.text = text
this.textColor = textColor
this.font = font
this.createMesh()
autoBind(this as any);
this.gl = gl;
this.plane = plane;
this.renderer = renderer;
this.text = text;
this.textColor = textColor;
this.font = font;
this.createMesh();
}
createMesh() {
const { texture, width, height } = createTextTexture(this.gl, this.text, this.font, this.textColor)
const geometry = new Plane(this.gl)
const { texture, width, height } = createTextTexture(this.gl, this.text, this.font, this.textColor);
const geometry = new Plane(this.gl);
const program = new Program(this.gl, {
vertex: `
attribute vec3 position;
@@ -144,72 +144,72 @@ class Title {
}
`,
uniforms: { tMap: { value: texture } },
transparent: true,
})
this.mesh = new Mesh(this.gl, { geometry, program })
const aspect = width / height
const textHeightScaled = this.plane.scale.y * 0.15
const textWidthScaled = textHeightScaled * aspect
this.mesh.scale.set(textWidthScaled, textHeightScaled, 1)
this.mesh.position.y = -this.plane.scale.y * 0.5 - textHeightScaled * 0.5 - 0.05
this.mesh.setParent(this.plane)
transparent: true
});
this.mesh = new Mesh(this.gl, { geometry, program });
const aspect = width / height;
const textHeightScaled = this.plane.scale.y * 0.15;
const textWidthScaled = textHeightScaled * aspect;
this.mesh.scale.set(textWidthScaled, textHeightScaled, 1);
this.mesh.position.y = -this.plane.scale.y * 0.5 - textHeightScaled * 0.5 - 0.05;
this.mesh.setParent(this.plane);
}
}
interface ScreenSize {
width: number
height: number
width: number;
height: number;
}
interface Viewport {
width: number
height: number
width: number;
height: number;
}
interface MediaProps {
geometry: Plane
gl: GL
image: string
index: number
length: number
renderer: Renderer
scene: Transform
screen: ScreenSize
text: string
viewport: Viewport
bend: number
textColor: string
borderRadius?: number
font?: string
geometry: Plane;
gl: GL;
image: string;
index: number;
length: number;
renderer: Renderer;
scene: Transform;
screen: ScreenSize;
text: string;
viewport: Viewport;
bend: number;
textColor: string;
borderRadius?: number;
font?: string;
}
class Media {
extra: number = 0
geometry: Plane
gl: GL
image: string
index: number
length: number
renderer: Renderer
scene: Transform
screen: ScreenSize
text: string
viewport: Viewport
bend: number
textColor: string
borderRadius: number
font?: string
program!: Program
plane!: Mesh
title!: Title
scale!: number
padding!: number
width!: number
widthTotal!: number
x!: number
speed: number = 0
isBefore: boolean = false
isAfter: boolean = false
extra: number = 0;
geometry: Plane;
gl: GL;
image: string;
index: number;
length: number;
renderer: Renderer;
scene: Transform;
screen: ScreenSize;
text: string;
viewport: Viewport;
bend: number;
textColor: string;
borderRadius: number;
font?: string;
program!: Program;
plane!: Mesh;
title!: Title;
scale!: number;
padding!: number;
width!: number;
widthTotal!: number;
x!: number;
speed: number = 0;
isBefore: boolean = false;
isAfter: boolean = false;
constructor({
geometry,
@@ -225,30 +225,30 @@ class Media {
bend,
textColor,
borderRadius = 0,
font,
font
}: MediaProps) {
this.geometry = geometry
this.gl = gl
this.image = image
this.index = index
this.length = length
this.renderer = renderer
this.scene = scene
this.screen = screen
this.text = text
this.viewport = viewport
this.bend = bend
this.textColor = textColor
this.borderRadius = borderRadius
this.font = font
this.createShader()
this.createMesh()
this.createTitle()
this.onResize()
this.geometry = geometry;
this.gl = gl;
this.image = image;
this.index = index;
this.length = length;
this.renderer = renderer;
this.scene = scene;
this.screen = screen;
this.text = text;
this.viewport = viewport;
this.bend = bend;
this.textColor = textColor;
this.borderRadius = borderRadius;
this.font = font;
this.createShader();
this.createMesh();
this.createTitle();
this.onResize();
}
createShader() {
const texture = new Texture(this.gl, { generateMipmaps: false })
const texture = new Texture(this.gl, { generateMipmaps: false });
this.program = new Program(this.gl, {
depthTest: false,
depthWrite: false,
@@ -306,25 +306,25 @@ class Media {
uImageSizes: { value: [0, 0] },
uSpeed: { value: 0 },
uTime: { value: 100 * Math.random() },
uBorderRadius: { value: this.borderRadius },
uBorderRadius: { value: this.borderRadius }
},
transparent: true,
})
const img = new Image()
img.crossOrigin = 'anonymous'
img.src = this.image
transparent: true
});
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = this.image;
img.onload = () => {
texture.image = img
this.program.uniforms.uImageSizes.value = [img.naturalWidth, img.naturalHeight]
}
texture.image = img;
this.program.uniforms.uImageSizes.value = [img.naturalWidth, img.naturalHeight];
};
}
createMesh() {
this.plane = new Mesh(this.gl, {
geometry: this.geometry,
program: this.program,
})
this.plane.setParent(this.scene)
program: this.program
});
this.plane.setParent(this.scene);
}
createTitle() {
@@ -334,111 +334,111 @@ class Media {
renderer: this.renderer,
text: this.text,
textColor: this.textColor,
font: this.font,
})
font: this.font
});
}
update(scroll: { current: number; last: number }, direction: 'right' | 'left') {
this.plane.position.x = this.x - scroll.current - this.extra
this.plane.position.x = this.x - scroll.current - this.extra;
const x = this.plane.position.x
const H = this.viewport.width / 2
const x = this.plane.position.x;
const H = this.viewport.width / 2;
if (this.bend === 0) {
this.plane.position.y = 0
this.plane.rotation.z = 0
this.plane.position.y = 0;
this.plane.rotation.z = 0;
} else {
const B_abs = Math.abs(this.bend)
const R = (H * H + B_abs * B_abs) / (2 * B_abs)
const effectiveX = Math.min(Math.abs(x), H)
const B_abs = Math.abs(this.bend);
const R = (H * H + B_abs * B_abs) / (2 * B_abs);
const effectiveX = Math.min(Math.abs(x), H);
const arc = R - Math.sqrt(R * R - effectiveX * effectiveX)
const arc = R - Math.sqrt(R * R - effectiveX * effectiveX);
if (this.bend > 0) {
this.plane.position.y = -arc
this.plane.rotation.z = -Math.sign(x) * Math.asin(effectiveX / R)
this.plane.position.y = -arc;
this.plane.rotation.z = -Math.sign(x) * Math.asin(effectiveX / R);
} else {
this.plane.position.y = arc
this.plane.rotation.z = Math.sign(x) * Math.asin(effectiveX / R)
this.plane.position.y = arc;
this.plane.rotation.z = Math.sign(x) * Math.asin(effectiveX / R);
}
}
this.speed = scroll.current - scroll.last
this.program.uniforms.uTime.value += 0.04
this.program.uniforms.uSpeed.value = this.speed
this.speed = scroll.current - scroll.last;
this.program.uniforms.uTime.value += 0.04;
this.program.uniforms.uSpeed.value = this.speed;
const planeOffset = this.plane.scale.x / 2
const viewportOffset = this.viewport.width / 2
this.isBefore = this.plane.position.x + planeOffset < -viewportOffset
this.isAfter = this.plane.position.x - planeOffset > viewportOffset
const planeOffset = this.plane.scale.x / 2;
const viewportOffset = this.viewport.width / 2;
this.isBefore = this.plane.position.x + planeOffset < -viewportOffset;
this.isAfter = this.plane.position.x - planeOffset > viewportOffset;
if (direction === 'right' && this.isBefore) {
this.extra -= this.widthTotal
this.isBefore = this.isAfter = false
this.extra -= this.widthTotal;
this.isBefore = this.isAfter = false;
}
if (direction === 'left' && this.isAfter) {
this.extra += this.widthTotal
this.isBefore = this.isAfter = false
this.extra += this.widthTotal;
this.isBefore = this.isAfter = false;
}
}
onResize({ screen, viewport }: { screen?: ScreenSize; viewport?: Viewport } = {}) {
if (screen) this.screen = screen
if (screen) this.screen = screen;
if (viewport) {
this.viewport = viewport
this.viewport = viewport;
if (this.plane.program.uniforms.uViewportSizes) {
this.plane.program.uniforms.uViewportSizes.value = [this.viewport.width, this.viewport.height]
this.plane.program.uniforms.uViewportSizes.value = [this.viewport.width, this.viewport.height];
}
}
this.scale = this.screen.height / 1500
this.plane.scale.y = (this.viewport.height * (900 * this.scale)) / this.screen.height
this.plane.scale.x = (this.viewport.width * (700 * this.scale)) / this.screen.width
this.plane.program.uniforms.uPlaneSizes.value = [this.plane.scale.x, this.plane.scale.y]
this.padding = 2
this.width = this.plane.scale.x + this.padding
this.widthTotal = this.width * this.length
this.x = this.width * this.index
this.scale = this.screen.height / 1500;
this.plane.scale.y = (this.viewport.height * (900 * this.scale)) / this.screen.height;
this.plane.scale.x = (this.viewport.width * (700 * this.scale)) / this.screen.width;
this.plane.program.uniforms.uPlaneSizes.value = [this.plane.scale.x, this.plane.scale.y];
this.padding = 2;
this.width = this.plane.scale.x + this.padding;
this.widthTotal = this.width * this.length;
this.x = this.width * this.index;
}
}
interface AppConfig {
items?: { image: string; text: string }[]
bend?: number
textColor?: string
borderRadius?: number
font?: string
scrollSpeed?: number
scrollEase?: number
items?: { image: string; text: string }[];
bend?: number;
textColor?: string;
borderRadius?: number;
font?: string;
scrollSpeed?: number;
scrollEase?: number;
}
class App {
container: HTMLElement
scrollSpeed: number
container: HTMLElement;
scrollSpeed: number;
scroll: {
ease: number
current: number
target: number
last: number
position?: number
}
onCheckDebounce: (...args: unknown[]) => void
renderer!: Renderer
gl!: GL
camera!: Camera
scene!: Transform
planeGeometry!: Plane
medias: Media[] = []
mediasImages: { image: string; text: string }[] = []
screen!: { width: number; height: number }
viewport!: { width: number; height: number }
raf: number = 0
ease: number;
current: number;
target: number;
last: number;
position?: number;
};
onCheckDebounce: (...args: unknown[]) => void;
renderer!: Renderer;
gl!: GL;
camera!: Camera;
scene!: Transform;
planeGeometry!: Plane;
medias: Media[] = [];
mediasImages: { image: string; text: string }[] = [];
screen!: { width: number; height: number };
viewport!: { width: number; height: number };
raf: number = 0;
boundOnResize!: () => void
boundOnWheel!: (e: Event) => void
boundOnTouchDown!: (e: MouseEvent | TouchEvent) => void
boundOnTouchMove!: (e: MouseEvent | TouchEvent) => void
boundOnTouchUp!: () => void
boundOnResize!: () => void;
boundOnWheel!: (e: Event) => void;
boundOnTouchDown!: (e: MouseEvent | TouchEvent) => void;
boundOnTouchMove!: (e: MouseEvent | TouchEvent) => void;
boundOnTouchUp!: () => void;
isDown: boolean = false
start: number = 0
isDown: boolean = false;
start: number = 0;
constructor(
container: HTMLElement,
@@ -449,46 +449,46 @@ class App {
borderRadius = 0,
font = 'bold 30px Figtree',
scrollSpeed = 2,
scrollEase = 0.05,
scrollEase = 0.05
}: AppConfig
) {
document.documentElement.classList.remove('no-js')
this.container = container
this.scrollSpeed = scrollSpeed
this.scroll = { ease: scrollEase, current: 0, target: 0, last: 0 }
this.onCheckDebounce = debounce(this.onCheck.bind(this), 200)
this.createRenderer()
this.createCamera()
this.createScene()
this.onResize()
this.createGeometry()
this.createMedias(items, bend, textColor, borderRadius, font)
this.update()
this.addEventListeners()
document.documentElement.classList.remove('no-js');
this.container = container;
this.scrollSpeed = scrollSpeed;
this.scroll = { ease: scrollEase, current: 0, target: 0, last: 0 };
this.onCheckDebounce = debounce(this.onCheck.bind(this), 200);
this.createRenderer();
this.createCamera();
this.createScene();
this.onResize();
this.createGeometry();
this.createMedias(items, bend, textColor, borderRadius, font);
this.update();
this.addEventListeners();
}
createRenderer() {
this.renderer = new Renderer({ alpha: true })
this.gl = this.renderer.gl
this.gl.clearColor(0, 0, 0, 0)
this.container.appendChild(this.renderer.gl.canvas as HTMLCanvasElement)
this.renderer = new Renderer({ alpha: true });
this.gl = this.renderer.gl;
this.gl.clearColor(0, 0, 0, 0);
this.container.appendChild(this.renderer.gl.canvas as HTMLCanvasElement);
}
createCamera() {
this.camera = new Camera(this.gl)
this.camera.fov = 45
this.camera.position.z = 20
this.camera = new Camera(this.gl);
this.camera.fov = 45;
this.camera.position.z = 20;
}
createScene() {
this.scene = new Transform()
this.scene = new Transform();
}
createGeometry() {
this.planeGeometry = new Plane(this.gl, {
heightSegments: 50,
widthSegments: 100,
})
widthSegments: 100
});
}
createMedias(
@@ -501,55 +501,55 @@ class App {
const defaultItems = [
{
image: `https://picsum.photos/seed/1/800/600?grayscale`,
text: 'Bridge',
text: 'Bridge'
},
{
image: `https://picsum.photos/seed/2/800/600?grayscale`,
text: 'Desk Setup',
text: 'Desk Setup'
},
{
image: `https://picsum.photos/seed/3/800/600?grayscale`,
text: 'Waterfall',
text: 'Waterfall'
},
{
image: `https://picsum.photos/seed/4/800/600?grayscale`,
text: 'Strawberries',
text: 'Strawberries'
},
{
image: `https://picsum.photos/seed/5/800/600?grayscale`,
text: 'Deep Diving',
text: 'Deep Diving'
},
{
image: `https://picsum.photos/seed/16/800/600?grayscale`,
text: 'Train Track',
text: 'Train Track'
},
{
image: `https://picsum.photos/seed/17/800/600?grayscale`,
text: 'Santorini',
text: 'Santorini'
},
{
image: `https://picsum.photos/seed/8/800/600?grayscale`,
text: 'Blurry Lights',
text: 'Blurry Lights'
},
{
image: `https://picsum.photos/seed/9/800/600?grayscale`,
text: 'New York',
text: 'New York'
},
{
image: `https://picsum.photos/seed/10/800/600?grayscale`,
text: 'Good Boy',
text: 'Good Boy'
},
{
image: `https://picsum.photos/seed/21/800/600?grayscale`,
text: 'Coastline',
text: 'Coastline'
},
{
image: `https://picsum.photos/seed/12/800/600?grayscale`,
text: 'Palm Trees',
},
]
const galleryItems = items && items.length ? items : defaultItems
this.mediasImages = galleryItems.concat(galleryItems)
text: 'Palm Trees'
}
];
const galleryItems = items && items.length ? items : defaultItems;
this.mediasImages = galleryItems.concat(galleryItems);
this.medias = this.mediasImages.map((data, index) => {
return new Media({
geometry: this.planeGeometry,
@@ -565,114 +565,114 @@ class App {
bend,
textColor,
borderRadius,
font,
})
})
font
});
});
}
onTouchDown(e: MouseEvent | TouchEvent) {
this.isDown = true
this.scroll.position = this.scroll.current
this.start = 'touches' in e ? e.touches[0].clientX : e.clientX
this.isDown = true;
this.scroll.position = this.scroll.current;
this.start = 'touches' in e ? e.touches[0].clientX : e.clientX;
}
onTouchMove(e: MouseEvent | TouchEvent) {
if (!this.isDown) return
const x = 'touches' in e ? e.touches[0].clientX : e.clientX
const distance = (this.start - x) * (this.scrollSpeed * 0.025)
this.scroll.target = (this.scroll.position ?? 0) + distance
if (!this.isDown) return;
const x = 'touches' in e ? e.touches[0].clientX : e.clientX;
const distance = (this.start - x) * (this.scrollSpeed * 0.025);
this.scroll.target = (this.scroll.position ?? 0) + distance;
}
onTouchUp() {
this.isDown = false
this.onCheck()
this.isDown = false;
this.onCheck();
}
onWheel(e: Event) {
const wheelEvent = e as WheelEvent
const wheelEvent = e as WheelEvent;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const delta = wheelEvent.deltaY || (wheelEvent as any).wheelDelta || (wheelEvent as any).detail
this.scroll.target += delta > 0 ? this.scrollSpeed : -this.scrollSpeed
this.onCheckDebounce()
const delta = wheelEvent.deltaY || (wheelEvent as any).wheelDelta || (wheelEvent as any).detail;
this.scroll.target += delta > 0 ? this.scrollSpeed : -this.scrollSpeed;
this.onCheckDebounce();
}
onCheck() {
if (!this.medias || !this.medias[0]) return
const width = this.medias[0].width
const itemIndex = Math.round(Math.abs(this.scroll.target) / width)
const item = width * itemIndex
this.scroll.target = this.scroll.target < 0 ? -item : item
if (!this.medias || !this.medias[0]) return;
const width = this.medias[0].width;
const itemIndex = Math.round(Math.abs(this.scroll.target) / width);
const item = width * itemIndex;
this.scroll.target = this.scroll.target < 0 ? -item : item;
}
onResize() {
this.screen = {
width: this.container.clientWidth,
height: this.container.clientHeight,
}
this.renderer.setSize(this.screen.width, this.screen.height)
height: this.container.clientHeight
};
this.renderer.setSize(this.screen.width, this.screen.height);
this.camera.perspective({
aspect: this.screen.width / this.screen.height,
})
const fov = (this.camera.fov * Math.PI) / 180
const height = 2 * Math.tan(fov / 2) * this.camera.position.z
const width = height * this.camera.aspect
this.viewport = { width, height }
aspect: this.screen.width / this.screen.height
});
const fov = (this.camera.fov * Math.PI) / 180;
const height = 2 * Math.tan(fov / 2) * this.camera.position.z;
const width = height * this.camera.aspect;
this.viewport = { width, height };
if (this.medias) {
this.medias.forEach((media) => media.onResize({ screen: this.screen, viewport: this.viewport }))
this.medias.forEach(media => media.onResize({ screen: this.screen, viewport: this.viewport }));
}
}
update() {
this.scroll.current = lerp(this.scroll.current, this.scroll.target, this.scroll.ease)
const direction = this.scroll.current > this.scroll.last ? 'right' : 'left'
this.scroll.current = lerp(this.scroll.current, this.scroll.target, this.scroll.ease);
const direction = this.scroll.current > this.scroll.last ? 'right' : 'left';
if (this.medias) {
this.medias.forEach((media) => media.update(this.scroll, direction))
this.medias.forEach(media => media.update(this.scroll, direction));
}
this.renderer.render({ scene: this.scene, camera: this.camera })
this.scroll.last = this.scroll.current
this.raf = window.requestAnimationFrame(this.update.bind(this))
this.renderer.render({ scene: this.scene, camera: this.camera });
this.scroll.last = this.scroll.current;
this.raf = window.requestAnimationFrame(this.update.bind(this));
}
addEventListeners() {
this.boundOnResize = this.onResize.bind(this)
this.boundOnWheel = this.onWheel.bind(this)
this.boundOnTouchDown = this.onTouchDown.bind(this)
this.boundOnTouchMove = this.onTouchMove.bind(this)
this.boundOnTouchUp = this.onTouchUp.bind(this)
window.addEventListener('resize', this.boundOnResize)
this.container.addEventListener('wheel', this.boundOnWheel)
this.container.addEventListener('mousedown', this.boundOnTouchDown)
this.container.addEventListener('touchstart', this.boundOnTouchDown)
window.addEventListener('mousemove', this.boundOnTouchMove)
window.addEventListener('mouseup', this.boundOnTouchUp)
window.addEventListener('touchmove', this.boundOnTouchMove)
window.addEventListener('touchend', this.boundOnTouchUp)
this.boundOnResize = this.onResize.bind(this);
this.boundOnWheel = this.onWheel.bind(this);
this.boundOnTouchDown = this.onTouchDown.bind(this);
this.boundOnTouchMove = this.onTouchMove.bind(this);
this.boundOnTouchUp = this.onTouchUp.bind(this);
window.addEventListener('resize', this.boundOnResize);
this.container.addEventListener('wheel', this.boundOnWheel);
this.container.addEventListener('mousedown', this.boundOnTouchDown);
this.container.addEventListener('touchstart', this.boundOnTouchDown);
window.addEventListener('mousemove', this.boundOnTouchMove);
window.addEventListener('mouseup', this.boundOnTouchUp);
window.addEventListener('touchmove', this.boundOnTouchMove);
window.addEventListener('touchend', this.boundOnTouchUp);
}
destroy() {
window.cancelAnimationFrame(this.raf)
window.removeEventListener('resize', this.boundOnResize)
window.removeEventListener('mousemove', this.boundOnTouchMove)
window.removeEventListener('mouseup', this.boundOnTouchUp)
window.removeEventListener('touchmove', this.boundOnTouchMove)
window.removeEventListener('touchend', this.boundOnTouchUp)
this.container.removeEventListener('wheel', this.boundOnWheel)
this.container.removeEventListener('mousedown', this.boundOnTouchDown)
this.container.removeEventListener('touchstart', this.boundOnTouchDown)
window.cancelAnimationFrame(this.raf);
window.removeEventListener('resize', this.boundOnResize);
window.removeEventListener('mousemove', this.boundOnTouchMove);
window.removeEventListener('mouseup', this.boundOnTouchUp);
window.removeEventListener('touchmove', this.boundOnTouchMove);
window.removeEventListener('touchend', this.boundOnTouchUp);
this.container.removeEventListener('wheel', this.boundOnWheel);
this.container.removeEventListener('mousedown', this.boundOnTouchDown);
this.container.removeEventListener('touchstart', this.boundOnTouchDown);
if (this.renderer && this.renderer.gl && this.renderer.gl.canvas.parentNode) {
this.renderer.gl.canvas.parentNode.removeChild(this.renderer.gl.canvas as HTMLCanvasElement)
this.renderer.gl.canvas.parentNode.removeChild(this.renderer.gl.canvas as HTMLCanvasElement);
}
}
}
onMounted(() => {
if (!containerRef.value) return
if (!containerRef.value) return;
app = new App(containerRef.value, {
items: props.items,
@@ -681,16 +681,16 @@ onMounted(() => {
borderRadius: props.borderRadius,
font: props.font,
scrollSpeed: props.scrollSpeed,
scrollEase: props.scrollEase,
})
})
scrollEase: props.scrollEase
});
});
onUnmounted(() => {
if (app) {
app.destroy()
app = null
app.destroy();
app = null;
}
})
});
watch(
() => ({
@@ -700,16 +700,16 @@ watch(
borderRadius: props.borderRadius,
font: props.font,
scrollSpeed: props.scrollSpeed,
scrollEase: props.scrollEase,
scrollEase: props.scrollEase
}),
(newProps) => {
newProps => {
if (app) {
app.destroy()
app.destroy();
}
if (containerRef.value) {
app = new App(containerRef.value, newProps)
app = new App(containerRef.value, newProps);
}
},
{ deep: true }
)
);
</script>

View File

@@ -1,161 +1,167 @@
<template>
<div ref="svgRef" class="relative" :style="{ width: `${width}px`, height: `${height}px` }">
<svg viewBox="-60 -75 720 900" preserveAspectRatio="xMidYMid slice"
class="relative w-full h-full block [will-change:transform]">
<svg
viewBox="-60 -75 720 900"
preserveAspectRatio="xMidYMid slice"
class="relative w-full h-full block [will-change:transform]"
>
<filter id="imgFilter">
<feTurbulence type="turbulence" baseFrequency="0.015" numOctaves="5" seed="4" stitchTiles="stitch" x="0%" y="0%"
width="100%" height="100%" result="turbulence1" />
<feDisplacementMap ref="displacementMapRef" in="SourceGraphic" in2="turbulence1" scale="0" xChannelSelector="R"
yChannelSelector="B" x="0%" y="0%" width="100%" height="100%" result="displacementMap3" />
<feTurbulence
type="turbulence"
baseFrequency="0.015"
numOctaves="5"
seed="4"
stitchTiles="stitch"
x="0%"
y="0%"
width="100%"
height="100%"
result="turbulence1"
/>
<feDisplacementMap
ref="displacementMapRef"
in="SourceGraphic"
in2="turbulence1"
scale="0"
xChannelSelector="R"
yChannelSelector="B"
x="0%"
y="0%"
width="100%"
height="100%"
result="displacementMap3"
/>
</filter>
<g>
<image :href="image" x="0" y="0" width="600" height="750" filter="url(#imgFilter)"
preserveAspectRatio="xMidYMid slice" />
<image
:href="image"
x="0"
y="0"
width="600"
height="750"
filter="url(#imgFilter)"
preserveAspectRatio="xMidYMid slice"
/>
</g>
</svg>
<div
class="absolute bottom-[1.2em] left-[1em] tracking-[-0.5px] font-black text-[2rem] leading-[1.5em] first-line:text-[4rem]">
class="absolute bottom-[1.2em] left-[1em] tracking-[-0.5px] font-black text-[2rem] leading-[1.5em] first-line:text-[4rem]"
>
<slot />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { gsap } from 'gsap'
import { ref, onMounted, onUnmounted } from 'vue';
import { gsap } from 'gsap';
interface Props {
width?: number
height?: number
image?: string
width?: number;
height?: number;
image?: string;
}
withDefaults(defineProps<Props>(), {
width: 300,
height: 400,
image: 'https://picsum.photos/300/400?grayscale'
})
});
const svgRef = ref<HTMLDivElement | null>(null)
const displacementMapRef = ref<SVGFEDisplacementMapElement | null>(null)
const svgRef = ref<HTMLDivElement | null>(null);
const displacementMapRef = ref<SVGFEDisplacementMapElement | null>(null);
let cursor = {
x: typeof window !== 'undefined' ? window.innerWidth / 2 : 0,
y: typeof window !== 'undefined' ? window.innerHeight / 2 : 0
}
};
let cachedCursor = { ...cursor }
let cachedCursor = { ...cursor };
let winsize = {
width: typeof window !== 'undefined' ? window.innerWidth : 0,
height: typeof window !== 'undefined' ? window.innerHeight : 0
}
};
let animationFrameId: number | null = null
let animationFrameId: number | null = null;
const lerp = (a: number, b: number, n: number): number =>
(1 - n) * a + n * b
const lerp = (a: number, b: number, n: number): number => (1 - n) * a + n * b;
const map = (
x: number,
a: number,
b: number,
c: number,
d: number
): number => ((x - a) * (d - c)) / (b - a) + c
const map = (x: number, a: number, b: number, c: number, d: number): number => ((x - a) * (d - c)) / (b - a) + c;
const distance = (x1: number, x2: number, y1: number, y2: number): number =>
Math.hypot(x1 - x2, y1 - y2)
const distance = (x1: number, x2: number, y1: number, y2: number): number => Math.hypot(x1 - x2, y1 - y2);
const handleResize = (): void => {
winsize = {
width: window.innerWidth,
height: window.innerHeight
}
}
};
};
const handleMouseMove = (ev: MouseEvent): void => {
cursor = { x: ev.clientX, y: ev.clientY }
}
cursor = { x: ev.clientX, y: ev.clientY };
};
const imgValues = {
imgTransforms: { x: 0, y: 0, rz: 0 },
displacementScale: 0
}
};
const render = () => {
let targetX = lerp(
imgValues.imgTransforms.x,
map(cursor.x, 0, winsize.width, -120, 120),
0.1
)
let targetY = lerp(
imgValues.imgTransforms.y,
map(cursor.y, 0, winsize.height, -120, 120),
0.1
)
const targetRz = lerp(
imgValues.imgTransforms.rz,
map(cursor.x, 0, winsize.width, -10, 10),
0.1
)
let targetX = lerp(imgValues.imgTransforms.x, map(cursor.x, 0, winsize.width, -120, 120), 0.1);
let targetY = lerp(imgValues.imgTransforms.y, map(cursor.y, 0, winsize.height, -120, 120), 0.1);
const targetRz = lerp(imgValues.imgTransforms.rz, map(cursor.x, 0, winsize.width, -10, 10), 0.1);
const bound = 50
if (targetX > bound) targetX = bound + (targetX - bound) * 0.2
if (targetX < -bound) targetX = -bound + (targetX + bound) * 0.2
if (targetY > bound) targetY = bound + (targetY - bound) * 0.2
if (targetY < -bound) targetY = -bound + (targetY + bound) * 0.2
const bound = 50;
if (targetX > bound) targetX = bound + (targetX - bound) * 0.2;
if (targetX < -bound) targetX = -bound + (targetX + bound) * 0.2;
if (targetY > bound) targetY = bound + (targetY - bound) * 0.2;
if (targetY < -bound) targetY = -bound + (targetY + bound) * 0.2;
imgValues.imgTransforms.x = targetX
imgValues.imgTransforms.y = targetY
imgValues.imgTransforms.rz = targetRz
imgValues.imgTransforms.x = targetX;
imgValues.imgTransforms.y = targetY;
imgValues.imgTransforms.rz = targetRz;
if (svgRef.value) {
gsap.set(svgRef.value, {
x: imgValues.imgTransforms.x,
y: imgValues.imgTransforms.y,
rotateZ: imgValues.imgTransforms.rz
})
});
}
const cursorTravelledDistance = distance(
cachedCursor.x,
cursor.x,
cachedCursor.y,
cursor.y
)
imgValues.displacementScale = lerp(
imgValues.displacementScale,
map(cursorTravelledDistance, 0, 200, 0, 400),
0.06
)
const cursorTravelledDistance = distance(cachedCursor.x, cursor.x, cachedCursor.y, cursor.y);
imgValues.displacementScale = lerp(imgValues.displacementScale, map(cursorTravelledDistance, 0, 200, 0, 400), 0.06);
if (displacementMapRef.value) {
gsap.set(displacementMapRef.value, {
attr: { scale: imgValues.displacementScale }
})
});
}
cachedCursor = { ...cursor }
cachedCursor = { ...cursor };
animationFrameId = requestAnimationFrame(render)
}
animationFrameId = requestAnimationFrame(render);
};
onMounted(() => {
if (typeof window !== 'undefined') {
window.addEventListener('resize', handleResize)
window.addEventListener('mousemove', handleMouseMove)
render()
window.addEventListener('resize', handleResize);
window.addEventListener('mousemove', handleMouseMove);
render();
}
})
});
onUnmounted(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('resize', handleResize)
window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('resize', handleResize);
window.removeEventListener('mousemove', handleMouseMove);
}
if (animationFrameId) {
cancelAnimationFrame(animationFrameId)
cancelAnimationFrame(animationFrameId);
}
})
});
</script>

View File

@@ -1,24 +1,24 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, defineComponent, h } from 'vue'
import { useMotionValue, useSpring, useTransform, type SpringOptions } from 'motion-v'
import { ref, computed, onMounted, onUnmounted, defineComponent, h } from 'vue';
import { useMotionValue, useSpring, useTransform, type SpringOptions } from 'motion-v';
export type DockItemData = {
icon: unknown
label: unknown
onClick: () => void
className?: string
}
icon: unknown;
label: unknown;
onClick: () => void;
className?: string;
};
export type DockProps = {
items: DockItemData[]
className?: string
distance?: number
panelHeight?: number
baseItemSize?: number
dockHeight?: number
magnification?: number
spring?: SpringOptions
}
items: DockItemData[];
className?: string;
distance?: number;
panelHeight?: number;
baseItemSize?: number;
dockHeight?: number;
magnification?: number;
spring?: SpringOptions;
};
const props = withDefaults(defineProps<DockProps>(), {
className: '',
@@ -28,52 +28,64 @@ const props = withDefaults(defineProps<DockProps>(), {
dockHeight: 256,
magnification: 70,
spring: () => ({ mass: 0.1, stiffness: 150, damping: 12 })
})
});
const mouseX = useMotionValue(Infinity)
const isHovered = useMotionValue(0)
const currentHeight = ref(props.panelHeight)
const mouseX = useMotionValue(Infinity);
const isHovered = useMotionValue(0);
const currentHeight = ref(props.panelHeight);
const maxHeight = computed(() =>
Math.max(props.dockHeight, props.magnification + props.magnification / 2 + 4)
)
const maxHeight = computed(() => Math.max(props.dockHeight, props.magnification + props.magnification / 2 + 4));
const heightRow = useTransform(isHovered, [0, 1], [props.panelHeight, maxHeight.value])
const height = useSpring(heightRow, props.spring)
const heightRow = useTransform(isHovered, [0, 1], [props.panelHeight, maxHeight.value]);
const height = useSpring(heightRow, props.spring);
let unsubscribeHeight: (() => void) | null = null
let unsubscribeHeight: (() => void) | null = null;
onMounted(() => {
unsubscribeHeight = height.on('change', (latest: number) => {
currentHeight.value = latest
})
})
currentHeight.value = latest;
});
});
onUnmounted(() => {
if (unsubscribeHeight) {
unsubscribeHeight()
unsubscribeHeight();
}
})
});
const handleMouseMove = (event: MouseEvent) => {
isHovered.set(1)
mouseX.set(event.pageX)
}
isHovered.set(1);
mouseX.set(event.pageX);
};
const handleMouseLeave = () => {
isHovered.set(0)
mouseX.set(Infinity)
}
isHovered.set(0);
mouseX.set(Infinity);
};
</script>
<template>
<div :style="{ height: currentHeight + 'px', scrollbarWidth: 'none' }" class="mx-2 flex max-w-full items-center">
<div @mousemove="handleMouseMove" @mouseleave="handleMouseLeave"
<div
@mousemove="handleMouseMove"
@mouseleave="handleMouseLeave"
:class="`${props.className} absolute bottom-2 left-1/2 transform -translate-x-1/2 flex items-end w-fit gap-4 rounded-2xl border-neutral-700 border-2 pb-2 px-4`"
:style="{ height: props.panelHeight + 'px' }" role="toolbar" aria-="Application dock">
<DockItem v-for="(item, index) in props.items" :key="index" :onClick="item.onClick" :className="item.className"
:mouseX="mouseX" :spring="props.spring" :distance="props.distance" :magnification="props.magnification"
:baseItemSize="props.baseItemSize" :item="item" />
:style="{ height: props.panelHeight + 'px' }"
role="toolbar"
aria-="Application dock"
>
<DockItem
v-for="(item, index) in props.items"
:key="index"
:onClick="item.onClick"
:className="item.className"
:mouseX="mouseX"
:spring="props.spring"
:distance="props.distance"
:magnification="props.magnification"
:baseItemSize="props.baseItemSize"
:item="item"
/>
</div>
</div>
</template>
@@ -88,7 +100,7 @@ const DockItem = defineComponent({
},
onClick: {
type: Function,
default: () => { }
default: () => {}
},
mouseX: {
type: Object as () => ReturnType<typeof useMotionValue<number>>,
@@ -116,43 +128,43 @@ const DockItem = defineComponent({
}
},
setup(props) {
const itemRef = ref<HTMLDivElement>()
const isHovered = useMotionValue(0)
const currentSize = ref(props.baseItemSize)
const itemRef = ref<HTMLDivElement>();
const isHovered = useMotionValue(0);
const currentSize = ref(props.baseItemSize);
const mouseDistance = useTransform(props.mouseX, (val: number) => {
const rect = itemRef.value?.getBoundingClientRect() ?? {
x: 0,
width: props.baseItemSize,
}
return val - rect.x - props.baseItemSize / 2
})
width: props.baseItemSize
};
return val - rect.x - props.baseItemSize / 2;
});
const targetSize = useTransform(
mouseDistance,
[-props.distance, 0, props.distance],
[props.baseItemSize, props.magnification, props.baseItemSize]
)
const size = useSpring(targetSize, props.spring)
);
const size = useSpring(targetSize, props.spring);
let unsubscribeSize: (() => void) | null = null
let unsubscribeSize: (() => void) | null = null;
onMounted(() => {
unsubscribeSize = size.on('change', (latest: number) => {
currentSize.value = latest
})
})
currentSize.value = latest;
});
});
onUnmounted(() => {
if (unsubscribeSize) {
unsubscribeSize()
unsubscribeSize();
}
})
});
const handleHoverStart = () => isHovered.set(1)
const handleHoverEnd = () => isHovered.set(0)
const handleFocus = () => isHovered.set(1)
const handleBlur = () => isHovered.set(0)
const handleHoverStart = () => isHovered.set(1);
const handleHoverEnd = () => isHovered.set(0);
const handleFocus = () => isHovered.set(1);
const handleBlur = () => isHovered.set(0);
return {
itemRef,
@@ -163,33 +175,37 @@ const DockItem = defineComponent({
handleHoverEnd,
handleFocus,
handleBlur
}
};
},
render() {
const icon = typeof this.item.icon === 'function' ? this.item.icon() : this.item.icon
const label = typeof this.item.label === 'function' ? this.item.label() : this.item.label
const icon = typeof this.item.icon === 'function' ? this.item.icon() : this.item.icon;
const label = typeof this.item.label === 'function' ? this.item.label() : this.item.label;
return h('div', {
ref: 'itemRef',
style: {
width: this.currentSize + 'px',
height: this.currentSize + 'px',
return h(
'div',
{
ref: 'itemRef',
style: {
width: this.currentSize + 'px',
height: this.currentSize + 'px'
},
onMouseenter: this.handleHoverStart,
onMouseleave: this.handleHoverEnd,
onFocus: this.handleFocus,
onBlur: this.handleBlur,
onClick: this.onClick,
class: `relative cursor-pointer inline-flex items-center justify-center rounded-full bg-[#111] border-neutral-700 border-2 shadow-md ${this.className}`,
tabindex: 0,
role: 'button',
'aria-haspopup': 'true'
},
onMouseenter: this.handleHoverStart,
onMouseleave: this.handleHoverEnd,
onFocus: this.handleFocus,
onBlur: this.handleBlur,
onClick: this.onClick,
class: `relative cursor-pointer inline-flex items-center justify-center rounded-full bg-[#111] border-neutral-700 border-2 shadow-md ${this.className}`,
tabindex: 0,
role: 'button',
'aria-haspopup': 'true'
}, [
h(DockIcon, {}, () => [icon]),
h(DockLabel, { isHovered: this.isHovered }, () => [typeof label === 'string' ? label : label])
])
[
h(DockIcon, {}, () => [icon]),
h(DockLabel, { isHovered: this.isHovered }, () => [typeof label === 'string' ? label : label])
]
);
}
})
});
const DockLabel = defineComponent({
name: 'DockLabel',
@@ -204,38 +220,42 @@ const DockLabel = defineComponent({
}
},
setup(props) {
const isVisible = ref(false)
const isVisible = ref(false);
let unsubscribe: (() => void) | null = null
let unsubscribe: (() => void) | null = null;
onMounted(() => {
unsubscribe = props.isHovered.on('change', (latest: number) => {
isVisible.value = latest === 1
})
})
isVisible.value = latest === 1;
});
});
onUnmounted(() => {
if (unsubscribe) {
unsubscribe()
unsubscribe();
}
})
});
return {
isVisible
}
};
},
render() {
return h('div', {
class: `${this.className} absolute -top-8 left-1/2 w-fit whitespace-pre rounded-md border border-neutral-700 bg-[#111] px-2 py-0.5 text-xs text-white transition-all duration-200`,
role: 'tooltip',
style: {
transform: 'translateX(-50%)',
opacity: this.isVisible ? 1 : 0,
visibility: this.isVisible ? 'visible' : 'hidden'
}
}, this.$slots.default?.())
return h(
'div',
{
class: `${this.className} absolute -top-8 left-1/2 w-fit whitespace-pre rounded-md border border-neutral-700 bg-[#111] px-2 py-0.5 text-xs text-white transition-all duration-200`,
role: 'tooltip',
style: {
transform: 'translateX(-50%)',
opacity: this.isVisible ? 1 : 0,
visibility: this.isVisible ? 'visible' : 'hidden'
}
},
this.$slots.default?.()
);
}
})
});
const DockIcon = defineComponent({
name: 'DockIcon',
@@ -246,16 +266,20 @@ const DockIcon = defineComponent({
}
},
render() {
return h('div', {
class: `flex items-center justify-center ${this.className}`
}, this.$slots.default?.())
return h(
'div',
{
class: `flex items-center justify-center ${this.className}`
},
this.$slots.default?.()
);
}
})
});
export default defineComponent({
name: 'Dock',
components: {
DockItem
}
})
</script>
});
</script>

View File

@@ -1,67 +1,90 @@
<template>
<div :class="`flex flex-col items-center justify-center gap-4 w-48 ${className}`">
<div class="flex w-full touch-none select-none items-center justify-center gap-4" :style="{
scale: scale,
opacity: sliderOpacity
}" @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave" @touchstart="handleTouchStart"
@touchend="handleTouchEnd">
<div ref="leftIconRef" :style="{
transform: `translateX(${leftIconTranslateX}px) scale(${leftIconScale})`,
}" class="transition-transform duration-200 ease-out">
<div
class="flex w-full touch-none select-none items-center justify-center gap-4"
:style="{
scale: scale,
opacity: sliderOpacity
}"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@touchstart="handleTouchStart"
@touchend="handleTouchEnd"
>
<div
ref="leftIconRef"
:style="{
transform: `translateX(${leftIconTranslateX}px) scale(${leftIconScale})`
}"
class="transition-transform duration-200 ease-out"
>
<slot name="left-icon">
<component :is="leftIcon" v-if="leftIcon && typeof leftIcon === 'object'" />
<span v-else-if="leftIcon">{{ leftIcon }}</span>
<span v-else>-</span>
</slot>
</div>
<div ref="sliderRef"
<div
ref="sliderRef"
class="relative flex w-full max-w-xs flex-grow cursor-grab touch-none select-none items-center py-4"
@pointermove="handlePointerMove" @pointerdown="handlePointerDown" @pointerup="handlePointerUp">
<div :style="{
transform: `scaleX(${sliderScaleX}) scaleY(${sliderScaleY})`,
transformOrigin: transformOrigin,
height: `${sliderHeight}px`,
marginTop: `${sliderMarginTop}px`,
marginBottom: `${sliderMarginBottom}px`,
}" class="flex flex-grow">
@pointermove="handlePointerMove"
@pointerdown="handlePointerDown"
@pointerup="handlePointerUp"
>
<div
:style="{
transform: `scaleX(${sliderScaleX}) scaleY(${sliderScaleY})`,
transformOrigin: transformOrigin,
height: `${sliderHeight}px`,
marginTop: `${sliderMarginTop}px`,
marginBottom: `${sliderMarginBottom}px`
}"
class="flex flex-grow"
>
<div class="relative h-full flex-grow overflow-hidden rounded-full bg-gray-400">
<div class="absolute h-full bg-[#27FF64] rounded-full" :style="{ width: `${rangePercentage}%` }" />
</div>
</div>
</div>
<div ref="rightIconRef" :style="{
transform: `translateX(${rightIconTranslateX}px) scale(${rightIconScale})`,
}" class="transition-transform duration-200 ease-out">
<div
ref="rightIconRef"
:style="{
transform: `translateX(${rightIconTranslateX}px) scale(${rightIconScale})`
}"
class="transition-transform duration-200 ease-out"
>
<slot name="right-icon">
<component :is="rightIcon" v-if="rightIcon && typeof rightIcon === 'object'" />
<span v-else-if="rightIcon">{{ rightIcon }}</span>
<span v-else>+</span>
</slot>
</div>
</div>
<p class="absolute text-gray-400 transform -translate-y-6 font-medium tracking-wide">
{{ Math.round(value) }}
</p>
<p class="absolute text-gray-400 transform -translate-y-6 font-medium tracking-wide">{{ Math.round(value) }}</p>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, type Component } from 'vue'
import { ref, computed, watch, onMounted, type Component } from 'vue';
const MAX_OVERFLOW = 50
const MAX_OVERFLOW = 50;
interface Props {
defaultValue?: number
startingValue?: number
maxValue?: number
className?: string
isStepped?: boolean
stepSize?: number
leftIcon?: Component | string
rightIcon?: Component | string
defaultValue?: number;
startingValue?: number;
maxValue?: number;
className?: string;
isStepped?: boolean;
stepSize?: number;
leftIcon?: Component | string;
rightIcon?: Component | string;
}
const props = withDefaults(defineProps<Props>(), {
@@ -73,256 +96,263 @@ const props = withDefaults(defineProps<Props>(), {
stepSize: 1,
leftIcon: '-',
rightIcon: '+'
})
});
const sliderRef = ref<HTMLDivElement>()
const leftIconRef = ref<HTMLDivElement>()
const rightIconRef = ref<HTMLDivElement>()
const sliderRef = ref<HTMLDivElement>();
const leftIconRef = ref<HTMLDivElement>();
const rightIconRef = ref<HTMLDivElement>();
const value = ref(props.defaultValue)
const region = ref<'left' | 'middle' | 'right'>('middle')
const clientX = ref(0)
const overflow = ref(0)
const scale = ref(1)
const leftIconScale = ref(1)
const rightIconScale = ref(1)
const value = ref(props.defaultValue);
const region = ref<'left' | 'middle' | 'right'>('middle');
const clientX = ref(0);
const overflow = ref(0);
const scale = ref(1);
const leftIconScale = ref(1);
const rightIconScale = ref(1);
let scaleAnimation: number | null = null
let overflowAnimation: number | null = null
let scaleAnimation: number | null = null;
let overflowAnimation: number | null = null;
watch(() => props.defaultValue, (newValue) => {
value.value = newValue
})
watch(clientX, (latest) => {
if (sliderRef.value) {
const { left, right } = sliderRef.value.getBoundingClientRect()
let newValue: number
if (latest < left) {
region.value = 'left'
newValue = left - latest
} else if (latest > right) {
region.value = 'right'
newValue = latest - right
} else {
region.value = 'middle'
newValue = 0
}
overflow.value = decay(newValue, MAX_OVERFLOW)
watch(
() => props.defaultValue,
newValue => {
value.value = newValue;
}
})
);
watch(clientX, latest => {
if (sliderRef.value) {
const { left, right } = sliderRef.value.getBoundingClientRect();
let newValue: number;
if (latest < left) {
region.value = 'left';
newValue = left - latest;
} else if (latest > right) {
region.value = 'right';
newValue = latest - right;
} else {
region.value = 'middle';
newValue = 0;
}
overflow.value = decay(newValue, MAX_OVERFLOW);
}
});
const rangePercentage = computed(() => {
const totalRange = props.maxValue - props.startingValue
if (totalRange === 0) return 0
return ((value.value - props.startingValue) / totalRange) * 100
})
const totalRange = props.maxValue - props.startingValue;
if (totalRange === 0) return 0;
return ((value.value - props.startingValue) / totalRange) * 100;
});
const sliderScaleX = computed(() => {
if (!sliderRef.value) return 1
const { width } = sliderRef.value.getBoundingClientRect()
return 1 + overflow.value / width
})
if (!sliderRef.value) return 1;
const { width } = sliderRef.value.getBoundingClientRect();
return 1 + overflow.value / width;
});
const sliderScaleY = computed(() => {
const t = overflow.value / MAX_OVERFLOW
return 1 + t * (0.8 - 1)
})
const t = overflow.value / MAX_OVERFLOW;
return 1 + t * (0.8 - 1);
});
const transformOrigin = computed(() => {
if (!sliderRef.value) return 'center'
const { left, width } = sliderRef.value.getBoundingClientRect()
return clientX.value < left + width / 2 ? 'right' : 'left'
})
if (!sliderRef.value) return 'center';
const { left, width } = sliderRef.value.getBoundingClientRect();
return clientX.value < left + width / 2 ? 'right' : 'left';
});
const sliderHeight = computed(() => {
const t = (scale.value - 1) / (1.2 - 1)
return 6 + t * (12 - 6)
})
const t = (scale.value - 1) / (1.2 - 1);
return 6 + t * (12 - 6);
});
const sliderMarginTop = computed(() => {
const t = (scale.value - 1) / (1.2 - 1)
return 0 + t * (-3 - 0)
})
const t = (scale.value - 1) / (1.2 - 1);
return 0 + t * (-3 - 0);
});
const sliderMarginBottom = computed(() => {
const t = (scale.value - 1) / (1.2 - 1)
return 0 + t * (-3 - 0)
})
const t = (scale.value - 1) / (1.2 - 1);
return 0 + t * (-3 - 0);
});
const sliderOpacity = computed(() => {
const t = (scale.value - 1) / (1.2 - 1)
return 0.7 + t * (1 - 0.7)
})
const t = (scale.value - 1) / (1.2 - 1);
return 0.7 + t * (1 - 0.7);
});
const leftIconTranslateX = computed(() => {
return region.value === 'left' ? -overflow.value / scale.value : 0
})
return region.value === 'left' ? -overflow.value / scale.value : 0;
});
const rightIconTranslateX = computed(() => {
return region.value === 'right' ? overflow.value / scale.value : 0
})
return region.value === 'right' ? overflow.value / scale.value : 0;
});
const decay = (inputValue: number, max: number): number => {
if (max === 0) return 0
const entry = inputValue / max
const sigmoid = 2 * (1 / (1 + Math.exp(-entry)) - 0.5)
return sigmoid * max
}
if (max === 0) return 0;
const entry = inputValue / max;
const sigmoid = 2 * (1 / (1 + Math.exp(-entry)) - 0.5);
return sigmoid * max;
};
const animate = (target: { value: number }, to: number, options: { type?: string; bounce?: number; duration?: number } = {}) => {
const { type = 'tween', bounce = 0, duration = 0.3 } = options
const animate = (
target: { value: number },
to: number,
options: { type?: string; bounce?: number; duration?: number } = {}
) => {
const { type = 'tween', bounce = 0, duration = 0.3 } = options;
if (type === 'spring') {
return animateSpring(target, to, bounce, duration)
return animateSpring(target, to, bounce, duration);
} else {
return animateValue(target, to, duration)
return animateValue(target, to, duration);
}
}
};
const animateValue = (target: { value: number }, to: number, duration = 300) => {
const start = target.value
const diff = to - start
const startTime = performance.now()
const start = target.value;
const diff = to - start;
const startTime = performance.now();
const animateFrame = (currentTime: number) => {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / duration, 1)
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const easeOut = 1 - Math.pow(1 - progress, 3)
target.value = start + diff * easeOut
const easeOut = 1 - Math.pow(1 - progress, 3);
target.value = start + diff * easeOut;
if (progress < 1) {
return requestAnimationFrame(animateFrame)
return requestAnimationFrame(animateFrame);
}
return null
}
return null;
};
return requestAnimationFrame(animateFrame)
}
return requestAnimationFrame(animateFrame);
};
const animateSpring = (target: { value: number }, to: number, bounce = 0.5, duration = 600) => {
const start = target.value
const startTime = performance.now()
const start = target.value;
const startTime = performance.now();
const mass = 1
const stiffness = 170
const damping = 26 * (1 - bounce)
const mass = 1;
const stiffness = 170;
const damping = 26 * (1 - bounce);
const dampingRatio = damping / (2 * Math.sqrt(mass * stiffness))
const angularFreq = Math.sqrt(stiffness / mass)
const dampedFreq = angularFreq * Math.sqrt(1 - dampingRatio * dampingRatio)
const dampingRatio = damping / (2 * Math.sqrt(mass * stiffness));
const angularFreq = Math.sqrt(stiffness / mass);
const dampedFreq = angularFreq * Math.sqrt(1 - dampingRatio * dampingRatio);
const animateFrame = (currentTime: number) => {
const elapsed = currentTime - startTime
const t = elapsed / 1000
const elapsed = currentTime - startTime;
const t = elapsed / 1000;
let displacement: number
let displacement: number;
if (dampingRatio < 1) {
const envelope = Math.exp(-dampingRatio * angularFreq * t)
const cos = Math.cos(dampedFreq * t)
const sin = Math.sin(dampedFreq * t)
const envelope = Math.exp(-dampingRatio * angularFreq * t);
const cos = Math.cos(dampedFreq * t);
const sin = Math.sin(dampedFreq * t);
displacement = envelope * (cos + (dampingRatio * angularFreq / dampedFreq) * sin)
displacement = envelope * (cos + ((dampingRatio * angularFreq) / dampedFreq) * sin);
} else {
displacement = Math.exp(-angularFreq * t)
displacement = Math.exp(-angularFreq * t);
}
const currentValue = to + (start - to) * displacement
target.value = currentValue
const currentValue = to + (start - to) * displacement;
target.value = currentValue;
const velocity = Math.abs(currentValue - to)
const isSettled = velocity < 0.01 && elapsed > 100
const velocity = Math.abs(currentValue - to);
const isSettled = velocity < 0.01 && elapsed > 100;
if (!isSettled && elapsed < duration * 3) {
return requestAnimationFrame(animateFrame)
return requestAnimationFrame(animateFrame);
} else {
target.value = to
return null
target.value = to;
return null;
}
}
};
return requestAnimationFrame(animateFrame)
}
return requestAnimationFrame(animateFrame);
};
const animateIconScale = (target: { value: number }, isActive: boolean) => {
if (isActive) {
animate(target, 1.4, { duration: 125 })
animate(target, 1.4, { duration: 125 });
setTimeout(() => {
animate(target, 1, { duration: 125 })
}, 125)
animate(target, 1, { duration: 125 });
}, 125);
} else {
animate(target, 1, { duration: 250 })
animate(target, 1, { duration: 250 });
}
}
};
watch(region, (newRegion, oldRegion) => {
if (newRegion === 'left' && oldRegion !== 'left') {
animateIconScale(leftIconScale, true)
animateIconScale(leftIconScale, true);
} else if (newRegion === 'right' && oldRegion !== 'right') {
animateIconScale(rightIconScale, true)
animateIconScale(rightIconScale, true);
}
})
});
const handlePointerMove = (e: PointerEvent) => {
if (e.buttons > 0 && sliderRef.value) {
const { left, width } = sliderRef.value.getBoundingClientRect()
const { left, width } = sliderRef.value.getBoundingClientRect();
let newValue = props.startingValue + ((e.clientX - left) / width) * (props.maxValue - props.startingValue)
let newValue = props.startingValue + ((e.clientX - left) / width) * (props.maxValue - props.startingValue);
if (props.isStepped) {
newValue = Math.round(newValue / props.stepSize) * props.stepSize
newValue = Math.round(newValue / props.stepSize) * props.stepSize;
}
newValue = Math.min(Math.max(newValue, props.startingValue), props.maxValue)
value.value = newValue
newValue = Math.min(Math.max(newValue, props.startingValue), props.maxValue);
value.value = newValue;
clientX.value = e.clientX
clientX.value = e.clientX;
}
}
};
const handlePointerDown = (e: PointerEvent) => {
handlePointerMove(e)
; (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
}
handlePointerMove(e);
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
};
const handlePointerUp = () => {
if (overflowAnimation) {
cancelAnimationFrame(overflowAnimation)
cancelAnimationFrame(overflowAnimation);
}
overflowAnimation = animate(overflow, 0, { type: 'spring', bounce: 0.4, duration: 500 })
}
overflowAnimation = animate(overflow, 0, { type: 'spring', bounce: 0.4, duration: 500 });
};
const handleMouseEnter = () => {
if (scaleAnimation) {
cancelAnimationFrame(scaleAnimation)
cancelAnimationFrame(scaleAnimation);
}
scaleAnimation = animate(scale, 1.2, { duration: 200 })
}
scaleAnimation = animate(scale, 1.2, { duration: 200 });
};
const handleMouseLeave = () => {
if (scaleAnimation) {
cancelAnimationFrame(scaleAnimation)
cancelAnimationFrame(scaleAnimation);
}
scaleAnimation = animate(scale, 1, { duration: 200 })
}
scaleAnimation = animate(scale, 1, { duration: 200 });
};
const handleTouchStart = () => {
if (scaleAnimation) {
cancelAnimationFrame(scaleAnimation)
cancelAnimationFrame(scaleAnimation);
}
scaleAnimation = animate(scale, 1.2, { duration: 200 })
}
scaleAnimation = animate(scale, 1.2, { duration: 200 });
};
const handleTouchEnd = () => {
if (scaleAnimation) {
cancelAnimationFrame(scaleAnimation)
cancelAnimationFrame(scaleAnimation);
}
scaleAnimation = animate(scale, 1, { duration: 200 })
}
scaleAnimation = animate(scale, 1, { duration: 200 });
};
onMounted(() => {
value.value = props.defaultValue
})
value.value = props.defaultValue;
});
</script>

View File

@@ -1,24 +1,36 @@
<template>
<div class="w-full h-full overflow-hidden">
<nav class="flex flex-col h-full m-0 p-0">
<div v-for="(item, idx) in items" :key="idx"
<div
v-for="(item, idx) in items"
:key="idx"
class="flex-1 relative overflow-hidden text-center shadow-[0_-1px_0_0_#fff]"
:ref="(el) => setItemRef(el as HTMLDivElement, idx)">
<a class="flex items-center justify-center h-full relative cursor-pointer uppercase no-underline font-semibold text-white text-[4vh] hover:text-[#060010] focus:text-white focus-visible:text-[#060010]"
:href="item.link" @mouseenter="(ev) => handleMouseEnter(ev, idx)"
@mouseleave="(ev) => handleMouseLeave(ev, idx)">
:ref="el => setItemRef(el as HTMLDivElement, idx)"
>
<a
class="flex items-center justify-center h-full relative cursor-pointer uppercase no-underline font-semibold text-white text-[4vh] hover:text-[#060010] focus:text-white focus-visible:text-[#060010]"
:href="item.link"
@mouseenter="ev => handleMouseEnter(ev, idx)"
@mouseleave="ev => handleMouseLeave(ev, idx)"
>
{{ item.text }}
</a>
<div class="absolute top-0 left-0 w-full h-full overflow-hidden pointer-events-none bg-white translate-y-[101%]"
:ref="(el) => marqueeRefs[idx] = el as HTMLDivElement">
<div class="h-full w-[200%] flex" :ref="(el) => marqueeInnerRefs[idx] = el as HTMLDivElement">
<div
class="absolute top-0 left-0 w-full h-full overflow-hidden pointer-events-none bg-white translate-y-[101%]"
:ref="el => (marqueeRefs[idx] = el as HTMLDivElement)"
>
<div class="h-full w-[200%] flex" :ref="el => (marqueeInnerRefs[idx] = el as HTMLDivElement)">
<div class="flex items-center relative h-full w-[200%] will-change-transform animate-marquee">
<template v-for="i in 4" :key="`${idx}-${i}`">
<span class="text-[#060010] uppercase font-normal text-[4vh] leading-[1.2] p-[1vh_1vw_0]">
{{ item.text }}
</span>
<div class="w-[200px] h-[7vh] my-[2em] mx-[2vw] p-[1em_0] rounded-[50px] bg-cover bg-center"
:style="{ backgroundImage: `url(${item.image})` }" />
<div
class="w-[200px] h-[7vh] my-[2em] mx-[2vw] p-[1em_0] rounded-[50px] bg-cover bg-center"
:style="{ backgroundImage: `url(${item.image})` }"
/>
</template>
</div>
</div>
@@ -29,89 +41,72 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { gsap } from 'gsap'
import { ref } from 'vue';
import { gsap } from 'gsap';
interface MenuItemProps {
link: string
text: string
image: string
link: string;
text: string;
image: string;
}
interface Props {
items?: MenuItemProps[]
items?: MenuItemProps[];
}
withDefaults(defineProps<Props>(), {
items: () => []
})
});
const itemRefs = ref<(HTMLDivElement | null)[]>([])
const marqueeRefs = ref<(HTMLDivElement | null)[]>([])
const marqueeInnerRefs = ref<(HTMLDivElement | null)[]>([])
const itemRefs = ref<(HTMLDivElement | null)[]>([]);
const marqueeRefs = ref<(HTMLDivElement | null)[]>([]);
const marqueeInnerRefs = ref<(HTMLDivElement | null)[]>([]);
const animationDefaults = { duration: 0.6, ease: 'expo' }
const animationDefaults = { duration: 0.6, ease: 'expo' };
const setItemRef = (el: HTMLDivElement | null, idx: number) => {
if (el) {
itemRefs.value[idx] = el
itemRefs.value[idx] = el;
}
}
};
const findClosestEdge = (
mouseX: number,
mouseY: number,
width: number,
height: number
): 'top' | 'bottom' => {
const topEdgeDist = Math.pow(mouseX - width / 2, 2) + Math.pow(mouseY, 2)
const bottomEdgeDist =
Math.pow(mouseX - width / 2, 2) + Math.pow(mouseY - height, 2)
return topEdgeDist < bottomEdgeDist ? 'top' : 'bottom'
}
const findClosestEdge = (mouseX: number, mouseY: number, width: number, height: number): 'top' | 'bottom' => {
const topEdgeDist = Math.pow(mouseX - width / 2, 2) + Math.pow(mouseY, 2);
const bottomEdgeDist = Math.pow(mouseX - width / 2, 2) + Math.pow(mouseY - height, 2);
return topEdgeDist < bottomEdgeDist ? 'top' : 'bottom';
};
const handleMouseEnter = (ev: MouseEvent, idx: number) => {
const itemRef = itemRefs.value[idx]
const marqueeRef = marqueeRefs.value[idx]
const marqueeInnerRef = marqueeInnerRefs.value[idx]
const itemRef = itemRefs.value[idx];
const marqueeRef = marqueeRefs.value[idx];
const marqueeInnerRef = marqueeInnerRefs.value[idx];
if (!itemRef || !marqueeRef || !marqueeInnerRef) return
if (!itemRef || !marqueeRef || !marqueeInnerRef) return;
const rect = itemRef.getBoundingClientRect()
const edge = findClosestEdge(
ev.clientX - rect.left,
ev.clientY - rect.top,
rect.width,
rect.height
)
const rect = itemRef.getBoundingClientRect();
const edge = findClosestEdge(ev.clientX - rect.left, ev.clientY - rect.top, rect.width, rect.height);
const tl = gsap.timeline({ defaults: animationDefaults })
const tl = gsap.timeline({ defaults: animationDefaults });
tl.set(marqueeRef, { y: edge === 'top' ? '-101%' : '101%' })
.set(marqueeInnerRef, { y: edge === 'top' ? '101%' : '-101%' })
.to([marqueeRef, marqueeInnerRef], { y: '0%' })
}
.to([marqueeRef, marqueeInnerRef], { y: '0%' });
};
const handleMouseLeave = (ev: MouseEvent, idx: number) => {
const itemRef = itemRefs.value[idx]
const marqueeRef = marqueeRefs.value[idx]
const marqueeInnerRef = marqueeInnerRefs.value[idx]
const itemRef = itemRefs.value[idx];
const marqueeRef = marqueeRefs.value[idx];
const marqueeInnerRef = marqueeInnerRefs.value[idx];
if (!itemRef || !marqueeRef || !marqueeInnerRef) return
if (!itemRef || !marqueeRef || !marqueeInnerRef) return;
const rect = itemRef.getBoundingClientRect()
const edge = findClosestEdge(
ev.clientX - rect.left,
ev.clientY - rect.top,
rect.width,
rect.height
)
const rect = itemRef.getBoundingClientRect();
const edge = findClosestEdge(ev.clientX - rect.left, ev.clientY - rect.top, rect.width, rect.height);
const tl = gsap.timeline({ defaults: animationDefaults })
tl.to(marqueeRef, { y: edge === 'top' ? '-101%' : '101%' }).to(
marqueeInnerRef,
{ y: edge === 'top' ? '101%' : '-101%' }
)
}
const tl = gsap.timeline({ defaults: animationDefaults });
tl.to(marqueeRef, { y: edge === 'top' ? '-101%' : '101%' }).to(marqueeInnerRef, {
y: edge === 'top' ? '101%' : '-101%'
});
};
</script>
<style scoped>

View File

@@ -1,23 +1,11 @@
<template>
<div ref="containerRef" :class="[
'w-full h-full overflow-hidden relative z-2',
className
]" v-bind="$attrs">
<div ref="containerRef" :class="['w-full h-full overflow-hidden relative z-2', className]" v-bind="$attrs">
<canvas ref="canvasRef" class="block w-full h-full" />
</div>
</template>
<script lang="ts">
import {
Renderer,
Camera,
Transform,
Plane,
Program,
Mesh,
Texture,
type OGLRenderingContext,
} from "ogl";
import { Renderer, Camera, Transform, Plane, Program, Mesh, Texture, type OGLRenderingContext } from 'ogl';
type GL = OGLRenderingContext;
type OGLProgram = Program;
@@ -177,18 +165,13 @@ function AutoBind(self: any, { include, exclude }: AutoBindOptions = {}) {
for (const key of Reflect.ownKeys(currentObject)) {
properties.add([currentObject, key]);
}
} while (
(currentObject = Reflect.getPrototypeOf(currentObject)) &&
currentObject !== Object.prototype
);
} while ((currentObject = Reflect.getPrototypeOf(currentObject)) && currentObject !== Object.prototype);
return properties;
};
const filter = (key: string | symbol) => {
const match = (pattern: string | RegExp) =>
typeof pattern === "string"
? key === pattern
: (pattern as RegExp).test(key.toString());
typeof pattern === 'string' ? key === pattern : (pattern as RegExp).test(key.toString());
if (include) return include.some(match);
if (exclude) return !exclude.some(match);
@@ -196,9 +179,9 @@ function AutoBind(self: any, { include, exclude }: AutoBindOptions = {}) {
};
for (const [object, key] of getAllProperties(self.constructor.prototype)) {
if (key === "constructor" || !filter(key)) continue;
if (key === 'constructor' || !filter(key)) continue;
const descriptor = Reflect.getOwnPropertyDescriptor(object, key);
if (descriptor && typeof descriptor.value === "function" && typeof key === "string") {
if (descriptor && typeof descriptor.value === 'function' && typeof key === 'string') {
self[key] = self[key].bind(self);
}
}
@@ -209,14 +192,7 @@ function lerp(p1: number, p2: number, t: number): number {
return p1 + (p2 - p1) * t;
}
function map(
num: number,
min1: number,
max1: number,
min2: number,
max2: number,
round = false
): number {
function map(num: number, min1: number, max1: number, min2: number, max2: number, round = false): number {
const num1 = (num - min1) / (max1 - min1);
const num2 = num1 * (max2 - min2) + min2;
return round ? Math.round(num2) : num2;
@@ -254,7 +230,7 @@ class Media {
index,
planeWidth,
planeHeight,
distortion,
distortion
}: MediaParams) {
this.gl = gl;
this.geometry = geometry;
@@ -290,54 +266,40 @@ class Media {
distortionAxis: { value: [1, 1, 0] },
uDistortion: { value: this.distortion },
uViewportSize: { value: [this.viewport.width, this.viewport.height] },
uTime: { value: 0 },
uTime: { value: 0 }
},
cullFace: false,
cullFace: false
});
const img = new Image();
img.crossOrigin = "anonymous";
img.crossOrigin = 'anonymous';
img.src = this.image;
img.onload = () => {
texture.image = img;
this.program.uniforms.uImageSize.value = [
img.naturalWidth,
img.naturalHeight,
];
this.program.uniforms.uImageSize.value = [img.naturalWidth, img.naturalHeight];
};
}
createMesh() {
this.plane = new Mesh(this.gl, {
geometry: this.geometry,
program: this.program,
program: this.program
});
this.plane.setParent(this.scene);
}
setScale() {
this.plane.scale.x =
(this.viewport.width * this.planeWidth) / this.screen.width;
this.plane.scale.y =
(this.viewport.height * this.planeHeight) / this.screen.height;
this.plane.scale.x = (this.viewport.width * this.planeWidth) / this.screen.width;
this.plane.scale.y = (this.viewport.height * this.planeHeight) / this.screen.height;
this.plane.position.x = 0;
this.program.uniforms.uPlaneSize.value = [
this.plane.scale.x,
this.plane.scale.y,
];
this.program.uniforms.uPlaneSize.value = [this.plane.scale.x, this.plane.scale.y];
}
onResize({
screen,
viewport,
}: { screen?: ScreenSize; viewport?: ViewportSize } = {}) {
onResize({ screen, viewport }: { screen?: ScreenSize; viewport?: ViewportSize } = {}) {
if (screen) this.screen = screen;
if (viewport) {
this.viewport = viewport;
this.program.uniforms.uViewportSize.value = [
viewport.width,
viewport.height,
];
this.program.uniforms.uViewportSize.value = [viewport.width, viewport.height];
}
this.setScale();
@@ -349,13 +311,7 @@ class Media {
update(scroll: ScrollState) {
this.plane.position.y = this.y - scroll.current - this.extra;
const position = map(
this.plane.position.y,
-this.viewport.height,
this.viewport.height,
5,
15
);
const position = map(this.plane.position.y, -this.viewport.height, this.viewport.height, 5, 15);
this.program.uniforms.uPosition.value = position;
this.program.uniforms.uTime.value += 0.04;
@@ -406,7 +362,7 @@ class Canvas {
distortion,
scrollEase,
cameraFov,
cameraZ,
cameraZ
}: CanvasParams) {
this.container = container;
this.canvas = canvas;
@@ -418,7 +374,7 @@ class Canvas {
ease: scrollEase,
current: 0,
target: 0,
last: 0,
last: 0
};
this.cameraFov = cameraFov;
this.cameraZ = cameraZ;
@@ -441,7 +397,7 @@ class Canvas {
canvas: this.canvas,
alpha: true,
antialias: true,
dpr: Math.min(window.devicePixelRatio, 2),
dpr: Math.min(window.devicePixelRatio, 2)
});
this.gl = this.renderer.gl;
}
@@ -459,7 +415,7 @@ class Canvas {
createGeometry() {
this.planeGeometry = new Plane(this.gl, {
heightSegments: 1,
widthSegments: 100,
widthSegments: 100
});
}
@@ -477,7 +433,7 @@ class Canvas {
index,
planeWidth: this.planeWidth,
planeHeight: this.planeHeight,
distortion: this.distortion,
distortion: this.distortion
})
);
}
@@ -493,14 +449,14 @@ class Canvas {
createPreloader() {
this.loaded = 0;
this.items.forEach((src) => {
this.items.forEach(src => {
const image = new Image();
image.crossOrigin = "anonymous";
image.crossOrigin = 'anonymous';
image.src = src;
image.onload = () => {
if (++this.loaded === this.items.length) {
document.documentElement.classList.remove("loading");
document.documentElement.classList.add("loaded");
document.documentElement.classList.remove('loading');
document.documentElement.classList.add('loaded');
}
};
});
@@ -512,7 +468,7 @@ class Canvas {
this.renderer.setSize(this.screen.width, this.screen.height);
this.camera.perspective({
aspect: this.gl.canvas.width / this.gl.canvas.height,
aspect: this.gl.canvas.width / this.gl.canvas.height
});
const fov = (this.camera.fov * Math.PI) / 180;
@@ -520,9 +476,7 @@ class Canvas {
const width = height * this.camera.aspect;
this.viewport = { width, height };
this.medias?.forEach((media) =>
media.onResize({ screen: this.screen, viewport: this.viewport })
);
this.medias?.forEach(media => media.onResize({ screen: this.screen, viewport: this.viewport }));
}
onTouchDown(e: MouseEvent | TouchEvent) {
@@ -547,37 +501,33 @@ class Canvas {
}
update() {
this.scroll.current = lerp(
this.scroll.current,
this.scroll.target,
this.scroll.ease
);
this.medias?.forEach((media) => media.update(this.scroll));
this.scroll.current = lerp(this.scroll.current, this.scroll.target, this.scroll.ease);
this.medias?.forEach(media => media.update(this.scroll));
this.renderer.render({ scene: this.scene, camera: this.camera });
this.scroll.last = this.scroll.current;
requestAnimationFrame(this.update);
}
addEventListeners() {
window.addEventListener("resize", this.onResize);
window.addEventListener("wheel", this.onWheel);
window.addEventListener("mousedown", this.onTouchDown);
window.addEventListener("mousemove", this.onTouchMove);
window.addEventListener("mouseup", this.onTouchUp);
window.addEventListener("touchstart", this.onTouchDown as EventListener);
window.addEventListener("touchmove", this.onTouchMove as EventListener);
window.addEventListener("touchend", this.onTouchUp as EventListener);
window.addEventListener('resize', this.onResize);
window.addEventListener('wheel', this.onWheel);
window.addEventListener('mousedown', this.onTouchDown);
window.addEventListener('mousemove', this.onTouchMove);
window.addEventListener('mouseup', this.onTouchUp);
window.addEventListener('touchstart', this.onTouchDown as EventListener);
window.addEventListener('touchmove', this.onTouchMove as EventListener);
window.addEventListener('touchend', this.onTouchUp as EventListener);
}
destroy() {
window.removeEventListener("resize", this.onResize);
window.removeEventListener("wheel", this.onWheel);
window.removeEventListener("mousedown", this.onTouchDown);
window.removeEventListener("mousemove", this.onTouchMove);
window.removeEventListener("mouseup", this.onTouchUp);
window.removeEventListener("touchstart", this.onTouchDown as EventListener);
window.removeEventListener("touchmove", this.onTouchMove as EventListener);
window.removeEventListener("touchend", this.onTouchUp as EventListener);
window.removeEventListener('resize', this.onResize);
window.removeEventListener('wheel', this.onWheel);
window.removeEventListener('mousedown', this.onTouchDown);
window.removeEventListener('mousemove', this.onTouchMove);
window.removeEventListener('mouseup', this.onTouchUp);
window.removeEventListener('touchstart', this.onTouchDown as EventListener);
window.removeEventListener('touchmove', this.onTouchMove as EventListener);
window.removeEventListener('touchend', this.onTouchUp as EventListener);
}
}
@@ -596,7 +546,7 @@ export { Canvas, Media };
</script>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { ref, onMounted, onUnmounted, watch } from 'vue';
const props = withDefaults(defineProps<FlyingPostersProps>(), {
items: () => [],
@@ -606,15 +556,15 @@ const props = withDefaults(defineProps<FlyingPostersProps>(), {
scrollEase: 0.01,
cameraFov: 45,
cameraZ: 20,
className: '',
})
className: ''
});
const containerRef = ref<HTMLDivElement>()
const canvasRef = ref<HTMLCanvasElement>()
const instanceRef = ref<Canvas | null>(null)
const containerRef = ref<HTMLDivElement>();
const canvasRef = ref<HTMLCanvasElement>();
const instanceRef = ref<Canvas | null>(null);
const initCanvas = () => {
if (!containerRef.value || !canvasRef.value) return
if (!containerRef.value || !canvasRef.value) return;
instanceRef.value = new Canvas({
container: containerRef.value,
@@ -625,27 +575,27 @@ const initCanvas = () => {
distortion: props.distortion,
scrollEase: props.scrollEase,
cameraFov: props.cameraFov,
cameraZ: props.cameraZ,
})
}
cameraZ: props.cameraZ
});
};
const destroyCanvas = () => {
if (instanceRef.value) {
instanceRef.value.destroy()
instanceRef.value = null
instanceRef.value.destroy();
instanceRef.value = null;
}
}
};
const handleWheel = (e: WheelEvent) => {
e.preventDefault()
e.preventDefault();
if (instanceRef.value) {
instanceRef.value.onWheel(e)
instanceRef.value.onWheel(e);
}
}
};
const handleTouchMove = (e: TouchEvent) => {
e.preventDefault()
}
e.preventDefault();
};
watch(
() => [
@@ -655,32 +605,32 @@ watch(
props.distortion,
props.scrollEase,
props.cameraFov,
props.cameraZ,
props.cameraZ
],
() => {
destroyCanvas()
initCanvas()
destroyCanvas();
initCanvas();
},
{ deep: true }
)
);
onMounted(() => {
initCanvas()
initCanvas();
if (canvasRef.value) {
const canvasEl = canvasRef.value
canvasEl.addEventListener('wheel', handleWheel, { passive: false })
canvasEl.addEventListener('touchmove', handleTouchMove, { passive: false })
const canvasEl = canvasRef.value;
canvasEl.addEventListener('wheel', handleWheel, { passive: false });
canvasEl.addEventListener('touchmove', handleTouchMove, { passive: false });
}
})
});
onUnmounted(() => {
destroyCanvas()
destroyCanvas();
if (canvasRef.value) {
const canvasEl = canvasRef.value
canvasEl.removeEventListener('wheel', handleWheel)
canvasEl.removeEventListener('touchmove', handleTouchMove)
const canvasEl = canvasRef.value;
canvasEl.removeEventListener('wheel', handleWheel);
canvasEl.removeEventListener('touchmove', handleTouchMove);
}
})
</script>
});
</script>

View File

@@ -1,10 +1,5 @@
<template>
<div
:class="[
'grid gap-[5em] grid-cols-2 md:grid-cols-3 mx-auto py-[3em] overflow-visible',
className
]"
>
<div :class="['grid gap-[5em] grid-cols-2 md:grid-cols-3 mx-auto py-[3em] overflow-visible', className]">
<button
v-for="(item, index) in items"
:key="index"
@@ -29,15 +24,14 @@
boxShadow: '0 0 0 0.1em hsla(0, 0%, 100%, 0.3) inset'
}"
>
<span
class="m-auto w-[1.5em] h-[1.5em] flex items-center justify-center"
aria-hidden="true"
>
<span class="m-auto w-[1.5em] h-[1.5em] flex items-center justify-center" aria-hidden="true">
<i :class="item.icon" class="text-xl"></i>
</span>
</span>
<span class="absolute top-full left-0 right-0 text-center whitespace-nowrap leading-[2] text-base opacity-0 transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.83,0,0.17,1)] translate-y-0 group-hover:opacity-100 group-hover:[transform:translateY(20%)]">
<span
class="absolute top-full left-0 right-0 text-center whitespace-nowrap leading-[2] text-base opacity-0 transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.83,0,0.17,1)] translate-y-0 group-hover:opacity-100 group-hover:[transform:translateY(20%)]"
>
{{ item.label }}
</span>
</button>
@@ -46,21 +40,21 @@
<script setup lang="ts">
interface GlassIconsItem {
icon: string
color: string
label: string
customClass?: string
icon: string;
color: string;
label: string;
customClass?: string;
}
interface Props {
items: GlassIconsItem[]
className?: string
items: GlassIconsItem[];
className?: string;
}
withDefaults(defineProps<Props>(), {
items: () => [],
className: ''
})
});
const gradientMapping: Record<string, string> = {
blue: 'linear-gradient(hsl(223, 90%, 50%), hsl(208, 90%, 50%))',
@@ -69,12 +63,12 @@ const gradientMapping: Record<string, string> = {
indigo: 'linear-gradient(hsl(253, 90%, 50%), hsl(238, 90%, 50%))',
orange: 'linear-gradient(hsl(43, 90%, 50%), hsl(28, 90%, 50%))',
green: 'linear-gradient(hsl(123, 90%, 40%), hsl(108, 90%, 40%))'
}
};
const getBackgroundStyle = (color: string): Record<string, string> => {
if (gradientMapping[color]) {
return { background: gradientMapping[color] }
return { background: gradientMapping[color] };
}
return { background: color }
}
return { background: color };
};
</script>

View File

@@ -1,16 +1,13 @@
<template>
<div>
<div class="relative" ref="containerRef">
<nav
class="flex relative"
:style="{ transform: 'translate3d(0,0,0.01px)' }"
>
<nav class="flex relative" :style="{ transform: 'translate3d(0,0,0.01px)' }">
<ul
ref="navRef"
class="flex gap-8 list-none p-0 px-4 m-0 relative z-[3]"
:style="{
color: 'white',
textShadow: '0 1px 1px hsl(205deg 30% 10% / 0.2)',
textShadow: '0 1px 1px hsl(205deg 30% 10% / 0.2)'
}"
>
<li
@@ -23,8 +20,8 @@
>
<a
:href="item.href || undefined"
@click="(e) => handleClick(e, index)"
@keydown="(e) => handleKeyDown(e, index)"
@click="e => handleClick(e, index)"
@keydown="e => handleKeyDown(e, index)"
class="outline-none py-[0.6em] px-[1em] inline-block"
>
{{ item.label }}
@@ -32,29 +29,31 @@
</li>
</ul>
</nav>
<span class="effect filter" ref="filterRef" />
<span class="effect text" ref="textRef" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { ref, onMounted, onUnmounted, watch } from 'vue';
interface GooeyNavItem {
label: string
href: string | null
label: string;
href: string | null;
}
interface GooeyNavProps {
items: GooeyNavItem[]
animationTime?: number
particleCount?: number
particleDistances?: [number, number]
particleR?: number
timeVariance?: number
colors?: number[]
initialActiveIndex?: number
items: GooeyNavItem[];
animationTime?: number;
particleCount?: number;
particleDistances?: [number, number];
particleR?: number;
timeVariance?: number;
colors?: number[];
initialActiveIndex?: number;
}
const props = withDefaults(defineProps<GooeyNavProps>(), {
@@ -64,172 +63,181 @@ const props = withDefaults(defineProps<GooeyNavProps>(), {
particleR: 100,
timeVariance: 300,
colors: () => [1, 2, 3, 1, 2, 3, 1, 4],
initialActiveIndex: 0,
})
initialActiveIndex: 0
});
const containerRef = ref<HTMLDivElement>()
const navRef = ref<HTMLUListElement>()
const filterRef = ref<HTMLSpanElement>()
const textRef = ref<HTMLSpanElement>()
const activeIndex = ref<number>(props.initialActiveIndex)
const containerRef = ref<HTMLDivElement>();
const navRef = ref<HTMLUListElement>();
const filterRef = ref<HTMLSpanElement>();
const textRef = ref<HTMLSpanElement>();
const activeIndex = ref<number>(props.initialActiveIndex);
let resizeObserver: ResizeObserver | null = null
let resizeObserver: ResizeObserver | null = null;
const noise = (n = 1) => n / 2 - Math.random() * n
const noise = (n = 1) => n / 2 - Math.random() * n;
const getXY = (
distance: number,
pointIndex: number,
totalPoints: number
): [number, number] => {
const angle =
((360 + noise(8)) / totalPoints) * pointIndex * (Math.PI / 180)
return [distance * Math.cos(angle), distance * Math.sin(angle)]
}
const getXY = (distance: number, pointIndex: number, totalPoints: number): [number, number] => {
const angle = ((360 + noise(8)) / totalPoints) * pointIndex * (Math.PI / 180);
return [distance * Math.cos(angle), distance * Math.sin(angle)];
};
const createParticle = (
i: number,
t: number,
d: [number, number],
r: number
) => {
const rotate = noise(r / 10)
const createParticle = (i: number, t: number, d: [number, number], r: number) => {
const rotate = noise(r / 10);
return {
start: getXY(d[0], props.particleCount - i, props.particleCount),
end: getXY(d[1] + noise(7), props.particleCount - i, props.particleCount),
time: t,
scale: 1 + noise(0.2),
color: props.colors[Math.floor(Math.random() * props.colors.length)],
rotate: rotate > 0 ? (rotate + r / 20) * 10 : (rotate - r / 20) * 10,
}
}
rotate: rotate > 0 ? (rotate + r / 20) * 10 : (rotate - r / 20) * 10
};
};
const makeParticles = (element: HTMLElement) => {
const d: [number, number] = props.particleDistances
const r = props.particleR
const bubbleTime = props.animationTime * 2 + props.timeVariance
element.style.setProperty('--time', `${bubbleTime}ms`)
const d: [number, number] = props.particleDistances;
const r = props.particleR;
const bubbleTime = props.animationTime * 2 + props.timeVariance;
element.style.setProperty('--time', `${bubbleTime}ms`);
for (let i = 0; i < props.particleCount; i++) {
const t = props.animationTime * 2 + noise(props.timeVariance * 2)
const p = createParticle(i, t, d, r)
element.classList.remove('active')
const t = props.animationTime * 2 + noise(props.timeVariance * 2);
const p = createParticle(i, t, d, r);
element.classList.remove('active');
setTimeout(() => {
const particle = document.createElement('span')
const point = document.createElement('span')
particle.classList.add('particle')
particle.style.setProperty('--start-x', `${p.start[0]}px`)
particle.style.setProperty('--start-y', `${p.start[1]}px`)
particle.style.setProperty('--end-x', `${p.end[0]}px`)
particle.style.setProperty('--end-y', `${p.end[1]}px`)
particle.style.setProperty('--time', `${p.time}ms`)
particle.style.setProperty('--scale', `${p.scale}`)
particle.style.setProperty('--color', `var(--color-${p.color}, white)`)
particle.style.setProperty('--rotate', `${p.rotate}deg`)
point.classList.add('point')
particle.appendChild(point)
element.appendChild(particle)
const particle = document.createElement('span');
const point = document.createElement('span');
particle.classList.add('particle');
particle.style.setProperty('--start-x', `${p.start[0]}px`);
particle.style.setProperty('--start-y', `${p.start[1]}px`);
particle.style.setProperty('--end-x', `${p.end[0]}px`);
particle.style.setProperty('--end-y', `${p.end[1]}px`);
particle.style.setProperty('--time', `${p.time}ms`);
particle.style.setProperty('--scale', `${p.scale}`);
particle.style.setProperty('--color', `var(--color-${p.color}, white)`);
particle.style.setProperty('--rotate', `${p.rotate}deg`);
point.classList.add('point');
particle.appendChild(point);
element.appendChild(particle);
requestAnimationFrame(() => {
element.classList.add('active')
})
element.classList.add('active');
});
setTimeout(() => {
try {
element.removeChild(particle)
element.removeChild(particle);
} catch {}
}, t)
}, 30)
}, t);
}, 30);
}
}
};
const updateEffectPosition = (element: HTMLElement) => {
if (!containerRef.value || !filterRef.value || !textRef.value) return
const containerRect = containerRef.value.getBoundingClientRect()
const pos = element.getBoundingClientRect()
if (!containerRef.value || !filterRef.value || !textRef.value) return;
const containerRect = containerRef.value.getBoundingClientRect();
const pos = element.getBoundingClientRect();
const styles = {
left: `${pos.x - containerRect.x}px`,
top: `${pos.y - containerRect.y}px`,
width: `${pos.width}px`,
height: `${pos.height}px`,
}
Object.assign(filterRef.value.style, styles)
Object.assign(textRef.value.style, styles)
textRef.value.innerText = element.innerText
}
height: `${pos.height}px`
};
Object.assign(filterRef.value.style, styles);
Object.assign(textRef.value.style, styles);
textRef.value.innerText = element.innerText;
};
const handleClick = (e: Event, index: number) => {
const liEl = (e.currentTarget as HTMLElement).parentElement as HTMLElement
if (activeIndex.value === index) return
activeIndex.value = index
updateEffectPosition(liEl)
const liEl = (e.currentTarget as HTMLElement).parentElement as HTMLElement;
if (activeIndex.value === index) return;
activeIndex.value = index;
updateEffectPosition(liEl);
if (filterRef.value) {
const particles = filterRef.value.querySelectorAll('.particle')
particles.forEach((p) => filterRef.value!.removeChild(p))
const particles = filterRef.value.querySelectorAll('.particle');
particles.forEach(p => filterRef.value!.removeChild(p));
}
if (textRef.value) {
textRef.value.classList.remove('active')
void textRef.value.offsetWidth
textRef.value.classList.add('active')
textRef.value.classList.remove('active');
void textRef.value.offsetWidth;
textRef.value.classList.add('active');
}
if (filterRef.value) {
makeParticles(filterRef.value)
makeParticles(filterRef.value);
}
}
};
const handleKeyDown = (e: KeyboardEvent, index: number) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
const liEl = (e.currentTarget as HTMLElement).parentElement
e.preventDefault();
const liEl = (e.currentTarget as HTMLElement).parentElement;
if (liEl) {
handleClick(
{
currentTarget: liEl,
currentTarget: liEl
} as unknown as Event,
index
)
);
}
}
}
};
watch(activeIndex, () => {
if (!navRef.value || !containerRef.value) return
const activeLi = navRef.value.querySelectorAll('li')[
activeIndex.value
] as HTMLElement
if (!navRef.value || !containerRef.value) return;
const activeLi = navRef.value.querySelectorAll('li')[activeIndex.value] as HTMLElement;
if (activeLi) {
updateEffectPosition(activeLi)
textRef.value?.classList.add('active')
updateEffectPosition(activeLi);
textRef.value?.classList.add('active');
}
})
});
onMounted(() => {
if (!navRef.value || !containerRef.value) return
const activeLi = navRef.value.querySelectorAll('li')[
activeIndex.value
] as HTMLElement
if (!navRef.value || !containerRef.value) return;
const activeLi = navRef.value.querySelectorAll('li')[activeIndex.value] as HTMLElement;
if (activeLi) {
updateEffectPosition(activeLi)
textRef.value?.classList.add('active')
updateEffectPosition(activeLi);
textRef.value?.classList.add('active');
}
resizeObserver = new ResizeObserver(() => {
const currentActiveLi = navRef.value?.querySelectorAll('li')[
activeIndex.value
] as HTMLElement
const currentActiveLi = navRef.value?.querySelectorAll('li')[activeIndex.value] as HTMLElement;
if (currentActiveLi) {
updateEffectPosition(currentActiveLi)
updateEffectPosition(currentActiveLi);
}
})
resizeObserver.observe(containerRef.value)
})
});
resizeObserver.observe(containerRef.value);
});
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver.disconnect();
}
})
});
</script>
<style>
:root {
--linear-ease: linear(0, 0.068, 0.19 2.7%, 0.804 8.1%, 1.037, 1.199 13.2%, 1.245, 1.27 15.8%, 1.274, 1.272 17.4%, 1.249 19.1%, 0.996 28%, 0.949, 0.928 33.3%, 0.926, 0.933 36.8%, 1.001 45.6%, 1.013, 1.019 50.8%, 1.018 54.4%, 1 63.1%, 0.995 68%, 1.001 85%, 1);
--linear-ease: linear(
0,
0.068,
0.19 2.7%,
0.804 8.1%,
1.037,
1.199 13.2%,
1.245,
1.27 15.8%,
1.274,
1.272 17.4%,
1.249 19.1%,
0.996 28%,
0.949,
0.928 33.3%,
0.926,
0.933 36.8%,
1.001 45.6%,
1.013,
1.019 50.8%,
1.018 54.4%,
1 63.1%,
0.995 68%,
1.001 85%,
1
);
}
.effect {
@@ -256,7 +264,7 @@ onUnmounted(() => {
}
.effect.filter::before {
content: "";
content: '';
position: absolute;
inset: -75px;
z-index: -2;
@@ -264,7 +272,7 @@ onUnmounted(() => {
}
.effect.filter::after {
content: "";
content: '';
position: absolute;
inset: 0;
background: white;
@@ -368,7 +376,7 @@ li.active::after {
}
li::after {
content: "";
content: '';
position: absolute;
inset: 0;
border-radius: 8px;
@@ -378,4 +386,4 @@ li::after {
transition: all 0.3s ease;
z-index: -1;
}
</style>
</style>

View File

@@ -1,24 +1,35 @@
<template>
<div class="w-full">
<div class="infinite-scroll-wrapper relative flex items-center justify-center w-full overflow-hidden"
ref="wrapperRef" :style="{
<div
class="infinite-scroll-wrapper relative flex items-center justify-center w-full overflow-hidden"
ref="wrapperRef"
:style="{
maxHeight: maxHeight,
overscrollBehavior: 'none'
}">
<div class="infinite-scroll-container flex flex-col px-4 cursor-grab" ref="containerRef" :style="{
transform: getTiltTransform(),
width: width,
overscrollBehavior: 'contain',
transformOrigin: 'center center',
transformStyle: 'preserve-3d'
}">
<div v-for="(item, index) in items" :key="index"
}"
>
<div
class="infinite-scroll-container flex flex-col px-4 cursor-grab"
ref="containerRef"
:style="{
transform: getTiltTransform(),
width: width,
overscrollBehavior: 'contain',
transformOrigin: 'center center',
transformStyle: 'preserve-3d'
}"
>
<div
v-for="(item, index) in items"
:key="index"
class="infinite-scroll-item rounded-2xl flex items-center justify-center p-4 text-xl font-semibold text-center border-2 border-white select-none box-border relative"
:style="{
height: itemMinHeight + 'px',
marginTop: negativeMargin
}">
}"
>
<component :is="item.content" v-if="typeof item.content === 'object'" />
<template v-else>{{ item.content }}</template>
</div>
</div>
@@ -27,28 +38,28 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { gsap } from 'gsap'
import { Observer } from 'gsap/Observer'
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { gsap } from 'gsap';
import { Observer } from 'gsap/Observer';
gsap.registerPlugin(Observer)
gsap.registerPlugin(Observer);
interface InfiniteScrollItem {
content: string | object
content: string | object;
}
interface Props {
width?: string
maxHeight?: string
negativeMargin?: string
items?: InfiniteScrollItem[]
itemMinHeight?: number
isTilted?: boolean
tiltDirection?: 'left' | 'right'
autoplay?: boolean
autoplaySpeed?: number
autoplayDirection?: 'down' | 'up'
pauseOnHover?: boolean
width?: string;
maxHeight?: string;
negativeMargin?: string;
items?: InfiniteScrollItem[];
itemMinHeight?: number;
isTilted?: boolean;
tiltDirection?: 'left' | 'right';
autoplay?: boolean;
autoplaySpeed?: number;
autoplayDirection?: 'down' | 'up';
pauseOnHover?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
@@ -63,57 +74,57 @@ const props = withDefaults(defineProps<Props>(), {
autoplaySpeed: 0.5,
autoplayDirection: 'down',
pauseOnHover: false
})
});
const wrapperRef = ref<HTMLDivElement | null>(null)
const containerRef = ref<HTMLDivElement | null>(null)
let observer: Observer | null = null
let rafId: number | null = null
let velocity = 0
let stopTicker: (() => void) | null = null
let startTicker: (() => void) | null = null
const wrapperRef = ref<HTMLDivElement | null>(null);
const containerRef = ref<HTMLDivElement | null>(null);
let observer: Observer | null = null;
let rafId: number | null = null;
let velocity = 0;
let stopTicker: (() => void) | null = null;
let startTicker: (() => void) | null = null;
const getTiltTransform = (): string => {
if (!props.isTilted) return 'none'
if (!props.isTilted) return 'none';
return props.tiltDirection === 'left'
? 'rotateX(20deg) rotateZ(-20deg) skewX(20deg)'
: 'rotateX(20deg) rotateZ(20deg) skewX(-20deg)'
}
: 'rotateX(20deg) rotateZ(20deg) skewX(-20deg)';
};
const initializeScroll = () => {
const container = containerRef.value
if (!container) return
if (props.items.length === 0) return
const container = containerRef.value;
if (!container) return;
if (props.items.length === 0) return;
const divItems = gsap.utils.toArray<HTMLDivElement>(container.children)
if (!divItems.length) return
const divItems = gsap.utils.toArray<HTMLDivElement>(container.children);
if (!divItems.length) return;
const firstItem = divItems[0]
const itemStyle = getComputedStyle(firstItem)
const itemHeight = firstItem.offsetHeight
const itemMarginTop = parseFloat(itemStyle.marginTop) || 0
const totalItemHeight = itemHeight + itemMarginTop
const totalHeight = itemHeight * props.items.length + itemMarginTop * (props.items.length - 1)
const firstItem = divItems[0];
const itemStyle = getComputedStyle(firstItem);
const itemHeight = firstItem.offsetHeight;
const itemMarginTop = parseFloat(itemStyle.marginTop) || 0;
const totalItemHeight = itemHeight + itemMarginTop;
const totalHeight = itemHeight * props.items.length + itemMarginTop * (props.items.length - 1);
const wrapFn = gsap.utils.wrap(-totalHeight, totalHeight)
const wrapFn = gsap.utils.wrap(-totalHeight, totalHeight);
divItems.forEach((child, i) => {
const y = i * totalItemHeight
gsap.set(child, { y })
})
const y = i * totalItemHeight;
gsap.set(child, { y });
});
observer = Observer.create({
target: container,
type: 'wheel,touch,pointer',
preventDefault: true,
onPress: ({ target }) => {
; (target as HTMLElement).style.cursor = 'grabbing'
(target as HTMLElement).style.cursor = 'grabbing';
},
onRelease: ({ target }) => {
; (target as HTMLElement).style.cursor = 'grab'
(target as HTMLElement).style.cursor = 'grab';
if (Math.abs(velocity) > 0.1) {
const momentum = velocity * 0.8
divItems.forEach((child) => {
const momentum = velocity * 0.8;
divItems.forEach(child => {
gsap.to(child, {
duration: 1.5,
ease: 'power2.out',
@@ -121,18 +132,18 @@ const initializeScroll = () => {
modifiers: {
y: gsap.utils.unitize(wrapFn)
}
})
})
});
});
}
velocity = 0
velocity = 0;
},
onChange: ({ deltaY, isDragging, event }) => {
const d = event.type === 'wheel' ? -deltaY : deltaY
const distance = isDragging ? d * 5 : d * 1.5
const d = event.type === 'wheel' ? -deltaY : deltaY;
const distance = isDragging ? d * 5 : d * 1.5;
velocity = distance * 0.5
velocity = distance * 0.5;
divItems.forEach((child) => {
divItems.forEach(child => {
gsap.to(child, {
duration: isDragging ? 0.3 : 1.2,
ease: isDragging ? 'power1.out' : 'power3.out',
@@ -140,70 +151,70 @@ const initializeScroll = () => {
modifiers: {
y: gsap.utils.unitize(wrapFn)
}
})
})
});
});
}
})
});
if (props.autoplay) {
const directionFactor = props.autoplayDirection === 'down' ? 1 : -1
const speedPerFrame = props.autoplaySpeed * directionFactor
const directionFactor = props.autoplayDirection === 'down' ? 1 : -1;
const speedPerFrame = props.autoplaySpeed * directionFactor;
const tick = () => {
divItems.forEach((child) => {
divItems.forEach(child => {
gsap.set(child, {
y: `+=${speedPerFrame}`,
modifiers: {
y: gsap.utils.unitize(wrapFn)
}
})
})
rafId = requestAnimationFrame(tick)
}
});
});
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick)
rafId = requestAnimationFrame(tick);
if (props.pauseOnHover) {
stopTicker = () => rafId && cancelAnimationFrame(rafId)
stopTicker = () => rafId && cancelAnimationFrame(rafId);
startTicker = () => {
rafId = requestAnimationFrame(tick)
}
rafId = requestAnimationFrame(tick);
};
container.addEventListener('mouseenter', stopTicker)
container.addEventListener('mouseleave', startTicker)
container.addEventListener('mouseenter', stopTicker);
container.addEventListener('mouseleave', startTicker);
}
}
}
};
const cleanup = () => {
if (observer) {
observer.kill()
observer = null
observer.kill();
observer = null;
}
if (rafId) {
cancelAnimationFrame(rafId)
rafId = null
cancelAnimationFrame(rafId);
rafId = null;
}
velocity = 0
velocity = 0;
const container = containerRef.value
const container = containerRef.value;
if (container && props.pauseOnHover && stopTicker && startTicker) {
container.removeEventListener('mouseenter', stopTicker)
container.removeEventListener('mouseleave', startTicker)
container.removeEventListener('mouseenter', stopTicker);
container.removeEventListener('mouseleave', startTicker);
}
stopTicker = null
startTicker = null
}
stopTicker = null;
startTicker = null;
};
onMounted(() => {
initializeScroll()
})
initializeScroll();
});
onUnmounted(() => {
cleanup()
})
cleanup();
});
watch(
[
@@ -217,18 +228,18 @@ watch(
() => props.negativeMargin
],
() => {
cleanup()
cleanup();
setTimeout(() => {
initializeScroll()
}, 0)
initializeScroll();
}, 0);
}
)
);
</script>
<style scoped>
.infinite-scroll-wrapper::before,
.infinite-scroll-wrapper::after {
content: "";
content: '';
position: absolute;
background: linear-gradient(var(--dir, to bottom), #060010, transparent);
height: 25%;

View File

@@ -1,40 +1,49 @@
<template>
<div ref="containerRef" class="relative w-full h-full">
<div v-for="item in grid" :key="item.id" :data-key="item.id" class="absolute box-content"
:style="{ willChange: 'transform, width, height, opacity' }" @click="openUrl(item.url)"
@mouseenter="(e) => handleMouseEnter(item.id, e.currentTarget as HTMLElement)"
@mouseleave="(e) => handleMouseLeave(item.id, e.currentTarget as HTMLElement)">
<div
v-for="item in grid"
:key="item.id"
:data-key="item.id"
class="absolute box-content"
:style="{ willChange: 'transform, width, height, opacity' }"
@click="openUrl(item.url)"
@mouseenter="e => handleMouseEnter(item.id, e.currentTarget as HTMLElement)"
@mouseleave="e => handleMouseLeave(item.id, e.currentTarget as HTMLElement)"
>
<div
class="relative w-full h-full bg-cover bg-center rounded-[10px] shadow-[0px_10px_50px_-10px_rgba(0,0,0,0.2)] uppercase text-[10px] leading-[10px]"
:style="{ backgroundImage: `url(${item.img})` }">
<div v-if="colorShiftOnHover"
class="color-overlay absolute inset-0 rounded-[10px] bg-gradient-to-tr from-pink-500/50 to-sky-500/50 opacity-0 pointer-events-none" />
:style="{ backgroundImage: `url(${item.img})` }"
>
<div
v-if="colorShiftOnHover"
class="color-overlay absolute inset-0 rounded-[10px] bg-gradient-to-tr from-pink-500/50 to-sky-500/50 opacity-0 pointer-events-none"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watchEffect, nextTick } from 'vue'
import { gsap } from 'gsap'
import { ref, computed, onMounted, onUnmounted, watchEffect, nextTick } from 'vue';
import { gsap } from 'gsap';
interface Item {
id: string
img: string
url: string
height: number
id: string;
img: string;
url: string;
height: number;
}
interface MasonryProps {
items: Item[]
ease?: string
duration?: number
stagger?: number
animateFrom?: 'bottom' | 'top' | 'left' | 'right' | 'center' | 'random'
scaleOnHover?: boolean
hoverScale?: number
blurToFocus?: boolean
colorShiftOnHover?: boolean
items: Item[];
ease?: string;
duration?: number;
stagger?: number;
animateFrom?: 'bottom' | 'top' | 'left' | 'right' | 'center' | 'random';
scaleOnHover?: boolean;
hoverScale?: number;
blurToFocus?: boolean;
colorShiftOnHover?: boolean;
}
const props = withDefaults(defineProps<MasonryProps>(), {
@@ -46,134 +55,129 @@ const props = withDefaults(defineProps<MasonryProps>(), {
hoverScale: 0.95,
blurToFocus: true,
colorShiftOnHover: false
})
});
const useMedia = (queries: string[], values: number[], defaultValue: number) => {
const get = () => values[queries.findIndex((q) => matchMedia(q).matches)] ?? defaultValue
const value = ref<number>(get())
const get = () => values[queries.findIndex(q => matchMedia(q).matches)] ?? defaultValue;
const value = ref<number>(get());
onMounted(() => {
const handler = () => value.value = get()
queries.forEach((q) => matchMedia(q).addEventListener('change', handler))
const handler = () => (value.value = get());
queries.forEach(q => matchMedia(q).addEventListener('change', handler));
onUnmounted(() => {
queries.forEach((q) => matchMedia(q).removeEventListener('change', handler))
})
})
queries.forEach(q => matchMedia(q).removeEventListener('change', handler));
});
});
return value
}
return value;
};
const useMeasure = () => {
const containerRef = ref<HTMLDivElement | null>(null)
const size = ref({ width: 0, height: 0 })
let resizeObserver: ResizeObserver | null = null
const containerRef = ref<HTMLDivElement | null>(null);
const size = ref({ width: 0, height: 0 });
let resizeObserver: ResizeObserver | null = null;
onMounted(() => {
if (!containerRef.value) return
if (!containerRef.value) return;
resizeObserver = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect
size.value = { width, height }
})
const { width, height } = entry.contentRect;
size.value = { width, height };
});
resizeObserver.observe(containerRef.value)
})
resizeObserver.observe(containerRef.value);
});
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver.disconnect();
}
})
});
return [containerRef, size] as const
}
return [containerRef, size] as const;
};
const preloadImages = async (urls: string[]): Promise<void> => {
await Promise.all(
urls.map(
(src) =>
new Promise<void>((resolve) => {
const img = new Image()
img.src = src
img.onload = img.onerror = () => resolve()
src =>
new Promise<void>(resolve => {
const img = new Image();
img.src = src;
img.onload = img.onerror = () => resolve();
})
)
)
}
);
};
const columns = useMedia(
[
'(min-width:1500px)',
'(min-width:1000px)',
'(min-width:600px)',
'(min-width:400px)'
],
['(min-width:1500px)', '(min-width:1000px)', '(min-width:600px)', '(min-width:400px)'],
[5, 4, 3, 2],
1
)
);
const [containerRef, size] = useMeasure()
const imagesReady = ref(false)
const hasMounted = ref(false)
const [containerRef, size] = useMeasure();
const imagesReady = ref(false);
const hasMounted = ref(false);
const grid = computed(() => {
if (!size.value.width) return []
const colHeights = new Array(columns.value).fill(0)
const gap = 16
const totalGaps = (columns.value - 1) * gap
const columnWidth = (size.value.width - totalGaps) / columns.value
if (!size.value.width) return [];
const colHeights = new Array(columns.value).fill(0);
const gap = 16;
const totalGaps = (columns.value - 1) * gap;
const columnWidth = (size.value.width - totalGaps) / columns.value;
return props.items.map((child) => {
const col = colHeights.indexOf(Math.min(...colHeights))
const x = col * (columnWidth + gap)
const height = child.height / 2
const y = colHeights[col]
return props.items.map(child => {
const col = colHeights.indexOf(Math.min(...colHeights));
const x = col * (columnWidth + gap);
const height = child.height / 2;
const y = colHeights[col];
colHeights[col] += height + gap
return { ...child, x, y, w: columnWidth, h: height }
})
})
colHeights[col] += height + gap;
return { ...child, x, y, w: columnWidth, h: height };
});
});
const openUrl = (url: string) => {
window.open(url, '_blank', 'noopener')
}
window.open(url, '_blank', 'noopener');
};
interface GridItem extends Item {
x: number
y: number
w: number
h: number
x: number;
y: number;
w: number;
h: number;
}
const getInitialPosition = (item: GridItem) => {
const containerRect = containerRef.value?.getBoundingClientRect()
if (!containerRect) return { x: item.x, y: item.y }
const containerRect = containerRef.value?.getBoundingClientRect();
if (!containerRect) return { x: item.x, y: item.y };
let direction = props.animateFrom
let direction = props.animateFrom;
if (props.animateFrom === 'random') {
const dirs = ['top', 'bottom', 'left', 'right']
direction = dirs[Math.floor(Math.random() * dirs.length)] as typeof props.animateFrom
const dirs = ['top', 'bottom', 'left', 'right'];
direction = dirs[Math.floor(Math.random() * dirs.length)] as typeof props.animateFrom;
}
switch (direction) {
case 'top':
return { x: item.x, y: -200 }
return { x: item.x, y: -200 };
case 'bottom':
return { x: item.x, y: window.innerHeight + 200 }
return { x: item.x, y: window.innerHeight + 200 };
case 'left':
return { x: -200, y: item.y }
return { x: -200, y: item.y };
case 'right':
return { x: window.innerWidth + 200, y: item.y }
return { x: window.innerWidth + 200, y: item.y };
case 'center':
return {
x: containerRect.width / 2 - item.w / 2,
y: containerRect.height / 2 - item.h / 2
}
};
default:
return { x: item.x, y: item.y + 100 }
return { x: item.x, y: item.y + 100 };
}
}
};
const handleMouseEnter = (id: string, element: HTMLElement) => {
if (props.scaleOnHover) {
@@ -181,13 +185,13 @@ const handleMouseEnter = (id: string, element: HTMLElement) => {
scale: props.hoverScale,
duration: 0.3,
ease: 'power2.out'
})
});
}
if (props.colorShiftOnHover) {
const overlay = element.querySelector('.color-overlay') as HTMLElement
if (overlay) gsap.to(overlay, { opacity: 0.3, duration: 0.3 })
const overlay = element.querySelector('.color-overlay') as HTMLElement;
if (overlay) gsap.to(overlay, { opacity: 0.3, duration: 0.3 });
}
}
};
const handleMouseLeave = (id: string, element: HTMLElement) => {
if (props.scaleOnHover) {
@@ -195,37 +199,37 @@ const handleMouseLeave = (id: string, element: HTMLElement) => {
scale: 1,
duration: 0.3,
ease: 'power2.out'
})
});
}
if (props.colorShiftOnHover) {
const overlay = element.querySelector('.color-overlay') as HTMLElement
if (overlay) gsap.to(overlay, { opacity: 0, duration: 0.3 })
const overlay = element.querySelector('.color-overlay') as HTMLElement;
if (overlay) gsap.to(overlay, { opacity: 0, duration: 0.3 });
}
}
};
watchEffect(() => {
preloadImages(props.items.map((i) => i.img)).then(() => {
imagesReady.value = true
})
})
preloadImages(props.items.map(i => i.img)).then(() => {
imagesReady.value = true;
});
});
watchEffect(() => {
if (!imagesReady.value) return
if (!imagesReady.value) return;
const currentGrid = grid.value
void props.items.length
void columns.value
void size.value.width
const currentGrid = grid.value;
void props.items.length;
void columns.value;
void size.value.width;
if (!currentGrid.length) return
if (!currentGrid.length) return;
nextTick(() => {
currentGrid.forEach((item, index) => {
const selector = `[data-key="${item.id}"]`
const animProps = { x: item.x, y: item.y, width: item.w, height: item.h }
const selector = `[data-key="${item.id}"]`;
const animProps = { x: item.x, y: item.y, width: item.w, height: item.h };
if (!hasMounted.value) {
const start = getInitialPosition(item)
const start = getInitialPosition(item);
gsap.fromTo(
selector,
{
@@ -244,18 +248,18 @@ watchEffect(() => {
ease: 'power3.out',
delay: index * props.stagger
}
)
);
} else {
gsap.to(selector, {
...animProps,
duration: props.duration,
ease: props.ease,
overwrite: 'auto'
})
});
}
})
});
hasMounted.value = true
})
})
</script>
hasMounted.value = true;
});
});
</script>

View File

@@ -1,36 +1,44 @@
<template>
<div ref="containerRef" :class="[
'h-[400px] w-[300px] relative overflow-hidden grid place-items-center aspect-[4/5] border border-[#27272a] rounded-[25px] isolate transition-colors duration-200 ease-[cubic-bezier(0.5,1,0.89,1)] select-none',
className
]" @mouseenter="onMouseEnter" @mouseleave="onMouseLeave" @focus="finalNoFocus ? undefined : onFocus"
@blur="finalNoFocus ? undefined : onBlur" :tabindex="finalNoFocus ? -1 : 0">
<div
ref="containerRef"
:class="[
'h-[400px] w-[300px] relative overflow-hidden grid place-items-center aspect-[4/5] border border-[#27272a] rounded-[25px] isolate transition-colors duration-200 ease-[cubic-bezier(0.5,1,0.89,1)] select-none',
className
]"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
@focus="finalNoFocus ? undefined : onFocus"
@blur="finalNoFocus ? undefined : onBlur"
:tabindex="finalNoFocus ? -1 : 0"
>
<canvas class="w-full h-full block" ref="canvasRef" />
<slot />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import { ref, onMounted, onUnmounted, computed, watch } from 'vue';
class Pixel {
width: number
height: number
ctx: CanvasRenderingContext2D
x: number
y: number
color: string
speed: number
size: number
sizeStep: number
minSize: number
maxSizeInteger: number
maxSize: number
delay: number
counter: number
counterStep: number
isIdle: boolean
isReverse: boolean
isShimmer: boolean
width: number;
height: number;
ctx: CanvasRenderingContext2D;
x: number;
y: number;
color: string;
speed: number;
size: number;
sizeStep: number;
minSize: number;
maxSizeInteger: number;
maxSize: number;
delay: number;
counter: number;
counterStep: number;
isIdle: boolean;
isReverse: boolean;
isShimmer: boolean;
constructor(
canvas: HTMLCanvasElement,
@@ -41,95 +49,90 @@ class Pixel {
speed: number,
delay: number
) {
this.width = canvas.width
this.height = canvas.height
this.ctx = context
this.x = x
this.y = y
this.color = color
this.speed = this.getRandomValue(0.1, 0.9) * speed
this.size = 0
this.sizeStep = Math.random() * 0.4
this.minSize = 0.5
this.maxSizeInteger = 2
this.maxSize = this.getRandomValue(this.minSize, this.maxSizeInteger)
this.delay = delay
this.counter = 0
this.counterStep = Math.random() * 4 + (this.width + this.height) * 0.01
this.isIdle = false
this.isReverse = false
this.isShimmer = false
this.width = canvas.width;
this.height = canvas.height;
this.ctx = context;
this.x = x;
this.y = y;
this.color = color;
this.speed = this.getRandomValue(0.1, 0.9) * speed;
this.size = 0;
this.sizeStep = Math.random() * 0.4;
this.minSize = 0.5;
this.maxSizeInteger = 2;
this.maxSize = this.getRandomValue(this.minSize, this.maxSizeInteger);
this.delay = delay;
this.counter = 0;
this.counterStep = Math.random() * 4 + (this.width + this.height) * 0.01;
this.isIdle = false;
this.isReverse = false;
this.isShimmer = false;
}
getRandomValue(min: number, max: number) {
return Math.random() * (max - min) + min
return Math.random() * (max - min) + min;
}
draw() {
const centerOffset = this.maxSizeInteger * 0.5 - this.size * 0.5
this.ctx.fillStyle = this.color
this.ctx.fillRect(
this.x + centerOffset,
this.y + centerOffset,
this.size,
this.size
)
const centerOffset = this.maxSizeInteger * 0.5 - this.size * 0.5;
this.ctx.fillStyle = this.color;
this.ctx.fillRect(this.x + centerOffset, this.y + centerOffset, this.size, this.size);
}
appear() {
this.isIdle = false
this.isIdle = false;
if (this.counter <= this.delay) {
this.counter += this.counterStep
return
this.counter += this.counterStep;
return;
}
if (this.size >= this.maxSize) {
this.isShimmer = true
this.isShimmer = true;
}
if (this.isShimmer) {
this.shimmer()
this.shimmer();
} else {
this.size += this.sizeStep
this.size += this.sizeStep;
}
this.draw()
this.draw();
}
disappear() {
this.isShimmer = false
this.counter = 0
this.isShimmer = false;
this.counter = 0;
if (this.size <= 0) {
this.isIdle = true
return
this.isIdle = true;
return;
} else {
this.size -= 0.1
this.size -= 0.1;
}
this.draw()
this.draw();
}
shimmer() {
if (this.size >= this.maxSize) {
this.isReverse = true
this.isReverse = true;
} else if (this.size <= this.minSize) {
this.isReverse = false
this.isReverse = false;
}
if (this.isReverse) {
this.size -= this.speed
this.size -= this.speed;
} else {
this.size += this.speed
this.size += this.speed;
}
}
}
function getEffectiveSpeed(value: number, reducedMotion: boolean) {
const min = 0
const max = 100
const throttle = 0.001
const min = 0;
const max = 100;
const throttle = 0.001;
if (value <= min || reducedMotion) {
return min
return min;
} else if (value >= max) {
return max * throttle
return max * throttle;
} else {
return value * throttle
return value * throttle;
}
}
@@ -139,177 +142,166 @@ const VARIANTS = {
gap: 5,
speed: 35,
colors: '#f8fafc,#f1f5f9,#cbd5e1',
noFocus: false,
noFocus: false
},
blue: {
activeColor: '#e0f2fe',
gap: 10,
speed: 25,
colors: '#e0f2fe,#7dd3fc,#0ea5e9',
noFocus: false,
noFocus: false
},
yellow: {
activeColor: '#fef08a',
gap: 3,
speed: 20,
colors: '#fef08a,#fde047,#eab308',
noFocus: false,
noFocus: false
},
pink: {
activeColor: '#fecdd3',
gap: 6,
speed: 80,
colors: '#fecdd3,#fda4af,#e11d48',
noFocus: true,
},
}
noFocus: true
}
};
interface PixelCardProps {
variant?: 'default' | 'blue' | 'yellow' | 'pink'
gap?: number
speed?: number
colors?: string
noFocus?: boolean
className?: string
variant?: 'default' | 'blue' | 'yellow' | 'pink';
gap?: number;
speed?: number;
colors?: string;
noFocus?: boolean;
className?: string;
}
interface VariantConfig {
activeColor: string | null
gap: number
speed: number
colors: string
noFocus: boolean
activeColor: string | null;
gap: number;
speed: number;
colors: string;
noFocus: boolean;
}
const props = withDefaults(defineProps<PixelCardProps>(), {
variant: 'default',
className: '',
})
className: ''
});
const containerRef = ref<HTMLDivElement>()
const canvasRef = ref<HTMLCanvasElement>()
const pixelsRef = ref<Pixel[]>([])
const animationRef = ref<number | null>(null)
const timePreviousRef = ref(performance.now())
const reducedMotion = ref(
window.matchMedia('(prefers-reduced-motion: reduce)').matches
)
const containerRef = ref<HTMLDivElement>();
const canvasRef = ref<HTMLCanvasElement>();
const pixelsRef = ref<Pixel[]>([]);
const animationRef = ref<number | null>(null);
const timePreviousRef = ref(performance.now());
const reducedMotion = ref(window.matchMedia('(prefers-reduced-motion: reduce)').matches);
const variantCfg = computed((): VariantConfig => VARIANTS[props.variant] || VARIANTS.default)
const finalGap = computed(() => props.gap ?? variantCfg.value.gap)
const finalSpeed = computed(() => props.speed ?? variantCfg.value.speed)
const finalColors = computed(() => props.colors ?? variantCfg.value.colors)
const finalNoFocus = computed(() => props.noFocus ?? variantCfg.value.noFocus)
const variantCfg = computed((): VariantConfig => VARIANTS[props.variant] || VARIANTS.default);
const finalGap = computed(() => props.gap ?? variantCfg.value.gap);
const finalSpeed = computed(() => props.speed ?? variantCfg.value.speed);
const finalColors = computed(() => props.colors ?? variantCfg.value.colors);
const finalNoFocus = computed(() => props.noFocus ?? variantCfg.value.noFocus);
let resizeObserver: ResizeObserver | null = null
let resizeObserver: ResizeObserver | null = null;
const initPixels = () => {
if (!containerRef.value || !canvasRef.value) return
if (!containerRef.value || !canvasRef.value) return;
const rect = containerRef.value.getBoundingClientRect()
const width = Math.floor(rect.width)
const height = Math.floor(rect.height)
const ctx = canvasRef.value.getContext('2d')
const rect = containerRef.value.getBoundingClientRect();
const width = Math.floor(rect.width);
const height = Math.floor(rect.height);
const ctx = canvasRef.value.getContext('2d');
canvasRef.value.width = width
canvasRef.value.height = height
canvasRef.value.style.width = `${width}px`
canvasRef.value.style.height = `${height}px`
canvasRef.value.width = width;
canvasRef.value.height = height;
canvasRef.value.style.width = `${width}px`;
canvasRef.value.style.height = `${height}px`;
const colorsArray = finalColors.value.split(',')
const pxs = []
const colorsArray = finalColors.value.split(',');
const pxs = [];
for (let x = 0; x < width; x += parseInt(finalGap.value.toString(), 10)) {
for (let y = 0; y < height; y += parseInt(finalGap.value.toString(), 10)) {
const color =
colorsArray[Math.floor(Math.random() * colorsArray.length)]
const color = colorsArray[Math.floor(Math.random() * colorsArray.length)];
const dx = x - width / 2
const dy = y - height / 2
const distance = Math.sqrt(dx * dx + dy * dy)
const delay = reducedMotion.value ? 0 : distance
if (!ctx) return
const dx = x - width / 2;
const dy = y - height / 2;
const distance = Math.sqrt(dx * dx + dy * dy);
const delay = reducedMotion.value ? 0 : distance;
if (!ctx) return;
pxs.push(
new Pixel(
canvasRef.value,
ctx,
x,
y,
color,
getEffectiveSpeed(finalSpeed.value, reducedMotion.value),
delay
)
)
new Pixel(canvasRef.value, ctx, x, y, color, getEffectiveSpeed(finalSpeed.value, reducedMotion.value), delay)
);
}
}
pixelsRef.value = pxs
}
pixelsRef.value = pxs;
};
const doAnimate = (fnName: keyof Pixel) => {
animationRef.value = requestAnimationFrame(() => doAnimate(fnName))
const timeNow = performance.now()
const timePassed = timeNow - timePreviousRef.value
const timeInterval = 1000 / 60
animationRef.value = requestAnimationFrame(() => doAnimate(fnName));
const timeNow = performance.now();
const timePassed = timeNow - timePreviousRef.value;
const timeInterval = 1000 / 60;
if (timePassed < timeInterval) return
timePreviousRef.value = timeNow - (timePassed % timeInterval)
if (timePassed < timeInterval) return;
timePreviousRef.value = timeNow - (timePassed % timeInterval);
const ctx = canvasRef.value?.getContext('2d')
if (!ctx || !canvasRef.value) return
const ctx = canvasRef.value?.getContext('2d');
if (!ctx || !canvasRef.value) return;
ctx.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height)
ctx.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height);
let allIdle = true
let allIdle = true;
for (let i = 0; i < pixelsRef.value.length; i++) {
const pixel = pixelsRef.value[i]
const pixel = pixelsRef.value[i];
// @ts-expect-error - Dynamic method call on Pixel class
pixel[fnName]()
pixel[fnName]();
if (!pixel.isIdle) {
allIdle = false
allIdle = false;
}
}
if (allIdle && animationRef.value) {
cancelAnimationFrame(animationRef.value)
cancelAnimationFrame(animationRef.value);
}
}
};
const handleAnimation = (name: keyof Pixel) => {
if (animationRef.value !== null) {
cancelAnimationFrame(animationRef.value)
cancelAnimationFrame(animationRef.value);
}
animationRef.value = requestAnimationFrame(() => doAnimate(name))
}
animationRef.value = requestAnimationFrame(() => doAnimate(name));
};
const onMouseEnter = () => handleAnimation('appear')
const onMouseLeave = () => handleAnimation('disappear')
const onMouseEnter = () => handleAnimation('appear');
const onMouseLeave = () => handleAnimation('disappear');
const onFocus = (e: FocusEvent) => {
if ((e.currentTarget as HTMLElement).contains(e.relatedTarget as Node)) return
handleAnimation('appear')
}
if ((e.currentTarget as HTMLElement).contains(e.relatedTarget as Node)) return;
handleAnimation('appear');
};
const onBlur = (e: FocusEvent) => {
if ((e.currentTarget as HTMLElement).contains(e.relatedTarget as Node)) return
handleAnimation('disappear')
}
if ((e.currentTarget as HTMLElement).contains(e.relatedTarget as Node)) return;
handleAnimation('disappear');
};
watch([finalGap, finalSpeed, finalColors, finalNoFocus], () => {
initPixels()
})
initPixels();
});
onMounted(() => {
initPixels()
initPixels();
resizeObserver = new ResizeObserver(() => {
initPixels()
})
initPixels();
});
if (containerRef.value) {
resizeObserver.observe(containerRef.value)
resizeObserver.observe(containerRef.value);
}
})
});
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver.disconnect();
}
if (animationRef.value !== null) {
cancelAnimationFrame(animationRef.value)
cancelAnimationFrame(animationRef.value);
}
})
</script>
});
</script>

View File

@@ -3,30 +3,52 @@
<section ref="cardRef" class="pc-card">
<div class="pc-inside">
<div class="pc-shine" />
<div class="pc-glare" />
<div class="pc-content pc-avatar-content">
<img class="avatar" :src="avatarUrl" :alt="`${name || 'User'} avatar`" loading="lazy"
@error="handleAvatarError" />
<img
class="avatar"
:src="avatarUrl"
:alt="`${name || 'User'} avatar`"
loading="lazy"
@error="handleAvatarError"
/>
<div v-if="showUserInfo" class="pc-user-info">
<div class="pc-user-details">
<div class="pc-mini-avatar">
<img :src="miniAvatarUrl || avatarUrl" :alt="`${name || 'User'} mini avatar`" loading="lazy"
@error="handleMiniAvatarError" />
<img
:src="miniAvatarUrl || avatarUrl"
:alt="`${name || 'User'} mini avatar`"
loading="lazy"
@error="handleMiniAvatarError"
/>
</div>
<div class="pc-user-text">
<div class="pc-handle">@{{ handle }}</div>
<div class="pc-status">{{ status }}</div>
</div>
</div>
<button class="pc-contact-btn" @click="handleContactClick" style="pointer-events: auto" type="button"
:aria-label="`Contact ${name || 'user'}`">
<button
class="pc-contact-btn"
@click="handleContactClick"
style="pointer-events: auto"
type="button"
:aria-label="`Contact ${name || 'user'}`"
>
{{ contactText }}
</button>
</div>
</div>
<div class="pc-content">
<div class="pc-details">
<h3>{{ name }}</h3>
<p>{{ title }}</p>
</div>
</div>
@@ -36,24 +58,24 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue';
interface Props {
avatarUrl?: string
iconUrl?: string
grainUrl?: string
behindGradient?: string
innerGradient?: string
showBehindGradient?: boolean
className?: string
enableTilt?: boolean
miniAvatarUrl?: string
name?: string
title?: string
handle?: string
status?: string
contactText?: string
showUserInfo?: boolean
avatarUrl?: string;
iconUrl?: string;
grainUrl?: string;
behindGradient?: string;
innerGradient?: string;
showBehindGradient?: boolean;
className?: string;
enableTilt?: boolean;
miniAvatarUrl?: string;
name?: string;
title?: string;
handle?: string;
status?: string;
contactText?: string;
showUserInfo?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
@@ -72,60 +94,47 @@ const props = withDefaults(defineProps<Props>(), {
status: 'Online',
contactText: 'Contact',
showUserInfo: true
})
});
const emit = defineEmits<{
contactClick: []
}>()
contactClick: [];
}>();
const wrapRef = ref<HTMLDivElement>()
const cardRef = ref<HTMLElement>()
const wrapRef = ref<HTMLDivElement>();
const cardRef = ref<HTMLElement>();
const DEFAULT_BEHIND_GRADIENT = "radial-gradient(farthest-side circle at var(--pointer-x) var(--pointer-y),hsla(266,100%,90%,var(--card-opacity)) 4%,hsla(266,50%,80%,calc(var(--card-opacity)*0.75)) 10%,hsla(266,25%,70%,calc(var(--card-opacity)*0.5)) 50%,hsla(266,0%,60%,0) 100%),radial-gradient(35% 52% at 55% 20%,#00ffaac4 0%,#073aff00 100%),radial-gradient(100% 100% at 50% 50%,#00c1ffff 1%,#073aff00 76%),conic-gradient(from 124deg at 50% 50%,#c137ffff 0%,#07c6ffff 40%,#07c6ffff 60%,#c137ffff 100%)"
const DEFAULT_BEHIND_GRADIENT =
'radial-gradient(farthest-side circle at var(--pointer-x) var(--pointer-y),hsla(266,100%,90%,var(--card-opacity)) 4%,hsla(266,50%,80%,calc(var(--card-opacity)*0.75)) 10%,hsla(266,25%,70%,calc(var(--card-opacity)*0.5)) 50%,hsla(266,0%,60%,0) 100%),radial-gradient(35% 52% at 55% 20%,#00ffaac4 0%,#073aff00 100%),radial-gradient(100% 100% at 50% 50%,#00c1ffff 1%,#073aff00 76%),conic-gradient(from 124deg at 50% 50%,#c137ffff 0%,#07c6ffff 40%,#07c6ffff 60%,#c137ffff 100%)';
const DEFAULT_INNER_GRADIENT = "linear-gradient(145deg,#60496e8c 0%,#71C4FF44 100%)"
const DEFAULT_INNER_GRADIENT = 'linear-gradient(145deg,#60496e8c 0%,#71C4FF44 100%)';
const ANIMATION_CONFIG = {
SMOOTH_DURATION: 600,
INITIAL_DURATION: 1500,
INITIAL_X_OFFSET: 70,
INITIAL_Y_OFFSET: 60,
} as const
INITIAL_Y_OFFSET: 60
} as const;
const clamp = (value: number, min = 0, max = 100): number =>
Math.min(Math.max(value, min), max)
const clamp = (value: number, min = 0, max = 100): number => Math.min(Math.max(value, min), max);
const round = (value: number, precision = 3): number =>
parseFloat(value.toFixed(precision))
const round = (value: number, precision = 3): number => parseFloat(value.toFixed(precision));
const adjust = (
value: number,
fromMin: number,
fromMax: number,
toMin: number,
toMax: number
): number =>
round(toMin + ((toMax - toMin) * (value - fromMin)) / (fromMax - fromMin))
const adjust = (value: number, fromMin: number, fromMax: number, toMin: number, toMax: number): number =>
round(toMin + ((toMax - toMin) * (value - fromMin)) / (fromMax - fromMin));
const easeInOutCubic = (x: number): number =>
x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2
const easeInOutCubic = (x: number): number => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2);
let rafId: number | null = null
let rafId: number | null = null;
const updateCardTransform = (
offsetX: number,
offsetY: number,
card: HTMLElement,
wrap: HTMLElement
) => {
const width = card.clientWidth
const height = card.clientHeight
const updateCardTransform = (offsetX: number, offsetY: number, card: HTMLElement, wrap: HTMLElement) => {
const width = card.clientWidth;
const height = card.clientHeight;
const percentX = clamp((100 / width) * offsetX)
const percentY = clamp((100 / height) * offsetY)
const percentX = clamp((100 / width) * offsetX);
const percentY = clamp((100 / height) * offsetY);
const centerX = percentX - 50
const centerY = percentY - 50
const centerX = percentX - 50;
const centerY = percentY - 50;
const properties = {
'--pointer-x': `${percentX}%`,
@@ -136,13 +145,13 @@ const updateCardTransform = (
'--pointer-from-top': `${percentY / 100}`,
'--pointer-from-left': `${percentX / 100}`,
'--rotate-x': `${round(-(centerX / 5))}deg`,
'--rotate-y': `${round(centerY / 4)}deg`,
}
'--rotate-y': `${round(centerY / 4)}deg`
};
Object.entries(properties).forEach(([property, value]) => {
wrap.style.setProperty(property, value)
})
}
wrap.style.setProperty(property, value);
});
};
const createSmoothAnimation = (
duration: number,
@@ -151,138 +160,119 @@ const createSmoothAnimation = (
card: HTMLElement,
wrap: HTMLElement
) => {
const startTime = performance.now()
const targetX = wrap.clientWidth / 2
const targetY = wrap.clientHeight / 2
const startTime = performance.now();
const targetX = wrap.clientWidth / 2;
const targetY = wrap.clientHeight / 2;
const animationLoop = (currentTime: number) => {
const elapsed = currentTime - startTime
const progress = clamp(elapsed / duration)
const easedProgress = easeInOutCubic(progress)
const elapsed = currentTime - startTime;
const progress = clamp(elapsed / duration);
const easedProgress = easeInOutCubic(progress);
const currentX = adjust(easedProgress, 0, 1, startX, targetX)
const currentY = adjust(easedProgress, 0, 1, startY, targetY)
const currentX = adjust(easedProgress, 0, 1, startX, targetX);
const currentY = adjust(easedProgress, 0, 1, startY, targetY);
updateCardTransform(currentX, currentY, card, wrap)
updateCardTransform(currentX, currentY, card, wrap);
if (progress < 1) {
rafId = requestAnimationFrame(animationLoop)
rafId = requestAnimationFrame(animationLoop);
}
}
};
rafId = requestAnimationFrame(animationLoop)
}
rafId = requestAnimationFrame(animationLoop);
};
const cancelAnimation = () => {
if (rafId) {
cancelAnimationFrame(rafId)
rafId = null
cancelAnimationFrame(rafId);
rafId = null;
}
}
};
const handlePointerMove = (event: PointerEvent) => {
const card = cardRef.value
const wrap = wrapRef.value
const card = cardRef.value;
const wrap = wrapRef.value;
if (!card || !wrap || !props.enableTilt) return
if (!card || !wrap || !props.enableTilt) return;
const rect = card.getBoundingClientRect()
updateCardTransform(
event.clientX - rect.left,
event.clientY - rect.top,
card,
wrap
)
}
const rect = card.getBoundingClientRect();
updateCardTransform(event.clientX - rect.left, event.clientY - rect.top, card, wrap);
};
const handlePointerEnter = () => {
const card = cardRef.value
const wrap = wrapRef.value
const card = cardRef.value;
const wrap = wrapRef.value;
if (!card || !wrap || !props.enableTilt) return
if (!card || !wrap || !props.enableTilt) return;
cancelAnimation()
wrap.classList.add('active')
card.classList.add('active')
}
cancelAnimation();
wrap.classList.add('active');
card.classList.add('active');
};
const handlePointerLeave = (event: PointerEvent) => {
const card = cardRef.value
const wrap = wrapRef.value
const card = cardRef.value;
const wrap = wrapRef.value;
if (!card || !wrap || !props.enableTilt) return
if (!card || !wrap || !props.enableTilt) return;
createSmoothAnimation(
ANIMATION_CONFIG.SMOOTH_DURATION,
event.offsetX,
event.offsetY,
card,
wrap
)
wrap.classList.remove('active')
card.classList.remove('active')
}
createSmoothAnimation(ANIMATION_CONFIG.SMOOTH_DURATION, event.offsetX, event.offsetY, card, wrap);
wrap.classList.remove('active');
card.classList.remove('active');
};
const cardStyle = computed(() => ({
'--icon': props.iconUrl ? `url(${props.iconUrl})` : 'none',
'--grain': props.grainUrl ? `url(${props.grainUrl})` : 'none',
'--behind-gradient': props.showBehindGradient
? (props.behindGradient ?? DEFAULT_BEHIND_GRADIENT)
: 'none',
'--inner-gradient': props.innerGradient ?? DEFAULT_INNER_GRADIENT,
}))
'--behind-gradient': props.showBehindGradient ? (props.behindGradient ?? DEFAULT_BEHIND_GRADIENT) : 'none',
'--inner-gradient': props.innerGradient ?? DEFAULT_INNER_GRADIENT
}));
const handleContactClick = () => {
emit('contactClick')
}
emit('contactClick');
};
const handleAvatarError = (event: Event) => {
const target = event.target as HTMLImageElement
target.style.display = 'none'
}
const target = event.target as HTMLImageElement;
target.style.display = 'none';
};
const handleMiniAvatarError = (event: Event) => {
const target = event.target as HTMLImageElement
target.style.opacity = '0.5'
target.src = props.avatarUrl
}
const target = event.target as HTMLImageElement;
target.style.opacity = '0.5';
target.src = props.avatarUrl;
};
onMounted(() => {
if (!props.enableTilt) return
if (!props.enableTilt) return;
const card = cardRef.value
const wrap = wrapRef.value
const card = cardRef.value;
const wrap = wrapRef.value;
if (!card || !wrap) return
if (!card || !wrap) return;
card.addEventListener('pointerenter', handlePointerEnter)
card.addEventListener('pointermove', handlePointerMove)
card.addEventListener('pointerleave', handlePointerLeave)
card.addEventListener('pointerenter', handlePointerEnter);
card.addEventListener('pointermove', handlePointerMove);
card.addEventListener('pointerleave', handlePointerLeave);
const initialX = wrap.clientWidth - ANIMATION_CONFIG.INITIAL_X_OFFSET
const initialY = ANIMATION_CONFIG.INITIAL_Y_OFFSET
const initialX = wrap.clientWidth - ANIMATION_CONFIG.INITIAL_X_OFFSET;
const initialY = ANIMATION_CONFIG.INITIAL_Y_OFFSET;
updateCardTransform(initialX, initialY, card, wrap)
createSmoothAnimation(
ANIMATION_CONFIG.INITIAL_DURATION,
initialX,
initialY,
card,
wrap
)
})
updateCardTransform(initialX, initialY, card, wrap);
createSmoothAnimation(ANIMATION_CONFIG.INITIAL_DURATION, initialX, initialY, card, wrap);
});
onUnmounted(() => {
const card = cardRef.value
const card = cardRef.value;
if (card) {
card.removeEventListener('pointerenter', handlePointerEnter)
card.removeEventListener('pointermove', handlePointerMove)
card.removeEventListener('pointerleave', handlePointerLeave)
card.removeEventListener('pointerenter', handlePointerEnter);
card.removeEventListener('pointermove', handlePointerMove);
card.removeEventListener('pointerleave', handlePointerLeave);
}
cancelAnimation()
})
cancelAnimation();
});
</script>
<style scoped>
@@ -357,12 +347,27 @@ onUnmounted(() => {
position: relative;
background-blend-mode: color-dodge, normal, normal, normal;
animation: glow-bg 12s linear infinite;
box-shadow: rgba(0, 0, 0, 0.8) calc((var(--pointer-from-left) * 10px) - 3px) calc((var(--pointer-from-top) * 20px) - 6px) 20px -5px;
box-shadow: rgba(0, 0, 0, 0.8) calc((var(--pointer-from-left) * 10px) - 3px)
calc((var(--pointer-from-top) * 20px) - 6px) 20px -5px;
transition: transform 1s ease;
transform: translate3d(0, 0, 0.1px) rotateX(0deg) rotateY(0deg);
background-size: 100% 100%;
background-position: 0 0, 0 0, 50% 50%, 0 0;
background-image: radial-gradient(farthest-side circle at var(--pointer-x) var(--pointer-y), hsla(266, 100%, 90%, var(--card-opacity)) 4%, hsla(266, 50%, 80%, calc(var(--card-opacity) * 0.75)) 10%, hsla(266, 25%, 70%, calc(var(--card-opacity) * 0.5)) 50%, hsla(266, 0%, 60%, 0) 100%), radial-gradient(35% 52% at 55% 20%, #00ffaac4 0%, #073aff00 100%), radial-gradient(100% 100% at 50% 50%, #00c1ffff 1%, #073aff00 76%), conic-gradient(from 124deg at 50% 50%, #c137ffff 0%, #07c6ffff 40%, #07c6ffff 60%, #c137ffff 100%);
background-position:
0 0,
0 0,
50% 50%,
0 0;
background-image:
radial-gradient(
farthest-side circle at var(--pointer-x) var(--pointer-y),
hsla(266, 100%, 90%, var(--card-opacity)) 4%,
hsla(266, 50%, 80%, calc(var(--card-opacity) * 0.75)) 10%,
hsla(266, 25%, 70%, calc(var(--card-opacity) * 0.5)) 50%,
hsla(266, 0%, 60%, 0) 100%
),
radial-gradient(35% 52% at 55% 20%, #00ffaac4 0%, #073aff00 100%),
radial-gradient(100% 100% at 50% 50%, #00c1ffff 1%, #073aff00 76%),
conic-gradient(from 124deg at 50% 50%, #c137ffff 0%, #07c6ffff 40%, #07c6ffff 60%, #c137ffff 100%);
overflow: hidden;
}
@@ -415,10 +420,41 @@ onUnmounted(() => {
background: transparent;
background-size: cover;
background-position: center;
background-image: repeating-linear-gradient(0deg, var(--sunpillar-clr-1) calc(var(--space) * 1), var(--sunpillar-clr-2) calc(var(--space) * 2), var(--sunpillar-clr-3) calc(var(--space) * 3), var(--sunpillar-clr-4) calc(var(--space) * 4), var(--sunpillar-clr-5) calc(var(--space) * 5), var(--sunpillar-clr-6) calc(var(--space) * 6), var(--sunpillar-clr-1) calc(var(--space) * 7)), repeating-linear-gradient(var(--angle), #0e152e 0%, hsl(180, 10%, 60%) 3.8%, hsl(180, 29%, 66%) 4.5%, hsl(180, 10%, 60%) 5.2%, #0e152e 10%, #0e152e 12%), radial-gradient(farthest-corner circle at var(--pointer-x) var(--pointer-y), hsla(0, 0%, 0%, 0.1) 12%, hsla(0, 0%, 0%, 0.15) 20%, hsla(0, 0%, 0%, 0.25) 120%);
background-position: 0 var(--background-y), var(--background-x) var(--background-y), center;
background-image:
repeating-linear-gradient(
0deg,
var(--sunpillar-clr-1) calc(var(--space) * 1),
var(--sunpillar-clr-2) calc(var(--space) * 2),
var(--sunpillar-clr-3) calc(var(--space) * 3),
var(--sunpillar-clr-4) calc(var(--space) * 4),
var(--sunpillar-clr-5) calc(var(--space) * 5),
var(--sunpillar-clr-6) calc(var(--space) * 6),
var(--sunpillar-clr-1) calc(var(--space) * 7)
),
repeating-linear-gradient(
var(--angle),
#0e152e 0%,
hsl(180, 10%, 60%) 3.8%,
hsl(180, 29%, 66%) 4.5%,
hsl(180, 10%, 60%) 5.2%,
#0e152e 10%,
#0e152e 12%
),
radial-gradient(
farthest-corner circle at var(--pointer-x) var(--pointer-y),
hsla(0, 0%, 0%, 0.1) 12%,
hsla(0, 0%, 0%, 0.15) 20%,
hsla(0, 0%, 0%, 0.25) 120%
);
background-position:
0 var(--background-y),
var(--background-x) var(--background-y),
center;
background-blend-mode: color, hard-light;
background-size: 500% 500%, 300% 300%, 200% 200%;
background-size:
500% 500%,
300% 300%,
200% 200%;
background-repeat: repeat;
}
@@ -445,19 +481,68 @@ onUnmounted(() => {
}
.pc-shine::before {
background-image: linear-gradient(45deg, var(--sunpillar-4), var(--sunpillar-5), var(--sunpillar-6), var(--sunpillar-1), var(--sunpillar-2), var(--sunpillar-3)), radial-gradient(circle at var(--pointer-x) var(--pointer-y), hsl(0, 0%, 70%) 0%, hsla(0, 0%, 30%, 0.2) 90%), var(--grain);
background-size: 250% 250%, 100% 100%, 220px 220px;
background-position: var(--pointer-x) var(--pointer-y), center, calc(var(--pointer-x) * 0.01) calc(var(--pointer-y) * 0.01);
background-image:
linear-gradient(
45deg,
var(--sunpillar-4),
var(--sunpillar-5),
var(--sunpillar-6),
var(--sunpillar-1),
var(--sunpillar-2),
var(--sunpillar-3)
),
radial-gradient(circle at var(--pointer-x) var(--pointer-y), hsl(0, 0%, 70%) 0%, hsla(0, 0%, 30%, 0.2) 90%),
var(--grain);
background-size:
250% 250%,
100% 100%,
220px 220px;
background-position:
var(--pointer-x) var(--pointer-y),
center,
calc(var(--pointer-x) * 0.01) calc(var(--pointer-y) * 0.01);
background-blend-mode: color-dodge;
filter: brightness(calc(2 - var(--pointer-from-center))) contrast(calc(var(--pointer-from-center) + 2)) saturate(calc(0.5 + var(--pointer-from-center)));
filter: brightness(calc(2 - var(--pointer-from-center))) contrast(calc(var(--pointer-from-center) + 2))
saturate(calc(0.5 + var(--pointer-from-center)));
mix-blend-mode: luminosity;
}
.pc-shine::after {
content: '';
background-image: repeating-linear-gradient(0deg, var(--sunpillar-clr-1) calc(5% * 1), var(--sunpillar-clr-2) calc(5% * 2), var(--sunpillar-clr-3) calc(5% * 3), var(--sunpillar-clr-4) calc(5% * 4), var(--sunpillar-clr-5) calc(5% * 5), var(--sunpillar-clr-6) calc(5% * 6), var(--sunpillar-clr-1) calc(5% * 7)), repeating-linear-gradient(-45deg, #0e152e 0%, hsl(180, 10%, 60%) 3.8%, hsl(180, 29%, 66%) 4.5%, hsl(180, 10%, 60%) 5.2%, #0e152e 10%, #0e152e 12%), radial-gradient(farthest-corner circle at var(--pointer-x) var(--pointer-y), hsla(0, 0%, 0%, 0.1) 12%, hsla(0, 0%, 0%, 0.15) 20%, hsla(0, 0%, 0%, 0.25) 120%);
background-position: 0 var(--background-y), calc(var(--background-x) * 0.4) calc(var(--background-y) * 0.5), center;
background-size: 200% 300%, 700% 700%, 100% 100%;
background-image:
repeating-linear-gradient(
0deg,
var(--sunpillar-clr-1) calc(5% * 1),
var(--sunpillar-clr-2) calc(5% * 2),
var(--sunpillar-clr-3) calc(5% * 3),
var(--sunpillar-clr-4) calc(5% * 4),
var(--sunpillar-clr-5) calc(5% * 5),
var(--sunpillar-clr-6) calc(5% * 6),
var(--sunpillar-clr-1) calc(5% * 7)
),
repeating-linear-gradient(
-45deg,
#0e152e 0%,
hsl(180, 10%, 60%) 3.8%,
hsl(180, 29%, 66%) 4.5%,
hsl(180, 10%, 60%) 5.2%,
#0e152e 10%,
#0e152e 12%
),
radial-gradient(
farthest-corner circle at var(--pointer-x) var(--pointer-y),
hsla(0, 0%, 0%, 0.1) 12%,
hsla(0, 0%, 0%, 0.15) 20%,
hsla(0, 0%, 0%, 0.25) 120%
);
background-position:
0 var(--background-y),
calc(var(--background-x) * 0.4) calc(var(--background-y) * 0.5),
center;
background-size:
200% 300%,
700% 700%,
100% 100%;
mix-blend-mode: difference;
filter: brightness(0.8) contrast(1.5);
}
@@ -465,7 +550,11 @@ onUnmounted(() => {
.pc-glare {
transform: translate3d(0, 0, 1.1px);
overflow: hidden;
background-image: radial-gradient(farthest-corner circle at var(--pointer-x) var(--pointer-y), hsl(248, 25%, 80%) 12%, hsla(207, 40%, 30%, 0.8) 90%);
background-image: radial-gradient(
farthest-corner circle at var(--pointer-x) var(--pointer-y),
hsl(248, 25%, 80%) 12%,
hsla(207, 40%, 30%, 0.8) 90%
);
mix-blend-mode: overlay;
filter: brightness(0.8) contrast(1.2);
z-index: 4;
@@ -486,16 +575,18 @@ onUnmounted(() => {
}
.pc-avatar-content::before {
content: "";
content: '';
position: absolute;
inset: 0;
z-index: 1;
backdrop-filter: blur(30px);
mask: linear-gradient(to bottom,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0) 60%,
rgba(0, 0, 0, 1) 90%,
rgba(0, 0, 0, 1) 100%);
mask: linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0) 60%,
rgba(0, 0, 0, 1) 90%,
rgba(0, 0, 0, 1) 100%
);
pointer-events: none;
}
@@ -582,7 +673,11 @@ onUnmounted(() => {
overflow: hidden;
text-align: center;
position: relative;
transform: translate3d(calc(var(--pointer-from-left) * -6px + 3px), calc(var(--pointer-from-top) * -6px + 3px), 0.1px) !important;
transform: translate3d(
calc(var(--pointer-from-left) * -6px + 3px),
calc(var(--pointer-from-top) * -6px + 3px),
0.1px
) !important;
z-index: 5;
mix-blend-mode: luminosity;
}
@@ -634,11 +729,17 @@ onUnmounted(() => {
@keyframes holo-bg {
0% {
background-position: 0 var(--background-y), 0 0, center;
background-position:
0 var(--background-y),
0 0,
center;
}
100% {
background-position: 0 var(--background-y), 90% 90%, center;
background-position:
0 var(--background-y),
90% 90%,
center;
}
}
@@ -783,4 +884,4 @@ onUnmounted(() => {
border-radius: 50px;
}
}
</style>
</style>

View File

@@ -1,59 +1,67 @@
<template>
<div ref="divRef" @mousemove="handleMouseMove" @focus="handleFocus" @blur="handleBlur" @mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave" :class="[
'relative rounded-3xl border overflow-hidden p-8',
className
]">
<div class="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-500 ease-in-out" :style="{
opacity,
background: `radial-gradient(circle at ${position.x}px ${position.y}px, ${spotlightColor}, transparent 80%)`,
}" />
<div
ref="divRef"
@mousemove="handleMouseMove"
@focus="handleFocus"
@blur="handleBlur"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
:class="['relative rounded-3xl border overflow-hidden p-8', className]"
>
<div
class="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-500 ease-in-out"
:style="{
opacity,
background: `radial-gradient(circle at ${position.x}px ${position.y}px, ${spotlightColor}, transparent 80%)`
}"
/>
<slot />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref } from 'vue';
interface Position {
x: number
y: number
x: number;
y: number;
}
interface SpotlightCardProps {
className?: string
spotlightColor?: string
className?: string;
spotlightColor?: string;
}
const { className = '', spotlightColor = 'rgba(255, 255, 255, 0.25)' } = defineProps<SpotlightCardProps>()
const { className = '', spotlightColor = 'rgba(255, 255, 255, 0.25)' } = defineProps<SpotlightCardProps>();
const divRef = ref<HTMLDivElement>()
const isFocused = ref<boolean>(false)
const position = ref<Position>({ x: 0, y: 0 })
const opacity = ref<number>(0)
const divRef = ref<HTMLDivElement>();
const isFocused = ref<boolean>(false);
const position = ref<Position>({ x: 0, y: 0 });
const opacity = ref<number>(0);
const handleMouseMove = (e: MouseEvent) => {
if (!divRef.value || isFocused.value) return
if (!divRef.value || isFocused.value) return;
const rect = divRef.value.getBoundingClientRect()
position.value = { x: e.clientX - rect.left, y: e.clientY - rect.top }
}
const rect = divRef.value.getBoundingClientRect();
position.value = { x: e.clientX - rect.left, y: e.clientY - rect.top };
};
const handleFocus = () => {
isFocused.value = true
opacity.value = 0.6
}
isFocused.value = true;
opacity.value = 0.6;
};
const handleBlur = () => {
isFocused.value = false
opacity.value = 0
}
isFocused.value = false;
opacity.value = 0;
};
const handleMouseEnter = () => {
opacity.value = 0.6
}
opacity.value = 0.6;
};
const handleMouseLeave = () => {
opacity.value = 0
}
</script>
opacity.value = 0;
};
</script>

View File

@@ -1,65 +1,87 @@
<template>
<figure ref="cardRef" class="relative w-full h-full [perspective:800px] flex flex-col items-center justify-center"
<figure
ref="cardRef"
class="relative w-full h-full [perspective:800px] flex flex-col items-center justify-center"
:style="{
height: containerHeight,
width: containerWidth,
}" @mousemove="handleMouse" @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave">
width: containerWidth
}"
@mousemove="handleMouse"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<div v-if="showMobileWarning" class="absolute top-4 text-center text-sm block sm:hidden">
This effect is not optimized for mobile. Check on desktop.
</div>
<Motion tag="div" class="relative [transform-style:preserve-3d]" :style="{
width: imageWidth,
height: imageHeight,
}" :animate="{
<Motion
tag="div"
class="relative [transform-style:preserve-3d]"
:style="{
width: imageWidth,
height: imageHeight
}"
:animate="{
rotateX: rotateXValue,
rotateY: rotateYValue,
scale: scaleValue,
}" :transition="springTransition">
<img :src="imageSrc" :alt="altText"
scale: scaleValue
}"
:transition="springTransition"
>
<img
:src="imageSrc"
:alt="altText"
class="absolute top-0 left-0 object-cover rounded-[15px] will-change-transform [transform:translateZ(0)]"
:style="{
width: imageWidth,
height: imageHeight,
}" />
height: imageHeight
}"
/>
<Motion v-if="displayOverlayContent && overlayContent" tag="div"
class="absolute top-0 left-0 z-[2] will-change-transform [transform:translateZ(30px)]">
<Motion
v-if="displayOverlayContent && overlayContent"
tag="div"
class="absolute top-0 left-0 z-[2] will-change-transform [transform:translateZ(30px)]"
>
<slot name="overlay" />
</Motion>
</Motion>
<Motion v-if="showTooltip && captionText" tag="figcaption"
<Motion
v-if="showTooltip && captionText"
tag="figcaption"
class="pointer-events-none absolute left-0 top-0 rounded-[4px] bg-white px-[10px] py-[4px] text-[10px] text-[#2d2d2d] opacity-0 z-[3] hidden sm:block"
:animate="{
x: xValue,
y: yValue,
opacity: opacityValue,
rotate: rotateFigcaptionValue,
}" :transition="tooltipTransition">
rotate: rotateFigcaptionValue
}"
:transition="tooltipTransition"
>
{{ captionText }}
</Motion>
</figure>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Motion } from 'motion-v'
import { ref, computed } from 'vue';
import { Motion } from 'motion-v';
interface TiltedCardProps {
imageSrc: string
altText?: string
captionText?: string
containerHeight?: string
containerWidth?: string
imageHeight?: string
imageWidth?: string
scaleOnHover?: number
rotateAmplitude?: number
showMobileWarning?: boolean
showTooltip?: boolean
overlayContent?: boolean
displayOverlayContent?: boolean
imageSrc: string;
altText?: string;
captionText?: string;
containerHeight?: string;
containerWidth?: string;
imageHeight?: string;
imageWidth?: string;
scaleOnHover?: number;
rotateAmplitude?: number;
showMobileWarning?: boolean;
showTooltip?: boolean;
overlayContent?: boolean;
displayOverlayContent?: boolean;
}
const props = withDefaults(defineProps<TiltedCardProps>(), {
@@ -74,64 +96,64 @@ const props = withDefaults(defineProps<TiltedCardProps>(), {
showMobileWarning: true,
showTooltip: true,
overlayContent: false,
displayOverlayContent: false,
})
displayOverlayContent: false
});
const cardRef = ref<HTMLElement | null>(null)
const xValue = ref(0)
const yValue = ref(0)
const rotateXValue = ref(0)
const rotateYValue = ref(0)
const scaleValue = ref(1)
const opacityValue = ref(0)
const rotateFigcaptionValue = ref(0)
const lastY = ref(0)
const cardRef = ref<HTMLElement | null>(null);
const xValue = ref(0);
const yValue = ref(0);
const rotateXValue = ref(0);
const rotateYValue = ref(0);
const scaleValue = ref(1);
const opacityValue = ref(0);
const rotateFigcaptionValue = ref(0);
const lastY = ref(0);
const springTransition = computed(() => ({
type: 'spring' as const,
damping: 30,
stiffness: 100,
mass: 2,
}))
mass: 2
}));
const tooltipTransition = computed(() => ({
type: 'spring' as const,
damping: 30,
stiffness: 350,
mass: 1,
}))
mass: 1
}));
function handleMouse(e: MouseEvent) {
if (!cardRef.value) return
if (!cardRef.value) return;
const rect = cardRef.value.getBoundingClientRect()
const offsetX = e.clientX - rect.left - rect.width / 2
const offsetY = e.clientY - rect.top - rect.height / 2
const rect = cardRef.value.getBoundingClientRect();
const offsetX = e.clientX - rect.left - rect.width / 2;
const offsetY = e.clientY - rect.top - rect.height / 2;
const rotationX = (offsetY / (rect.height / 2)) * -props.rotateAmplitude
const rotationY = (offsetX / (rect.width / 2)) * props.rotateAmplitude
const rotationX = (offsetY / (rect.height / 2)) * -props.rotateAmplitude;
const rotationY = (offsetX / (rect.width / 2)) * props.rotateAmplitude;
rotateXValue.value = rotationX
rotateYValue.value = rotationY
rotateXValue.value = rotationX;
rotateYValue.value = rotationY;
xValue.value = e.clientX - rect.left
yValue.value = e.clientY - rect.top
xValue.value = e.clientX - rect.left;
yValue.value = e.clientY - rect.top;
const velocityY = offsetY - lastY.value
rotateFigcaptionValue.value = -velocityY * 0.6
lastY.value = offsetY
const velocityY = offsetY - lastY.value;
rotateFigcaptionValue.value = -velocityY * 0.6;
lastY.value = offsetY;
}
function handleMouseEnter() {
scaleValue.value = props.scaleOnHover
opacityValue.value = 1
scaleValue.value = props.scaleOnHover;
opacityValue.value = 1;
}
function handleMouseLeave() {
opacityValue.value = 0
scaleValue.value = 1
rotateXValue.value = 0
rotateYValue.value = 0
rotateFigcaptionValue.value = 0
opacityValue.value = 0;
scaleValue.value = 1;
rotateXValue.value = 0;
rotateYValue.value = 0;
rotateFigcaptionValue.value = 0;
}
</script>
</script>