mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-09 08:29:30 -06:00
Component Boom
This commit is contained in:
290
src/content/Components/CardSwap/CardSwap.vue
Normal file
290
src/content/Components/CardSwap/CardSwap.vue
Normal file
@@ -0,0 +1,290 @@
|
||||
<template>
|
||||
<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 }"
|
||||
>
|
||||
<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 }"
|
||||
@click="handleCardClick(index)"
|
||||
>
|
||||
<slot :name="`card-${index}`" :index="index" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
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'
|
||||
}
|
||||
|
||||
interface Slot {
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
zIndex: number
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
const placeNow = (el: HTMLElement, slot: Slot, skew: number) => {
|
||||
gsap.set(el, {
|
||||
x: slot.x,
|
||||
y: slot.y,
|
||||
z: slot.z,
|
||||
xPercent: -50,
|
||||
yPercent: -50,
|
||||
skewY: skew,
|
||||
transformOrigin: 'center center',
|
||||
zIndex: slot.zIndex,
|
||||
force3D: true,
|
||||
})
|
||||
}
|
||||
|
||||
export { makeSlot, placeNow }
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<CardSwapProps>(), {
|
||||
width: 500,
|
||||
height: 400,
|
||||
cardDistance: 60,
|
||||
verticalDistance: 70,
|
||||
delay: 5000,
|
||||
pauseOnHover: false,
|
||||
skewAmount: 6,
|
||||
easing: 'elastic',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'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 handleCardClick = (index: number) => {
|
||||
emit('card-click', index)
|
||||
props.onCardClick?.(index)
|
||||
}
|
||||
|
||||
const config = computed(() => {
|
||||
return props.easing === 'elastic'
|
||||
? {
|
||||
ease: 'elastic.out(0.6,0.9)',
|
||||
durDrop: 2,
|
||||
durMove: 2,
|
||||
durReturn: 2,
|
||||
promoteOverlap: 0.9,
|
||||
returnDelay: 0.05,
|
||||
}
|
||||
: {
|
||||
ease: 'power1.inOut',
|
||||
durDrop: 0.8,
|
||||
durMove: 0.8,
|
||||
durReturn: 0.8,
|
||||
promoteOverlap: 0.45,
|
||||
returnDelay: 0.2,
|
||||
}
|
||||
})
|
||||
|
||||
const initializeCards = () => {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const updateCardPositions = () => {
|
||||
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)
|
||||
gsap.set(el, {
|
||||
x: slot.x,
|
||||
y: slot.y,
|
||||
z: slot.z,
|
||||
skewY: props.skewAmount,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const swap = () => {
|
||||
if (order.value.length < 2) return
|
||||
|
||||
const [front, ...rest] = order.value
|
||||
const elFront = cardRefs.value[front]
|
||||
if (!elFront) return
|
||||
|
||||
const tl = gsap.timeline()
|
||||
tlRef.value = tl
|
||||
|
||||
tl.to(elFront, {
|
||||
y: '+=500',
|
||||
duration: config.value.durDrop,
|
||||
ease: config.value.ease,
|
||||
})
|
||||
|
||||
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')
|
||||
tl.to(
|
||||
el,
|
||||
{
|
||||
x: slot.x,
|
||||
y: slot.y,
|
||||
z: slot.z,
|
||||
duration: config.value.durMove,
|
||||
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.call(
|
||||
() => {
|
||||
gsap.set(elFront, { zIndex: backSlot.zIndex })
|
||||
},
|
||||
undefined,
|
||||
'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,
|
||||
},
|
||||
'return'
|
||||
)
|
||||
|
||||
tl.call(() => {
|
||||
order.value = [...rest, front]
|
||||
})
|
||||
}
|
||||
|
||||
const startAnimation = () => {
|
||||
stopAnimation()
|
||||
swap()
|
||||
intervalRef.value = window.setInterval(swap, props.delay)
|
||||
}
|
||||
|
||||
const stopAnimation = () => {
|
||||
tlRef.value?.kill()
|
||||
if (intervalRef.value) {
|
||||
clearInterval(intervalRef.value)
|
||||
}
|
||||
}
|
||||
|
||||
const resumeAnimation = () => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
const removeHoverListeners = () => {
|
||||
if (containerRef.value) {
|
||||
containerRef.value.removeEventListener('mouseenter', stopAnimation)
|
||||
containerRef.value.removeEventListener('mouseleave', resumeAnimation)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.cardDistance, props.verticalDistance, props.skewAmount],
|
||||
() => {
|
||||
updateCardPositions()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.delay,
|
||||
() => {
|
||||
if (intervalRef.value) {
|
||||
clearInterval(intervalRef.value)
|
||||
intervalRef.value = window.setInterval(swap, props.delay)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.pauseOnHover,
|
||||
() => {
|
||||
removeHoverListeners()
|
||||
setupHoverListeners()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.easing,
|
||||
() => {}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initializeCards()
|
||||
startAnimation()
|
||||
setupHoverListeners()
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAnimation()
|
||||
removeHoverListeners()
|
||||
})
|
||||
</script>
|
||||
277
src/content/Components/Carousel/Carousel.vue
Normal file
277
src/content/Components/Carousel/Carousel.vue
Normal file
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<div ref="containerRef" :class="[
|
||||
'relative overflow-hidden p-4',
|
||||
round
|
||||
? 'rounded-full border border-[#333]'
|
||||
: 'rounded-[24px] border border-[#333]'
|
||||
]" :style="{
|
||||
width: `${baseWidth}px`,
|
||||
...(round && { height: `${baseWidth}px` }),
|
||||
}">
|
||||
<Motion tag="div" class="flex" drag="x" :dragConstraints="dragConstraints" :style="{
|
||||
width: itemWidth + 'px',
|
||||
gap: `${GAP}px`,
|
||||
perspective: 1000,
|
||||
perspectiveOrigin: `${currentIndex * trackItemOffset + itemWidth / 2}px 50%`,
|
||||
x: motionX,
|
||||
}" @dragEnd="handleDragEnd" :animate="{ x: -(currentIndex * trackItemOffset) }" :transition="effectiveTransition"
|
||||
@animationComplete="handleAnimationComplete">
|
||||
<Motion v-for="(item, index) in carouselItems" :key="index" tag="div" :class="[
|
||||
'relative shrink-0 flex flex-col overflow-hidden cursor-grab active:cursor-grabbing',
|
||||
round
|
||||
? 'items-center justify-center text-center bg-[#111] border border-[#333] rounded-full'
|
||||
: 'items-start justify-between bg-[#111] border border-[#333] rounded-[12px]'
|
||||
]" :style="{
|
||||
width: itemWidth + 'px',
|
||||
height: round ? itemWidth + 'px' : '100%',
|
||||
rotateY: getRotateY(index),
|
||||
...(round && { borderRadius: '50%' }),
|
||||
}" :transition="effectiveTransition">
|
||||
<div :class="round ? 'p-0 m-0' : 'mb-4 p-5'">
|
||||
<span class="flex h-[28px] w-[28px] items-center justify-center rounded-full bg-[#060010]">
|
||||
<i :class="item.icon" class="text-white text-base"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div class="mb-1 font-black text-lg text-white">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<p class="text-sm text-white">{{ item.description }}</p>
|
||||
</div>
|
||||
</Motion>
|
||||
</Motion>
|
||||
<div :class="[
|
||||
'flex w-full justify-center',
|
||||
round ? 'absolute z-20 bottom-12 left-1/2 -translate-x-1/2' : ''
|
||||
]">
|
||||
<div class="mt-4 flex w-[150px] justify-between px-8">
|
||||
<Motion v-for="(_, index) in items" :key="index" tag="div" :class="[
|
||||
'h-2 w-2 rounded-full cursor-pointer transition-colors duration-150',
|
||||
currentIndex % items.length === index
|
||||
? round
|
||||
? 'bg-white'
|
||||
: 'bg-[#333333]'
|
||||
: round
|
||||
? 'bg-[#555]'
|
||||
: 'bg-[rgba(51,51,51,0.4)]'
|
||||
]" :animate="{
|
||||
scale: currentIndex % items.length === index ? 1.2 : 1,
|
||||
}" @click="() => setCurrentIndex(index)" :transition="{ duration: 0.15 }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export interface CarouselItem {
|
||||
title: string
|
||||
description: string
|
||||
id: number
|
||||
icon: string
|
||||
}
|
||||
|
||||
export interface CarouselProps {
|
||||
items?: CarouselItem[]
|
||||
baseWidth?: number
|
||||
autoplay?: boolean
|
||||
autoplayDelay?: number
|
||||
pauseOnHover?: boolean
|
||||
loop?: boolean
|
||||
round?: boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_ITEMS: CarouselItem[] = [
|
||||
{
|
||||
title: "Text Animations",
|
||||
description: "Cool text animations for your projects.",
|
||||
id: 1,
|
||||
icon: "pi pi-file",
|
||||
},
|
||||
{
|
||||
title: "Animations",
|
||||
description: "Smooth animations for your projects.",
|
||||
id: 2,
|
||||
icon: "pi pi-circle",
|
||||
},
|
||||
{
|
||||
title: "Components",
|
||||
description: "Reusable components for your projects.",
|
||||
id: 3,
|
||||
icon: "pi pi-objects-column",
|
||||
},
|
||||
{
|
||||
title: "Backgrounds",
|
||||
description: "Beautiful backgrounds and patterns for your projects.",
|
||||
id: 4,
|
||||
icon: "pi pi-table",
|
||||
},
|
||||
{
|
||||
title: "Common UI",
|
||||
description: "Common UI components are coming soon!",
|
||||
id: 5,
|
||||
icon: "pi pi-code",
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { Motion, useMotionValue, useTransform } from 'motion-v'
|
||||
|
||||
const DRAG_BUFFER = 0
|
||||
const VELOCITY_THRESHOLD = 500
|
||||
const GAP = 16
|
||||
const SPRING_OPTIONS = { type: "spring" as const, stiffness: 300, damping: 30 }
|
||||
|
||||
const props = withDefaults(defineProps<CarouselProps>(), {
|
||||
items: () => DEFAULT_ITEMS,
|
||||
baseWidth: 300,
|
||||
autoplay: false,
|
||||
autoplayDelay: 3000,
|
||||
pauseOnHover: false,
|
||||
loop: false,
|
||||
round: false,
|
||||
})
|
||||
|
||||
const containerPadding = 16
|
||||
const itemWidth = computed(() => props.baseWidth - containerPadding * 2)
|
||||
const trackItemOffset = computed(() => itemWidth.value + GAP)
|
||||
|
||||
const carouselItems = computed(() => props.loop ? [...props.items, props.items[0]] : props.items)
|
||||
const currentIndex = ref<number>(0)
|
||||
const motionX = useMotionValue(0)
|
||||
const isHovered = ref<boolean>(false)
|
||||
const isResetting = ref<boolean>(false)
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
let autoplayTimer: number | null = null
|
||||
|
||||
const dragConstraints = computed(() => {
|
||||
return props.loop
|
||||
? {}
|
||||
: {
|
||||
left: -trackItemOffset.value * (carouselItems.value.length - 1),
|
||||
right: 0,
|
||||
}
|
||||
})
|
||||
|
||||
const effectiveTransition = computed(() =>
|
||||
isResetting.value ? { duration: 0 } : SPRING_OPTIONS
|
||||
)
|
||||
|
||||
const maxItems = Math.max(props.items.length + 1, 10)
|
||||
const rotateYTransforms = Array.from({ length: maxItems }, (_, index) => {
|
||||
const range = computed(() => [
|
||||
-(index + 1) * trackItemOffset.value,
|
||||
-index * trackItemOffset.value,
|
||||
-(index - 1) * trackItemOffset.value,
|
||||
])
|
||||
const outputRange = [90, 0, -90]
|
||||
return useTransform(motionX, range, outputRange, { clamp: false })
|
||||
})
|
||||
|
||||
const getRotateY = (index: number) => {
|
||||
return rotateYTransforms[index] || rotateYTransforms[0]
|
||||
}
|
||||
|
||||
const setCurrentIndex = (index: number) => {
|
||||
currentIndex.value = index
|
||||
}
|
||||
|
||||
const handleAnimationComplete = () => {
|
||||
if (props.loop && currentIndex.value === carouselItems.value.length - 1) {
|
||||
isResetting.value = true
|
||||
motionX.set(0)
|
||||
currentIndex.value = 0
|
||||
setTimeout(() => {
|
||||
isResetting.value = false
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
|
||||
interface DragInfo {
|
||||
offset: { x: number; y: number }
|
||||
velocity: { x: number; y: number }
|
||||
}
|
||||
|
||||
const handleDragEnd = (event: Event, info: DragInfo) => {
|
||||
const offset = info.offset.x
|
||||
const velocity = info.velocity.x
|
||||
|
||||
if (offset < -DRAG_BUFFER || velocity < -VELOCITY_THRESHOLD) {
|
||||
if (props.loop && currentIndex.value === props.items.length - 1) {
|
||||
currentIndex.value = currentIndex.value + 1
|
||||
} else {
|
||||
currentIndex.value = Math.min(currentIndex.value + 1, carouselItems.value.length - 1)
|
||||
}
|
||||
} else if (offset > DRAG_BUFFER || velocity > VELOCITY_THRESHOLD) {
|
||||
if (props.loop && currentIndex.value === 0) {
|
||||
currentIndex.value = props.items.length - 1
|
||||
} else {
|
||||
currentIndex.value = Math.max(currentIndex.value - 1, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const startAutoplay = () => {
|
||||
if (props.autoplay && (!props.pauseOnHover || !isHovered.value)) {
|
||||
autoplayTimer = window.setInterval(() => {
|
||||
currentIndex.value = (() => {
|
||||
const prev = currentIndex.value
|
||||
if (prev === props.items.length - 1 && props.loop) {
|
||||
return prev + 1
|
||||
}
|
||||
if (prev === carouselItems.value.length - 1) {
|
||||
return props.loop ? 0 : prev
|
||||
}
|
||||
return prev + 1
|
||||
})()
|
||||
}, props.autoplayDelay)
|
||||
}
|
||||
}
|
||||
|
||||
const stopAutoplay = () => {
|
||||
if (autoplayTimer) {
|
||||
clearInterval(autoplayTimer)
|
||||
autoplayTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
isHovered.value = true
|
||||
if (props.pauseOnHover) {
|
||||
stopAutoplay()
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
isHovered.value = false
|
||||
if (props.pauseOnHover) {
|
||||
startAutoplay()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => props.autoplay, () => props.autoplayDelay, isHovered, () => props.loop, () => props.items.length, () => carouselItems.value.length, () => props.pauseOnHover],
|
||||
() => {
|
||||
stopAutoplay()
|
||||
startAutoplay()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (props.pauseOnHover && containerRef.value) {
|
||||
containerRef.value.addEventListener('mouseenter', handleMouseEnter)
|
||||
containerRef.value.addEventListener('mouseleave', handleMouseLeave)
|
||||
}
|
||||
startAutoplay()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (containerRef.value) {
|
||||
containerRef.value.removeEventListener('mouseenter', handleMouseEnter)
|
||||
containerRef.value.removeEventListener('mouseleave', handleMouseLeave)
|
||||
}
|
||||
stopAutoplay()
|
||||
})
|
||||
</script>
|
||||
161
src/content/Components/DecayCard/DecayCard.vue
Normal file
161
src/content/Components/DecayCard/DecayCard.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<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]">
|
||||
<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" />
|
||||
</filter>
|
||||
<g>
|
||||
<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]">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { gsap } from 'gsap'
|
||||
|
||||
interface Props {
|
||||
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)
|
||||
|
||||
let cursor = {
|
||||
x: typeof window !== 'undefined' ? window.innerWidth / 2 : 0,
|
||||
y: typeof window !== 'undefined' ? window.innerHeight / 2 : 0
|
||||
}
|
||||
|
||||
let cachedCursor = { ...cursor }
|
||||
|
||||
let winsize = {
|
||||
width: typeof window !== 'undefined' ? window.innerWidth : 0,
|
||||
height: typeof window !== 'undefined' ? window.innerHeight : 0
|
||||
}
|
||||
|
||||
let animationFrameId: number | null = null
|
||||
|
||||
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 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 }
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
if (displacementMapRef.value) {
|
||||
gsap.set(displacementMapRef.value, {
|
||||
attr: { scale: imgValues.displacementScale }
|
||||
})
|
||||
}
|
||||
|
||||
cachedCursor = { ...cursor }
|
||||
|
||||
animationFrameId = requestAnimationFrame(render)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('resize', handleResize)
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
render()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
window.removeEventListener('mousemove', handleMouseMove)
|
||||
}
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
261
src/content/Components/Dock/Dock.vue
Normal file
261
src/content/Components/Dock/Dock.vue
Normal file
@@ -0,0 +1,261 @@
|
||||
<script setup lang="ts">
|
||||
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
|
||||
}
|
||||
|
||||
export type DockProps = {
|
||||
items: DockItemData[]
|
||||
className?: string
|
||||
distance?: number
|
||||
panelHeight?: number
|
||||
baseItemSize?: number
|
||||
dockHeight?: number
|
||||
magnification?: number
|
||||
spring?: SpringOptions
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<DockProps>(), {
|
||||
className: '',
|
||||
distance: 200,
|
||||
panelHeight: 64,
|
||||
baseItemSize: 50,
|
||||
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 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)
|
||||
|
||||
let unsubscribeHeight: (() => void) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
unsubscribeHeight = height.on('change', (latest: number) => {
|
||||
currentHeight.value = latest
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unsubscribeHeight) {
|
||||
unsubscribeHeight()
|
||||
}
|
||||
})
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
isHovered.set(1)
|
||||
mouseX.set(event.pageX)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
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"
|
||||
: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" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
const DockItem = defineComponent({
|
||||
name: 'DockItem',
|
||||
props: {
|
||||
className: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
onClick: {
|
||||
type: Function,
|
||||
default: () => { }
|
||||
},
|
||||
mouseX: {
|
||||
type: Object as () => ReturnType<typeof useMotionValue<number>>,
|
||||
required: true
|
||||
},
|
||||
spring: {
|
||||
type: Object as () => SpringOptions,
|
||||
required: true
|
||||
},
|
||||
distance: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
baseItemSize: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
magnification: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
item: {
|
||||
type: Object as () => DockItemData,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
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
|
||||
})
|
||||
|
||||
const targetSize = useTransform(
|
||||
mouseDistance,
|
||||
[-props.distance, 0, props.distance],
|
||||
[props.baseItemSize, props.magnification, props.baseItemSize]
|
||||
)
|
||||
const size = useSpring(targetSize, props.spring)
|
||||
|
||||
let unsubscribeSize: (() => void) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
unsubscribeSize = size.on('change', (latest: number) => {
|
||||
currentSize.value = latest
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unsubscribeSize) {
|
||||
unsubscribeSize()
|
||||
}
|
||||
})
|
||||
|
||||
const handleHoverStart = () => isHovered.set(1)
|
||||
const handleHoverEnd = () => isHovered.set(0)
|
||||
const handleFocus = () => isHovered.set(1)
|
||||
const handleBlur = () => isHovered.set(0)
|
||||
|
||||
return {
|
||||
itemRef,
|
||||
size,
|
||||
currentSize,
|
||||
isHovered,
|
||||
handleHoverStart,
|
||||
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
|
||||
|
||||
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'
|
||||
}, [
|
||||
h(DockIcon, {}, () => [icon]),
|
||||
h(DockLabel, { isHovered: this.isHovered }, () => [typeof label === 'string' ? label : label])
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
const DockLabel = defineComponent({
|
||||
name: 'DockLabel',
|
||||
props: {
|
||||
className: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
isHovered: {
|
||||
type: Object as () => ReturnType<typeof useMotionValue<number>>,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const isVisible = ref(false)
|
||||
|
||||
let unsubscribe: (() => void) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
unsubscribe = props.isHovered.on('change', (latest: number) => {
|
||||
isVisible.value = latest === 1
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (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?.())
|
||||
}
|
||||
})
|
||||
|
||||
const DockIcon = defineComponent({
|
||||
name: 'DockIcon',
|
||||
props: {
|
||||
className: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
render() {
|
||||
return h('div', {
|
||||
class: `flex items-center justify-center ${this.className}`
|
||||
}, this.$slots.default?.())
|
||||
}
|
||||
})
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Dock',
|
||||
components: {
|
||||
DockItem
|
||||
}
|
||||
})
|
||||
</script>
|
||||
328
src/content/Components/ElasticSlider/ElasticSlider.vue
Normal file
328
src/content/Components/ElasticSlider/ElasticSlider.vue
Normal file
@@ -0,0 +1,328 @@
|
||||
<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">
|
||||
<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"
|
||||
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">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, type Component } from 'vue'
|
||||
|
||||
const MAX_OVERFLOW = 50
|
||||
|
||||
interface Props {
|
||||
defaultValue?: number
|
||||
startingValue?: number
|
||||
maxValue?: number
|
||||
className?: string
|
||||
isStepped?: boolean
|
||||
stepSize?: number
|
||||
leftIcon?: Component | string
|
||||
rightIcon?: Component | string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
defaultValue: 50,
|
||||
startingValue: 0,
|
||||
maxValue: 100,
|
||||
className: '',
|
||||
isStepped: false,
|
||||
stepSize: 1,
|
||||
leftIcon: '-',
|
||||
rightIcon: '+'
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
const rangePercentage = computed(() => {
|
||||
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
|
||||
})
|
||||
|
||||
const sliderScaleY = computed(() => {
|
||||
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'
|
||||
})
|
||||
|
||||
const sliderHeight = computed(() => {
|
||||
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 sliderMarginBottom = computed(() => {
|
||||
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 leftIconTranslateX = computed(() => {
|
||||
return region.value === 'left' ? -overflow.value / scale.value : 0
|
||||
})
|
||||
|
||||
const rightIconTranslateX = computed(() => {
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
} else {
|
||||
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 animateFrame = (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
|
||||
const easeOut = 1 - Math.pow(1 - progress, 3)
|
||||
target.value = start + diff * easeOut
|
||||
|
||||
if (progress < 1) {
|
||||
return requestAnimationFrame(animateFrame)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return requestAnimationFrame(animateFrame)
|
||||
}
|
||||
|
||||
const animateSpring = (target: { value: number }, to: number, bounce = 0.5, duration = 600) => {
|
||||
const start = target.value
|
||||
const startTime = performance.now()
|
||||
|
||||
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 animateFrame = (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime
|
||||
const t = elapsed / 1000
|
||||
|
||||
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)
|
||||
|
||||
displacement = envelope * (cos + (dampingRatio * angularFreq / dampedFreq) * sin)
|
||||
} else {
|
||||
displacement = Math.exp(-angularFreq * t)
|
||||
}
|
||||
|
||||
const currentValue = to + (start - to) * displacement
|
||||
target.value = currentValue
|
||||
|
||||
const velocity = Math.abs(currentValue - to)
|
||||
const isSettled = velocity < 0.01 && elapsed > 100
|
||||
|
||||
if (!isSettled && elapsed < duration * 3) {
|
||||
return requestAnimationFrame(animateFrame)
|
||||
} else {
|
||||
target.value = to
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return requestAnimationFrame(animateFrame)
|
||||
}
|
||||
|
||||
const animateIconScale = (target: { value: number }, isActive: boolean) => {
|
||||
if (isActive) {
|
||||
animate(target, 1.4, { duration: 125 })
|
||||
setTimeout(() => {
|
||||
animate(target, 1, { duration: 125 })
|
||||
}, 125)
|
||||
} else {
|
||||
animate(target, 1, { duration: 250 })
|
||||
}
|
||||
}
|
||||
|
||||
watch(region, (newRegion, oldRegion) => {
|
||||
if (newRegion === 'left' && oldRegion !== 'left') {
|
||||
animateIconScale(leftIconScale, true)
|
||||
} else if (newRegion === 'right' && oldRegion !== 'right') {
|
||||
animateIconScale(rightIconScale, true)
|
||||
}
|
||||
})
|
||||
|
||||
const handlePointerMove = (e: PointerEvent) => {
|
||||
if (e.buttons > 0 && sliderRef.value) {
|
||||
const { left, width } = sliderRef.value.getBoundingClientRect()
|
||||
|
||||
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.min(Math.max(newValue, props.startingValue), props.maxValue)
|
||||
value.value = newValue
|
||||
|
||||
clientX.value = e.clientX
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerDown = (e: PointerEvent) => {
|
||||
handlePointerMove(e)
|
||||
; (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
}
|
||||
|
||||
const handlePointerUp = () => {
|
||||
if (overflowAnimation) {
|
||||
cancelAnimationFrame(overflowAnimation)
|
||||
}
|
||||
overflowAnimation = animate(overflow, 0, { type: 'spring', bounce: 0.4, duration: 500 })
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (scaleAnimation) {
|
||||
cancelAnimationFrame(scaleAnimation)
|
||||
}
|
||||
scaleAnimation = animate(scale, 1.2, { duration: 200 })
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (scaleAnimation) {
|
||||
cancelAnimationFrame(scaleAnimation)
|
||||
}
|
||||
scaleAnimation = animate(scale, 1, { duration: 200 })
|
||||
}
|
||||
|
||||
const handleTouchStart = () => {
|
||||
if (scaleAnimation) {
|
||||
cancelAnimationFrame(scaleAnimation)
|
||||
}
|
||||
scaleAnimation = animate(scale, 1.2, { duration: 200 })
|
||||
}
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (scaleAnimation) {
|
||||
cancelAnimationFrame(scaleAnimation)
|
||||
}
|
||||
scaleAnimation = animate(scale, 1, { duration: 200 })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
value.value = props.defaultValue
|
||||
})
|
||||
</script>
|
||||
131
src/content/Components/FlowingMenu/FlowingMenu.vue
Normal file
131
src/content/Components/FlowingMenu/FlowingMenu.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<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"
|
||||
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)">
|
||||
{{ 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="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})` }" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { gsap } from 'gsap'
|
||||
|
||||
interface MenuItemProps {
|
||||
link: string
|
||||
text: string
|
||||
image: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items?: MenuItemProps[]
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
items: () => []
|
||||
})
|
||||
|
||||
const itemRefs = ref<(HTMLDivElement | null)[]>([])
|
||||
const marqueeRefs = ref<(HTMLDivElement | null)[]>([])
|
||||
const marqueeInnerRefs = ref<(HTMLDivElement | null)[]>([])
|
||||
|
||||
const animationDefaults = { duration: 0.6, ease: 'expo' }
|
||||
|
||||
const setItemRef = (el: HTMLDivElement | null, idx: number) => {
|
||||
if (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 handleMouseEnter = (ev: MouseEvent, idx: number) => {
|
||||
const itemRef = itemRefs.value[idx]
|
||||
const marqueeRef = marqueeRefs.value[idx]
|
||||
const marqueeInnerRef = marqueeInnerRefs.value[idx]
|
||||
|
||||
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 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%' })
|
||||
}
|
||||
|
||||
const handleMouseLeave = (ev: MouseEvent, idx: number) => {
|
||||
const itemRef = itemRefs.value[idx]
|
||||
const marqueeRef = marqueeRefs.value[idx]
|
||||
const marqueeInnerRef = marqueeInnerRefs.value[idx]
|
||||
|
||||
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 tl = gsap.timeline({ defaults: animationDefaults })
|
||||
tl.to(marqueeRef, { y: edge === 'top' ? '-101%' : '101%' }).to(
|
||||
marqueeInnerRef,
|
||||
{ y: edge === 'top' ? '101%' : '-101%' }
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes marquee {
|
||||
from {
|
||||
transform: translateX(0%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-marquee {
|
||||
animation: marquee 15s linear infinite;
|
||||
}
|
||||
</style>
|
||||
686
src/content/Components/FlyingPosters/FlyingPosters.vue
Normal file
686
src/content/Components/FlyingPosters/FlyingPosters.vue
Normal file
@@ -0,0 +1,686 @@
|
||||
<template>
|
||||
<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";
|
||||
|
||||
type GL = OGLRenderingContext;
|
||||
type OGLProgram = Program;
|
||||
type OGLMesh = Mesh;
|
||||
type OGLTransform = Transform;
|
||||
type OGLPlane = Plane;
|
||||
|
||||
interface ScreenSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface ViewportSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface ScrollState {
|
||||
position?: number;
|
||||
ease: number;
|
||||
current: number;
|
||||
target: number;
|
||||
last: number;
|
||||
}
|
||||
|
||||
interface AutoBindOptions {
|
||||
include?: Array<string | RegExp>;
|
||||
exclude?: Array<string | RegExp>;
|
||||
}
|
||||
|
||||
interface MediaParams {
|
||||
gl: GL;
|
||||
geometry: OGLPlane;
|
||||
scene: OGLTransform;
|
||||
screen: ScreenSize;
|
||||
viewport: ViewportSize;
|
||||
image: string;
|
||||
length: number;
|
||||
index: number;
|
||||
planeWidth: number;
|
||||
planeHeight: number;
|
||||
distortion: number;
|
||||
}
|
||||
|
||||
interface CanvasParams {
|
||||
container: HTMLElement;
|
||||
canvas: HTMLCanvasElement;
|
||||
items: string[];
|
||||
planeWidth: number;
|
||||
planeHeight: number;
|
||||
distortion: number;
|
||||
scrollEase: number;
|
||||
cameraFov: number;
|
||||
cameraZ: number;
|
||||
}
|
||||
|
||||
const vertexShader = `
|
||||
precision highp float;
|
||||
|
||||
attribute vec3 position;
|
||||
attribute vec2 uv;
|
||||
attribute vec3 normal;
|
||||
|
||||
uniform mat4 modelViewMatrix;
|
||||
uniform mat4 projectionMatrix;
|
||||
uniform mat3 normalMatrix;
|
||||
|
||||
uniform float uPosition;
|
||||
uniform float uTime;
|
||||
uniform float uSpeed;
|
||||
uniform vec3 distortionAxis;
|
||||
uniform vec3 rotationAxis;
|
||||
uniform float uDistortion;
|
||||
|
||||
varying vec2 vUv;
|
||||
varying vec3 vNormal;
|
||||
|
||||
float PI = 3.141592653589793238;
|
||||
mat4 rotationMatrix(vec3 axis, float angle) {
|
||||
axis = normalize(axis);
|
||||
float s = sin(angle);
|
||||
float c = cos(angle);
|
||||
float oc = 1.0 - c;
|
||||
|
||||
return mat4(
|
||||
oc * axis.x * axis.x + c, oc * axis.x * axis.y - axis.z * s, oc * axis.z * axis.x + axis.y * s, 0.0,
|
||||
oc * axis.x * axis.y + axis.z * s,oc * axis.y * axis.y + c, oc * axis.y * axis.z - axis.x * s, 0.0,
|
||||
oc * axis.z * axis.x - axis.y * s,oc * axis.y * axis.z + axis.x * s, oc * axis.z * axis.z + c, 0.0,
|
||||
0.0, 0.0, 0.0, 1.0
|
||||
);
|
||||
}
|
||||
|
||||
vec3 rotate(vec3 v, vec3 axis, float angle) {
|
||||
mat4 m = rotationMatrix(axis, angle);
|
||||
return (m * vec4(v, 1.0)).xyz;
|
||||
}
|
||||
|
||||
float qinticInOut(float t) {
|
||||
return t < 0.5
|
||||
? 16.0 * pow(t, 5.0)
|
||||
: -0.5 * abs(pow(2.0 * t - 2.0, 5.0)) + 1.0;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vUv = uv;
|
||||
|
||||
float norm = 0.5;
|
||||
vec3 newpos = position;
|
||||
float offset = (dot(distortionAxis, position) + norm / 2.) / norm;
|
||||
float localprogress = clamp(
|
||||
(fract(uPosition * 5.0 * 0.01) - 0.01 * uDistortion * offset) / (1. - 0.01 * uDistortion),
|
||||
0.,
|
||||
2.
|
||||
);
|
||||
localprogress = qinticInOut(localprogress) * PI;
|
||||
newpos = rotate(newpos, rotationAxis, localprogress);
|
||||
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(newpos, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = `
|
||||
precision highp float;
|
||||
|
||||
uniform vec2 uImageSize;
|
||||
uniform vec2 uPlaneSize;
|
||||
uniform sampler2D tMap;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vec2 imageSize = uImageSize;
|
||||
vec2 planeSize = uPlaneSize;
|
||||
|
||||
float imageAspect = imageSize.x / imageSize.y;
|
||||
float planeAspect = planeSize.x / planeSize.y;
|
||||
vec2 scale = vec2(1.0, 1.0);
|
||||
|
||||
if (planeAspect > imageAspect) {
|
||||
scale.x = imageAspect / planeAspect;
|
||||
} else {
|
||||
scale.y = planeAspect / imageAspect;
|
||||
}
|
||||
|
||||
vec2 uv = vUv * scale + (1.0 - scale) * 0.5;
|
||||
|
||||
gl_FragColor = texture2D(tMap, uv);
|
||||
}
|
||||
`;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function AutoBind(self: any, { include, exclude }: AutoBindOptions = {}) {
|
||||
const getAllProperties = (object: object): Set<[object, string | symbol]> => {
|
||||
const properties = new Set<[object, string | symbol]>();
|
||||
let currentObject: object | null = object;
|
||||
do {
|
||||
for (const key of Reflect.ownKeys(currentObject)) {
|
||||
properties.add([currentObject, key]);
|
||||
}
|
||||
} 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());
|
||||
|
||||
if (include) return include.some(match);
|
||||
if (exclude) return !exclude.some(match);
|
||||
return true;
|
||||
};
|
||||
|
||||
for (const [object, key] of getAllProperties(self.constructor.prototype)) {
|
||||
if (key === "constructor" || !filter(key)) continue;
|
||||
const descriptor = Reflect.getOwnPropertyDescriptor(object, key);
|
||||
if (descriptor && typeof descriptor.value === "function" && typeof key === "string") {
|
||||
self[key] = self[key].bind(self);
|
||||
}
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
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 {
|
||||
const num1 = (num - min1) / (max1 - min1);
|
||||
const num2 = num1 * (max2 - min2) + min2;
|
||||
return round ? Math.round(num2) : num2;
|
||||
}
|
||||
|
||||
class Media {
|
||||
gl: GL;
|
||||
geometry: OGLPlane;
|
||||
scene: OGLTransform;
|
||||
screen: ScreenSize;
|
||||
viewport: ViewportSize;
|
||||
image: string;
|
||||
length: number;
|
||||
index: number;
|
||||
planeWidth: number;
|
||||
planeHeight: number;
|
||||
distortion: number;
|
||||
|
||||
program!: OGLProgram;
|
||||
plane!: OGLMesh;
|
||||
extra = 0;
|
||||
padding = 0;
|
||||
height = 0;
|
||||
heightTotal = 0;
|
||||
y = 0;
|
||||
|
||||
constructor({
|
||||
gl,
|
||||
geometry,
|
||||
scene,
|
||||
screen,
|
||||
viewport,
|
||||
image,
|
||||
length,
|
||||
index,
|
||||
planeWidth,
|
||||
planeHeight,
|
||||
distortion,
|
||||
}: MediaParams) {
|
||||
this.gl = gl;
|
||||
this.geometry = geometry;
|
||||
this.scene = scene;
|
||||
this.screen = screen;
|
||||
this.viewport = viewport;
|
||||
this.image = image;
|
||||
this.length = length;
|
||||
this.index = index;
|
||||
this.planeWidth = planeWidth;
|
||||
this.planeHeight = planeHeight;
|
||||
this.distortion = distortion;
|
||||
|
||||
this.createShader();
|
||||
this.createMesh();
|
||||
this.onResize();
|
||||
}
|
||||
|
||||
createShader() {
|
||||
const texture = new Texture(this.gl, { generateMipmaps: false });
|
||||
this.program = new Program(this.gl, {
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
fragment: fragmentShader,
|
||||
vertex: vertexShader,
|
||||
uniforms: {
|
||||
tMap: { value: texture },
|
||||
uPosition: { value: 0 },
|
||||
uPlaneSize: { value: [0, 0] },
|
||||
uImageSize: { value: [0, 0] },
|
||||
uSpeed: { value: 0 },
|
||||
rotationAxis: { value: [0, 1, 0] },
|
||||
distortionAxis: { value: [1, 1, 0] },
|
||||
uDistortion: { value: this.distortion },
|
||||
uViewportSize: { value: [this.viewport.width, this.viewport.height] },
|
||||
uTime: { value: 0 },
|
||||
},
|
||||
cullFace: false,
|
||||
});
|
||||
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.src = this.image;
|
||||
img.onload = () => {
|
||||
texture.image = img;
|
||||
this.program.uniforms.uImageSize.value = [
|
||||
img.naturalWidth,
|
||||
img.naturalHeight,
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
createMesh() {
|
||||
this.plane = new Mesh(this.gl, {
|
||||
geometry: this.geometry,
|
||||
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.position.x = 0;
|
||||
this.program.uniforms.uPlaneSize.value = [
|
||||
this.plane.scale.x,
|
||||
this.plane.scale.y,
|
||||
];
|
||||
}
|
||||
|
||||
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.setScale();
|
||||
|
||||
this.padding = 5;
|
||||
this.height = this.plane.scale.y + this.padding;
|
||||
this.heightTotal = this.height * this.length;
|
||||
this.y = -this.heightTotal / 2 + (this.index + 0.5) * this.height;
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
this.program.uniforms.uPosition.value = position;
|
||||
this.program.uniforms.uTime.value += 0.04;
|
||||
this.program.uniforms.uSpeed.value = scroll.current;
|
||||
|
||||
const planeHeight = this.plane.scale.y;
|
||||
const viewportHeight = this.viewport.height;
|
||||
const topEdge = this.plane.position.y + planeHeight / 2;
|
||||
const bottomEdge = this.plane.position.y - planeHeight / 2;
|
||||
|
||||
if (topEdge < -viewportHeight / 2) {
|
||||
this.extra -= this.heightTotal;
|
||||
} else if (bottomEdge > viewportHeight / 2) {
|
||||
this.extra += this.heightTotal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Canvas {
|
||||
container: HTMLElement;
|
||||
canvas: HTMLCanvasElement;
|
||||
items: string[];
|
||||
planeWidth: number;
|
||||
planeHeight: number;
|
||||
distortion: number;
|
||||
scroll: ScrollState;
|
||||
cameraFov: number;
|
||||
cameraZ: number;
|
||||
|
||||
renderer!: Renderer;
|
||||
gl!: GL;
|
||||
camera!: Camera;
|
||||
scene!: OGLTransform;
|
||||
planeGeometry!: OGLPlane;
|
||||
medias!: Media[];
|
||||
screen!: ScreenSize;
|
||||
viewport!: ViewportSize;
|
||||
isDown = false;
|
||||
start = 0;
|
||||
loaded = 0;
|
||||
|
||||
constructor({
|
||||
container,
|
||||
canvas,
|
||||
items,
|
||||
planeWidth,
|
||||
planeHeight,
|
||||
distortion,
|
||||
scrollEase,
|
||||
cameraFov,
|
||||
cameraZ,
|
||||
}: CanvasParams) {
|
||||
this.container = container;
|
||||
this.canvas = canvas;
|
||||
this.items = items;
|
||||
this.planeWidth = planeWidth;
|
||||
this.planeHeight = planeHeight;
|
||||
this.distortion = distortion;
|
||||
this.scroll = {
|
||||
ease: scrollEase,
|
||||
current: 0,
|
||||
target: 0,
|
||||
last: 0,
|
||||
};
|
||||
this.cameraFov = cameraFov;
|
||||
this.cameraZ = cameraZ;
|
||||
|
||||
AutoBind(this);
|
||||
this.createRenderer();
|
||||
this.createCamera();
|
||||
this.createScene();
|
||||
this.onResize();
|
||||
this.createGeometry();
|
||||
this.createMedias();
|
||||
this.initializeScrollPosition();
|
||||
this.update();
|
||||
this.addEventListeners();
|
||||
this.createPreloader();
|
||||
}
|
||||
|
||||
createRenderer() {
|
||||
this.renderer = new Renderer({
|
||||
canvas: this.canvas,
|
||||
alpha: true,
|
||||
antialias: true,
|
||||
dpr: Math.min(window.devicePixelRatio, 2),
|
||||
});
|
||||
this.gl = this.renderer.gl;
|
||||
}
|
||||
|
||||
createCamera() {
|
||||
this.camera = new Camera(this.gl);
|
||||
this.camera.fov = this.cameraFov;
|
||||
this.camera.position.z = this.cameraZ;
|
||||
}
|
||||
|
||||
createScene() {
|
||||
this.scene = new Transform();
|
||||
}
|
||||
|
||||
createGeometry() {
|
||||
this.planeGeometry = new Plane(this.gl, {
|
||||
heightSegments: 1,
|
||||
widthSegments: 100,
|
||||
});
|
||||
}
|
||||
|
||||
createMedias() {
|
||||
this.medias = this.items.map(
|
||||
(image, index) =>
|
||||
new Media({
|
||||
gl: this.gl,
|
||||
geometry: this.planeGeometry,
|
||||
scene: this.scene,
|
||||
screen: this.screen,
|
||||
viewport: this.viewport,
|
||||
image,
|
||||
length: this.items.length,
|
||||
index,
|
||||
planeWidth: this.planeWidth,
|
||||
planeHeight: this.planeHeight,
|
||||
distortion: this.distortion,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
initializeScrollPosition() {
|
||||
if (this.medias && this.medias.length > 0) {
|
||||
const centerIndex = Math.floor(this.medias.length / 2);
|
||||
const centerMedia = this.medias[centerIndex];
|
||||
this.scroll.current = centerMedia.y;
|
||||
this.scroll.target = centerMedia.y;
|
||||
}
|
||||
}
|
||||
|
||||
createPreloader() {
|
||||
this.loaded = 0;
|
||||
this.items.forEach((src) => {
|
||||
const image = new Image();
|
||||
image.crossOrigin = "anonymous";
|
||||
image.src = src;
|
||||
image.onload = () => {
|
||||
if (++this.loaded === this.items.length) {
|
||||
document.documentElement.classList.remove("loading");
|
||||
document.documentElement.classList.add("loaded");
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
onResize() {
|
||||
const rect = this.container.getBoundingClientRect();
|
||||
this.screen = { width: rect.width, height: rect.height };
|
||||
this.renderer.setSize(this.screen.width, this.screen.height);
|
||||
|
||||
this.camera.perspective({
|
||||
aspect: this.gl.canvas.width / this.gl.canvas.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 };
|
||||
|
||||
this.medias?.forEach((media) =>
|
||||
media.onResize({ screen: this.screen, viewport: this.viewport })
|
||||
);
|
||||
}
|
||||
|
||||
onTouchDown(e: MouseEvent | TouchEvent) {
|
||||
this.isDown = true;
|
||||
this.scroll.position = this.scroll.current;
|
||||
this.start = e instanceof TouchEvent ? e.touches[0].clientY : e.clientY;
|
||||
}
|
||||
|
||||
onTouchMove(e: MouseEvent | TouchEvent) {
|
||||
if (!this.isDown || !this.scroll.position) return;
|
||||
const y = e instanceof TouchEvent ? e.touches[0].clientY : e.clientY;
|
||||
const distance = (this.start - y) * 0.1;
|
||||
this.scroll.target = this.scroll.position + distance;
|
||||
}
|
||||
|
||||
onTouchUp() {
|
||||
this.isDown = false;
|
||||
}
|
||||
|
||||
onWheel(e: WheelEvent) {
|
||||
this.scroll.target += e.deltaY * 0.005;
|
||||
}
|
||||
|
||||
update() {
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
export interface FlyingPostersProps {
|
||||
items?: string[];
|
||||
planeWidth?: number;
|
||||
planeHeight?: number;
|
||||
distortion?: number;
|
||||
scrollEase?: number;
|
||||
cameraFov?: number;
|
||||
cameraZ?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export { Canvas, Media };
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<FlyingPostersProps>(), {
|
||||
items: () => [],
|
||||
planeWidth: 320,
|
||||
planeHeight: 320,
|
||||
distortion: 3,
|
||||
scrollEase: 0.01,
|
||||
cameraFov: 45,
|
||||
cameraZ: 20,
|
||||
className: '',
|
||||
})
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const canvasRef = ref<HTMLCanvasElement>()
|
||||
const instanceRef = ref<Canvas | null>(null)
|
||||
|
||||
const initCanvas = () => {
|
||||
if (!containerRef.value || !canvasRef.value) return
|
||||
|
||||
instanceRef.value = new Canvas({
|
||||
container: containerRef.value,
|
||||
canvas: canvasRef.value,
|
||||
items: props.items,
|
||||
planeWidth: props.planeWidth,
|
||||
planeHeight: props.planeHeight,
|
||||
distortion: props.distortion,
|
||||
scrollEase: props.scrollEase,
|
||||
cameraFov: props.cameraFov,
|
||||
cameraZ: props.cameraZ,
|
||||
})
|
||||
}
|
||||
|
||||
const destroyCanvas = () => {
|
||||
if (instanceRef.value) {
|
||||
instanceRef.value.destroy()
|
||||
instanceRef.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
e.preventDefault()
|
||||
if (instanceRef.value) {
|
||||
instanceRef.value.onWheel(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [
|
||||
props.items,
|
||||
props.planeWidth,
|
||||
props.planeHeight,
|
||||
props.distortion,
|
||||
props.scrollEase,
|
||||
props.cameraFov,
|
||||
props.cameraZ,
|
||||
],
|
||||
() => {
|
||||
destroyCanvas()
|
||||
initCanvas()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
initCanvas()
|
||||
|
||||
if (canvasRef.value) {
|
||||
const canvasEl = canvasRef.value
|
||||
canvasEl.addEventListener('wheel', handleWheel, { passive: false })
|
||||
canvasEl.addEventListener('touchmove', handleTouchMove, { passive: false })
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
destroyCanvas()
|
||||
|
||||
if (canvasRef.value) {
|
||||
const canvasEl = canvasRef.value
|
||||
canvasEl.removeEventListener('wheel', handleWheel)
|
||||
canvasEl.removeEventListener('touchmove', handleTouchMove)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
80
src/content/Components/GlassIcons/GlassIcons.vue
Normal file
80
src/content/Components/GlassIcons/GlassIcons.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<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"
|
||||
type="button"
|
||||
:aria-label="item.label"
|
||||
:class="[
|
||||
'relative bg-transparent outline-none w-[4.5em] h-[4.5em] [perspective:24em] [transform-style:preserve-3d] [-webkit-tap-highlight-color:transparent] group',
|
||||
item.customClass
|
||||
]"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0 left-0 w-full h-full rounded-[1.25em] block transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.83,0,0.17,1)] origin-[100%_100%] rotate-[15deg] group-hover:[transform:rotate(25deg)_translate3d(-0.5em,-0.5em,0.5em)]"
|
||||
:style="{
|
||||
...getBackgroundStyle(item.color),
|
||||
boxShadow: '0.5em -0.5em 0.75em hsla(223, 10%, 10%, 0.15)'
|
||||
}"
|
||||
></span>
|
||||
|
||||
<span
|
||||
class="absolute top-0 left-0 w-full h-full rounded-[1.25em] bg-[hsla(0,0%,100%,0.15)] transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.83,0,0.17,1)] origin-[80%_50%] flex backdrop-blur-[0.75em] [-webkit-backdrop-filter:blur(0.75em)] transform group-hover:[transform:translateZ(2em)]"
|
||||
:style="{
|
||||
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"
|
||||
>
|
||||
<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%)]">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface GlassIconsItem {
|
||||
icon: string
|
||||
color: string
|
||||
label: string
|
||||
customClass?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
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%))',
|
||||
purple: 'linear-gradient(hsl(283, 90%, 50%), hsl(268, 90%, 50%))',
|
||||
red: 'linear-gradient(hsl(3, 90%, 50%), hsl(348, 90%, 50%))',
|
||||
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: color }
|
||||
}
|
||||
</script>
|
||||
381
src/content/Components/GooeyNav/GooeyNav.vue
Normal file
381
src/content/Components/GooeyNav/GooeyNav.vue
Normal file
@@ -0,0 +1,381 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="relative" ref="containerRef">
|
||||
<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)',
|
||||
}"
|
||||
>
|
||||
<li
|
||||
v-for="(item, index) in items"
|
||||
:key="index"
|
||||
:class="[
|
||||
'rounded-full relative cursor-pointer transition-[background-color_color_box-shadow] duration-300 ease shadow-[0_0_0.5px_1.5px_transparent] text-white',
|
||||
{ active: activeIndex === index }
|
||||
]"
|
||||
>
|
||||
<a
|
||||
:href="item.href || undefined"
|
||||
@click="(e) => handleClick(e, index)"
|
||||
@keydown="(e) => handleKeyDown(e, index)"
|
||||
class="outline-none py-[0.6em] px-[1em] inline-block"
|
||||
>
|
||||
{{ item.label }}
|
||||
</a>
|
||||
</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'
|
||||
|
||||
interface GooeyNavItem {
|
||||
label: string
|
||||
href: string | null
|
||||
}
|
||||
|
||||
interface GooeyNavProps {
|
||||
items: GooeyNavItem[]
|
||||
animationTime?: number
|
||||
particleCount?: number
|
||||
particleDistances?: [number, number]
|
||||
particleR?: number
|
||||
timeVariance?: number
|
||||
colors?: number[]
|
||||
initialActiveIndex?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<GooeyNavProps>(), {
|
||||
animationTime: 600,
|
||||
particleCount: 15,
|
||||
particleDistances: () => [90, 10],
|
||||
particleR: 100,
|
||||
timeVariance: 300,
|
||||
colors: () => [1, 2, 3, 1, 2, 3, 1, 4],
|
||||
initialActiveIndex: 0,
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
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 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,
|
||||
}
|
||||
}
|
||||
|
||||
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`)
|
||||
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')
|
||||
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)
|
||||
requestAnimationFrame(() => {
|
||||
element.classList.add('active')
|
||||
})
|
||||
setTimeout(() => {
|
||||
try {
|
||||
element.removeChild(particle)
|
||||
} catch {}
|
||||
}, t)
|
||||
}, 30)
|
||||
}
|
||||
}
|
||||
|
||||
const updateEffectPosition = (element: HTMLElement) => {
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
if (filterRef.value) {
|
||||
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')
|
||||
}
|
||||
if (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
|
||||
if (liEl) {
|
||||
handleClick(
|
||||
{
|
||||
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 (activeLi) {
|
||||
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 (activeLi) {
|
||||
updateEffectPosition(activeLi)
|
||||
textRef.value?.classList.add('active')
|
||||
}
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
const currentActiveLi = navRef.value?.querySelectorAll('li')[
|
||||
activeIndex.value
|
||||
] as HTMLElement
|
||||
if (currentActiveLi) {
|
||||
updateEffectPosition(currentActiveLi)
|
||||
}
|
||||
})
|
||||
resizeObserver.observe(containerRef.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (resizeObserver) {
|
||||
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);
|
||||
}
|
||||
|
||||
.effect {
|
||||
position: absolute;
|
||||
opacity: 1;
|
||||
pointer-events: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.effect.text {
|
||||
color: white;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.effect.text.active {
|
||||
color: black;
|
||||
}
|
||||
|
||||
.effect.filter {
|
||||
filter: blur(7px) contrast(100) blur(0);
|
||||
mix-blend-mode: lighten;
|
||||
}
|
||||
|
||||
.effect.filter::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -75px;
|
||||
z-index: -2;
|
||||
background: black;
|
||||
}
|
||||
|
||||
.effect.filter::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: white;
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
z-index: -1;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.effect.active::after {
|
||||
animation: pill 0.3s ease both;
|
||||
}
|
||||
|
||||
@keyframes pill {
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.particle,
|
||||
.point {
|
||||
display: block;
|
||||
opacity: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 9999px;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.particle {
|
||||
--time: 5s;
|
||||
position: absolute;
|
||||
top: calc(50% - 8px);
|
||||
left: calc(50% - 8px);
|
||||
animation: particle calc(var(--time)) ease 1 -350ms;
|
||||
}
|
||||
|
||||
.point {
|
||||
background: var(--color);
|
||||
opacity: 1;
|
||||
animation: point calc(var(--time)) ease 1 -350ms;
|
||||
}
|
||||
|
||||
@keyframes particle {
|
||||
0% {
|
||||
transform: rotate(0deg) translate(calc(var(--start-x)), calc(var(--start-y)));
|
||||
opacity: 1;
|
||||
animation-timing-function: cubic-bezier(0.55, 0, 1, 0.45);
|
||||
}
|
||||
70% {
|
||||
transform: rotate(calc(var(--rotate) * 0.5)) translate(calc(var(--end-x) * 1.2), calc(var(--end-y) * 1.2));
|
||||
opacity: 1;
|
||||
animation-timing-function: ease;
|
||||
}
|
||||
85% {
|
||||
transform: rotate(calc(var(--rotate) * 0.66)) translate(calc(var(--end-x)), calc(var(--end-y)));
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: rotate(calc(var(--rotate) * 1.2)) translate(calc(var(--end-x) * 0.5), calc(var(--end-y) * 0.5));
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes point {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
animation-timing-function: cubic-bezier(0.55, 0, 1, 0.45);
|
||||
}
|
||||
25% {
|
||||
transform: scale(calc(var(--scale) * 0.25));
|
||||
}
|
||||
38% {
|
||||
opacity: 1;
|
||||
}
|
||||
65% {
|
||||
transform: scale(var(--scale));
|
||||
opacity: 1;
|
||||
animation-timing-function: ease;
|
||||
}
|
||||
85% {
|
||||
transform: scale(var(--scale));
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
li.active {
|
||||
color: black;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
li.active::after {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
li::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
opacity: 0;
|
||||
transform: scale(0);
|
||||
transition: all 0.3s ease;
|
||||
z-index: -1;
|
||||
}
|
||||
</style>
|
||||
265
src/content/Components/InfiniteScroll/InfiniteScroll.vue
Normal file
265
src/content/Components/InfiniteScroll/InfiniteScroll.vue
Normal file
@@ -0,0 +1,265 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<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"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { gsap } from 'gsap'
|
||||
import { Observer } from 'gsap/Observer'
|
||||
|
||||
gsap.registerPlugin(Observer)
|
||||
|
||||
interface InfiniteScrollItem {
|
||||
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
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
width: '30rem',
|
||||
maxHeight: '100%',
|
||||
negativeMargin: '-0.5em',
|
||||
items: () => [],
|
||||
itemMinHeight: 150,
|
||||
isTilted: false,
|
||||
tiltDirection: 'left',
|
||||
autoplay: false,
|
||||
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 getTiltTransform = (): string => {
|
||||
if (!props.isTilted) return 'none'
|
||||
return props.tiltDirection === 'left'
|
||||
? '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 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 wrapFn = gsap.utils.wrap(-totalHeight, totalHeight)
|
||||
|
||||
divItems.forEach((child, i) => {
|
||||
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'
|
||||
},
|
||||
onRelease: ({ target }) => {
|
||||
; (target as HTMLElement).style.cursor = 'grab'
|
||||
if (Math.abs(velocity) > 0.1) {
|
||||
const momentum = velocity * 0.8
|
||||
divItems.forEach((child) => {
|
||||
gsap.to(child, {
|
||||
duration: 1.5,
|
||||
ease: 'power2.out',
|
||||
y: `+=${momentum}`,
|
||||
modifiers: {
|
||||
y: gsap.utils.unitize(wrapFn)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
velocity = 0
|
||||
},
|
||||
onChange: ({ deltaY, isDragging, event }) => {
|
||||
const d = event.type === 'wheel' ? -deltaY : deltaY
|
||||
const distance = isDragging ? d * 5 : d * 1.5
|
||||
|
||||
velocity = distance * 0.5
|
||||
|
||||
divItems.forEach((child) => {
|
||||
gsap.to(child, {
|
||||
duration: isDragging ? 0.3 : 1.2,
|
||||
ease: isDragging ? 'power1.out' : 'power3.out',
|
||||
y: `+=${distance}`,
|
||||
modifiers: {
|
||||
y: gsap.utils.unitize(wrapFn)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (props.autoplay) {
|
||||
const directionFactor = props.autoplayDirection === 'down' ? 1 : -1
|
||||
const speedPerFrame = props.autoplaySpeed * directionFactor
|
||||
|
||||
const tick = () => {
|
||||
divItems.forEach((child) => {
|
||||
gsap.set(child, {
|
||||
y: `+=${speedPerFrame}`,
|
||||
modifiers: {
|
||||
y: gsap.utils.unitize(wrapFn)
|
||||
}
|
||||
})
|
||||
})
|
||||
rafId = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(tick)
|
||||
|
||||
if (props.pauseOnHover) {
|
||||
stopTicker = () => rafId && cancelAnimationFrame(rafId)
|
||||
startTicker = () => {
|
||||
rafId = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
container.addEventListener('mouseenter', stopTicker)
|
||||
container.addEventListener('mouseleave', startTicker)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
if (observer) {
|
||||
observer.kill()
|
||||
observer = null
|
||||
}
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
|
||||
velocity = 0
|
||||
|
||||
const container = containerRef.value
|
||||
if (container && props.pauseOnHover && stopTicker && startTicker) {
|
||||
container.removeEventListener('mouseenter', stopTicker)
|
||||
container.removeEventListener('mouseleave', startTicker)
|
||||
}
|
||||
|
||||
stopTicker = null
|
||||
startTicker = null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initializeScroll()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
watch(
|
||||
[
|
||||
() => props.items,
|
||||
() => props.autoplay,
|
||||
() => props.autoplaySpeed,
|
||||
() => props.autoplayDirection,
|
||||
() => props.pauseOnHover,
|
||||
() => props.isTilted,
|
||||
() => props.tiltDirection,
|
||||
() => props.negativeMargin
|
||||
],
|
||||
() => {
|
||||
cleanup()
|
||||
setTimeout(() => {
|
||||
initializeScroll()
|
||||
}, 0)
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.infinite-scroll-wrapper::before,
|
||||
.infinite-scroll-wrapper::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background: linear-gradient(var(--dir, to bottom), #060010, transparent);
|
||||
height: 25%;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.infinite-scroll-wrapper::before {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.infinite-scroll-wrapper::after {
|
||||
--dir: to top;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.infinite-scroll-container {
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
-moz-backface-visibility: hidden;
|
||||
-ms-backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.infinite-scroll-item {
|
||||
--accent-color: #ffffff;
|
||||
border-color: var(--accent-color);
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
-moz-backface-visibility: hidden;
|
||||
-ms-backface-visibility: hidden;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
</style>
|
||||
315
src/content/Components/PixelCard/PixelCard.vue
Normal file
315
src/content/Components/PixelCard/PixelCard.vue
Normal file
@@ -0,0 +1,315 @@
|
||||
<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">
|
||||
<canvas class="w-full h-full block" ref="canvasRef" />
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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
|
||||
|
||||
constructor(
|
||||
canvas: HTMLCanvasElement,
|
||||
context: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
color: string,
|
||||
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
|
||||
}
|
||||
|
||||
getRandomValue(min: number, max: number) {
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
appear() {
|
||||
this.isIdle = false
|
||||
if (this.counter <= this.delay) {
|
||||
this.counter += this.counterStep
|
||||
return
|
||||
}
|
||||
if (this.size >= this.maxSize) {
|
||||
this.isShimmer = true
|
||||
}
|
||||
if (this.isShimmer) {
|
||||
this.shimmer()
|
||||
} else {
|
||||
this.size += this.sizeStep
|
||||
}
|
||||
this.draw()
|
||||
}
|
||||
|
||||
disappear() {
|
||||
this.isShimmer = false
|
||||
this.counter = 0
|
||||
if (this.size <= 0) {
|
||||
this.isIdle = true
|
||||
return
|
||||
} else {
|
||||
this.size -= 0.1
|
||||
}
|
||||
this.draw()
|
||||
}
|
||||
|
||||
shimmer() {
|
||||
if (this.size >= this.maxSize) {
|
||||
this.isReverse = true
|
||||
} else if (this.size <= this.minSize) {
|
||||
this.isReverse = false
|
||||
}
|
||||
if (this.isReverse) {
|
||||
this.size -= this.speed
|
||||
} else {
|
||||
this.size += this.speed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getEffectiveSpeed(value: number, reducedMotion: boolean) {
|
||||
const min = 0
|
||||
const max = 100
|
||||
const throttle = 0.001
|
||||
|
||||
if (value <= min || reducedMotion) {
|
||||
return min
|
||||
} else if (value >= max) {
|
||||
return max * throttle
|
||||
} else {
|
||||
return value * throttle
|
||||
}
|
||||
}
|
||||
|
||||
const VARIANTS = {
|
||||
default: {
|
||||
activeColor: null,
|
||||
gap: 5,
|
||||
speed: 35,
|
||||
colors: '#f8fafc,#f1f5f9,#cbd5e1',
|
||||
noFocus: false,
|
||||
},
|
||||
blue: {
|
||||
activeColor: '#e0f2fe',
|
||||
gap: 10,
|
||||
speed: 25,
|
||||
colors: '#e0f2fe,#7dd3fc,#0ea5e9',
|
||||
noFocus: false,
|
||||
},
|
||||
yellow: {
|
||||
activeColor: '#fef08a',
|
||||
gap: 3,
|
||||
speed: 20,
|
||||
colors: '#fef08a,#fde047,#eab308',
|
||||
noFocus: false,
|
||||
},
|
||||
pink: {
|
||||
activeColor: '#fecdd3',
|
||||
gap: 6,
|
||||
speed: 80,
|
||||
colors: '#fecdd3,#fda4af,#e11d48',
|
||||
noFocus: true,
|
||||
},
|
||||
}
|
||||
|
||||
interface PixelCardProps {
|
||||
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
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<PixelCardProps>(), {
|
||||
variant: 'default',
|
||||
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 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
|
||||
|
||||
const initPixels = () => {
|
||||
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')
|
||||
|
||||
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 = []
|
||||
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 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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
if (timePassed < timeInterval) return
|
||||
timePreviousRef.value = timeNow - (timePassed % timeInterval)
|
||||
|
||||
const ctx = canvasRef.value?.getContext('2d')
|
||||
if (!ctx || !canvasRef.value) return
|
||||
|
||||
ctx.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height)
|
||||
|
||||
let allIdle = true
|
||||
for (let i = 0; i < pixelsRef.value.length; i++) {
|
||||
const pixel = pixelsRef.value[i]
|
||||
// @ts-expect-error - Dynamic method call on Pixel class
|
||||
pixel[fnName]()
|
||||
if (!pixel.isIdle) {
|
||||
allIdle = false
|
||||
}
|
||||
}
|
||||
if (allIdle && animationRef.value) {
|
||||
cancelAnimationFrame(animationRef.value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAnimation = (name: keyof Pixel) => {
|
||||
if (animationRef.value !== null) {
|
||||
cancelAnimationFrame(animationRef.value)
|
||||
}
|
||||
animationRef.value = requestAnimationFrame(() => doAnimate(name))
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
const onBlur = (e: FocusEvent) => {
|
||||
if ((e.currentTarget as HTMLElement).contains(e.relatedTarget as Node)) return
|
||||
handleAnimation('disappear')
|
||||
}
|
||||
|
||||
watch([finalGap, finalSpeed, finalColors, finalNoFocus], () => {
|
||||
initPixels()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initPixels()
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
initPixels()
|
||||
})
|
||||
if (containerRef.value) {
|
||||
resizeObserver.observe(containerRef.value)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
if (animationRef.value !== null) {
|
||||
cancelAnimationFrame(animationRef.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
786
src/content/Components/ProfileCard/ProfileCard.vue
Normal file
786
src/content/Components/ProfileCard/ProfileCard.vue
Normal file
@@ -0,0 +1,786 @@
|
||||
<template>
|
||||
<div ref="wrapRef" :class="`pc-card-wrapper ${className}`.trim()" :style="cardStyle">
|
||||
<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" />
|
||||
<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" />
|
||||
</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'}`">
|
||||
{{ contactText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pc-content">
|
||||
<div class="pc-details">
|
||||
<h3>{{ name }}</h3>
|
||||
<p>{{ title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
avatarUrl: '<Placeholder for avatar URL>',
|
||||
iconUrl: '<Placeholder for icon URL>',
|
||||
grainUrl: '<Placeholder for grain URL>',
|
||||
behindGradient: undefined,
|
||||
innerGradient: undefined,
|
||||
showBehindGradient: true,
|
||||
className: '',
|
||||
enableTilt: true,
|
||||
miniAvatarUrl: undefined,
|
||||
name: 'Javi A. Torres',
|
||||
title: 'Software Engineer',
|
||||
handle: 'javicodes',
|
||||
status: 'Online',
|
||||
contactText: 'Contact',
|
||||
showUserInfo: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
contactClick: []
|
||||
}>()
|
||||
|
||||
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_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
|
||||
|
||||
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 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
|
||||
|
||||
let rafId: number | null = null
|
||||
|
||||
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 centerX = percentX - 50
|
||||
const centerY = percentY - 50
|
||||
|
||||
const properties = {
|
||||
'--pointer-x': `${percentX}%`,
|
||||
'--pointer-y': `${percentY}%`,
|
||||
'--background-x': `${adjust(percentX, 0, 100, 35, 65)}%`,
|
||||
'--background-y': `${adjust(percentY, 0, 100, 35, 65)}%`,
|
||||
'--pointer-from-center': `${clamp(Math.hypot(percentY - 50, percentX - 50) / 50, 0, 1)}`,
|
||||
'--pointer-from-top': `${percentY / 100}`,
|
||||
'--pointer-from-left': `${percentX / 100}`,
|
||||
'--rotate-x': `${round(-(centerX / 5))}deg`,
|
||||
'--rotate-y': `${round(centerY / 4)}deg`,
|
||||
}
|
||||
|
||||
Object.entries(properties).forEach(([property, value]) => {
|
||||
wrap.style.setProperty(property, value)
|
||||
})
|
||||
}
|
||||
|
||||
const createSmoothAnimation = (
|
||||
duration: number,
|
||||
startX: number,
|
||||
startY: number,
|
||||
card: HTMLElement,
|
||||
wrap: HTMLElement
|
||||
) => {
|
||||
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 currentX = adjust(easedProgress, 0, 1, startX, targetX)
|
||||
const currentY = adjust(easedProgress, 0, 1, startY, targetY)
|
||||
|
||||
updateCardTransform(currentX, currentY, card, wrap)
|
||||
|
||||
if (progress < 1) {
|
||||
rafId = requestAnimationFrame(animationLoop)
|
||||
}
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(animationLoop)
|
||||
}
|
||||
|
||||
const cancelAnimation = () => {
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const card = cardRef.value
|
||||
const wrap = wrapRef.value
|
||||
|
||||
if (!card || !wrap || !props.enableTilt) return
|
||||
|
||||
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
|
||||
|
||||
if (!card || !wrap || !props.enableTilt) return
|
||||
|
||||
cancelAnimation()
|
||||
wrap.classList.add('active')
|
||||
card.classList.add('active')
|
||||
}
|
||||
|
||||
const handlePointerLeave = (event: PointerEvent) => {
|
||||
const card = cardRef.value
|
||||
const wrap = wrapRef.value
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
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,
|
||||
}))
|
||||
|
||||
const handleContactClick = () => {
|
||||
emit('contactClick')
|
||||
}
|
||||
|
||||
const handleAvatarError = (event: Event) => {
|
||||
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
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.enableTilt) return
|
||||
|
||||
const card = cardRef.value
|
||||
const wrap = wrapRef.value
|
||||
|
||||
if (!card || !wrap) return
|
||||
|
||||
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
|
||||
|
||||
updateCardTransform(initialX, initialY, card, wrap)
|
||||
createSmoothAnimation(
|
||||
ANIMATION_CONFIG.INITIAL_DURATION,
|
||||
initialX,
|
||||
initialY,
|
||||
card,
|
||||
wrap
|
||||
)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
const card = cardRef.value
|
||||
|
||||
if (card) {
|
||||
card.removeEventListener('pointerenter', handlePointerEnter)
|
||||
card.removeEventListener('pointermove', handlePointerMove)
|
||||
card.removeEventListener('pointerleave', handlePointerLeave)
|
||||
}
|
||||
|
||||
cancelAnimation()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pc-card-wrapper {
|
||||
--pointer-x: 50%;
|
||||
--pointer-y: 50%;
|
||||
--pointer-from-center: 0;
|
||||
--pointer-from-top: 0.5;
|
||||
--pointer-from-left: 0.5;
|
||||
--card-opacity: 0;
|
||||
--rotate-x: 0deg;
|
||||
--rotate-y: 0deg;
|
||||
--background-x: 50%;
|
||||
--background-y: 50%;
|
||||
--grain: none;
|
||||
--icon: none;
|
||||
--behind-gradient: none;
|
||||
--inner-gradient: none;
|
||||
--sunpillar-1: hsl(2, 100%, 73%);
|
||||
--sunpillar-2: hsl(53, 100%, 69%);
|
||||
--sunpillar-3: hsl(93, 100%, 69%);
|
||||
--sunpillar-4: hsl(176, 100%, 76%);
|
||||
--sunpillar-5: hsl(228, 100%, 74%);
|
||||
--sunpillar-6: hsl(283, 100%, 73%);
|
||||
--sunpillar-clr-1: var(--sunpillar-1);
|
||||
--sunpillar-clr-2: var(--sunpillar-2);
|
||||
--sunpillar-clr-3: var(--sunpillar-3);
|
||||
--sunpillar-clr-4: var(--sunpillar-4);
|
||||
--sunpillar-clr-5: var(--sunpillar-5);
|
||||
--sunpillar-clr-6: var(--sunpillar-6);
|
||||
--card-radius: 30px;
|
||||
}
|
||||
|
||||
.pc-card-wrapper {
|
||||
perspective: 500px;
|
||||
transform: translate3d(0, 0, 0.1px);
|
||||
position: relative;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.pc-card-wrapper::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -10px;
|
||||
background: inherit;
|
||||
background-position: inherit;
|
||||
border-radius: inherit;
|
||||
transition: all 0.5s ease;
|
||||
filter: contrast(2) saturate(2) blur(36px);
|
||||
transform: scale(0.8) translate3d(0, 0, 0.1px);
|
||||
background-size: 100% 100%;
|
||||
background-image: var(--behind-gradient);
|
||||
}
|
||||
|
||||
.pc-card-wrapper:hover,
|
||||
.pc-card-wrapper.active {
|
||||
--card-opacity: 1;
|
||||
}
|
||||
|
||||
.pc-card-wrapper:hover::before,
|
||||
.pc-card-wrapper.active::before {
|
||||
filter: contrast(1) saturate(2) blur(40px) opacity(1);
|
||||
transform: scale(0.9) translate3d(0, 0, 0.1px);
|
||||
}
|
||||
|
||||
.pc-card {
|
||||
height: 80svh;
|
||||
max-height: 540px;
|
||||
display: grid;
|
||||
aspect-ratio: 0.718;
|
||||
border-radius: var(--card-radius);
|
||||
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;
|
||||
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%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pc-card:hover,
|
||||
.pc-card.active {
|
||||
transition: none;
|
||||
transform: translate3d(0, 0, 0.1px) rotateX(var(--rotate-y)) rotateY(var(--rotate-x));
|
||||
}
|
||||
|
||||
.pc-card * {
|
||||
display: grid;
|
||||
grid-area: 1/-1;
|
||||
border-radius: var(--card-radius);
|
||||
transform: translate3d(0, 0, 0.1px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pc-inside {
|
||||
inset: 1px;
|
||||
position: absolute;
|
||||
background-image: var(--inner-gradient);
|
||||
background-color: rgba(0, 0, 0, 0.9);
|
||||
transform: translate3d(0, 0, 0.01px);
|
||||
}
|
||||
|
||||
.pc-shine {
|
||||
mask-image: var(--icon);
|
||||
mask-mode: luminance;
|
||||
mask-repeat: repeat;
|
||||
mask-size: 150%;
|
||||
mask-position: top calc(200% - (var(--background-y) * 5)) left calc(100% - var(--background-x));
|
||||
-webkit-mask-image: var(--icon);
|
||||
-webkit-mask-mode: luminance;
|
||||
-webkit-mask-repeat: repeat;
|
||||
-webkit-mask-size: 150%;
|
||||
-webkit-mask-position: top calc(200% - (var(--background-y) * 5)) left calc(100% - var(--background-x));
|
||||
transition: filter 0.6s ease;
|
||||
filter: brightness(0.66) contrast(1.33) saturate(0.33) opacity(0.5);
|
||||
animation: holo-bg 18s linear infinite;
|
||||
mix-blend-mode: color-dodge;
|
||||
}
|
||||
|
||||
.pc-shine,
|
||||
.pc-shine::after {
|
||||
--space: 5%;
|
||||
--angle: -45deg;
|
||||
transform: translate3d(0, 0, 1px);
|
||||
overflow: hidden;
|
||||
z-index: 3;
|
||||
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-blend-mode: color, hard-light;
|
||||
background-size: 500% 500%, 300% 300%, 200% 200%;
|
||||
background-repeat: repeat;
|
||||
}
|
||||
|
||||
.pc-shine::before,
|
||||
.pc-shine::after {
|
||||
content: '';
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
grid-area: 1/1;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.pc-card:hover .pc-shine,
|
||||
.pc-card.active .pc-shine {
|
||||
filter: brightness(0.85) contrast(1.5) saturate(0.5);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.pc-card:hover .pc-shine::before,
|
||||
.pc-card.active .pc-shine::before,
|
||||
.pc-card:hover .pc-shine::after,
|
||||
.pc-card.active .pc-shine::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.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-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)));
|
||||
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%;
|
||||
mix-blend-mode: difference;
|
||||
filter: brightness(0.8) contrast(1.5);
|
||||
}
|
||||
|
||||
.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%);
|
||||
mix-blend-mode: overlay;
|
||||
filter: brightness(0.8) contrast(1.2);
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.pc-avatar-content {
|
||||
mix-blend-mode: screen;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pc-avatar-content .avatar {
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) scale(1);
|
||||
bottom: 2px;
|
||||
opacity: calc(1.75 - var(--pointer-from-center));
|
||||
}
|
||||
|
||||
.pc-avatar-content::before {
|
||||
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%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pc-user-info {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(30px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 15px;
|
||||
padding: 12px 14px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.pc-user-details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pc-mini-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pc-mini-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.pc-user-text {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pc-handle {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.pc-status {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.pc-contact-btn {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.pc-contact-btn:hover {
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
transform: translateY(-1px);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.pc-content {
|
||||
max-height: 100%;
|
||||
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;
|
||||
z-index: 5;
|
||||
mix-blend-mode: luminosity;
|
||||
}
|
||||
|
||||
.pc-details {
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
top: 3em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pc-details h3 {
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
font-size: min(5svh, 3em);
|
||||
margin: 0;
|
||||
background-image: linear-gradient(to bottom, #fff, #6f6fbe);
|
||||
background-size: 1em 1.5em;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
}
|
||||
|
||||
.pc-details p {
|
||||
font-weight: 600;
|
||||
position: relative;
|
||||
top: -12px;
|
||||
white-space: nowrap;
|
||||
font-size: 16px;
|
||||
margin: 0 auto;
|
||||
width: min-content;
|
||||
background-image: linear-gradient(to bottom, #fff, #4a4ac0);
|
||||
background-size: 1em 1.5em;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
}
|
||||
|
||||
@keyframes glow-bg {
|
||||
0% {
|
||||
--bgrotate: 0deg;
|
||||
}
|
||||
|
||||
100% {
|
||||
--bgrotate: 360deg;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes holo-bg {
|
||||
0% {
|
||||
background-position: 0 var(--background-y), 0 0, center;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 0 var(--background-y), 90% 90%, center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pc-card {
|
||||
height: 70svh;
|
||||
max-height: 450px;
|
||||
}
|
||||
|
||||
.pc-details {
|
||||
top: 2em;
|
||||
}
|
||||
|
||||
.pc-details h3 {
|
||||
font-size: min(4svh, 2.5em);
|
||||
}
|
||||
|
||||
.pc-details p {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pc-user-info {
|
||||
bottom: 15px;
|
||||
left: 15px;
|
||||
right: 15px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.pc-mini-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.pc-user-details {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pc-handle {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pc-status {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.pc-contact-btn {
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.pc-card {
|
||||
height: 60svh;
|
||||
max-height: 380px;
|
||||
}
|
||||
|
||||
.pc-details {
|
||||
top: 1.5em;
|
||||
}
|
||||
|
||||
.pc-details h3 {
|
||||
font-size: min(3.5svh, 2em);
|
||||
}
|
||||
|
||||
.pc-details p {
|
||||
font-size: 12px;
|
||||
top: -8px;
|
||||
}
|
||||
|
||||
.pc-user-info {
|
||||
bottom: 12px;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 50px;
|
||||
}
|
||||
|
||||
.pc-mini-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.pc-user-details {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pc-handle {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pc-status {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.pc-contact-btn {
|
||||
padding: 5px 10px;
|
||||
font-size: 10px;
|
||||
border-radius: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 320px) {
|
||||
.pc-card {
|
||||
height: 55svh;
|
||||
max-height: 320px;
|
||||
}
|
||||
|
||||
.pc-details h3 {
|
||||
font-size: min(3svh, 1.5em);
|
||||
}
|
||||
|
||||
.pc-details p {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.pc-user-info {
|
||||
padding: 6px 8px;
|
||||
border-radius: 50px;
|
||||
}
|
||||
|
||||
.pc-mini-avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.pc-user-details {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pc-handle {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.pc-status {
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.pc-contact-btn {
|
||||
padding: 4px 8px;
|
||||
font-size: 9px;
|
||||
border-radius: 50px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
59
src/content/Components/SpotlightCard/SpotlightCard.vue
Normal file
59
src/content/Components/SpotlightCard/SpotlightCard.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<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%)`,
|
||||
}" />
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface Position {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
interface SpotlightCardProps {
|
||||
className?: string
|
||||
spotlightColor?: string
|
||||
}
|
||||
|
||||
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 handleMouseMove = (e: MouseEvent) => {
|
||||
if (!divRef.value || isFocused.value) return
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
isFocused.value = false
|
||||
opacity.value = 0
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
opacity.value = 0.6
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
opacity.value = 0
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user