Merge branch 'main' into add-ascii-texts

This commit is contained in:
Max
2025-07-12 18:41:53 +02:00
committed by GitHub
239 changed files with 18121 additions and 8750 deletions

View File

@@ -1,22 +1,22 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { gsap } from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger)
gsap.registerPlugin(ScrollTrigger);
interface AnimatedContentProps {
distance?: number
direction?: 'vertical' | 'horizontal'
reverse?: boolean
duration?: number
ease?: string | ((progress: number) => number)
initialOpacity?: number
animateOpacity?: boolean
scale?: number
threshold?: number
delay?: number
className?: string
distance?: number;
direction?: 'vertical' | 'horizontal';
reverse?: boolean;
duration?: number;
ease?: string | ((progress: number) => number);
initialOpacity?: number;
animateOpacity?: boolean;
scale?: number;
threshold?: number;
delay?: number;
className?: string;
}
const props = withDefaults(defineProps<AnimatedContentProps>(), {
@@ -31,27 +31,27 @@ const props = withDefaults(defineProps<AnimatedContentProps>(), {
threshold: 0.1,
delay: 0,
className: ''
})
});
const emit = defineEmits<{
complete: []
}>()
complete: [];
}>();
const containerRef = ref<HTMLDivElement>()
const containerRef = ref<HTMLDivElement>();
onMounted(() => {
const el = containerRef.value
if (!el) return
const el = containerRef.value;
if (!el) return;
const axis = props.direction === 'horizontal' ? 'x' : 'y'
const offset = props.reverse ? -props.distance : props.distance
const startPct = (1 - props.threshold) * 100
const axis = props.direction === 'horizontal' ? 'x' : 'y';
const offset = props.reverse ? -props.distance : props.distance;
const startPct = (1 - props.threshold) * 100;
gsap.set(el, {
[axis]: offset,
scale: props.scale,
opacity: props.animateOpacity ? props.initialOpacity : 1,
})
opacity: props.animateOpacity ? props.initialOpacity : 1
});
gsap.to(el, {
[axis]: 0,
@@ -65,10 +65,10 @@ onMounted(() => {
trigger: el,
start: `top ${startPct}%`,
toggleActions: 'play none none none',
once: true,
},
})
})
once: true
}
});
});
watch(
() => [
@@ -81,24 +81,24 @@ watch(
props.animateOpacity,
props.scale,
props.threshold,
props.delay,
props.delay
],
() => {
const el = containerRef.value
if (!el) return
const el = containerRef.value;
if (!el) return;
ScrollTrigger.getAll().forEach((t) => t.kill())
gsap.killTweensOf(el)
ScrollTrigger.getAll().forEach(t => t.kill());
gsap.killTweensOf(el);
const axis = props.direction === 'horizontal' ? 'x' : 'y'
const offset = props.reverse ? -props.distance : props.distance
const startPct = (1 - props.threshold) * 100
const axis = props.direction === 'horizontal' ? 'x' : 'y';
const offset = props.reverse ? -props.distance : props.distance;
const startPct = (1 - props.threshold) * 100;
gsap.set(el, {
[axis]: offset,
scale: props.scale,
opacity: props.animateOpacity ? props.initialOpacity : 1,
})
opacity: props.animateOpacity ? props.initialOpacity : 1
});
gsap.to(el, {
[axis]: 0,
@@ -112,27 +112,24 @@ watch(
trigger: el,
start: `top ${startPct}%`,
toggleActions: 'play none none none',
once: true,
},
})
once: true
}
});
},
{ deep: true }
)
);
onUnmounted(() => {
const el = containerRef.value
const el = containerRef.value;
if (el) {
ScrollTrigger.getAll().forEach((t) => t.kill())
gsap.killTweensOf(el)
ScrollTrigger.getAll().forEach(t => t.kill());
gsap.killTweensOf(el);
}
})
});
</script>
<template>
<div
ref="containerRef"
:class="`animated-content ${props.className}`"
>
<div ref="containerRef" :class="`animated-content ${props.className}`">
<slot />
</div>
</template>

View File

@@ -1,35 +1,29 @@
<template>
<div
ref="containerRef"
class="relative w-full h-full"
@click="handleClick"
>
<canvas
ref="canvasRef"
class="absolute inset-0 pointer-events-none"
/>
<div ref="containerRef" class="relative w-full h-full" @click="handleClick">
<canvas ref="canvasRef" class="absolute inset-0 pointer-events-none" />
<slot />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import { ref, onMounted, onUnmounted, computed, watch } from 'vue';
interface Spark {
x: number
y: number
angle: number
startTime: number
x: number;
y: number;
angle: number;
startTime: number;
}
interface Props {
sparkColor?: string
sparkSize?: number
sparkRadius?: number
sparkCount?: number
duration?: number
easing?: "linear" | "ease-in" | "ease-out" | "ease-in-out"
extraScale?: number
sparkColor?: string;
sparkSize?: number;
sparkRadius?: number;
sparkCount?: number;
duration?: number;
easing?: 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out';
extraScale?: number;
}
const props = withDefaults(defineProps<Props>(), {
@@ -40,149 +34,152 @@ const props = withDefaults(defineProps<Props>(), {
duration: 400,
easing: 'ease-out',
extraScale: 1.0
})
});
const containerRef = ref<HTMLDivElement | null>(null)
const canvasRef = ref<HTMLCanvasElement | null>(null)
const sparks = ref<Spark[]>([])
const startTimeRef = ref<number | null>(null)
const animationId = ref<number | null>(null)
const containerRef = ref<HTMLDivElement | null>(null);
const canvasRef = ref<HTMLCanvasElement | null>(null);
const sparks = ref<Spark[]>([]);
const startTimeRef = ref<number | null>(null);
const animationId = ref<number | null>(null);
const easeFunc = computed(() => {
return (t: number) => {
switch (props.easing) {
case "linear":
return t
case "ease-in":
return t * t
case "ease-in-out":
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t
case 'linear':
return t;
case 'ease-in':
return t * t;
case 'ease-in-out':
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
default:
return t * (2 - t)
return t * (2 - t);
}
}
})
};
});
const handleClick = (e: MouseEvent) => {
const canvas = canvasRef.value
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
const canvas = canvasRef.value;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const now = performance.now()
const now = performance.now();
const newSparks: Spark[] = Array.from({ length: props.sparkCount }, (_, i) => ({
x,
y,
angle: (2 * Math.PI * i) / props.sparkCount,
startTime: now,
}))
startTime: now
}));
sparks.value.push(...newSparks)
}
sparks.value.push(...newSparks);
};
const draw = (timestamp: number) => {
if (!startTimeRef.value) {
startTimeRef.value = timestamp
startTimeRef.value = timestamp;
}
const canvas = canvasRef.value
const ctx = canvas?.getContext('2d')
if (!ctx || !canvas) return
ctx.clearRect(0, 0, canvas.width, canvas.height)
const canvas = canvasRef.value;
const ctx = canvas?.getContext('2d');
if (!ctx || !canvas) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
sparks.value = sparks.value.filter((spark: Spark) => {
const elapsed = timestamp - spark.startTime
const elapsed = timestamp - spark.startTime;
if (elapsed >= props.duration) {
return false
return false;
}
const progress = elapsed / props.duration
const eased = easeFunc.value(progress)
const progress = elapsed / props.duration;
const eased = easeFunc.value(progress);
const distance = eased * props.sparkRadius * props.extraScale
const lineLength = props.sparkSize * (1 - eased)
const distance = eased * props.sparkRadius * props.extraScale;
const lineLength = props.sparkSize * (1 - eased);
const x1 = spark.x + distance * Math.cos(spark.angle)
const y1 = spark.y + distance * Math.sin(spark.angle)
const x2 = spark.x + (distance + lineLength) * Math.cos(spark.angle)
const y2 = spark.y + (distance + lineLength) * Math.sin(spark.angle)
const x1 = spark.x + distance * Math.cos(spark.angle);
const y1 = spark.y + distance * Math.sin(spark.angle);
const x2 = spark.x + (distance + lineLength) * Math.cos(spark.angle);
const y2 = spark.y + (distance + lineLength) * Math.sin(spark.angle);
ctx.strokeStyle = props.sparkColor
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(x1, y1)
ctx.lineTo(x2, y2)
ctx.stroke()
ctx.strokeStyle = props.sparkColor;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
return true
})
return true;
});
animationId.value = requestAnimationFrame(draw)
}
animationId.value = requestAnimationFrame(draw);
};
const resizeCanvas = () => {
const canvas = canvasRef.value
if (!canvas) return
const canvas = canvasRef.value;
if (!canvas) return;
const parent = canvas.parentElement
if (!parent) return
const parent = canvas.parentElement;
if (!parent) return;
const { width, height } = parent.getBoundingClientRect()
const { width, height } = parent.getBoundingClientRect();
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width
canvas.height = height
canvas.width = width;
canvas.height = height;
}
}
};
let resizeTimeout: number
let resizeTimeout: number;
const handleResize = () => {
clearTimeout(resizeTimeout)
resizeTimeout = setTimeout(resizeCanvas, 100)
}
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(resizeCanvas, 100);
};
let resizeObserver: ResizeObserver | null = null
let resizeObserver: ResizeObserver | null = null;
onMounted(() => {
const canvas = canvasRef.value
if (!canvas) return
const canvas = canvasRef.value;
if (!canvas) return;
const parent = canvas.parentElement
if (!parent) return
const parent = canvas.parentElement;
if (!parent) return;
resizeObserver = new ResizeObserver(handleResize)
resizeObserver.observe(parent)
resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(parent);
resizeCanvas()
resizeCanvas();
animationId.value = requestAnimationFrame(draw)
})
animationId.value = requestAnimationFrame(draw);
});
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver.disconnect();
}
clearTimeout(resizeTimeout)
if (animationId.value) {
cancelAnimationFrame(animationId.value)
}
})
clearTimeout(resizeTimeout);
watch([
() => props.sparkColor,
() => props.sparkSize,
() => props.sparkRadius,
() => props.sparkCount,
() => props.duration,
easeFunc,
() => props.extraScale
], () => {
if (animationId.value) {
cancelAnimationFrame(animationId.value)
cancelAnimationFrame(animationId.value);
}
animationId.value = requestAnimationFrame(draw)
})
});
watch(
[
() => props.sparkColor,
() => props.sparkSize,
() => props.sparkRadius,
() => props.sparkCount,
() => props.duration,
easeFunc,
() => props.extraScale
],
() => {
if (animationId.value) {
cancelAnimationFrame(animationId.value);
}
animationId.value = requestAnimationFrame(draw);
}
);
</script>

View File

@@ -3,161 +3,164 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
import { ref, onMounted, onUnmounted, watch, computed } from 'vue';
interface Props {
to: number
from?: number
direction?: "up" | "down"
delay?: number
duration?: number
className?: string
startWhen?: boolean
separator?: string
onStart?: () => void
onEnd?: () => void
to: number;
from?: number;
direction?: 'up' | 'down';
delay?: number;
duration?: number;
className?: string;
startWhen?: boolean;
separator?: string;
onStart?: () => void;
onEnd?: () => void;
}
const props = withDefaults(defineProps<Props>(), {
from: 0,
direction: "up",
direction: 'up',
delay: 0,
duration: 2,
className: "",
className: '',
startWhen: true,
separator: ""
})
separator: ''
});
const elementRef = ref<HTMLSpanElement | null>(null)
const currentValue = ref(props.direction === "down" ? props.to : props.from)
const isInView = ref(false)
const animationId = ref<number | null>(null)
const hasStarted = ref(false)
const elementRef = ref<HTMLSpanElement | null>(null);
const currentValue = ref(props.direction === 'down' ? props.to : props.from);
const isInView = ref(false);
const animationId = ref<number | null>(null);
const hasStarted = ref(false);
let intersectionObserver: IntersectionObserver | null = null
let intersectionObserver: IntersectionObserver | null = null;
const damping = computed(() => 20 + 40 * (1 / props.duration))
const stiffness = computed(() => 100 * (1 / props.duration))
const damping = computed(() => 20 + 40 * (1 / props.duration));
const stiffness = computed(() => 100 * (1 / props.duration));
let velocity = 0
let startTime = 0
let velocity = 0;
let startTime = 0;
const formatNumber = (value: number) => {
const options = {
useGrouping: !!props.separator,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}
maximumFractionDigits: 0
};
const formattedNumber = Intl.NumberFormat("en-US", options).format(
Number(value.toFixed(0))
)
const formattedNumber = Intl.NumberFormat('en-US', options).format(Number(value.toFixed(0)));
return props.separator
? formattedNumber.replace(/,/g, props.separator)
: formattedNumber
}
return props.separator ? formattedNumber.replace(/,/g, props.separator) : formattedNumber;
};
const updateDisplay = () => {
if (elementRef.value) {
elementRef.value.textContent = formatNumber(currentValue.value)
elementRef.value.textContent = formatNumber(currentValue.value);
}
}
};
const springAnimation = (timestamp: number) => {
if (!startTime) startTime = timestamp
if (!startTime) startTime = timestamp;
const target = props.direction === "down" ? props.from : props.to
const current = currentValue.value
const target = props.direction === 'down' ? props.from : props.to;
const current = currentValue.value;
const displacement = target - current
const springForce = displacement * stiffness.value
const dampingForce = velocity * damping.value
const acceleration = springForce - dampingForce
const displacement = target - current;
const springForce = displacement * stiffness.value;
const dampingForce = velocity * damping.value;
const acceleration = springForce - dampingForce;
velocity += acceleration * 0.016 // Assuming 60fps
currentValue.value += velocity * 0.016
velocity += acceleration * 0.016; // Assuming 60fps
currentValue.value += velocity * 0.016;
updateDisplay()
updateDisplay();
if (Math.abs(displacement) > 0.01 || Math.abs(velocity) > 0.01) {
animationId.value = requestAnimationFrame(springAnimation)
animationId.value = requestAnimationFrame(springAnimation);
} else {
currentValue.value = target
updateDisplay()
animationId.value = null
currentValue.value = target;
updateDisplay();
animationId.value = null;
if (props.onEnd) {
props.onEnd()
props.onEnd();
}
}
}
};
const startAnimation = () => {
if (hasStarted.value || !isInView.value || !props.startWhen) return
if (hasStarted.value || !isInView.value || !props.startWhen) return;
hasStarted.value = true
hasStarted.value = true;
if (props.onStart) {
props.onStart()
props.onStart();
}
setTimeout(() => {
startTime = 0
velocity = 0
animationId.value = requestAnimationFrame(springAnimation)
}, props.delay * 1000)
}
startTime = 0;
velocity = 0;
animationId.value = requestAnimationFrame(springAnimation);
}, props.delay * 1000);
};
const setupIntersectionObserver = () => {
if (!elementRef.value) return
if (!elementRef.value) return;
intersectionObserver = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && !isInView.value) {
isInView.value = true
startAnimation()
isInView.value = true;
startAnimation();
}
},
{
threshold: 0,
rootMargin: "0px"
rootMargin: '0px'
}
)
);
intersectionObserver.observe(elementRef.value)
}
intersectionObserver.observe(elementRef.value);
};
const cleanup = () => {
if (animationId.value) {
cancelAnimationFrame(animationId.value)
animationId.value = null
cancelAnimationFrame(animationId.value);
animationId.value = null;
}
if (intersectionObserver) {
intersectionObserver.disconnect()
intersectionObserver = null
intersectionObserver.disconnect();
intersectionObserver = null;
}
}
};
watch([() => props.from, () => props.to, () => props.direction], () => {
currentValue.value = props.direction === "down" ? props.to : props.from
updateDisplay()
hasStarted.value = false
}, { immediate: true })
watch(
[() => props.from, () => props.to, () => props.direction],
() => {
currentValue.value = props.direction === 'down' ? props.to : props.from;
updateDisplay();
hasStarted.value = false;
},
{ immediate: true }
);
watch(() => props.startWhen, () => {
if (props.startWhen && isInView.value && !hasStarted.value) {
startAnimation()
watch(
() => props.startWhen,
() => {
if (props.startWhen && isInView.value && !hasStarted.value) {
startAnimation();
}
}
})
);
onMounted(() => {
updateDisplay()
setupIntersectionObserver()
})
updateDisplay();
setupIntersectionObserver();
});
onUnmounted(() => {
cleanup()
})
</script>
cleanup();
});
</script>

View File

@@ -2,46 +2,74 @@
<div class="relative w-1/2 max-md:w-11/12 aspect-square" :style="wrapperStyle">
<div ref="sceneRef" class="grid w-full h-full" :style="sceneStyle">
<template v-for="(_, r) in cells" :key="`row-${r}`">
<div v-for="(__, c) in cells" :key="`${r}-${c}`"
class="cube relative w-full h-full aspect-square [transform-style:preserve-3d]" :data-row="r" :data-col="c">
<div
v-for="(__, c) in cells"
:key="`${r}-${c}`"
class="cube relative w-full h-full aspect-square [transform-style:preserve-3d]"
:data-row="r"
:data-col="c"
>
<span class="absolute pointer-events-none -inset-9" />
<div class="cube-face absolute inset-0 flex items-center justify-center" :style="{
background: 'var(--cube-face-bg)',
border: 'var(--cube-face-border)',
boxShadow: 'var(--cube-face-shadow)',
transform: 'translateY(-50%) rotateX(90deg)',
}" />
<div class="cube-face absolute inset-0 flex items-center justify-center" :style="{
background: 'var(--cube-face-bg)',
border: 'var(--cube-face-border)',
boxShadow: 'var(--cube-face-shadow)',
transform: 'translateY(50%) rotateX(-90deg)',
}" />
<div class="cube-face absolute inset-0 flex items-center justify-center" :style="{
background: 'var(--cube-face-bg)',
border: 'var(--cube-face-border)',
boxShadow: 'var(--cube-face-shadow)',
transform: 'translateX(-50%) rotateY(-90deg)',
}" />
<div class="cube-face absolute inset-0 flex items-center justify-center" :style="{
background: 'var(--cube-face-bg)',
border: 'var(--cube-face-border)',
boxShadow: 'var(--cube-face-shadow)',
transform: 'translateX(50%) rotateY(90deg)',
}" />
<div class="cube-face absolute inset-0 flex items-center justify-center" :style="{
background: 'var(--cube-face-bg)',
border: 'var(--cube-face-border)',
boxShadow: 'var(--cube-face-shadow)',
transform: 'rotateY(-90deg) translateX(50%) rotateY(90deg)',
}" />
<div class="cube-face absolute inset-0 flex items-center justify-center" :style="{
background: 'var(--cube-face-bg)',
border: 'var(--cube-face-border)',
boxShadow: 'var(--cube-face-shadow)',
transform: 'rotateY(90deg) translateX(-50%) rotateY(-90deg)',
}" />
<div
class="cube-face absolute inset-0 flex items-center justify-center"
:style="{
background: 'var(--cube-face-bg)',
border: 'var(--cube-face-border)',
boxShadow: 'var(--cube-face-shadow)',
transform: 'translateY(-50%) rotateX(90deg)'
}"
/>
<div
class="cube-face absolute inset-0 flex items-center justify-center"
:style="{
background: 'var(--cube-face-bg)',
border: 'var(--cube-face-border)',
boxShadow: 'var(--cube-face-shadow)',
transform: 'translateY(50%) rotateX(-90deg)'
}"
/>
<div
class="cube-face absolute inset-0 flex items-center justify-center"
:style="{
background: 'var(--cube-face-bg)',
border: 'var(--cube-face-border)',
boxShadow: 'var(--cube-face-shadow)',
transform: 'translateX(-50%) rotateY(-90deg)'
}"
/>
<div
class="cube-face absolute inset-0 flex items-center justify-center"
:style="{
background: 'var(--cube-face-bg)',
border: 'var(--cube-face-border)',
boxShadow: 'var(--cube-face-shadow)',
transform: 'translateX(50%) rotateY(90deg)'
}"
/>
<div
class="cube-face absolute inset-0 flex items-center justify-center"
:style="{
background: 'var(--cube-face-bg)',
border: 'var(--cube-face-border)',
boxShadow: 'var(--cube-face-shadow)',
transform: 'rotateY(-90deg) translateX(50%) rotateY(90deg)'
}"
/>
<div
class="cube-face absolute inset-0 flex items-center justify-center"
:style="{
background: 'var(--cube-face-bg)',
border: 'var(--cube-face-border)',
boxShadow: 'var(--cube-face-shadow)',
transform: 'rotateY(90deg) translateX(-50%) rotateY(-90deg)'
}"
/>
</div>
</template>
</div>
@@ -49,34 +77,34 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, withDefaults } from 'vue'
import gsap from 'gsap'
import { ref, computed, onMounted, onUnmounted, withDefaults } from 'vue';
import gsap from 'gsap';
interface Gap {
row: number
col: number
row: number;
col: number;
}
interface Duration {
enter: number
leave: number
enter: number;
leave: number;
}
interface Props {
gridSize?: number
cubeSize?: number
maxAngle?: number
radius?: number
easing?: gsap.EaseString
duration?: Duration
cellGap?: number | Gap
borderStyle?: string
faceColor?: string
shadow?: boolean | string
autoAnimate?: boolean
rippleOnClick?: boolean
rippleColor?: string
rippleSpeed?: number
gridSize?: number;
cubeSize?: number;
maxAngle?: number;
radius?: number;
easing?: gsap.EaseString;
duration?: Duration;
cellGap?: number | Gap;
borderStyle?: string;
faceColor?: string;
shadow?: boolean | string;
autoAnimate?: boolean;
rippleOnClick?: boolean;
rippleColor?: string;
rippleSpeed?: number;
}
const props = withDefaults(defineProps<Props>(), {
@@ -91,37 +119,37 @@ const props = withDefaults(defineProps<Props>(), {
autoAnimate: true,
rippleOnClick: true,
rippleColor: '#fff',
rippleSpeed: 2,
})
rippleSpeed: 2
});
const sceneRef = ref<HTMLDivElement | null>(null)
const rafRef = ref<number | null>(null)
const idleTimerRef = ref<number | null>(null)
const userActiveRef = ref(false)
const simPosRef = ref<{ x: number; y: number }>({ x: 0, y: 0 })
const simTargetRef = ref<{ x: number; y: number }>({ x: 0, y: 0 })
const simRAFRef = ref<number | null>(null)
const sceneRef = ref<HTMLDivElement | null>(null);
const rafRef = ref<number | null>(null);
const idleTimerRef = ref<number | null>(null);
const userActiveRef = ref(false);
const simPosRef = ref<{ x: number; y: number }>({ x: 0, y: 0 });
const simTargetRef = ref<{ x: number; y: number }>({ x: 0, y: 0 });
const simRAFRef = ref<number | null>(null);
const colGap = computed(() => {
return typeof props.cellGap === 'number'
? `${props.cellGap}px`
: (props.cellGap as Gap)?.col !== undefined
? `${(props.cellGap as Gap).col}px`
: '5%'
})
: '5%';
});
const rowGap = computed(() => {
return typeof props.cellGap === 'number'
? `${props.cellGap}px`
: (props.cellGap as Gap)?.row !== undefined
? `${(props.cellGap as Gap).row}px`
: '5%'
})
: '5%';
});
const enterDur = computed(() => props.duration.enter)
const leaveDur = computed(() => props.duration.leave)
const enterDur = computed(() => props.duration.enter);
const leaveDur = computed(() => props.duration.leave);
const cells = computed(() => Array.from({ length: props.gridSize }))
const cells = computed(() => Array.from({ length: props.gridSize }));
const sceneStyle = computed(() => ({
gridTemplateColumns: props.cubeSize
@@ -133,189 +161,184 @@ const sceneStyle = computed(() => ({
columnGap: colGap.value,
rowGap: rowGap.value,
perspective: '99999999px',
gridAutoRows: '1fr',
}))
gridAutoRows: '1fr'
}));
const wrapperStyle = computed(() => ({
'--cube-face-border': props.borderStyle,
'--cube-face-bg': props.faceColor,
'--cube-face-shadow':
props.shadow === true ? '0 0 6px rgba(0,0,0,.5)' : props.shadow || 'none',
'--cube-face-shadow': props.shadow === true ? '0 0 6px rgba(0,0,0,.5)' : props.shadow || 'none',
...(props.cubeSize
? {
width: `${props.gridSize * props.cubeSize}px`,
height: `${props.gridSize * props.cubeSize}px`,
}
: {}),
}))
width: `${props.gridSize * props.cubeSize}px`,
height: `${props.gridSize * props.cubeSize}px`
}
: {})
}));
const tiltAt = (rowCenter: number, colCenter: number) => {
if (!sceneRef.value) return
if (!sceneRef.value) return;
sceneRef.value.querySelectorAll<HTMLDivElement>('.cube').forEach((cube) => {
const r = +(cube.dataset.row!)
const c = +(cube.dataset.col!)
const dist = Math.hypot(r - rowCenter, c - colCenter)
sceneRef.value.querySelectorAll<HTMLDivElement>('.cube').forEach(cube => {
const r = +cube.dataset.row!;
const c = +cube.dataset.col!;
const dist = Math.hypot(r - rowCenter, c - colCenter);
if (dist <= props.radius) {
const pct = 1 - dist / props.radius
const angle = pct * props.maxAngle
const pct = 1 - dist / props.radius;
const angle = pct * props.maxAngle;
gsap.to(cube, {
duration: enterDur.value,
ease: props.easing,
overwrite: true,
rotateX: -angle,
rotateY: angle,
})
rotateY: angle
});
} else {
gsap.to(cube, {
duration: leaveDur.value,
ease: 'power3.out',
overwrite: true,
rotateX: 0,
rotateY: 0,
})
rotateY: 0
});
}
})
}
});
};
const onPointerMove = (e: PointerEvent) => {
userActiveRef.value = true
if (idleTimerRef.value) clearTimeout(idleTimerRef.value)
userActiveRef.value = true;
if (idleTimerRef.value) clearTimeout(idleTimerRef.value);
const rect = sceneRef.value!.getBoundingClientRect()
const cellW = rect.width / props.gridSize
const cellH = rect.height / props.gridSize
const colCenter = (e.clientX - rect.left) / cellW
const rowCenter = (e.clientY - rect.top) / cellH
const rect = sceneRef.value!.getBoundingClientRect();
const cellW = rect.width / props.gridSize;
const cellH = rect.height / props.gridSize;
const colCenter = (e.clientX - rect.left) / cellW;
const rowCenter = (e.clientY - rect.top) / cellH;
if (rafRef.value) cancelAnimationFrame(rafRef.value)
rafRef.value = requestAnimationFrame(() =>
tiltAt(rowCenter, colCenter)
)
if (rafRef.value) cancelAnimationFrame(rafRef.value);
rafRef.value = requestAnimationFrame(() => tiltAt(rowCenter, colCenter));
idleTimerRef.value = setTimeout(() => {
userActiveRef.value = false
}, 3000)
}
userActiveRef.value = false;
}, 3000);
};
const resetAll = () => {
if (!sceneRef.value) return
sceneRef.value.querySelectorAll<HTMLDivElement>('.cube').forEach((cube) =>
if (!sceneRef.value) return;
sceneRef.value.querySelectorAll<HTMLDivElement>('.cube').forEach(cube =>
gsap.to(cube, {
duration: leaveDur.value,
rotateX: 0,
rotateY: 0,
ease: 'power3.out',
ease: 'power3.out'
})
)
}
);
};
const onClick = (e: MouseEvent) => {
if (!props.rippleOnClick || !sceneRef.value) return
if (!props.rippleOnClick || !sceneRef.value) return;
const rect = sceneRef.value.getBoundingClientRect()
const cellW = rect.width / props.gridSize
const cellH = rect.height / props.gridSize
const colHit = Math.floor((e.clientX - rect.left) / cellW)
const rowHit = Math.floor((e.clientY - rect.top) / cellH)
const rect = sceneRef.value.getBoundingClientRect();
const cellW = rect.width / props.gridSize;
const cellH = rect.height / props.gridSize;
const colHit = Math.floor((e.clientX - rect.left) / cellW);
const rowHit = Math.floor((e.clientY - rect.top) / cellH);
const baseRingDelay = 0.15
const baseAnimDur = 0.3
const baseHold = 0.6
const baseRingDelay = 0.15;
const baseAnimDur = 0.3;
const baseHold = 0.6;
const spreadDelay = baseRingDelay / props.rippleSpeed
const animDuration = baseAnimDur / props.rippleSpeed
const holdTime = baseHold / props.rippleSpeed
const spreadDelay = baseRingDelay / props.rippleSpeed;
const animDuration = baseAnimDur / props.rippleSpeed;
const holdTime = baseHold / props.rippleSpeed;
const rings: Record<number, HTMLDivElement[]> = {}
sceneRef.value.querySelectorAll<HTMLDivElement>('.cube').forEach((cube) => {
const r = +(cube.dataset.row!)
const c = +(cube.dataset.col!)
const dist = Math.hypot(r - rowHit, c - colHit)
const ring = Math.round(dist)
if (!rings[ring]) rings[ring] = []
rings[ring].push(cube)
})
const rings: Record<number, HTMLDivElement[]> = {};
sceneRef.value.querySelectorAll<HTMLDivElement>('.cube').forEach(cube => {
const r = +cube.dataset.row!;
const c = +cube.dataset.col!;
const dist = Math.hypot(r - rowHit, c - colHit);
const ring = Math.round(dist);
if (!rings[ring]) rings[ring] = [];
rings[ring].push(cube);
});
Object.keys(rings)
.map(Number)
.sort((a, b) => a - b)
.forEach((ring) => {
const delay = ring * spreadDelay
const faces = rings[ring].flatMap((cube) =>
Array.from(cube.querySelectorAll<HTMLElement>('.cube-face'))
)
.forEach(ring => {
const delay = ring * spreadDelay;
const faces = rings[ring].flatMap(cube => Array.from(cube.querySelectorAll<HTMLElement>('.cube-face')));
gsap.to(faces, {
backgroundColor: props.rippleColor,
duration: animDuration,
delay,
ease: 'power3.out',
})
ease: 'power3.out'
});
gsap.to(faces, {
backgroundColor: props.faceColor,
duration: animDuration,
delay: delay + animDuration + holdTime,
ease: 'power3.out',
})
})
}
ease: 'power3.out'
});
});
};
const startAutoAnimation = () => {
if (!props.autoAnimate || !sceneRef.value) return
if (!props.autoAnimate || !sceneRef.value) return;
simPosRef.value = {
x: Math.random() * props.gridSize,
y: Math.random() * props.gridSize,
}
y: Math.random() * props.gridSize
};
simTargetRef.value = {
x: Math.random() * props.gridSize,
y: Math.random() * props.gridSize,
}
y: Math.random() * props.gridSize
};
const speed = 0.02
const speed = 0.02;
const loop = () => {
if (!userActiveRef.value) {
const pos = simPosRef.value
const tgt = simTargetRef.value
pos.x += (tgt.x - pos.x) * speed
pos.y += (tgt.y - pos.y) * speed
tiltAt(pos.y, pos.x)
const pos = simPosRef.value;
const tgt = simTargetRef.value;
pos.x += (tgt.x - pos.x) * speed;
pos.y += (tgt.y - pos.y) * speed;
tiltAt(pos.y, pos.x);
if (Math.hypot(pos.x - tgt.x, pos.y - tgt.y) < 0.1) {
simTargetRef.value = {
x: Math.random() * props.gridSize,
y: Math.random() * props.gridSize,
}
y: Math.random() * props.gridSize
};
}
}
simRAFRef.value = requestAnimationFrame(loop)
}
simRAFRef.value = requestAnimationFrame(loop)
}
simRAFRef.value = requestAnimationFrame(loop);
};
simRAFRef.value = requestAnimationFrame(loop);
};
onMounted(() => {
const el = sceneRef.value
if (!el) return
const el = sceneRef.value;
if (!el) return;
el.addEventListener('pointermove', onPointerMove)
el.addEventListener('pointerleave', resetAll)
el.addEventListener('click', onClick)
el.addEventListener('pointermove', onPointerMove);
el.addEventListener('pointerleave', resetAll);
el.addEventListener('click', onClick);
startAutoAnimation()
})
startAutoAnimation();
});
onUnmounted(() => {
const el = sceneRef.value
const el = sceneRef.value;
if (el) {
el.removeEventListener('pointermove', onPointerMove)
el.removeEventListener('pointerleave', resetAll)
el.removeEventListener('click', onClick)
el.removeEventListener('pointermove', onPointerMove);
el.removeEventListener('pointerleave', resetAll);
el.removeEventListener('click', onClick);
}
if (rafRef.value !== null) cancelAnimationFrame(rafRef.value)
if (idleTimerRef.value !== null) clearTimeout(idleTimerRef.value)
if (simRAFRef.value !== null) cancelAnimationFrame(simRAFRef.value)
})
if (rafRef.value !== null) cancelAnimationFrame(rafRef.value);
if (idleTimerRef.value !== null) clearTimeout(idleTimerRef.value);
if (simRAFRef.value !== null) cancelAnimationFrame(simRAFRef.value);
});
</script>

View File

@@ -5,7 +5,7 @@
:style="{
opacity: inView ? 1 : initialOpacity,
transition: `opacity ${duration}ms ${easing}, filter ${duration}ms ${easing}`,
filter: blur ? (inView ? 'blur(0px)' : 'blur(10px)') : 'none',
filter: blur ? (inView ? 'blur(0px)' : 'blur(10px)') : 'none'
}"
>
<slot />
@@ -13,16 +13,16 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue';
interface Props {
blur?: boolean
duration?: number
easing?: string
delay?: number
threshold?: number
initialOpacity?: number
className?: string
blur?: boolean;
duration?: number;
easing?: string;
delay?: number;
threshold?: number;
initialOpacity?: number;
className?: string;
}
const props = withDefaults(defineProps<Props>(), {
@@ -33,34 +33,34 @@ const props = withDefaults(defineProps<Props>(), {
threshold: 0.1,
initialOpacity: 0,
className: ''
})
});
const inView = ref(false)
const elementRef = ref<HTMLDivElement | null>(null)
let observer: IntersectionObserver | null = null
const inView = ref(false);
const elementRef = ref<HTMLDivElement | null>(null);
let observer: IntersectionObserver | null = null;
onMounted(() => {
const element = elementRef.value
if (!element) return
const element = elementRef.value;
if (!element) return;
observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
observer?.unobserve(element)
observer?.unobserve(element);
setTimeout(() => {
inView.value = true
}, props.delay)
inView.value = true;
}, props.delay);
}
},
{ threshold: props.threshold }
)
);
observer.observe(element)
})
observer.observe(element);
});
onUnmounted(() => {
if (observer) {
observer.disconnect()
observer.disconnect();
}
})
</script>
});
</script>

View File

@@ -1,20 +1,20 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed } from 'vue';
interface GlareHoverProps {
width?: string
height?: string
background?: string
borderRadius?: string
borderColor?: string
glareColor?: string
glareOpacity?: number
glareAngle?: number
glareSize?: number
transitionDuration?: number
playOnce?: boolean
className?: string
style?: Record<string, string | number>
width?: string;
height?: string;
background?: string;
borderRadius?: string;
borderColor?: string;
glareColor?: string;
glareOpacity?: number;
glareAngle?: number;
glareSize?: number;
transitionDuration?: number;
playOnce?: boolean;
className?: string;
style?: Record<string, string | number>;
}
const props = withDefaults(defineProps<GlareHoverProps>(), {
@@ -31,28 +31,28 @@ const props = withDefaults(defineProps<GlareHoverProps>(), {
playOnce: false,
className: '',
style: () => ({})
})
});
const overlayRef = ref<HTMLDivElement | null>(null)
const overlayRef = ref<HTMLDivElement | null>(null);
const rgba = computed(() => {
const hex = props.glareColor.replace('#', '')
let result = props.glareColor
const hex = props.glareColor.replace('#', '');
let result = props.glareColor;
if (/^[\dA-Fa-f]{6}$/.test(hex)) {
const r = parseInt(hex.slice(0, 2), 16)
const g = parseInt(hex.slice(2, 4), 16)
const b = parseInt(hex.slice(4, 6), 16)
result = `rgba(${r}, ${g}, ${b}, ${props.glareOpacity})`
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
result = `rgba(${r}, ${g}, ${b}, ${props.glareOpacity})`;
} else if (/^[\dA-Fa-f]{3}$/.test(hex)) {
const r = parseInt(hex[0] + hex[0], 16)
const g = parseInt(hex[1] + hex[1], 16)
const b = parseInt(hex[2] + hex[2], 16)
result = `rgba(${r}, ${g}, ${b}, ${props.glareOpacity})`
const r = parseInt(hex[0] + hex[0], 16);
const g = parseInt(hex[1] + hex[1], 16);
const b = parseInt(hex[2] + hex[2], 16);
result = `rgba(${r}, ${g}, ${b}, ${props.glareOpacity})`;
}
return result
})
return result;
});
const overlayStyle = computed(() => ({
position: 'absolute' as const,
@@ -64,32 +64,32 @@ const overlayStyle = computed(() => ({
backgroundSize: `${props.glareSize}% ${props.glareSize}%, 100% 100%`,
backgroundRepeat: 'no-repeat',
backgroundPosition: '-100% -100%, 0 0',
pointerEvents: 'none' as const,
}))
pointerEvents: 'none' as const
}));
const animateIn = () => {
const el = overlayRef.value
if (!el) return
const el = overlayRef.value;
if (!el) return;
el.style.transition = 'none'
el.style.backgroundPosition = '-100% -100%, 0 0'
void el.offsetHeight
el.style.transition = `${props.transitionDuration}ms ease`
el.style.backgroundPosition = '100% 100%, 0 0'
}
el.style.transition = 'none';
el.style.backgroundPosition = '-100% -100%, 0 0';
void el.offsetHeight;
el.style.transition = `${props.transitionDuration}ms ease`;
el.style.backgroundPosition = '100% 100%, 0 0';
};
const animateOut = () => {
const el = overlayRef.value
if (!el) return
const el = overlayRef.value;
if (!el) return;
if (props.playOnce) {
el.style.transition = 'none'
el.style.backgroundPosition = '-100% -100%, 0 0'
el.style.transition = 'none';
el.style.backgroundPosition = '-100% -100%, 0 0';
} else {
el.style.transition = `${props.transitionDuration}ms ease`
el.style.backgroundPosition = '-100% -100%, 0 0'
el.style.transition = `${props.transitionDuration}ms ease`;
el.style.backgroundPosition = '-100% -100%, 0 0';
}
}
};
</script>
<template>
@@ -101,12 +101,13 @@ const animateOut = () => {
background: props.background,
borderRadius: props.borderRadius,
borderColor: props.borderColor,
...props.style,
...props.style
}"
@mouseenter="animateIn"
@mouseleave="animateOut"
>
<div ref="overlayRef" :style="overlayStyle" />
<slot />
</div>
</template>

View File

@@ -1,27 +1,34 @@
<template>
<div ref="magnetRef" :class="wrapperClassName" :style="{ position: 'relative', display: 'inline-block' }"
v-bind="$attrs">
<div :class="innerClassName" :style="{
transform: `translate3d(${position.x}px, ${position.y}px, 0)`,
transition: transitionStyle,
willChange: 'transform',
}">
<div
ref="magnetRef"
:class="wrapperClassName"
:style="{ position: 'relative', display: 'inline-block' }"
v-bind="$attrs"
>
<div
:class="innerClassName"
:style="{
transform: `translate3d(${position.x}px, ${position.y}px, 0)`,
transition: transitionStyle,
willChange: 'transform'
}"
>
<slot />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
interface Props {
padding?: number
disabled?: boolean
magnetStrength?: number
activeTransition?: string
inactiveTransition?: string
wrapperClassName?: string
innerClassName?: string
padding?: number;
disabled?: boolean;
magnetStrength?: number;
activeTransition?: string;
inactiveTransition?: string;
wrapperClassName?: string;
innerClassName?: string;
}
const props = withDefaults(defineProps<Props>(), {
@@ -32,53 +39,54 @@ const props = withDefaults(defineProps<Props>(), {
inactiveTransition: 'transform 0.5s ease-in-out',
wrapperClassName: '',
innerClassName: ''
})
});
defineOptions({
inheritAttrs: false
})
});
const magnetRef = ref<HTMLDivElement | null>(null)
const isActive = ref(false)
const position = ref({ x: 0, y: 0 })
const magnetRef = ref<HTMLDivElement | null>(null);
const isActive = ref(false);
const position = ref({ x: 0, y: 0 });
const transitionStyle = computed(() =>
isActive.value ? props.activeTransition : props.inactiveTransition
)
const transitionStyle = computed(() => (isActive.value ? props.activeTransition : props.inactiveTransition));
const handleMouseMove = (e: MouseEvent) => {
if (!magnetRef.value || props.disabled) return
if (!magnetRef.value || props.disabled) return;
const { left, top, width, height } = magnetRef.value.getBoundingClientRect()
const centerX = left + width / 2
const centerY = top + height / 2
const { left, top, width, height } = magnetRef.value.getBoundingClientRect();
const centerX = left + width / 2;
const centerY = top + height / 2;
const distX = Math.abs(centerX - e.clientX)
const distY = Math.abs(centerY - e.clientY)
const distX = Math.abs(centerX - e.clientX);
const distY = Math.abs(centerY - e.clientY);
if (distX < width / 2 + props.padding && distY < height / 2 + props.padding) {
isActive.value = true
const offsetX = (e.clientX - centerX) / props.magnetStrength
const offsetY = (e.clientY - centerY) / props.magnetStrength
position.value = { x: offsetX, y: offsetY }
isActive.value = true;
const offsetX = (e.clientX - centerX) / props.magnetStrength;
const offsetY = (e.clientY - centerY) / props.magnetStrength;
position.value = { x: offsetX, y: offsetY };
} else {
isActive.value = false
position.value = { x: 0, y: 0 }
isActive.value = false;
position.value = { x: 0, y: 0 };
}
}
};
onMounted(() => {
window.addEventListener('mousemove', handleMouseMove)
})
window.addEventListener('mousemove', handleMouseMove);
});
onUnmounted(() => {
window.removeEventListener('mousemove', handleMouseMove)
})
window.removeEventListener('mousemove', handleMouseMove);
});
watch(() => props.disabled, (newDisabled) => {
if (newDisabled) {
position.value = { x: 0, y: 0 }
isActive.value = false
watch(
() => props.disabled,
newDisabled => {
if (newDisabled) {
position.value = { x: 0, y: 0 };
isActive.value = false;
}
}
})
);
</script>

View File

@@ -1,16 +1,16 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { ref, onMounted, onUnmounted, computed } from 'vue';
interface MagnetLinesProps {
rows?: number
columns?: number
containerSize?: string
lineColor?: string
lineWidth?: string
lineHeight?: string
baseAngle?: number
className?: string
style?: Record<string, string | number>
rows?: number;
columns?: number;
containerSize?: string;
lineColor?: string;
lineWidth?: string;
lineHeight?: string;
baseAngle?: number;
className?: string;
style?: Record<string, string | number>;
}
const props = withDefaults(defineProps<MagnetLinesProps>(), {
@@ -23,53 +23,53 @@ const props = withDefaults(defineProps<MagnetLinesProps>(), {
baseAngle: -10,
className: '',
style: () => ({})
})
});
const containerRef = ref<HTMLDivElement | null>(null)
const containerRef = ref<HTMLDivElement | null>(null);
const total = computed(() => props.rows * props.columns)
const total = computed(() => props.rows * props.columns);
const onPointerMove = (pointer: { x: number; y: number }) => {
const container = containerRef.value
if (!container) return
const container = containerRef.value;
if (!container) return;
const items = container.querySelectorAll<HTMLSpanElement>('span')
const items = container.querySelectorAll<HTMLSpanElement>('span');
items.forEach((item) => {
const rect = item.getBoundingClientRect()
const centerX = rect.x + rect.width / 2
const centerY = rect.y + rect.height / 2
items.forEach(item => {
const rect = item.getBoundingClientRect();
const centerX = rect.x + rect.width / 2;
const centerY = rect.y + rect.height / 2;
const b = pointer.x - centerX
const a = pointer.y - centerY
const c = Math.sqrt(a * a + b * b) || 1
const r = ((Math.acos(b / c) * 180) / Math.PI) * (pointer.y > centerY ? 1 : -1)
const b = pointer.x - centerX;
const a = pointer.y - centerY;
const c = Math.sqrt(a * a + b * b) || 1;
const r = ((Math.acos(b / c) * 180) / Math.PI) * (pointer.y > centerY ? 1 : -1);
item.style.setProperty('--rotate', `${r}deg`)
})
}
item.style.setProperty('--rotate', `${r}deg`);
});
};
const handlePointerMove = (e: PointerEvent) => {
onPointerMove({ x: e.x, y: e.y })
}
onPointerMove({ x: e.x, y: e.y });
};
onMounted(() => {
const container = containerRef.value
if (!container) return
const container = containerRef.value;
if (!container) return;
window.addEventListener('pointermove', handlePointerMove)
window.addEventListener('pointermove', handlePointerMove);
const items = container.querySelectorAll<HTMLSpanElement>('span')
const items = container.querySelectorAll<HTMLSpanElement>('span');
if (items.length) {
const middleIndex = Math.floor(items.length / 2)
const rect = items[middleIndex].getBoundingClientRect()
onPointerMove({ x: rect.x, y: rect.y })
const middleIndex = Math.floor(items.length / 2);
const rect = items[middleIndex].getBoundingClientRect();
onPointerMove({ x: rect.x, y: rect.y });
}
})
});
onUnmounted(() => {
window.removeEventListener('pointermove', handlePointerMove)
})
window.removeEventListener('pointermove', handlePointerMove);
});
</script>
<template>
@@ -81,7 +81,7 @@ onUnmounted(() => {
gridTemplateRows: `repeat(${props.rows}, 1fr)`,
width: props.containerSize,
height: props.containerSize,
...props.style,
...props.style
}"
>
<span
@@ -94,7 +94,7 @@ onUnmounted(() => {
height: props.lineHeight,
'--rotate': `${props.baseAngle}deg`,
transform: 'rotate(var(--rotate))',
willChange: 'transform',
willChange: 'transform'
}"
/>
</div>

View File

@@ -0,0 +1,394 @@
<template>
<canvas ref="canvasRef" class="block w-full h-full object-contain" />
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
interface ShaderParams {
patternScale: number;
refraction: number;
edge: number;
patternBlur: number;
liquid: number;
speed: number;
}
interface Props {
imageData: ImageData;
params?: ShaderParams;
}
const props = withDefaults(defineProps<Props>(), {
params: () => ({
patternScale: 2,
refraction: 0.015,
edge: 1,
patternBlur: 0.005,
liquid: 0.07,
speed: 0.3
})
});
const canvasRef = ref<HTMLCanvasElement | null>(null);
const gl = ref<WebGL2RenderingContext | null>(null);
const uniforms = ref<Record<string, WebGLUniformLocation>>({});
const totalAnimationTime = ref(0);
const lastRenderTime = ref(0);
const animationId = ref<number>();
const vertexShaderSource = `#version 300 es
precision mediump float;
in vec2 a_position;
out vec2 vUv;
void main() {
vUv = .5 * (a_position + 1.);
gl_Position = vec4(a_position, 0.0, 1.0);
}`;
const liquidFragSource = `#version 300 es
precision mediump float;
in vec2 vUv;
out vec4 fragColor;
uniform sampler2D u_image_texture;
uniform float u_time;
uniform float u_ratio;
uniform float u_img_ratio;
uniform float u_patternScale;
uniform float u_refraction;
uniform float u_edge;
uniform float u_patternBlur;
uniform float u_liquid;
#define TWO_PI 6.28318530718
#define PI 3.14159265358979323846
vec3 mod289(vec3 x) { return x - floor(x * (1. / 289.)) * 289.; }
vec2 mod289(vec2 x) { return x - floor(x * (1. / 289.)) * 289.; }
vec3 permute(vec3 x) { return mod289(((x*34.)+1.)*x); }
float snoise(vec2 v) {
const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);
vec2 i = floor(v + dot(v, C.yy));
vec2 x0 = v - i + dot(i, C.xx);
vec2 i1;
i1 = (x0.x > x0.y) ? vec2(1., 0.) : vec2(0., 1.);
vec4 x12 = x0.xyxy + C.xxzz;
x12.xy -= i1;
i = mod289(i);
vec3 p = permute(permute(i.y + vec3(0., i1.y, 1.)) + i.x + vec3(0., i1.x, 1.));
vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.);
m = m*m;
m = m*m;
vec3 x = 2. * fract(p * C.www) - 1.;
vec3 h = abs(x) - 0.5;
vec3 ox = floor(x + 0.5);
vec3 a0 = x - ox;
m *= 1.79284291400159 - 0.85373472095314 * (a0*a0 + h*h);
vec3 g;
g.x = a0.x * x0.x + h.x * x0.y;
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
return 130. * dot(m, g);
}
vec2 get_img_uv() {
vec2 img_uv = vUv;
img_uv -= .5;
if (u_ratio > u_img_ratio) {
img_uv.x = img_uv.x * u_ratio / u_img_ratio;
} else {
img_uv.y = img_uv.y * u_img_ratio / u_ratio;
}
float scale_factor = 1.;
img_uv *= scale_factor;
img_uv += .5;
img_uv.y = 1. - img_uv.y;
return img_uv;
}
vec2 rotate(vec2 uv, float th) {
return mat2(cos(th), sin(th), -sin(th), cos(th)) * uv;
}
float get_color_channel(float c1, float c2, float stripe_p, vec3 w, float extra_blur, float b) {
float ch = c2;
float border = 0.;
float blur = u_patternBlur + extra_blur;
ch = mix(ch, c1, smoothstep(.0, blur, stripe_p));
border = w[0];
ch = mix(ch, c2, smoothstep(border - blur, border + blur, stripe_p));
b = smoothstep(.2, .8, b);
border = w[0] + .4 * (1. - b) * w[1];
ch = mix(ch, c1, smoothstep(border - blur, border + blur, stripe_p));
border = w[0] + .5 * (1. - b) * w[1];
ch = mix(ch, c2, smoothstep(border - blur, border + blur, stripe_p));
border = w[0] + w[1];
ch = mix(ch, c1, smoothstep(border - blur, border + blur, stripe_p));
float gradient_t = (stripe_p - w[0] - w[1]) / w[2];
float gradient = mix(c1, c2, smoothstep(0., 1., gradient_t));
ch = mix(ch, gradient, smoothstep(border - blur, border + blur, stripe_p));
return ch;
}
float get_img_frame_alpha(vec2 uv, float img_frame_width) {
float img_frame_alpha = smoothstep(0., img_frame_width, uv.x) * smoothstep(1., 1. - img_frame_width, uv.x);
img_frame_alpha *= smoothstep(0., img_frame_width, uv.y) * smoothstep(1., 1. - img_frame_width, uv.y);
return img_frame_alpha;
}
void main() {
vec2 uv = vUv;
uv.y = 1. - uv.y;
uv.x *= u_ratio;
float diagonal = uv.x - uv.y;
float t = .001 * u_time;
vec2 img_uv = get_img_uv();
vec4 img = texture(u_image_texture, img_uv);
vec3 color = vec3(0.);
float opacity = 1.;
vec3 color1 = vec3(.98, 0.98, 1.);
vec3 color2 = vec3(.1, .1, .1 + .1 * smoothstep(.7, 1.3, uv.x + uv.y));
float edge = img.r;
vec2 grad_uv = uv;
grad_uv -= .5;
float dist = length(grad_uv + vec2(0., .2 * diagonal));
grad_uv = rotate(grad_uv, (.25 - .2 * diagonal) * PI);
float bulge = pow(1.8 * dist, 1.2);
bulge = 1. - bulge;
bulge *= pow(uv.y, .3);
float cycle_width = u_patternScale;
float thin_strip_1_ratio = .12 / cycle_width * (1. - .4 * bulge);
float thin_strip_2_ratio = .07 / cycle_width * (1. + .4 * bulge);
float wide_strip_ratio = (1. - thin_strip_1_ratio - thin_strip_2_ratio);
float thin_strip_1_width = cycle_width * thin_strip_1_ratio;
float thin_strip_2_width = cycle_width * thin_strip_2_ratio;
opacity = 1. - smoothstep(.9 - .5 * u_edge, 1. - .5 * u_edge, edge);
opacity *= get_img_frame_alpha(img_uv, 0.01);
float noise = snoise(uv - t);
edge += (1. - edge) * u_liquid * noise;
float refr = 0.;
refr += (1. - bulge);
refr = clamp(refr, 0., 1.);
float dir = grad_uv.x;
dir += diagonal;
dir -= 2. * noise * diagonal * (smoothstep(0., 1., edge) * smoothstep(1., 0., edge));
bulge *= clamp(pow(uv.y, .1), .3, 1.);
dir *= (.1 + (1.1 - edge) * bulge);
dir *= smoothstep(1., .7, edge);
dir += .18 * (smoothstep(.1, .2, uv.y) * smoothstep(.4, .2, uv.y));
dir += .03 * (smoothstep(.1, .2, 1. - uv.y) * smoothstep(.4, .2, 1. - uv.y));
dir *= (.5 + .5 * pow(uv.y, 2.));
dir *= cycle_width;
dir -= t;
float refr_r = refr;
refr_r += .03 * bulge * noise;
float refr_b = 1.3 * refr;
refr_r += 5. * (smoothstep(-.1, .2, uv.y) * smoothstep(.5, .1, uv.y)) * (smoothstep(.4, .6, bulge) * smoothstep(1., .4, bulge));
refr_r -= diagonal;
refr_b += (smoothstep(0., .4, uv.y) * smoothstep(.8, .1, uv.y)) * (smoothstep(.4, .6, bulge) * smoothstep(.8, .4, bulge));
refr_b -= .2 * edge;
refr_r *= u_refraction;
refr_b *= u_refraction;
vec3 w = vec3(thin_strip_1_width, thin_strip_2_width, wide_strip_ratio);
w[1] -= .02 * smoothstep(.0, 1., edge + bulge);
float stripe_r = mod(dir + refr_r, 1.);
float r = get_color_channel(color1.r, color2.r, stripe_r, w, 0.02 + .03 * u_refraction * bulge, bulge);
float stripe_g = mod(dir, 1.);
float g = get_color_channel(color1.g, color2.g, stripe_g, w, 0.01 / (1. - diagonal), bulge);
float stripe_b = mod(dir - refr_b, 1.);
float b = get_color_channel(color1.b, color2.b, stripe_b, w, .01, bulge);
color = vec3(r, g, b);
color *= opacity;
fragColor = vec4(color, opacity);
}
`;
function updateUniforms() {
if (!gl.value || !uniforms.value) return;
gl.value.uniform1f(uniforms.value.u_edge, props.params.edge);
gl.value.uniform1f(uniforms.value.u_patternBlur, props.params.patternBlur);
gl.value.uniform1f(uniforms.value.u_time, 0);
gl.value.uniform1f(uniforms.value.u_patternScale, props.params.patternScale);
gl.value.uniform1f(uniforms.value.u_refraction, props.params.refraction);
gl.value.uniform1f(uniforms.value.u_liquid, props.params.liquid);
}
function createShader(gl: WebGL2RenderingContext, sourceCode: string, type: number) {
const shader = gl.createShader(type);
if (!shader) {
return null;
}
gl.shaderSource(shader, sourceCode);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error('An error occurred compiling the shaders: ' + gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
function getUniforms(program: WebGLProgram, gl: WebGL2RenderingContext) {
const uniformsObj: Record<string, WebGLUniformLocation> = {};
const uniformCount = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS);
for (let i = 0; i < uniformCount; i++) {
const uniformName = gl.getActiveUniform(program, i)?.name;
if (!uniformName) continue;
uniformsObj[uniformName] = gl.getUniformLocation(program, uniformName) as WebGLUniformLocation;
}
return uniformsObj;
}
function initShader() {
const canvas = canvasRef.value;
const glContext = canvas?.getContext('webgl2', {
antialias: true,
alpha: true
});
if (!canvas || !glContext) {
return;
}
const vertexShader = createShader(glContext, vertexShaderSource, glContext.VERTEX_SHADER);
const fragmentShader = createShader(glContext, liquidFragSource, glContext.FRAGMENT_SHADER);
const program = glContext.createProgram();
if (!program || !vertexShader || !fragmentShader) {
return;
}
glContext.attachShader(program, vertexShader);
glContext.attachShader(program, fragmentShader);
glContext.linkProgram(program);
if (!glContext.getProgramParameter(program, glContext.LINK_STATUS)) {
console.error('Unable to initialize the shader program: ' + glContext.getProgramInfoLog(program));
return null;
}
const uniformsObj = getUniforms(program, glContext);
uniforms.value = uniformsObj;
const vertices = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]);
const vertexBuffer = glContext.createBuffer();
glContext.bindBuffer(glContext.ARRAY_BUFFER, vertexBuffer);
glContext.bufferData(glContext.ARRAY_BUFFER, vertices, glContext.STATIC_DRAW);
glContext.useProgram(program);
const positionLocation = glContext.getAttribLocation(program, 'a_position');
glContext.enableVertexAttribArray(positionLocation);
glContext.bindBuffer(glContext.ARRAY_BUFFER, vertexBuffer);
glContext.vertexAttribPointer(positionLocation, 2, glContext.FLOAT, false, 0, 0);
gl.value = glContext;
}
function resizeCanvas() {
if (!canvasRef.value || !gl.value || !uniforms.value || !props.imageData) return;
const imgRatio = props.imageData.width / props.imageData.height;
gl.value.uniform1f(uniforms.value.u_img_ratio, imgRatio);
const side = 1000;
canvasRef.value.width = side * devicePixelRatio;
canvasRef.value.height = side * devicePixelRatio;
gl.value.viewport(0, 0, canvasRef.value.height, canvasRef.value.height);
gl.value.uniform1f(uniforms.value.u_ratio, 1);
gl.value.uniform1f(uniforms.value.u_img_ratio, imgRatio);
}
function setupTexture() {
if (!gl.value || !uniforms.value) return;
const existingTexture = gl.value.getParameter(gl.value.TEXTURE_BINDING_2D);
if (existingTexture) {
gl.value.deleteTexture(existingTexture);
}
const imageTexture = gl.value.createTexture();
gl.value.activeTexture(gl.value.TEXTURE0);
gl.value.bindTexture(gl.value.TEXTURE_2D, imageTexture);
gl.value.texParameteri(gl.value.TEXTURE_2D, gl.value.TEXTURE_MIN_FILTER, gl.value.LINEAR);
gl.value.texParameteri(gl.value.TEXTURE_2D, gl.value.TEXTURE_MAG_FILTER, gl.value.LINEAR);
gl.value.texParameteri(gl.value.TEXTURE_2D, gl.value.TEXTURE_WRAP_S, gl.value.CLAMP_TO_EDGE);
gl.value.texParameteri(gl.value.TEXTURE_2D, gl.value.TEXTURE_WRAP_T, gl.value.CLAMP_TO_EDGE);
gl.value.pixelStorei(gl.value.UNPACK_ALIGNMENT, 1);
try {
gl.value.texImage2D(
gl.value.TEXTURE_2D,
0,
gl.value.RGBA,
props.imageData?.width,
props.imageData?.height,
0,
gl.value.RGBA,
gl.value.UNSIGNED_BYTE,
props.imageData?.data
);
gl.value.uniform1i(uniforms.value.u_image_texture, 0);
} catch (e) {
console.error('Error uploading texture:', e);
}
}
function render(currentTime: number) {
if (!gl.value || !uniforms.value) return;
const deltaTime = currentTime - lastRenderTime.value;
lastRenderTime.value = currentTime;
totalAnimationTime.value += deltaTime * props.params.speed;
gl.value.uniform1f(uniforms.value.u_time, totalAnimationTime.value);
gl.value.drawArrays(gl.value.TRIANGLE_STRIP, 0, 4);
animationId.value = requestAnimationFrame(render);
}
function startAnimation() {
if (animationId.value) {
cancelAnimationFrame(animationId.value);
}
lastRenderTime.value = performance.now();
animationId.value = requestAnimationFrame(render);
}
onMounted(async () => {
await nextTick();
initShader();
updateUniforms();
resizeCanvas();
setupTexture();
startAnimation();
window.addEventListener('resize', resizeCanvas);
});
onUnmounted(() => {
if (animationId.value) {
cancelAnimationFrame(animationId.value);
}
window.removeEventListener('resize', resizeCanvas);
});
watch(
() => props.params,
() => {
updateUniforms();
},
{ deep: true }
);
watch(
() => props.imageData,
() => {
setupTexture();
resizeCanvas();
},
{ deep: true }
);
</script>

View File

@@ -0,0 +1,177 @@
export function parseImage(file: File): Promise<{ imageData: ImageData; pngBlob: Blob }> {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
return new Promise((resolve, reject) => {
if (!file || !ctx) {
reject(new Error('Invalid file or context'));
return;
}
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = function () {
if (file.type === 'image/svg+xml') {
img.width = 1000;
img.height = 1000;
}
const MAX_SIZE = 1000;
const MIN_SIZE = 500;
let width = img.naturalWidth;
let height = img.naturalHeight;
if (width > MAX_SIZE || height > MAX_SIZE || width < MIN_SIZE || height < MIN_SIZE) {
if (width > height) {
if (width > MAX_SIZE) {
height = Math.round((height * MAX_SIZE) / width);
width = MAX_SIZE;
} else if (width < MIN_SIZE) {
height = Math.round((height * MIN_SIZE) / width);
width = MIN_SIZE;
}
} else {
if (height > MAX_SIZE) {
width = Math.round((width * MAX_SIZE) / height);
height = MAX_SIZE;
} else if (height < MIN_SIZE) {
width = Math.round((width * MIN_SIZE) / height);
height = MIN_SIZE;
}
}
}
canvas.width = width;
canvas.height = height;
const shapeCanvas = document.createElement('canvas');
shapeCanvas.width = width;
shapeCanvas.height = height;
const shapeCtx = shapeCanvas.getContext('2d')!;
shapeCtx.drawImage(img, 0, 0, width, height);
const shapeImageData = shapeCtx.getImageData(0, 0, width, height);
const data = shapeImageData.data;
const shapeMask = new Array(width * height).fill(false);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx4 = (y * width + x) * 4;
const r = data[idx4];
const g = data[idx4 + 1];
const b = data[idx4 + 2];
const a = data[idx4 + 3];
shapeMask[y * width + x] = !((r === 255 && g === 255 && b === 255 && a === 255) || a === 0);
}
}
function inside(x: number, y: number) {
if (x < 0 || x >= width || y < 0 || y >= height) return false;
return shapeMask[y * width + x];
}
const boundaryMask = new Array(width * height).fill(false);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = y * width + x;
if (!shapeMask[idx]) continue;
let isBoundary = false;
for (let ny = y - 1; ny <= y + 1 && !isBoundary; ny++) {
for (let nx = x - 1; nx <= x + 1 && !isBoundary; nx++) {
if (!inside(nx, ny)) {
isBoundary = true;
}
}
}
if (isBoundary) {
boundaryMask[idx] = true;
}
}
}
const interiorMask = new Array(width * height).fill(false);
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
const idx = y * width + x;
if (
shapeMask[idx] &&
shapeMask[idx - 1] &&
shapeMask[idx + 1] &&
shapeMask[idx - width] &&
shapeMask[idx + width]
) {
interiorMask[idx] = true;
}
}
}
const u = new Float32Array(width * height).fill(0);
const newU = new Float32Array(width * height).fill(0);
const C = 0.01;
const ITERATIONS = 300;
function getU(x: number, y: number, arr: Float32Array) {
if (x < 0 || x >= width || y < 0 || y >= height) return 0;
if (!shapeMask[y * width + x]) return 0;
return arr[y * width + x];
}
for (let iter = 0; iter < ITERATIONS; iter++) {
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = y * width + x;
if (!shapeMask[idx] || boundaryMask[idx]) {
newU[idx] = 0;
continue;
}
const sumN = getU(x + 1, y, u) + getU(x - 1, y, u) + getU(x, y + 1, u) + getU(x, y - 1, u);
newU[idx] = (C + sumN) / 4;
}
}
u.set(newU);
}
let maxVal = 0;
for (let i = 0; i < width * height; i++) {
if (u[i] > maxVal) maxVal = u[i];
}
const alpha = 2.0;
const outImg = ctx.createImageData(width, height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = y * width + x;
const px = idx * 4;
if (!shapeMask[idx]) {
outImg.data[px] = 255;
outImg.data[px + 1] = 255;
outImg.data[px + 2] = 255;
outImg.data[px + 3] = 255;
} else {
const raw = u[idx] / maxVal;
const remapped = Math.pow(raw, alpha);
const gray = 255 * (1 - remapped);
outImg.data[px] = gray;
outImg.data[px + 1] = gray;
outImg.data[px + 2] = gray;
outImg.data[px + 3] = 255;
}
}
}
ctx.putImageData(outImg, 0, 0);
canvas.toBlob(blob => {
if (!blob) {
reject(new Error('Failed to create PNG blob'));
return;
}
resolve({
imageData: outImg,
pngBlob: blob
});
}, 'image/png');
};
img.onerror = () => reject(new Error('Failed to load image'));
img.src = URL.createObjectURL(file);
});
}

View File

@@ -1,14 +1,14 @@
<script setup lang="ts">
import { ref, onMounted, watch, onUnmounted, nextTick } from 'vue'
import { gsap } from 'gsap'
import { ref, onMounted, watch, onUnmounted, nextTick } from 'vue';
import { gsap } from 'gsap';
interface PixelTransitionProps {
gridSize?: number
pixelColor?: string
animationStepDuration?: number
className?: string
style?: Record<string, string | number>
aspectRatio?: string
gridSize?: number;
pixelColor?: string;
animationStepDuration?: number;
className?: string;
style?: Record<string, string | number>;
aspectRatio?: string;
}
const props = withDefaults(defineProps<PixelTransitionProps>(), {
@@ -18,114 +18,127 @@ const props = withDefaults(defineProps<PixelTransitionProps>(), {
className: '',
style: () => ({}),
aspectRatio: '100%'
})
});
const containerRef = ref<HTMLDivElement | null>(null)
const pixelGridRef = ref<HTMLDivElement | null>(null)
const activeRef = ref<HTMLDivElement | null>(null)
const isActive = ref(false)
let delayedCall: gsap.core.Tween | null = null
const containerRef = ref<HTMLDivElement | null>(null);
const pixelGridRef = ref<HTMLDivElement | null>(null);
const activeRef = ref<HTMLDivElement | null>(null);
const isActive = ref(false);
let delayedCall: gsap.core.Tween | null = null;
const isTouchDevice =
typeof window !== 'undefined' &&
('ontouchstart' in window ||
(navigator && navigator.maxTouchPoints > 0) ||
(window.matchMedia && window.matchMedia('(pointer: coarse)').matches))
(window.matchMedia && window.matchMedia('(pointer: coarse)').matches));
function buildPixelGrid() {
const pixelGridEl = pixelGridRef.value
if (!pixelGridEl) return
pixelGridEl.innerHTML = ''
const pixelGridEl = pixelGridRef.value;
if (!pixelGridEl) return;
pixelGridEl.innerHTML = '';
for (let row = 0; row < props.gridSize; row++) {
for (let col = 0; col < props.gridSize; col++) {
const pixel = document.createElement('div')
pixel.classList.add('pixelated-image-card__pixel', 'absolute', 'hidden')
pixel.style.backgroundColor = props.pixelColor
const size = 100 / props.gridSize
pixel.style.width = `${size}%`
pixel.style.height = `${size}%`
pixel.style.left = `${col * size}%`
pixel.style.top = `${row * size}%`
pixelGridEl.appendChild(pixel)
const pixel = document.createElement('div');
pixel.classList.add('pixelated-image-card__pixel', 'absolute', 'hidden');
pixel.style.backgroundColor = props.pixelColor;
const size = 100 / props.gridSize;
pixel.style.width = `${size}%`;
pixel.style.height = `${size}%`;
pixel.style.left = `${col * size}%`;
pixel.style.top = `${row * size}%`;
pixelGridEl.appendChild(pixel);
}
}
}
async function animatePixels(activate: boolean) {
isActive.value = activate
await nextTick()
const pixelGridEl = pixelGridRef.value
const activeEl = activeRef.value
if (!pixelGridEl || !activeEl) return
const pixels = pixelGridEl.querySelectorAll<HTMLDivElement>('.pixelated-image-card__pixel')
if (!pixels.length) return
gsap.killTweensOf(pixels)
if (delayedCall) delayedCall.kill()
gsap.set(pixels, { display: 'none' })
const totalPixels = pixels.length
const staggerDuration = props.animationStepDuration / totalPixels
isActive.value = activate;
await nextTick();
const pixelGridEl = pixelGridRef.value;
const activeEl = activeRef.value;
if (!pixelGridEl || !activeEl) return;
const pixels = pixelGridEl.querySelectorAll<HTMLDivElement>('.pixelated-image-card__pixel');
if (!pixels.length) return;
gsap.killTweensOf(pixels);
if (delayedCall) delayedCall.kill();
gsap.set(pixels, { display: 'none' });
const totalPixels = pixels.length;
const staggerDuration = props.animationStepDuration / totalPixels;
gsap.to(pixels, {
display: 'block',
duration: 0,
stagger: {
each: staggerDuration,
from: 'random',
},
})
from: 'random'
}
});
delayedCall = gsap.delayedCall(props.animationStepDuration, () => {
activeEl.style.display = activate ? 'block' : 'none'
activeEl.style.pointerEvents = activate ? 'none' : ''
})
activeEl.style.display = activate ? 'block' : 'none';
activeEl.style.pointerEvents = activate ? 'none' : '';
});
gsap.to(pixels, {
display: 'none',
duration: 0,
delay: props.animationStepDuration,
stagger: {
each: staggerDuration,
from: 'random',
},
})
from: 'random'
}
});
}
function handleMouseEnter() {
if (isTouchDevice) return
if (!isActive.value) animatePixels(true)
if (isTouchDevice) return;
if (!isActive.value) animatePixels(true);
}
function handleMouseLeave() {
if (isTouchDevice) return
if (isActive.value) animatePixels(false)
if (isTouchDevice) return;
if (isActive.value) animatePixels(false);
}
function handleClick() {
if (!isTouchDevice) return
animatePixels(!isActive.value)
if (!isTouchDevice) return;
animatePixels(!isActive.value);
}
onMounted(async () => {
await nextTick()
buildPixelGrid()
})
await nextTick();
buildPixelGrid();
});
watch(() => [props.gridSize, props.pixelColor], () => {
buildPixelGrid()
})
watch(
() => [props.gridSize, props.pixelColor],
() => {
buildPixelGrid();
}
);
onUnmounted(() => {
if (delayedCall) delayedCall.kill()
})
if (delayedCall) delayedCall.kill();
});
</script>
<template>
<div ref="containerRef" :class="[
props.className,
'bg-[#222] text-white rounded-[15px] border-2 border-white w-[300px] max-w-full relative overflow-hidden'
]" :style="props.style" @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave" @click="handleClick">
<div
ref="containerRef"
:class="[
props.className,
'bg-[#222] text-white rounded-[15px] border-2 border-white w-[300px] max-w-full relative overflow-hidden'
]"
:style="props.style"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@click="handleClick"
>
<div :style="{ paddingTop: props.aspectRatio }" />
<div class="absolute inset-0 w-full h-full">
<slot name="firstContent" />
</div>
<div ref="activeRef" class="absolute inset-0 w-full h-full z-[2]" style="display: none;">
<div ref="activeRef" class="absolute inset-0 w-full h-full z-[2]" style="display: none">
<slot name="secondContent" />
</div>
<div ref="pixelGridRef" class="absolute inset-0 w-full h-full pointer-events-none z-[3]" />
</div>
</template>

View File

@@ -0,0 +1,355 @@
<template>
<div ref="ribbonsContainer" class="relative w-full h-full overflow-hidden" />
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { Renderer, Transform, Vec3, Color, Polyline } from 'ogl';
interface RibbonsProps {
colors?: string[];
baseSpring?: number;
baseFriction?: number;
baseThickness?: number;
offsetFactor?: number;
maxAge?: number;
pointCount?: number;
speedMultiplier?: number;
enableFade?: boolean;
enableShaderEffect?: boolean;
effectAmplitude?: number;
backgroundColor?: number[];
}
const props = withDefaults(defineProps<RibbonsProps>(), {
colors: () => ['#ff9346', '#7cff67', '#ffee51', '#5227FF'],
baseSpring: 0.03,
baseFriction: 0.9,
baseThickness: 30,
offsetFactor: 0.05,
maxAge: 500,
pointCount: 50,
speedMultiplier: 0.6,
enableFade: false,
enableShaderEffect: false,
effectAmplitude: 2,
backgroundColor: () => [0, 0, 0, 0]
});
const ribbonsContainer = ref<HTMLDivElement>();
let renderer: Renderer;
let scene: Transform;
let lines: {
spring: number;
friction: number;
mouseVelocity: Vec3;
mouseOffset: Vec3;
points: Vec3[];
polyline: Polyline;
}[] = [];
let frameId: number;
let lastTime = performance.now();
const mouse = new Vec3();
let resizeObserver: ResizeObserver | null = null;
const vertex = `
precision highp float;
attribute vec3 position;
attribute vec3 next;
attribute vec3 prev;
attribute vec2 uv;
attribute float side;
uniform vec2 uResolution;
uniform float uDPR;
uniform float uThickness;
uniform float uTime;
uniform float uEnableShaderEffect;
uniform float uEffectAmplitude;
varying vec2 vUV;
vec4 getPosition() {
vec4 current = vec4(position, 1.0);
vec2 aspect = vec2(uResolution.x / uResolution.y, 1.0);
vec2 nextScreen = next.xy * aspect;
vec2 prevScreen = prev.xy * aspect;
vec2 tangent = normalize(nextScreen - prevScreen);
vec2 normal = vec2(-tangent.y, tangent.x);
normal /= aspect;
normal *= mix(1.0, 0.1, pow(abs(uv.y - 0.5) * 2.0, 2.0));
float dist = length(nextScreen - prevScreen);
normal *= smoothstep(0.0, 0.02, dist);
float pixelWidthRatio = 1.0 / (uResolution.y / uDPR);
float pixelWidth = current.w * pixelWidthRatio;
normal *= pixelWidth * uThickness;
current.xy -= normal * side;
if(uEnableShaderEffect > 0.5) {
current.xy += normal * sin(uTime + current.x * 10.0) * uEffectAmplitude;
}
return current;
}
void main() {
vUV = uv;
gl_Position = getPosition();
}
`;
const fragment = `
precision highp float;
uniform vec3 uColor;
uniform float uOpacity;
uniform float uEnableFade;
varying vec2 vUV;
void main() {
float fadeFactor = 1.0;
if(uEnableFade > 0.5) {
fadeFactor = 1.0 - smoothstep(0.0, 1.0, vUV.y);
}
gl_FragColor = vec4(uColor, uOpacity * fadeFactor);
}
`;
const updateMouse = (e: MouseEvent | TouchEvent) => {
const container = ribbonsContainer.value;
if (!container) return;
let x: number, y: number;
const rect = container.getBoundingClientRect();
if ('changedTouches' in e && e.changedTouches.length) {
x = e.changedTouches[0].clientX - rect.left;
y = e.changedTouches[0].clientY - rect.top;
} else if (e instanceof MouseEvent) {
x = e.clientX - rect.left;
y = e.clientY - rect.top;
} else {
x = 0;
y = 0;
}
const width = container.clientWidth;
const height = container.clientHeight;
mouse.set((x / width) * 2 - 1, (y / height) * -2 + 1, 0);
};
const resize = () => {
const container = ribbonsContainer.value;
if (!container || !renderer) return;
const width = container.clientWidth;
const height = container.clientHeight;
renderer.setSize(width, height);
lines.forEach(line => line.polyline.resize());
};
const createLines = () => {
const center = (props.colors.length - 1) / 2;
lines = [];
props.colors.forEach((color, index) => {
const spring = props.baseSpring + (Math.random() - 0.5) * 0.05;
const friction = props.baseFriction + (Math.random() - 0.5) * 0.05;
const thickness = props.baseThickness + (Math.random() - 0.5) * 3;
const mouseOffset = new Vec3(
(index - center) * props.offsetFactor + (Math.random() - 0.5) * 0.01,
(Math.random() - 0.5) * 0.1,
0
);
const line = {
spring,
friction,
mouseVelocity: new Vec3(),
mouseOffset,
points: [] as Vec3[],
polyline: {} as Polyline
};
const count = props.pointCount;
const points: Vec3[] = [];
for (let i = 0; i < count; i++) {
points.push(new Vec3());
}
line.points = points;
line.polyline = new Polyline(renderer.gl, {
points,
vertex,
fragment,
uniforms: {
uColor: { value: new Color(color) },
uThickness: { value: thickness },
uOpacity: { value: 1.0 },
uTime: { value: 0.0 },
uEnableShaderEffect: { value: props.enableShaderEffect ? 1.0 : 0.0 },
uEffectAmplitude: { value: props.effectAmplitude },
uEnableFade: { value: props.enableFade ? 1.0 : 0.0 }
}
});
line.polyline.mesh.setParent(scene);
lines.push(line);
});
};
const update = () => {
frameId = requestAnimationFrame(update);
const currentTime = performance.now();
const dt = currentTime - lastTime;
lastTime = currentTime;
const tmp = new Vec3();
lines.forEach(line => {
tmp.copy(mouse).add(line.mouseOffset).sub(line.points[0]).multiply(line.spring);
line.mouseVelocity.add(tmp).multiply(line.friction);
line.points[0].add(line.mouseVelocity);
for (let i = 1; i < line.points.length; i++) {
if (isFinite(props.maxAge) && props.maxAge > 0) {
const segmentDelay = props.maxAge / (line.points.length - 1);
const alpha = Math.min(1, (dt * props.speedMultiplier) / segmentDelay);
line.points[i].lerp(line.points[i - 1], alpha);
} else {
line.points[i].lerp(line.points[i - 1], 0.9);
}
}
if (line.polyline.mesh.program.uniforms.uTime) {
line.polyline.mesh.program.uniforms.uTime.value = currentTime * 0.001;
}
line.polyline.updateGeometry();
});
renderer.render({ scene });
};
const initRibbons = () => {
const container = ribbonsContainer.value;
if (!container) return;
renderer = new Renderer({ dpr: window.devicePixelRatio || 2, alpha: true });
const gl = renderer.gl;
if (Array.isArray(props.backgroundColor) && props.backgroundColor.length === 4) {
gl.clearColor(
props.backgroundColor[0],
props.backgroundColor[1],
props.backgroundColor[2],
props.backgroundColor[3]
);
} else {
gl.clearColor(0, 0, 0, 0);
}
gl.canvas.style.position = 'absolute';
gl.canvas.style.top = '0';
gl.canvas.style.left = '0';
gl.canvas.style.width = '100%';
gl.canvas.style.height = '100%';
container.appendChild(gl.canvas);
scene = new Transform();
createLines();
container.addEventListener('mousemove', updateMouse);
container.addEventListener('touchstart', updateMouse);
container.addEventListener('touchmove', updateMouse);
resize();
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(container);
} else {
window.addEventListener('resize', resize);
}
update();
};
const cleanup = () => {
if (frameId) {
cancelAnimationFrame(frameId);
}
if (resizeObserver) {
resizeObserver.disconnect();
} else {
window.removeEventListener('resize', resize);
}
const container = ribbonsContainer.value;
if (container) {
container.removeEventListener('mousemove', updateMouse);
container.removeEventListener('touchstart', updateMouse);
container.removeEventListener('touchmove', updateMouse);
if (renderer && renderer.gl.canvas && renderer.gl.canvas.parentNode === container) {
container.removeChild(renderer.gl.canvas);
}
}
};
const recreateLines = () => {
lines.forEach(line => {
if (line.polyline.mesh && line.polyline.mesh.parent) {
line.polyline.mesh.setParent(null);
}
});
createLines();
};
watch(
() => [props.colors, props.pointCount],
() => {
if (renderer && scene) {
recreateLines();
}
},
{ deep: true }
);
watch(
() => [props.baseThickness, props.enableFade, props.enableShaderEffect, props.effectAmplitude, props.backgroundColor],
() => {
if (renderer && lines.length > 0) {
lines.forEach(line => {
if (line.polyline.mesh.program.uniforms.uEnableFade) {
line.polyline.mesh.program.uniforms.uEnableFade.value = props.enableFade ? 1.0 : 0.0;
}
if (line.polyline.mesh.program.uniforms.uEnableShaderEffect) {
line.polyline.mesh.program.uniforms.uEnableShaderEffect.value = props.enableShaderEffect ? 1.0 : 0.0;
}
if (line.polyline.mesh.program.uniforms.uEffectAmplitude) {
line.polyline.mesh.program.uniforms.uEffectAmplitude.value = props.effectAmplitude;
}
});
const gl = renderer.gl;
if (Array.isArray(props.backgroundColor) && props.backgroundColor.length === 4) {
gl.clearColor(
props.backgroundColor[0],
props.backgroundColor[1],
props.backgroundColor[2],
props.backgroundColor[3]
);
} else {
gl.clearColor(0, 0, 0, 0);
}
}
},
{ deep: true }
);
onMounted(() => {
initRibbons();
});
onUnmounted(() => {
cleanup();
});
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -3,18 +3,18 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, type CSSProperties } from 'vue'
import { Renderer, Program, Mesh, Color, Triangle } from 'ogl'
import { ref, onMounted, onUnmounted, watch, type CSSProperties } from 'vue';
import { Renderer, Program, Mesh, Color, Triangle } from 'ogl';
interface AuroraProps {
colorStops?: string[]
amplitude?: number
blend?: number
time?: number
speed?: number
intensity?: number
className?: string
style?: CSSProperties
colorStops?: string[];
amplitude?: number;
blend?: number;
time?: number;
speed?: number;
intensity?: number;
className?: string;
style?: CSSProperties;
}
const props = withDefaults(defineProps<AuroraProps>(), {
@@ -25,16 +25,16 @@ const props = withDefaults(defineProps<AuroraProps>(), {
intensity: 1.0,
className: '',
style: () => ({})
})
});
const containerRef = ref<HTMLDivElement>()
const containerRef = ref<HTMLDivElement>();
const VERT = `#version 300 es
in vec2 position;
void main() {
gl_Position = vec4(position, 0.0, 1.0);
}
`
`;
const FRAG = `#version 300 es
precision highp float;
@@ -136,56 +136,56 @@ void main() {
fragColor = vec4(auroraColor * finalAlpha, finalAlpha);
}
`
`;
let renderer: Renderer | null = null
let animateId = 0
let renderer: Renderer | null = null;
let animateId = 0;
const initAurora = () => {
const container = containerRef.value
if (!container) return
const container = containerRef.value;
if (!container) return;
renderer = new Renderer({
alpha: true,
premultipliedAlpha: true,
antialias: true,
})
antialias: true
});
const gl = renderer.gl
gl.clearColor(0, 0, 0, 0)
gl.enable(gl.BLEND)
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA)
gl.canvas.style.backgroundColor = 'transparent'
const gl = renderer.gl;
gl.clearColor(0, 0, 0, 0);
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.canvas.style.backgroundColor = 'transparent';
// eslint-disable-next-line prefer-const
let program: Program | undefined
let program: Program | undefined;
const resize = () => {
if (!container) return
if (!container) return;
const parentWidth = container.parentElement?.offsetWidth || container.offsetWidth || window.innerWidth
const parentHeight = container.parentElement?.offsetHeight || container.offsetHeight || window.innerHeight
const parentWidth = container.parentElement?.offsetWidth || container.offsetWidth || window.innerWidth;
const parentHeight = container.parentElement?.offsetHeight || container.offsetHeight || window.innerHeight;
const width = Math.max(parentWidth, 300)
const height = Math.max(parentHeight, 300)
const width = Math.max(parentWidth, 300);
const height = Math.max(parentHeight, 300);
renderer!.setSize(width, height)
renderer!.setSize(width, height);
if (program) {
program.uniforms.uResolution.value = [width, height]
program.uniforms.uResolution.value = [width, height];
}
}
};
window.addEventListener('resize', resize)
window.addEventListener('resize', resize);
const geometry = new Triangle(gl)
const geometry = new Triangle(gl);
if (geometry.attributes.uv) {
delete geometry.attributes.uv
delete geometry.attributes.uv;
}
const colorStopsArray = props.colorStops.map((hex) => {
const c = new Color(hex)
return [c.r, c.g, c.b]
})
const colorStopsArray = props.colorStops.map(hex => {
const c = new Color(hex);
return [c.r, c.g, c.b];
});
program = new Program(gl, {
vertex: VERT,
@@ -194,83 +194,88 @@ const initAurora = () => {
uTime: { value: 0 },
uAmplitude: { value: props.amplitude },
uColorStops: { value: colorStopsArray },
uResolution: { value: [Math.max(container.parentElement?.offsetWidth || container.offsetWidth || window.innerWidth, 300), Math.max(container.parentElement?.offsetHeight || container.offsetHeight || window.innerHeight, 300)] },
uResolution: {
value: [
Math.max(container.parentElement?.offsetWidth || container.offsetWidth || window.innerWidth, 300),
Math.max(container.parentElement?.offsetHeight || container.offsetHeight || window.innerHeight, 300)
]
},
uBlend: { value: props.blend },
uIntensity: { value: props.intensity },
},
})
uIntensity: { value: props.intensity }
}
});
const mesh = new Mesh(gl, { geometry, program })
container.appendChild(gl.canvas)
const mesh = new Mesh(gl, { geometry, program });
container.appendChild(gl.canvas);
gl.canvas.style.width = '100%'
gl.canvas.style.height = '100%'
gl.canvas.style.display = 'block'
gl.canvas.style.position = 'absolute'
gl.canvas.style.top = '0'
gl.canvas.style.left = '0'
gl.canvas.style.width = '100%';
gl.canvas.style.height = '100%';
gl.canvas.style.display = 'block';
gl.canvas.style.position = 'absolute';
gl.canvas.style.top = '0';
gl.canvas.style.left = '0';
const update = (t: number) => {
animateId = requestAnimationFrame(update)
const time = props.time ?? t * 0.01
const speed = props.speed ?? 1.0
animateId = requestAnimationFrame(update);
const time = props.time ?? t * 0.01;
const speed = props.speed ?? 1.0;
if (program) {
program.uniforms.uTime.value = time * speed * 0.1
program.uniforms.uAmplitude.value = props.amplitude ?? 1.0
program.uniforms.uBlend.value = props.blend ?? 0.5
program.uniforms.uIntensity.value = props.intensity ?? 1.0
const stops = props.colorStops ?? ['#27FF64', '#7cff67', '#27FF64']
program.uniforms.uTime.value = time * speed * 0.1;
program.uniforms.uAmplitude.value = props.amplitude ?? 1.0;
program.uniforms.uBlend.value = props.blend ?? 0.5;
program.uniforms.uIntensity.value = props.intensity ?? 1.0;
const stops = props.colorStops ?? ['#27FF64', '#7cff67', '#27FF64'];
program.uniforms.uColorStops.value = stops.map((hex: string) => {
const c = new Color(hex)
return [c.r, c.g, c.b]
})
renderer!.render({ scene: mesh })
const c = new Color(hex);
return [c.r, c.g, c.b];
});
renderer!.render({ scene: mesh });
}
}
animateId = requestAnimationFrame(update)
};
animateId = requestAnimationFrame(update);
resize()
resize();
return () => {
cancelAnimationFrame(animateId)
window.removeEventListener('resize', resize)
cancelAnimationFrame(animateId);
window.removeEventListener('resize', resize);
if (container && gl.canvas.parentNode === container) {
container.removeChild(gl.canvas)
container.removeChild(gl.canvas);
}
gl.getExtension('WEBGL_lose_context')?.loseContext()
}
}
gl.getExtension('WEBGL_lose_context')?.loseContext();
};
};
const cleanup = () => {
if (animateId) {
cancelAnimationFrame(animateId)
cancelAnimationFrame(animateId);
}
if (renderer) {
const gl = renderer.gl
const container = containerRef.value
const gl = renderer.gl;
const container = containerRef.value;
if (container && gl.canvas.parentNode === container) {
container.removeChild(gl.canvas)
container.removeChild(gl.canvas);
}
gl.getExtension('WEBGL_lose_context')?.loseContext()
gl.getExtension('WEBGL_lose_context')?.loseContext();
}
renderer = null
}
renderer = null;
};
onMounted(() => {
initAurora()
})
initAurora();
});
onUnmounted(() => {
cleanup()
})
cleanup();
});
watch(
() => [props.amplitude, props.intensity],
() => {
cleanup()
initAurora()
cleanup();
initAurora();
}
)
);
</script>
<style scoped>
@@ -289,4 +294,4 @@ div {
width: 100% !important;
height: 100% !important;
}
</style>
</style>

View File

@@ -3,19 +3,19 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
import * as THREE from 'three'
import { degToRad } from 'three/src/math/MathUtils.js'
import { ref, onMounted, onUnmounted, watch, computed } from 'vue';
import * as THREE from 'three';
import { degToRad } from 'three/src/math/MathUtils.js';
interface BeamsProps {
beamWidth?: number
beamHeight?: number
beamNumber?: number
lightColor?: string
speed?: number
noiseIntensity?: number
scale?: number
rotation?: number
beamWidth?: number;
beamHeight?: number;
beamNumber?: number;
lightColor?: string;
speed?: number;
noiseIntensity?: number;
scale?: number;
rotation?: number;
}
const props = withDefaults(defineProps<BeamsProps>(), {
@@ -27,41 +27,41 @@ const props = withDefaults(defineProps<BeamsProps>(), {
noiseIntensity: 1.75,
scale: 0.2,
rotation: 0
})
});
const containerRef = ref<HTMLDivElement>()
const containerRef = ref<HTMLDivElement>();
let renderer: THREE.WebGLRenderer | null = null
let scene: THREE.Scene | null = null
let camera: THREE.PerspectiveCamera | null = null
let beamMesh: THREE.Mesh<THREE.BufferGeometry, THREE.ShaderMaterial> | null = null
let directionalLight: THREE.DirectionalLight | null = null
let ambientLight: THREE.AmbientLight | null = null
let animationId: number | null = null
let renderer: THREE.WebGLRenderer | null = null;
let scene: THREE.Scene | null = null;
let camera: THREE.PerspectiveCamera | null = null;
let beamMesh: THREE.Mesh<THREE.BufferGeometry, THREE.ShaderMaterial> | null = null;
let directionalLight: THREE.DirectionalLight | null = null;
let ambientLight: THREE.AmbientLight | null = null;
let animationId: number | null = null;
type UniformValue = THREE.IUniform<unknown> | unknown
type UniformValue = THREE.IUniform<unknown> | unknown;
interface ExtendMaterialConfig {
header: string
vertexHeader?: string
fragmentHeader?: string
material?: THREE.MeshPhysicalMaterialParameters & { fog?: boolean }
uniforms?: Record<string, UniformValue>
vertex?: Record<string, string>
fragment?: Record<string, string>
header: string;
vertexHeader?: string;
fragmentHeader?: string;
material?: THREE.MeshPhysicalMaterialParameters & { fog?: boolean };
uniforms?: Record<string, UniformValue>;
vertex?: Record<string, string>;
fragment?: Record<string, string>;
}
type ShaderWithDefines = THREE.ShaderLibShader & {
defines?: Record<string, string | number | boolean>
}
defines?: Record<string, string | number | boolean>;
};
const hexToNormalizedRGB = (hex: string): [number, number, number] => {
const clean = hex.replace('#', '')
const r = parseInt(clean.substring(0, 2), 16)
const g = parseInt(clean.substring(2, 4), 16)
const b = parseInt(clean.substring(4, 6), 16)
return [r / 255, g / 255, b / 255]
}
const clean = hex.replace('#', '');
const r = parseInt(clean.substring(0, 2), 16);
const g = parseInt(clean.substring(2, 4), 16);
const b = parseInt(clean.substring(4, 6), 16);
return [r / 255, g / 255, b / 255];
};
const noise = `
float random (in vec2 st) {
@@ -138,53 +138,47 @@ float cnoise(vec3 P){
float n_xyz = mix(n_yz.x,n_yz.y,fade_xyz.x);
return 2.2 * n_xyz;
}
`
`;
function extendMaterial<T extends THREE.Material = THREE.Material>(
BaseMaterial: new (params?: THREE.MaterialParameters) => T,
cfg: ExtendMaterialConfig
): THREE.ShaderMaterial {
const physical = THREE.ShaderLib.physical as ShaderWithDefines
const {
vertexShader: baseVert,
fragmentShader: baseFrag,
uniforms: baseUniforms,
} = physical
const baseDefines = physical.defines ?? {}
const physical = THREE.ShaderLib.physical as ShaderWithDefines;
const { vertexShader: baseVert, fragmentShader: baseFrag, uniforms: baseUniforms } = physical;
const baseDefines = physical.defines ?? {};
const uniforms: Record<string, THREE.IUniform> =
THREE.UniformsUtils.clone(baseUniforms)
const uniforms: Record<string, THREE.IUniform> = THREE.UniformsUtils.clone(baseUniforms);
const defaults = new BaseMaterial(cfg.material || {}) as T & {
color?: THREE.Color
roughness?: number
metalness?: number
envMap?: THREE.Texture
envMapIntensity?: number
}
color?: THREE.Color;
roughness?: number;
metalness?: number;
envMap?: THREE.Texture;
envMapIntensity?: number;
};
if (defaults.color) uniforms.diffuse.value = defaults.color
if ('roughness' in defaults) uniforms.roughness.value = defaults.roughness
if ('metalness' in defaults) uniforms.metalness.value = defaults.metalness
if ('envMap' in defaults) uniforms.envMap.value = defaults.envMap
if ('envMapIntensity' in defaults)
uniforms.envMapIntensity.value = defaults.envMapIntensity
if (defaults.color) uniforms.diffuse.value = defaults.color;
if ('roughness' in defaults) uniforms.roughness.value = defaults.roughness;
if ('metalness' in defaults) uniforms.metalness.value = defaults.metalness;
if ('envMap' in defaults) uniforms.envMap.value = defaults.envMap;
if ('envMapIntensity' in defaults) uniforms.envMapIntensity.value = defaults.envMapIntensity;
Object.entries(cfg.uniforms ?? {}).forEach(([key, u]) => {
uniforms[key] =
u !== null && typeof u === 'object' && 'value' in u
? (u as THREE.IUniform<unknown>)
: ({ value: u } as THREE.IUniform<unknown>)
})
: ({ value: u } as THREE.IUniform<unknown>);
});
let vert = `${cfg.header}\n${cfg.vertexHeader ?? ''}\n${baseVert}`
let frag = `${cfg.header}\n${cfg.fragmentHeader ?? ''}\n${baseFrag}`
let vert = `${cfg.header}\n${cfg.vertexHeader ?? ''}\n${baseVert}`;
let frag = `${cfg.header}\n${cfg.fragmentHeader ?? ''}\n${baseFrag}`;
for (const [inc, code] of Object.entries(cfg.vertex ?? {})) {
vert = vert.replace(inc, `${inc}\n${code}`)
vert = vert.replace(inc, `${inc}\n${code}`);
}
for (const [inc, code] of Object.entries(cfg.fragment ?? {})) {
frag = frag.replace(inc, `${inc}\n${code}`)
frag = frag.replace(inc, `${inc}\n${code}`);
}
const mat = new THREE.ShaderMaterial({
@@ -193,10 +187,10 @@ function extendMaterial<T extends THREE.Material = THREE.Material>(
vertexShader: vert,
fragmentShader: frag,
lights: true,
fog: !!cfg.material?.fog,
})
fog: !!cfg.material?.fog
});
return mat
return mat;
}
function createStackedPlanesBufferGeometry(
@@ -206,54 +200,51 @@ function createStackedPlanesBufferGeometry(
spacing: number,
heightSegments: number
): THREE.BufferGeometry {
const geometry = new THREE.BufferGeometry()
const numVertices = n * (heightSegments + 1) * 2
const numFaces = n * heightSegments * 2
const positions = new Float32Array(numVertices * 3)
const indices = new Uint32Array(numFaces * 3)
const uvs = new Float32Array(numVertices * 2)
const geometry = new THREE.BufferGeometry();
const numVertices = n * (heightSegments + 1) * 2;
const numFaces = n * heightSegments * 2;
const positions = new Float32Array(numVertices * 3);
const indices = new Uint32Array(numFaces * 3);
const uvs = new Float32Array(numVertices * 2);
let vertexOffset = 0
let indexOffset = 0
let uvOffset = 0
const totalWidth = n * width + (n - 1) * spacing
const xOffsetBase = -totalWidth / 2
let vertexOffset = 0;
let indexOffset = 0;
let uvOffset = 0;
const totalWidth = n * width + (n - 1) * spacing;
const xOffsetBase = -totalWidth / 2;
for (let i = 0; i < n; i++) {
const xOffset = xOffsetBase + i * (width + spacing)
const uvXOffset = Math.random() * 300
const uvYOffset = Math.random() * 300
const xOffset = xOffsetBase + i * (width + spacing);
const uvXOffset = Math.random() * 300;
const uvYOffset = Math.random() * 300;
for (let j = 0; j <= heightSegments; j++) {
const y = height * (j / heightSegments - 0.5)
const v0 = [xOffset, y, 0]
const v1 = [xOffset + width, y, 0]
positions.set([...v0, ...v1], vertexOffset * 3)
const y = height * (j / heightSegments - 0.5);
const v0 = [xOffset, y, 0];
const v1 = [xOffset + width, y, 0];
positions.set([...v0, ...v1], vertexOffset * 3);
const uvY = j / heightSegments
uvs.set(
[uvXOffset, uvY + uvYOffset, uvXOffset + 1, uvY + uvYOffset],
uvOffset
)
const uvY = j / heightSegments;
uvs.set([uvXOffset, uvY + uvYOffset, uvXOffset + 1, uvY + uvYOffset], uvOffset);
if (j < heightSegments) {
const a = vertexOffset,
b = vertexOffset + 1,
c = vertexOffset + 2,
d = vertexOffset + 3
indices.set([a, b, c, c, b, d], indexOffset)
indexOffset += 6
d = vertexOffset + 3;
indices.set([a, b, c, c, b, d], indexOffset);
indexOffset += 6;
}
vertexOffset += 2
uvOffset += 4
vertexOffset += 2;
uvOffset += 4;
}
}
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
geometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2))
geometry.setIndex(new THREE.BufferAttribute(indices, 1))
geometry.computeVertexNormals()
return geometry
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
geometry.setIndex(new THREE.BufferAttribute(indices, 1));
geometry.computeVertexNormals();
return geometry;
}
const beamMaterial = computed(() =>
@@ -290,12 +281,12 @@ const beamMaterial = computed(() =>
fragmentHeader: '',
vertex: {
'#include <begin_vertex>': `transformed.z += getPos(transformed.xyz);`,
'#include <beginnormal_vertex>': `objectNormal = getNormal(position.xyz);`,
'#include <beginnormal_vertex>': `objectNormal = getNormal(position.xyz);`
},
fragment: {
'#include <dithering_fragment>': `
float randomNoise = noise(gl_FragCoord.xy);
gl_FragColor.rgb -= randomNoise / 15. * uNoiseIntensity;`,
gl_FragColor.rgb -= randomNoise / 15. * uNoiseIntensity;`
},
material: { fog: true },
uniforms: {
@@ -306,127 +297,120 @@ const beamMaterial = computed(() =>
uSpeed: { shared: true, mixed: true, linked: true, value: props.speed },
envMapIntensity: 10,
uNoiseIntensity: props.noiseIntensity,
uScale: props.scale,
},
uScale: props.scale
}
})
)
);
const initThreeJS = () => {
if (!containerRef.value) return
if (!containerRef.value) return;
cleanup()
cleanup();
const container = containerRef.value;
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setClearColor(0x000000, 1);
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(30, 1, 0.1, 1000);
camera.position.set(0, 0, 20);
const geometry = createStackedPlanesBufferGeometry(props.beamNumber, props.beamWidth, props.beamHeight, 0, 100);
const material = beamMaterial.value;
beamMesh = new THREE.Mesh(geometry, material);
const group = new THREE.Group();
group.rotation.z = degToRad(props.rotation);
group.add(beamMesh);
scene.add(group);
directionalLight = new THREE.DirectionalLight(new THREE.Color(props.lightColor), 1);
directionalLight.position.set(0, 3, 10);
const shadowCamera = directionalLight.shadow.camera as THREE.OrthographicCamera;
shadowCamera.top = 24;
shadowCamera.bottom = -24;
shadowCamera.left = -24;
shadowCamera.right = 24;
shadowCamera.far = 64;
directionalLight.shadow.bias = -0.004;
scene.add(directionalLight);
ambientLight = new THREE.AmbientLight(0xffffff, 1);
scene.add(ambientLight);
container.appendChild(renderer.domElement);
const container = containerRef.value
renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.setClearColor(0x000000, 1)
scene = new THREE.Scene()
camera = new THREE.PerspectiveCamera(30, 1, 0.1, 1000)
camera.position.set(0, 0, 20)
const geometry = createStackedPlanesBufferGeometry(
props.beamNumber,
props.beamWidth,
props.beamHeight,
0,
100
)
const material = beamMaterial.value
beamMesh = new THREE.Mesh(geometry, material)
const group = new THREE.Group()
group.rotation.z = degToRad(props.rotation)
group.add(beamMesh)
scene.add(group)
directionalLight = new THREE.DirectionalLight(new THREE.Color(props.lightColor), 1)
directionalLight.position.set(0, 3, 10)
const shadowCamera = directionalLight.shadow.camera as THREE.OrthographicCamera
shadowCamera.top = 24
shadowCamera.bottom = -24
shadowCamera.left = -24
shadowCamera.right = 24
shadowCamera.far = 64
directionalLight.shadow.bias = -0.004
scene.add(directionalLight)
ambientLight = new THREE.AmbientLight(0xffffff, 1)
scene.add(ambientLight)
container.appendChild(renderer.domElement)
const resize = () => {
if (!container || !renderer || !camera) return
const width = container.offsetWidth
const height = container.offsetHeight
renderer.setSize(width, height)
camera.aspect = width / height
camera.updateProjectionMatrix()
}
const resizeObserver = new ResizeObserver(resize)
resizeObserver.observe(container)
resize()
const animate = (time: number) => {
animationId = requestAnimationFrame(animate)
if (!container || !renderer || !camera) return;
const width = container.offsetWidth;
const height = container.offsetHeight;
renderer.setSize(width, height);
camera.aspect = width / height;
camera.updateProjectionMatrix();
};
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(container);
resize();
const animate = () => {
animationId = requestAnimationFrame(animate);
if (beamMesh && beamMesh.material) {
beamMesh.material.uniforms.time.value += 0.1 * 0.016
beamMesh.material.uniforms.time.value += 0.1 * 0.016;
}
if (renderer && scene && camera) {
renderer.render(scene, camera)
renderer.render(scene, camera);
}
}
animationId = requestAnimationFrame(animate)
;(container as any)._resizeObserver = resizeObserver
}
};
animationId = requestAnimationFrame(animate);
(container as HTMLDivElement & { _resizeObserver?: ResizeObserver })._resizeObserver = resizeObserver;
};
const cleanup = () => {
if (animationId) {
cancelAnimationFrame(animationId)
animationId = null
cancelAnimationFrame(animationId);
animationId = null;
}
if (containerRef.value) {
const container = containerRef.value
if ((container as any)._resizeObserver) {
;(container as any)._resizeObserver.disconnect()
delete (container as any)._resizeObserver
const container = containerRef.value as HTMLDivElement & { _resizeObserver?: ResizeObserver };
if (container._resizeObserver) {
container._resizeObserver.disconnect();
delete container._resizeObserver;
}
if (renderer && renderer.domElement.parentNode === container) {
container.removeChild(renderer.domElement)
container.removeChild(renderer.domElement);
}
}
if (beamMesh) {
if (beamMesh.geometry) beamMesh.geometry.dispose()
if (beamMesh.material) beamMesh.material.dispose()
beamMesh = null
if (beamMesh.geometry) beamMesh.geometry.dispose();
if (beamMesh.material) beamMesh.material.dispose();
beamMesh = null;
}
if (renderer) {
renderer.dispose()
renderer = null
renderer.dispose();
renderer = null;
}
scene = null
camera = null
directionalLight = null
ambientLight = null
}
scene = null;
camera = null;
directionalLight = null;
ambientLight = null;
};
watch(
() => [
@@ -437,19 +421,19 @@ watch(
props.speed,
props.noiseIntensity,
props.scale,
props.rotation,
props.rotation
],
() => {
initThreeJS()
initThreeJS();
},
{ deep: true }
)
);
onMounted(() => {
initThreeJS()
})
initThreeJS();
});
onUnmounted(() => {
cleanup()
})
cleanup();
});
</script>

View File

@@ -7,45 +7,45 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, watch, nextTick } from 'vue'
import { gsap } from 'gsap'
import { InertiaPlugin } from 'gsap/InertiaPlugin'
import { ref, onMounted, onUnmounted, computed, watch, nextTick } from 'vue';
import { gsap } from 'gsap';
import { InertiaPlugin } from 'gsap/InertiaPlugin';
gsap.registerPlugin(InertiaPlugin)
gsap.registerPlugin(InertiaPlugin);
const throttle = <T extends unknown[]>(func: (...args: T) => void, limit: number) => {
let lastCall = 0
let lastCall = 0;
return function (this: unknown, ...args: T) {
const now = performance.now()
const now = performance.now();
if (now - lastCall >= limit) {
lastCall = now
func.apply(this, args)
lastCall = now;
func.apply(this, args);
}
}
}
};
};
interface Dot {
cx: number
cy: number
xOffset: number
yOffset: number
_inertiaApplied: boolean
cx: number;
cy: number;
xOffset: number;
yOffset: number;
_inertiaApplied: boolean;
}
export interface DotGridProps {
dotSize?: number
gap?: number
baseColor?: string
activeColor?: string
proximity?: number
speedTrigger?: number
shockRadius?: number
shockStrength?: number
maxSpeed?: number
resistance?: number
returnDuration?: number
className?: string
style?: Record<string, string | number>
dotSize?: number;
gap?: number;
baseColor?: string;
activeColor?: string;
proximity?: number;
speedTrigger?: number;
shockRadius?: number;
shockStrength?: number;
maxSpeed?: number;
resistance?: number;
returnDuration?: number;
className?: string;
style?: Record<string, string | number>;
}
const props = withDefaults(defineProps<DotGridProps>(), {
@@ -62,11 +62,11 @@ const props = withDefaults(defineProps<DotGridProps>(), {
returnDuration: 1.5,
className: '',
style: () => ({})
})
});
const wrapperRef = ref<HTMLDivElement>()
const canvasRef = ref<HTMLCanvasElement>()
const dots = ref<Dot[]>([])
const wrapperRef = ref<HTMLDivElement>();
const canvasRef = ref<HTMLCanvasElement>();
const dots = ref<Dot[]>([]);
const pointer = ref({
x: 0,
y: 0,
@@ -75,146 +75,146 @@ const pointer = ref({
speed: 0,
lastTime: 0,
lastX: 0,
lastY: 0,
})
lastY: 0
});
function hexToRgb(hex: string) {
const m = hex.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i)
if (!m) return { r: 0, g: 0, b: 0 }
const m = hex.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
if (!m) return { r: 0, g: 0, b: 0 };
return {
r: parseInt(m[1], 16),
g: parseInt(m[2], 16),
b: parseInt(m[3], 16),
}
b: parseInt(m[3], 16)
};
}
const baseRgb = computed(() => hexToRgb(props.baseColor))
const activeRgb = computed(() => hexToRgb(props.activeColor))
const baseRgb = computed(() => hexToRgb(props.baseColor));
const activeRgb = computed(() => hexToRgb(props.activeColor));
const circlePath = computed(() => {
if (typeof window === 'undefined' || !window.Path2D) return null
if (typeof window === 'undefined' || !window.Path2D) return null;
const p = new Path2D()
p.arc(0, 0, props.dotSize / 2, 0, Math.PI * 2)
return p
})
const p = new Path2D();
p.arc(0, 0, props.dotSize / 2, 0, Math.PI * 2);
return p;
});
const buildGrid = () => {
const wrap = wrapperRef.value
const canvas = canvasRef.value
if (!wrap || !canvas) return
const wrap = wrapperRef.value;
const canvas = canvasRef.value;
if (!wrap || !canvas) return;
const { width, height } = wrap.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
const { width, height } = wrap.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr
canvas.height = height * dpr
canvas.style.width = `${width}px`
canvas.style.height = `${height}px`
const ctx = canvas.getContext('2d')
if (ctx) ctx.scale(dpr, dpr)
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
const ctx = canvas.getContext('2d');
if (ctx) ctx.scale(dpr, dpr);
const cols = Math.floor((width + props.gap) / (props.dotSize + props.gap))
const rows = Math.floor((height + props.gap) / (props.dotSize + props.gap))
const cell = props.dotSize + props.gap
const cols = Math.floor((width + props.gap) / (props.dotSize + props.gap));
const rows = Math.floor((height + props.gap) / (props.dotSize + props.gap));
const cell = props.dotSize + props.gap;
const gridW = cell * cols - props.gap
const gridH = cell * rows - props.gap
const gridW = cell * cols - props.gap;
const gridH = cell * rows - props.gap;
const extraX = width - gridW
const extraY = height - gridH
const extraX = width - gridW;
const extraY = height - gridH;
const startX = extraX / 2 + props.dotSize / 2
const startY = extraY / 2 + props.dotSize / 2
const startX = extraX / 2 + props.dotSize / 2;
const startY = extraY / 2 + props.dotSize / 2;
const newDots: Dot[] = []
const newDots: Dot[] = [];
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const cx = startX + x * cell
const cy = startY + y * cell
newDots.push({ cx, cy, xOffset: 0, yOffset: 0, _inertiaApplied: false })
const cx = startX + x * cell;
const cy = startY + y * cell;
newDots.push({ cx, cy, xOffset: 0, yOffset: 0, _inertiaApplied: false });
}
}
dots.value = newDots
}
dots.value = newDots;
};
let rafId: number
let resizeObserver: ResizeObserver | null = null
let rafId: number;
let resizeObserver: ResizeObserver | null = null;
const draw = () => {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.clearRect(0, 0, canvas.width, canvas.height)
const canvas = canvasRef.value;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
const { x: px, y: py } = pointer.value
const proxSq = props.proximity * props.proximity
const { x: px, y: py } = pointer.value;
const proxSq = props.proximity * props.proximity;
for (const dot of dots.value) {
const ox = dot.cx + dot.xOffset
const oy = dot.cy + dot.yOffset
const dx = dot.cx - px
const dy = dot.cy - py
const dsq = dx * dx + dy * dy
const ox = dot.cx + dot.xOffset;
const oy = dot.cy + dot.yOffset;
const dx = dot.cx - px;
const dy = dot.cy - py;
const dsq = dx * dx + dy * dy;
let style = props.baseColor
let style = props.baseColor;
if (dsq <= proxSq) {
const dist = Math.sqrt(dsq)
const t = 1 - dist / props.proximity
const r = Math.round(baseRgb.value.r + (activeRgb.value.r - baseRgb.value.r) * t)
const g = Math.round(baseRgb.value.g + (activeRgb.value.g - baseRgb.value.g) * t)
const b = Math.round(baseRgb.value.b + (activeRgb.value.b - baseRgb.value.b) * t)
style = `rgb(${r},${g},${b})`
const dist = Math.sqrt(dsq);
const t = 1 - dist / props.proximity;
const r = Math.round(baseRgb.value.r + (activeRgb.value.r - baseRgb.value.r) * t);
const g = Math.round(baseRgb.value.g + (activeRgb.value.g - baseRgb.value.g) * t);
const b = Math.round(baseRgb.value.b + (activeRgb.value.b - baseRgb.value.b) * t);
style = `rgb(${r},${g},${b})`;
}
if (circlePath.value) {
ctx.save()
ctx.translate(ox, oy)
ctx.fillStyle = style
ctx.fill(circlePath.value)
ctx.restore()
ctx.save();
ctx.translate(ox, oy);
ctx.fillStyle = style;
ctx.fill(circlePath.value);
ctx.restore();
}
}
rafId = requestAnimationFrame(draw)
}
rafId = requestAnimationFrame(draw);
};
const onMove = (e: MouseEvent) => {
const now = performance.now()
const pr = pointer.value
const dt = pr.lastTime ? now - pr.lastTime : 16
const dx = e.clientX - pr.lastX
const dy = e.clientY - pr.lastY
let vx = (dx / dt) * 1000
let vy = (dy / dt) * 1000
let speed = Math.hypot(vx, vy)
const now = performance.now();
const pr = pointer.value;
const dt = pr.lastTime ? now - pr.lastTime : 16;
const dx = e.clientX - pr.lastX;
const dy = e.clientY - pr.lastY;
let vx = (dx / dt) * 1000;
let vy = (dy / dt) * 1000;
let speed = Math.hypot(vx, vy);
if (speed > props.maxSpeed) {
const scale = props.maxSpeed / speed
vx *= scale
vy *= scale
speed = props.maxSpeed
const scale = props.maxSpeed / speed;
vx *= scale;
vy *= scale;
speed = props.maxSpeed;
}
pr.lastTime = now
pr.lastX = e.clientX
pr.lastY = e.clientY
pr.vx = vx
pr.vy = vy
pr.speed = speed
pr.lastTime = now;
pr.lastX = e.clientX;
pr.lastY = e.clientY;
pr.vx = vx;
pr.vy = vy;
pr.speed = speed;
const canvas = canvasRef.value
if (!canvas) return
const rect = canvas.getBoundingClientRect()
pr.x = e.clientX - rect.left
pr.y = e.clientY - rect.top
const canvas = canvasRef.value;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
pr.x = e.clientX - rect.left;
pr.y = e.clientY - rect.top;
for (const dot of dots.value) {
const dist = Math.hypot(dot.cx - pr.x, dot.cy - pr.y)
const dist = Math.hypot(dot.cx - pr.x, dot.cy - pr.y);
if (speed > props.speedTrigger && dist < props.proximity && !dot._inertiaApplied) {
dot._inertiaApplied = true
gsap.killTweensOf(dot)
const pushX = dot.cx - pr.x + vx * 0.005
const pushY = dot.cy - pr.y + vy * 0.005
dot._inertiaApplied = true;
gsap.killTweensOf(dot);
const pushX = dot.cx - pr.x + vx * 0.005;
const pushY = dot.cy - pr.y + vy * 0.005;
gsap.to(dot, {
inertia: { xOffset: pushX, yOffset: pushY, resistance: props.resistance },
onComplete: () => {
@@ -222,29 +222,29 @@ const onMove = (e: MouseEvent) => {
xOffset: 0,
yOffset: 0,
duration: props.returnDuration,
ease: 'elastic.out(1,0.75)',
})
dot._inertiaApplied = false
},
})
ease: 'elastic.out(1,0.75)'
});
dot._inertiaApplied = false;
}
});
}
}
}
};
const onClick = (e: MouseEvent) => {
const canvas = canvasRef.value
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const cx = e.clientX - rect.left
const cy = e.clientY - rect.top
const canvas = canvasRef.value;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
for (const dot of dots.value) {
const dist = Math.hypot(dot.cx - cx, dot.cy - cy)
const dist = Math.hypot(dot.cx - cx, dot.cy - cy);
if (dist < props.shockRadius && !dot._inertiaApplied) {
dot._inertiaApplied = true
gsap.killTweensOf(dot)
const falloff = Math.max(0, 1 - dist / props.shockRadius)
const pushX = (dot.cx - cx) * props.shockStrength * falloff
const pushY = (dot.cy - cy) * props.shockStrength * falloff
dot._inertiaApplied = true;
gsap.killTweensOf(dot);
const falloff = Math.max(0, 1 - dist / props.shockRadius);
const pushX = (dot.cx - cx) * props.shockStrength * falloff;
const pushY = (dot.cy - cy) * props.shockStrength * falloff;
gsap.to(dot, {
inertia: { xOffset: pushX, yOffset: pushY, resistance: props.resistance },
onComplete: () => {
@@ -252,64 +252,64 @@ const onClick = (e: MouseEvent) => {
xOffset: 0,
yOffset: 0,
duration: props.returnDuration,
ease: 'elastic.out(1,0.75)',
})
dot._inertiaApplied = false
},
})
ease: 'elastic.out(1,0.75)'
});
dot._inertiaApplied = false;
}
});
}
}
}
};
const throttledMove = throttle(onMove, 50)
const throttledMove = throttle(onMove, 50);
onMounted(async () => {
await nextTick()
await nextTick();
buildGrid()
buildGrid();
if (circlePath.value) {
draw()
draw();
}
if ('ResizeObserver' in window) {
resizeObserver = new ResizeObserver(buildGrid)
resizeObserver = new ResizeObserver(buildGrid);
if (wrapperRef.value) {
resizeObserver.observe(wrapperRef.value)
resizeObserver.observe(wrapperRef.value);
}
} else {
(window as Window).addEventListener('resize', buildGrid)
(window as Window).addEventListener('resize', buildGrid);
}
window.addEventListener('mousemove', throttledMove, { passive: true })
window.addEventListener('click', onClick)
})
window.addEventListener('mousemove', throttledMove, { passive: true });
window.addEventListener('click', onClick);
});
onUnmounted(() => {
if (rafId) {
cancelAnimationFrame(rafId)
cancelAnimationFrame(rafId);
}
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver.disconnect();
} else {
window.removeEventListener('resize', buildGrid)
window.removeEventListener('resize', buildGrid);
}
window.removeEventListener('mousemove', throttledMove)
window.removeEventListener('click', onClick)
})
window.removeEventListener('mousemove', throttledMove);
window.removeEventListener('click', onClick);
});
watch([() => props.dotSize, () => props.gap], () => {
buildGrid()
})
buildGrid();
});
watch([() => props.proximity, () => props.baseColor, activeRgb, baseRgb, circlePath], () => {
if (rafId) {
cancelAnimationFrame(rafId)
cancelAnimationFrame(rafId);
}
if (circlePath.value) {
draw()
draw();
}
})
</script>
});
</script>

View File

@@ -0,0 +1,128 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, computed } from 'vue';
import { gsap } from 'gsap';
interface GridMotionProps {
items?: string[];
gradientColor?: string;
}
const props = withDefaults(defineProps<GridMotionProps>(), {
items: () => [],
gradientColor: '#222222'
});
const gridRef = ref<HTMLElement | null>(null);
const rowRefs = ref<HTMLElement[]>([]);
const mouseX = ref(window.innerWidth / 2);
const totalItems = 150;
const combinedItems = computed(() => {
if (props.items.length === 0) {
return [];
}
const repeatedItems = [];
for (let i = 0; i < totalItems; i++) {
repeatedItems.push(props.items[i % props.items.length]);
}
return repeatedItems;
});
function isImage(item: string) {
return typeof item === 'string' && item.startsWith('http');
}
function isTag(item: string) {
return typeof item === 'string' && item.startsWith('<') && item.endsWith('>');
}
onMounted(() => {
gsap.ticker.lagSmoothing(0);
const handleMouseMove = (e: MouseEvent) => {
mouseX.value = e.clientX;
};
const updateMotion = () => {
const maxMoveAmount = 300;
const baseDuration = 0.8;
const inertiaFactors = [0.6, 0.4, 0.3, 0.2];
rowRefs.value.forEach((row, index) => {
const direction = index % 2 === 0 ? 1 : -1;
const moveAmount = ((mouseX.value / window.innerWidth) * maxMoveAmount - maxMoveAmount / 2) * direction;
gsap.to(row, {
x: moveAmount,
duration: baseDuration + inertiaFactors[index % inertiaFactors.length],
ease: 'power3.out',
overwrite: 'auto'
});
});
};
const removeAnimation = gsap.ticker.add(updateMotion);
window.addEventListener('mousemove', handleMouseMove);
onBeforeUnmount(() => {
window.removeEventListener('mousemove', handleMouseMove);
removeAnimation();
});
});
</script>
<template>
<div ref="gridRef" class="w-full h-full overflow-hidden">
<section
class="relative flex justify-center items-center w-full h-screen overflow-hidden"
:style="{
background: `radial-gradient(circle, ${gradientColor} 0%, transparent 100%)`,
backgroundPosition: 'center'
}"
>
<div class="z-[4] absolute inset-0 bg-[length:250px] pointer-events-none"></div>
<div class="z-[2] relative flex-none gap-4 grid grid-cols-1 w-[150vw] h-[150vh] rotate-[-15deg] origin-center">
<div
v-for="rowIndex in 10"
:key="rowIndex"
class="gap-4 flex"
:style="{ willChange: 'transform, filter' }"
ref="rowRefs"
>
<div
v-for="itemIndex in 15"
:key="itemIndex"
class="relative h-[250px] min-w-[300px] flex-shrink-0"
v-show="combinedItems[(rowIndex - 1) * 15 + (itemIndex - 1)]"
>
<div
class="relative flex justify-center items-center bg-[#111] rounded-[10px] w-full h-full overflow-hidden text-[1.5rem] text-white"
>
<div
v-if="isImage(combinedItems[(rowIndex - 1) * 15 + (itemIndex - 1)])"
class="top-0 left-0 absolute bg-cover bg-center w-full h-full"
:style="{
backgroundImage: `url(${combinedItems[(rowIndex - 1) * 15 + (itemIndex - 1)]})`
}"
></div>
<div
v-else-if="isTag(combinedItems[(rowIndex - 1) * 15 + (itemIndex - 1)])"
class="z-[2] p-4 text-center"
v-html="combinedItems[(rowIndex - 1) * 15 + (itemIndex - 1)]"
></div>
<div v-else class="z-[1] p-4 text-center">
{{ combinedItems[(rowIndex - 1) * 15 + (itemIndex - 1)] }}
</div>
</div>
</div>
</div>
</div>
<div class="top-0 left-0 relative w-full h-full pointer-events-none"></div>
</section>
</div>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,225 @@
export const hyperspeedPresets = {
one: {
onSpeedUp: () => {},
onSlowDown: () => {},
distortion: 'turbulentDistortion',
length: 400,
roadWidth: 10,
islandWidth: 2,
lanesPerRoad: 3,
fov: 90,
fovSpeedUp: 150,
speedUp: 2,
carLightsFade: 0.4,
totalSideLightSticks: 20,
lightPairsPerRoadWay: 40,
shoulderLinesWidthPercentage: 0.05,
brokenLinesWidthPercentage: 0.1,
brokenLinesLengthPercentage: 0.5,
lightStickWidth: [0.12, 0.5] as [number, number],
lightStickHeight: [1.3, 1.7] as [number, number],
movingAwaySpeed: [60, 80] as [number, number],
movingCloserSpeed: [-120, -160] as [number, number],
carLightsLength: [400 * 0.03, 400 * 0.2] as [number, number],
carLightsRadius: [0.05, 0.14] as [number, number],
carWidthPercentage: [0.3, 0.5] as [number, number],
carShiftX: [-0.8, 0.8] as [number, number],
carFloorSeparation: [0, 5] as [number, number],
colors: {
roadColor: 0x080808,
islandColor: 0x0a0a0a,
background: 0x000000,
shoulderLines: 0x131318,
brokenLines: 0x131318,
leftCars: [0xd856bf, 0x6750a2, 0xc247ac],
rightCars: [0x03b3c3, 0x0e5ea5, 0x324555],
sticks: 0x03b3c3
}
},
two: {
onSpeedUp: () => {},
onSlowDown: () => {},
distortion: 'mountainDistortion',
length: 400,
roadWidth: 9,
islandWidth: 2,
lanesPerRoad: 3,
fov: 90,
fovSpeedUp: 150,
speedUp: 2,
carLightsFade: 0.4,
totalSideLightSticks: 50,
lightPairsPerRoadWay: 50,
shoulderLinesWidthPercentage: 0.05,
brokenLinesWidthPercentage: 0.1,
brokenLinesLengthPercentage: 0.5,
lightStickWidth: [0.12, 0.5] as [number, number],
lightStickHeight: [1.3, 1.7] as [number, number],
movingAwaySpeed: [60, 80] as [number, number],
movingCloserSpeed: [-120, -160] as [number, number],
carLightsLength: [400 * 0.05, 400 * 0.15] as [number, number],
carLightsRadius: [0.05, 0.14] as [number, number],
carWidthPercentage: [0.3, 0.5] as [number, number],
carShiftX: [-0.2, 0.2] as [number, number],
carFloorSeparation: [0.05, 1] as [number, number],
colors: {
roadColor: 0x080808,
islandColor: 0x0a0a0a,
background: 0x000000,
shoulderLines: 0x131318,
brokenLines: 0x131318,
leftCars: [0xff102a, 0xeb383e, 0xff102a],
rightCars: [0xdadafa, 0xbebae3, 0x8f97e4],
sticks: 0xdadafa
}
},
three: {
onSpeedUp: () => {},
onSlowDown: () => {},
distortion: 'xyDistortion',
length: 400,
roadWidth: 9,
islandWidth: 2,
lanesPerRoad: 3,
fov: 90,
fovSpeedUp: 150,
speedUp: 3,
carLightsFade: 0.4,
totalSideLightSticks: 50,
lightPairsPerRoadWay: 30,
shoulderLinesWidthPercentage: 0.05,
brokenLinesWidthPercentage: 0.1,
brokenLinesLengthPercentage: 0.5,
lightStickWidth: [0.02, 0.05] as [number, number],
lightStickHeight: [0.3, 0.7] as [number, number],
movingAwaySpeed: [20, 50] as [number, number],
movingCloserSpeed: [-150, -230] as [number, number],
carLightsLength: [400 * 0.05, 400 * 0.2] as [number, number],
carLightsRadius: [0.03, 0.08] as [number, number],
carWidthPercentage: [0.1, 0.5] as [number, number],
carShiftX: [-0.5, 0.5] as [number, number],
carFloorSeparation: [0, 0.1] as [number, number],
colors: {
roadColor: 0x080808,
islandColor: 0x0a0a0a,
background: 0x000000,
shoulderLines: 0x131318,
brokenLines: 0x131318,
leftCars: [0x7d0d1b, 0xa90519, 0xff102a],
rightCars: [0xf1eece, 0xe6e2b1, 0xdfd98a],
sticks: 0xf1eece
}
},
four: {
onSpeedUp: () => {},
onSlowDown: () => {},
distortion: 'LongRaceDistortion',
length: 400,
roadWidth: 10,
islandWidth: 5,
lanesPerRoad: 2,
fov: 90,
fovSpeedUp: 150,
speedUp: 2,
carLightsFade: 0.4,
totalSideLightSticks: 50,
lightPairsPerRoadWay: 70,
shoulderLinesWidthPercentage: 0.05,
brokenLinesWidthPercentage: 0.1,
brokenLinesLengthPercentage: 0.5,
lightStickWidth: [0.12, 0.5] as [number, number],
lightStickHeight: [1.3, 1.7] as [number, number],
movingAwaySpeed: [60, 80] as [number, number],
movingCloserSpeed: [-120, -160] as [number, number],
carLightsLength: [400 * 0.05, 400 * 0.15] as [number, number],
carLightsRadius: [0.05, 0.14] as [number, number],
carWidthPercentage: [0.3, 0.5] as [number, number],
carShiftX: [-0.2, 0.2] as [number, number],
carFloorSeparation: [0.05, 1] as [number, number],
colors: {
roadColor: 0x080808,
islandColor: 0x0a0a0a,
background: 0x000000,
shoulderLines: 0x131318,
brokenLines: 0x131318,
leftCars: [0xff5f73, 0xe74d60, 0xff102a],
rightCars: [0xa4e3e6, 0x80d1d4, 0x53c2c6],
sticks: 0xa4e3e6
}
},
five: {
onSpeedUp: () => {},
onSlowDown: () => {},
distortion: 'turbulentDistortion',
length: 400,
roadWidth: 9,
islandWidth: 2,
lanesPerRoad: 3,
fov: 90,
fovSpeedUp: 150,
speedUp: 2,
carLightsFade: 0.4,
totalSideLightSticks: 50,
lightPairsPerRoadWay: 50,
shoulderLinesWidthPercentage: 0.05,
brokenLinesWidthPercentage: 0.1,
brokenLinesLengthPercentage: 0.5,
lightStickWidth: [0.12, 0.5] as [number, number],
lightStickHeight: [1.3, 1.7] as [number, number],
movingAwaySpeed: [60, 80] as [number, number],
movingCloserSpeed: [-120, -160] as [number, number],
carLightsLength: [400 * 0.05, 400 * 0.15] as [number, number],
carLightsRadius: [0.05, 0.14] as [number, number],
carWidthPercentage: [0.3, 0.5] as [number, number],
carShiftX: [-0.2, 0.2] as [number, number],
carFloorSeparation: [0.05, 1] as [number, number],
colors: {
roadColor: 0x080808,
islandColor: 0x0a0a0a,
background: 0x000000,
shoulderLines: 0x131318,
brokenLines: 0x131318,
leftCars: [0xdc5b20, 0xdca320, 0xdc2020],
rightCars: [0x334bf7, 0xe5e6ed, 0xbfc6f3],
sticks: 0xc5e8eb
}
},
six: {
onSpeedUp: () => {},
onSlowDown: () => {},
distortion: 'deepDistortion',
length: 400,
roadWidth: 18,
islandWidth: 2,
lanesPerRoad: 3,
fov: 90,
fovSpeedUp: 150,
speedUp: 2,
carLightsFade: 0.4,
totalSideLightSticks: 50,
lightPairsPerRoadWay: 50,
shoulderLinesWidthPercentage: 0.05,
brokenLinesWidthPercentage: 0.1,
brokenLinesLengthPercentage: 0.5,
lightStickWidth: [0.12, 0.5] as [number, number],
lightStickHeight: [1.3, 1.7] as [number, number],
movingAwaySpeed: [60, 80] as [number, number],
movingCloserSpeed: [-120, -160] as [number, number],
carLightsLength: [400 * 0.05, 400 * 0.15] as [number, number],
carLightsRadius: [0.05, 0.14] as [number, number],
carWidthPercentage: [0.3, 0.5] as [number, number],
carShiftX: [-0.2, 0.2] as [number, number],
carFloorSeparation: [0.05, 1] as [number, number],
colors: {
roadColor: 0x080808,
islandColor: 0x0a0a0a,
background: 0x000000,
shoulderLines: 0x131318,
brokenLines: 0x131318,
leftCars: [0xff322f, 0xa33010, 0xa81508],
rightCars: [0xfdfdf0, 0xf3dea0, 0xe2bb88],
sticks: 0xfdfdf0
}
}
};

View File

@@ -3,15 +3,15 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { Renderer, Program, Mesh, Color, Triangle } from 'ogl'
import type { OGLRenderingContext } from 'ogl'
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { Renderer, Program, Mesh, Color, Triangle } from 'ogl';
import type { OGLRenderingContext } from 'ogl';
interface Props {
color?: [number, number, number]
speed?: number
amplitude?: number
mouseReact?: boolean
color?: [number, number, number];
speed?: number;
amplitude?: number;
mouseReact?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
@@ -19,16 +19,16 @@ const props = withDefaults(defineProps<Props>(), {
speed: 1.0,
amplitude: 0.1,
mouseReact: true
})
});
const containerRef = ref<HTMLDivElement | null>(null)
const mousePos = ref({ x: 0.5, y: 0.5 })
const containerRef = ref<HTMLDivElement | null>(null);
const mousePos = ref({ x: 0.5, y: 0.5 });
let renderer: Renderer | null = null
let gl: OGLRenderingContext | null = null
let program: Program | null = null
let mesh: Mesh | null = null
let animationId: number | null = null
let renderer: Renderer | null = null;
let gl: OGLRenderingContext | null = null;
let program: Program | null = null;
let mesh: Mesh | null = null;
let animationId: number | null = null;
const vertexShader = `
attribute vec2 uv;
@@ -40,7 +40,7 @@ void main() {
vUv = uv;
gl_Position = vec4(position, 0, 1);
}
`
`;
const fragmentShader = `
precision highp float;
@@ -71,57 +71,57 @@ void main() {
col = cos(col * cos(vec3(d, a, 2.5)) * 0.5 + 0.5) * uColor;
gl_FragColor = vec4(col, 1.0);
}
`
`;
const resize = () => {
if (!containerRef.value || !renderer || !program || !gl) return
if (!containerRef.value || !renderer || !program || !gl) return;
const container = containerRef.value
const scale = 1
renderer.setSize(container.offsetWidth * scale, container.offsetHeight * scale)
const container = containerRef.value;
const scale = 1;
renderer.setSize(container.offsetWidth * scale, container.offsetHeight * scale);
if (program) {
program.uniforms.uResolution.value = new Color(
gl.canvas.width,
gl.canvas.height,
gl.canvas.width / gl.canvas.height
)
);
}
}
};
const handleMouseMove = (e: MouseEvent) => {
if (!containerRef.value || !program) return
if (!containerRef.value || !program) return;
const rect = containerRef.value.getBoundingClientRect()
const x = (e.clientX - rect.left) / rect.width
const y = 1.0 - (e.clientY - rect.top) / rect.height
const rect = containerRef.value.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = 1.0 - (e.clientY - rect.top) / rect.height;
mousePos.value = { x, y }
mousePos.value = { x, y };
if (program.uniforms.uMouse.value) {
program.uniforms.uMouse.value[0] = x
program.uniforms.uMouse.value[1] = y
program.uniforms.uMouse.value[0] = x;
program.uniforms.uMouse.value[1] = y;
}
}
};
const update = (t: number) => {
if (!program || !renderer || !mesh) return
if (!program || !renderer || !mesh) return;
animationId = requestAnimationFrame(update)
program.uniforms.uTime.value = t * 0.001
renderer.render({ scene: mesh })
}
animationId = requestAnimationFrame(update);
program.uniforms.uTime.value = t * 0.001;
renderer.render({ scene: mesh });
};
const initializeScene = () => {
if (!containerRef.value) return
if (!containerRef.value) return;
cleanup()
cleanup();
const container = containerRef.value
renderer = new Renderer()
gl = renderer.gl
gl.clearColor(1, 1, 1, 1)
const container = containerRef.value;
renderer = new Renderer();
gl = renderer.gl;
gl.clearColor(1, 1, 1, 1);
const geometry = new Triangle(gl)
const geometry = new Triangle(gl);
program = new Program(gl, {
vertex: vertexShader,
fragment: fragmentShader,
@@ -129,76 +129,72 @@ const initializeScene = () => {
uTime: { value: 0 },
uColor: { value: new Color(...props.color) },
uResolution: {
value: new Color(
gl.canvas.width,
gl.canvas.height,
gl.canvas.width / gl.canvas.height
)
value: new Color(gl.canvas.width, gl.canvas.height, gl.canvas.width / gl.canvas.height)
},
uMouse: { value: new Float32Array([mousePos.value.x, mousePos.value.y]) },
uAmplitude: { value: props.amplitude },
uSpeed: { value: props.speed }
}
})
});
mesh = new Mesh(gl, { geometry, program })
mesh = new Mesh(gl, { geometry, program });
const canvas = gl.canvas as HTMLCanvasElement
canvas.style.width = '100%'
canvas.style.height = '100%'
canvas.style.display = 'block'
const canvas = gl.canvas as HTMLCanvasElement;
canvas.style.width = '100%';
canvas.style.height = '100%';
canvas.style.display = 'block';
container.appendChild(canvas)
container.appendChild(canvas);
window.addEventListener('resize', resize)
window.addEventListener('resize', resize);
if (props.mouseReact) {
container.addEventListener('mousemove', handleMouseMove)
container.addEventListener('mousemove', handleMouseMove);
}
resize()
animationId = requestAnimationFrame(update)
}
resize();
animationId = requestAnimationFrame(update);
};
const cleanup = () => {
if (animationId) {
cancelAnimationFrame(animationId)
animationId = null
cancelAnimationFrame(animationId);
animationId = null;
}
window.removeEventListener('resize', resize)
window.removeEventListener('resize', resize);
if (containerRef.value) {
containerRef.value.removeEventListener('mousemove', handleMouseMove)
containerRef.value.removeEventListener('mousemove', handleMouseMove);
const canvas = containerRef.value.querySelector('canvas')
const canvas = containerRef.value.querySelector('canvas');
if (canvas) {
containerRef.value.removeChild(canvas)
containerRef.value.removeChild(canvas);
}
}
if (gl) {
gl.getExtension('WEBGL_lose_context')?.loseContext()
gl.getExtension('WEBGL_lose_context')?.loseContext();
}
renderer = null
gl = null
program = null
mesh = null
}
renderer = null;
gl = null;
program = null;
mesh = null;
};
onMounted(() => {
initializeScene()
})
initializeScene();
});
onUnmounted(() => {
cleanup()
})
cleanup();
});
watch(
[() => props.color, () => props.speed, () => props.amplitude, () => props.mouseReact],
() => {
initializeScene()
initializeScene();
},
{ deep: true }
)
</script>
);
</script>

View File

@@ -1,79 +1,140 @@
<template>
<div class="relative overflow-hidden">
<canvas ref="canvasRef" class="absolute top-0 left-0 w-full h-full" />
<div v-if="outerVignette"
class="absolute top-0 left-0 w-full h-full pointer-events-none bg-[radial-gradient(circle,_rgba(0,0,0,0)_60%,_rgba(0,0,0,1)_100%)]" />
<div v-if="centerVignette"
class="absolute top-0 left-0 w-full h-full pointer-events-none bg-[radial-gradient(circle,_rgba(0,0,0,0.8)_0%,_rgba(0,0,0,0)_60%)]" />
<div
v-if="outerVignette"
class="absolute top-0 left-0 w-full h-full pointer-events-none bg-[radial-gradient(circle,_rgba(0,0,0,0)_60%,_rgba(0,0,0,1)_100%)]"
/>
<div
v-if="centerVignette"
class="absolute top-0 left-0 w-full h-full pointer-events-none bg-[radial-gradient(circle,_rgba(0,0,0,0.8)_0%,_rgba(0,0,0,0)_60%)]"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { ref, onMounted, onUnmounted, watch } from 'vue';
interface Props {
glitchColors?: string[]
glitchSpeed?: number
centerVignette?: boolean
outerVignette?: boolean
smooth?: boolean
glitchColors?: string[];
glitchSpeed?: number;
centerVignette?: boolean;
outerVignette?: boolean;
smooth?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
glitchColors: () => ["#2b4539", "#61dca3", "#61b3dc"],
glitchColors: () => ['#2b4539', '#61dca3', '#61b3dc'],
glitchSpeed: 50,
centerVignette: false,
outerVignette: false,
smooth: true
})
});
const canvasRef = ref<HTMLCanvasElement | null>(null)
const animationRef = ref<number | null>(null)
const letters = ref<{
char: string
color: string
targetColor: string
colorProgress: number
}[]>([])
const grid = ref({ columns: 0, rows: 0 })
const context = ref<CanvasRenderingContext2D | null>(null)
const lastGlitchTime = ref(Date.now())
const canvasRef = ref<HTMLCanvasElement | null>(null);
const animationRef = ref<number | null>(null);
const letters = ref<
{
char: string;
color: string;
targetColor: string;
colorProgress: number;
}[]
>([]);
const grid = ref({ columns: 0, rows: 0 });
const context = ref<CanvasRenderingContext2D | null>(null);
const lastGlitchTime = ref(Date.now());
const fontSize = 16
const charWidth = 10
const charHeight = 20
const fontSize = 16;
const charWidth = 10;
const charHeight = 20;
const lettersAndSymbols = [
"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M",
"N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z",
"!", "@", "#", "$", "&", "*", "(", ")", "-", "_", "+", "=", "/",
"[", "]", "{", "}", ";", ":", "<", ">", ",", "0", "1", "2", "3",
"4", "5", "6", "7", "8", "9"
]
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'H',
'I',
'J',
'K',
'L',
'M',
'N',
'O',
'P',
'Q',
'R',
'S',
'T',
'U',
'V',
'W',
'X',
'Y',
'Z',
'!',
'@',
'#',
'$',
'&',
'*',
'(',
')',
'-',
'_',
'+',
'=',
'/',
'[',
']',
'{',
'}',
';',
':',
'<',
'>',
',',
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9'
];
const getRandomChar = () => {
return lettersAndSymbols[Math.floor(Math.random() * lettersAndSymbols.length)]
}
return lettersAndSymbols[Math.floor(Math.random() * lettersAndSymbols.length)];
};
const getRandomColor = () => {
return props.glitchColors[Math.floor(Math.random() * props.glitchColors.length)]
}
return props.glitchColors[Math.floor(Math.random() * props.glitchColors.length)];
};
const hexToRgb = (hex: string) => {
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
hex = hex.replace(shorthandRegex, (m, r, g, b) => {
return r + r + g + g + b + b
})
return r + r + g + g + b + b;
});
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: null
}
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
}
: null;
};
const interpolateColor = (
start: { r: number; g: number; b: number },
@@ -83,171 +144,167 @@ const interpolateColor = (
const result = {
r: Math.round(start.r + (end.r - start.r) * factor),
g: Math.round(start.g + (end.g - start.g) * factor),
b: Math.round(start.b + (end.b - start.b) * factor),
}
return `rgb(${result.r}, ${result.g}, ${result.b})`
}
b: Math.round(start.b + (end.b - start.b) * factor)
};
return `rgb(${result.r}, ${result.g}, ${result.b})`;
};
const calculateGrid = (width: number, height: number) => {
const columns = Math.ceil(width / charWidth)
const rows = Math.ceil(height / charHeight)
return { columns, rows }
}
const columns = Math.ceil(width / charWidth);
const rows = Math.ceil(height / charHeight);
return { columns, rows };
};
const initializeLetters = (columns: number, rows: number) => {
grid.value = { columns, rows }
const totalLetters = columns * rows
grid.value = { columns, rows };
const totalLetters = columns * rows;
letters.value = Array.from({ length: totalLetters }, () => ({
char: getRandomChar(),
color: getRandomColor(),
targetColor: getRandomColor(),
colorProgress: 1,
}))
}
colorProgress: 1
}));
};
const resizeCanvas = () => {
const canvas = canvasRef.value
if (!canvas) return
const parent = canvas.parentElement
if (!parent) return
const canvas = canvasRef.value;
if (!canvas) return;
const parent = canvas.parentElement;
if (!parent) return;
const dpr = window.devicePixelRatio || 1
const parentWidth = parent.parentElement?.offsetWidth || parent.offsetWidth || window.innerWidth
const parentHeight = parent.parentElement?.offsetHeight || parent.offsetHeight || window.innerHeight
const width = Math.max(parentWidth, 300)
const height = Math.max(parentHeight, 300)
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr
canvas.height = height * dpr
const parentWidth = parent.parentElement?.offsetWidth || parent.offsetWidth || window.innerWidth;
const parentHeight = parent.parentElement?.offsetHeight || parent.offsetHeight || window.innerHeight;
canvas.style.width = `${width}px`
canvas.style.height = `${height}px`
const width = Math.max(parentWidth, 300);
const height = Math.max(parentHeight, 300);
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
if (context.value) {
context.value.setTransform(dpr, 0, 0, dpr, 0, 0)
context.value.setTransform(dpr, 0, 0, dpr, 0, 0);
}
const { columns, rows } = calculateGrid(width, height)
initializeLetters(columns, rows)
drawLetters()
}
const { columns, rows } = calculateGrid(width, height);
initializeLetters(columns, rows);
drawLetters();
};
const drawLetters = () => {
if (!context.value || letters.value.length === 0) return
const ctx = context.value
const { width, height } = canvasRef.value!.getBoundingClientRect()
ctx.clearRect(0, 0, width, height)
ctx.font = `${fontSize}px monospace`
ctx.textBaseline = "top"
if (!context.value || letters.value.length === 0) return;
const ctx = context.value;
const { width, height } = canvasRef.value!.getBoundingClientRect();
ctx.clearRect(0, 0, width, height);
ctx.font = `${fontSize}px monospace`;
ctx.textBaseline = 'top';
letters.value.forEach((letter, index) => {
const x = (index % grid.value.columns) * charWidth
const y = Math.floor(index / grid.value.columns) * charHeight
ctx.fillStyle = letter.color
ctx.fillText(letter.char, x, y)
})
}
const x = (index % grid.value.columns) * charWidth;
const y = Math.floor(index / grid.value.columns) * charHeight;
ctx.fillStyle = letter.color;
ctx.fillText(letter.char, x, y);
});
};
const updateLetters = () => {
if (!letters.value || letters.value.length === 0) return
if (!letters.value || letters.value.length === 0) return;
const updateCount = Math.max(1, Math.floor(letters.value.length * 0.05))
const updateCount = Math.max(1, Math.floor(letters.value.length * 0.05));
for (let i = 0; i < updateCount; i++) {
const index = Math.floor(Math.random() * letters.value.length)
if (!letters.value[index]) continue
const index = Math.floor(Math.random() * letters.value.length);
if (!letters.value[index]) continue;
letters.value[index].char = getRandomChar()
letters.value[index].targetColor = getRandomColor()
letters.value[index].char = getRandomChar();
letters.value[index].targetColor = getRandomColor();
if (!props.smooth) {
letters.value[index].color = letters.value[index].targetColor
letters.value[index].colorProgress = 1
letters.value[index].color = letters.value[index].targetColor;
letters.value[index].colorProgress = 1;
} else {
letters.value[index].colorProgress = 0
letters.value[index].colorProgress = 0;
}
}
}
};
const handleSmoothTransitions = () => {
let needsRedraw = false
letters.value.forEach((letter) => {
let needsRedraw = false;
letters.value.forEach(letter => {
if (letter.colorProgress < 1) {
letter.colorProgress += 0.05
if (letter.colorProgress > 1) letter.colorProgress = 1
letter.colorProgress += 0.05;
if (letter.colorProgress > 1) letter.colorProgress = 1;
const startRgb = hexToRgb(letter.color)
const endRgb = hexToRgb(letter.targetColor)
const startRgb = hexToRgb(letter.color);
const endRgb = hexToRgb(letter.targetColor);
if (startRgb && endRgb) {
letter.color = interpolateColor(
startRgb,
endRgb,
letter.colorProgress
)
needsRedraw = true
letter.color = interpolateColor(startRgb, endRgb, letter.colorProgress);
needsRedraw = true;
}
}
})
});
if (needsRedraw) {
drawLetters()
drawLetters();
}
}
};
const animate = () => {
const now = Date.now()
const now = Date.now();
if (now - lastGlitchTime.value >= props.glitchSpeed) {
updateLetters()
drawLetters()
lastGlitchTime.value = now
updateLetters();
drawLetters();
lastGlitchTime.value = now;
}
if (props.smooth) {
handleSmoothTransitions()
handleSmoothTransitions();
}
animationRef.value = requestAnimationFrame(animate)
}
animationRef.value = requestAnimationFrame(animate);
};
let resizeTimeout: number
let resizeTimeout: number;
const handleResize = () => {
clearTimeout(resizeTimeout)
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
if (animationRef.value) {
cancelAnimationFrame(animationRef.value)
cancelAnimationFrame(animationRef.value);
}
resizeCanvas()
animate()
}, 100)
}
resizeCanvas();
animate();
}, 100);
};
onMounted(() => {
const canvas = canvasRef.value
if (!canvas) return
const canvas = canvasRef.value;
if (!canvas) return;
context.value = canvas.getContext("2d")
resizeCanvas()
animate()
context.value = canvas.getContext('2d');
resizeCanvas();
animate();
window.addEventListener("resize", handleResize)
})
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
if (animationRef.value) {
cancelAnimationFrame(animationRef.value)
cancelAnimationFrame(animationRef.value);
}
window.removeEventListener("resize", handleResize)
})
window.removeEventListener('resize', handleResize);
});
watch([() => props.glitchSpeed, () => props.smooth], () => {
if (animationRef.value) {
cancelAnimationFrame(animationRef.value)
cancelAnimationFrame(animationRef.value);
}
animate()
})
animate();
});
</script>
<style scoped>
@@ -266,4 +323,4 @@ div {
width: 100% !important;
height: 100% !important;
}
</style>
</style>

View File

@@ -3,14 +3,14 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { ref, onMounted, onUnmounted, watch } from 'vue';
interface LightningProps {
hue?: number
xOffset?: number
speed?: number
intensity?: number
size?: number
hue?: number;
xOffset?: number;
speed?: number;
intensity?: number;
size?: number;
}
const props = withDefaults(defineProps<LightningProps>(), {
@@ -19,20 +19,20 @@ const props = withDefaults(defineProps<LightningProps>(), {
speed: 1,
intensity: 1,
size: 1
})
});
const canvasRef = ref<HTMLCanvasElement>()
let animationId = 0
let gl: WebGLRenderingContext | null = null
let program: WebGLProgram | null = null
let startTime = 0
const canvasRef = ref<HTMLCanvasElement>();
let animationId = 0;
let gl: WebGLRenderingContext | null = null;
let program: WebGLProgram | null = null;
let startTime = 0;
const vertexShaderSource = `
attribute vec2 aPosition;
void main() {
gl_Position = vec4(aPosition, 0.0, 1.0);
}
`
`;
const fragmentShaderSource = `
precision mediump float;
@@ -112,155 +112,153 @@ void mainImage( out vec4 fragColor, in vec2 fragCoord ) {
void main() {
mainImage(gl_FragColor, gl_FragCoord.xy);
}
`
`;
const compileShader = (source: string, type: number): WebGLShader | null => {
if (!gl) return null
const shader = gl.createShader(type)
if (!shader) return null
gl.shaderSource(shader, source)
gl.compileShader(shader)
if (!gl) return null;
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error('Shader compile error:', gl.getShaderInfoLog(shader))
gl.deleteShader(shader)
return null
console.error('Shader compile error:', gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader
}
return shader;
};
const initWebGL = () => {
const canvas = canvasRef.value
if (!canvas) return
const canvas = canvasRef.value;
if (!canvas) return;
const resizeCanvas = () => {
const rect = canvas.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
let width = rect.width
let height = rect.height
let parent = canvas.parentElement
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
let width = rect.width;
let height = rect.height;
let parent = canvas.parentElement;
while (parent && (!width || !height)) {
if (parent.offsetWidth && parent.offsetHeight) {
width = parent.offsetWidth
height = parent.offsetHeight
break
width = parent.offsetWidth;
height = parent.offsetHeight;
break;
}
parent = parent.parentElement
parent = parent.parentElement;
}
if (!width || !height) {
width = window.innerWidth
height = window.innerHeight
width = window.innerWidth;
height = window.innerHeight;
}
width = Math.max(width, 300)
height = Math.max(height, 300)
canvas.width = width * dpr
canvas.height = height * dpr
canvas.style.width = '100%'
canvas.style.height = '100%'
canvas.style.display = 'block'
canvas.style.position = 'absolute'
canvas.style.top = '0'
canvas.style.left = '0'
}
resizeCanvas()
window.addEventListener('resize', resizeCanvas)
width = Math.max(width, 300);
height = Math.max(height, 300);
gl = canvas.getContext('webgl')
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = '100%';
canvas.style.height = '100%';
canvas.style.display = 'block';
canvas.style.position = 'absolute';
canvas.style.top = '0';
canvas.style.left = '0';
};
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
gl = canvas.getContext('webgl');
if (!gl) {
console.error('WebGL not supported')
return
console.error('WebGL not supported');
return;
}
const vertexShader = compileShader(vertexShaderSource, gl.VERTEX_SHADER)
const fragmentShader = compileShader(fragmentShaderSource, gl.FRAGMENT_SHADER)
if (!vertexShader || !fragmentShader) return
const vertexShader = compileShader(vertexShaderSource, gl.VERTEX_SHADER);
const fragmentShader = compileShader(fragmentShaderSource, gl.FRAGMENT_SHADER);
if (!vertexShader || !fragmentShader) return;
program = gl.createProgram()
if (!program) return
gl.attachShader(program, vertexShader)
gl.attachShader(program, fragmentShader)
gl.linkProgram(program)
program = gl.createProgram();
if (!program) return;
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error('Program linking error:', gl.getProgramInfoLog(program))
return
console.error('Program linking error:', gl.getProgramInfoLog(program));
return;
}
gl.useProgram(program)
gl.useProgram(program);
const vertices = new Float32Array([
-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1,
])
const vertexBuffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer)
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW)
const vertices = new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]);
const vertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
const aPosition = gl.getAttribLocation(program, 'aPosition')
gl.enableVertexAttribArray(aPosition)
gl.vertexAttribPointer(aPosition, 2, gl.FLOAT, false, 0, 0)
const aPosition = gl.getAttribLocation(program, 'aPosition');
gl.enableVertexAttribArray(aPosition);
gl.vertexAttribPointer(aPosition, 2, gl.FLOAT, false, 0, 0);
startTime = performance.now()
render()
startTime = performance.now();
render();
return () => {
window.removeEventListener('resize', resizeCanvas)
}
}
window.removeEventListener('resize', resizeCanvas);
};
};
const render = () => {
if (!gl || !program || !canvasRef.value) return
if (!gl || !program || !canvasRef.value) return;
const canvas = canvasRef.value
const canvas = canvasRef.value;
const rect = canvas.getBoundingClientRect()
const rect = canvas.getBoundingClientRect();
if (canvas.width !== rect.width || canvas.height !== rect.height) {
canvas.width = rect.width
canvas.height = rect.height
canvas.style.width = rect.width + 'px'
canvas.style.height = rect.height + 'px'
canvas.width = rect.width;
canvas.height = rect.height;
canvas.style.width = rect.width + 'px';
canvas.style.height = rect.height + 'px';
}
gl.viewport(0, 0, canvas.width, canvas.height)
gl.viewport(0, 0, canvas.width, canvas.height);
const iResolutionLocation = gl.getUniformLocation(program, 'iResolution')
const iTimeLocation = gl.getUniformLocation(program, 'iTime')
const uHueLocation = gl.getUniformLocation(program, 'uHue')
const uXOffsetLocation = gl.getUniformLocation(program, 'uXOffset')
const uSpeedLocation = gl.getUniformLocation(program, 'uSpeed')
const uIntensityLocation = gl.getUniformLocation(program, 'uIntensity')
const uSizeLocation = gl.getUniformLocation(program, 'uSize')
const iResolutionLocation = gl.getUniformLocation(program, 'iResolution');
const iTimeLocation = gl.getUniformLocation(program, 'iTime');
const uHueLocation = gl.getUniformLocation(program, 'uHue');
const uXOffsetLocation = gl.getUniformLocation(program, 'uXOffset');
const uSpeedLocation = gl.getUniformLocation(program, 'uSpeed');
const uIntensityLocation = gl.getUniformLocation(program, 'uIntensity');
const uSizeLocation = gl.getUniformLocation(program, 'uSize');
gl.uniform2f(iResolutionLocation, canvas.width, canvas.height)
const currentTime = performance.now()
gl.uniform1f(iTimeLocation, (currentTime - startTime) / 1000.0)
gl.uniform1f(uHueLocation, props.hue)
gl.uniform1f(uXOffsetLocation, props.xOffset)
gl.uniform1f(uSpeedLocation, props.speed)
gl.uniform1f(uIntensityLocation, props.intensity)
gl.uniform1f(uSizeLocation, props.size)
gl.uniform2f(iResolutionLocation, canvas.width, canvas.height);
const currentTime = performance.now();
gl.uniform1f(iTimeLocation, (currentTime - startTime) / 1000.0);
gl.uniform1f(uHueLocation, props.hue);
gl.uniform1f(uXOffsetLocation, props.xOffset);
gl.uniform1f(uSpeedLocation, props.speed);
gl.uniform1f(uIntensityLocation, props.intensity);
gl.uniform1f(uSizeLocation, props.size);
gl.drawArrays(gl.TRIANGLES, 0, 6)
animationId = requestAnimationFrame(render)
}
gl.drawArrays(gl.TRIANGLES, 0, 6);
animationId = requestAnimationFrame(render);
};
onMounted(() => {
initWebGL()
})
initWebGL();
});
onUnmounted(() => {
if (animationId) {
cancelAnimationFrame(animationId)
cancelAnimationFrame(animationId);
}
})
});
watch(
() => [props.hue, props.xOffset, props.speed, props.intensity, props.size],
() => {}
)
);
</script>
<style scoped>

View File

@@ -3,22 +3,22 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { Renderer, Camera, Geometry, Program, Mesh } from 'ogl'
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { Renderer, Camera, Geometry, Program, Mesh } from 'ogl';
interface ParticlesProps {
particleCount?: number
particleSpread?: number
speed?: number
particleColors?: string[]
moveParticlesOnHover?: boolean
particleHoverFactor?: number
alphaParticles?: boolean
particleBaseSize?: number
sizeRandomness?: number
cameraDistance?: number
disableRotation?: boolean
className?: string
particleCount?: number;
particleSpread?: number;
speed?: number;
particleColors?: string[];
moveParticlesOnHover?: boolean;
particleHoverFactor?: number;
alphaParticles?: boolean;
particleBaseSize?: number;
sizeRandomness?: number;
cameraDistance?: number;
disableRotation?: boolean;
className?: string;
}
const props = withDefaults(defineProps<ParticlesProps>(), {
@@ -34,32 +34,35 @@ const props = withDefaults(defineProps<ParticlesProps>(), {
cameraDistance: 20,
disableRotation: false,
className: ''
})
});
const containerRef = ref<HTMLDivElement>()
const mouseRef = ref({ x: 0, y: 0 })
const containerRef = ref<HTMLDivElement>();
const mouseRef = ref({ x: 0, y: 0 });
let renderer: Renderer | null = null
let camera: Camera | null = null
let particles: Mesh | null = null
let program: Program | null = null
let animationFrameId: number | null = null
let lastTime = 0
let elapsed = 0
let renderer: Renderer | null = null;
let camera: Camera | null = null;
let particles: Mesh | null = null;
let program: Program | null = null;
let animationFrameId: number | null = null;
let lastTime = 0;
let elapsed = 0;
const defaultColors = ['#ffffff', '#ffffff', '#ffffff']
const defaultColors = ['#ffffff', '#ffffff', '#ffffff'];
const hexToRgb = (hex: string): [number, number, number] => {
hex = hex.replace(/^#/, '')
hex = hex.replace(/^#/, '');
if (hex.length === 3) {
hex = hex.split('').map((c) => c + c).join('')
hex = hex
.split('')
.map(c => c + c)
.join('');
}
const int = parseInt(hex, 16)
const r = ((int >> 16) & 255) / 255
const g = ((int >> 8) & 255) / 255
const b = (int & 255) / 255
return [r, g, b]
}
const int = parseInt(hex, 16);
const r = ((int >> 16) & 255) / 255;
const g = ((int >> 8) & 255) / 255;
const b = (int & 255) / 255;
return [r, g, b];
};
const vertex = /* glsl */ `
attribute vec3 position;
@@ -94,7 +97,7 @@ const vertex = /* glsl */ `
gl_PointSize = (uBaseSize * (1.0 + uSizeRandomness * (random.x - 0.5))) / length(mvPos.xyz);
gl_Position = projectionMatrix * mvPos;
}
`
`;
const fragment = /* glsl */ `
precision highp float;
@@ -118,89 +121,89 @@ const fragment = /* glsl */ `
gl_FragColor = vec4(vColor + 0.2 * sin(uv.yxx + uTime + vRandom.y * 6.28), circle);
}
}
`
`;
const handleMouseMove = (e: MouseEvent) => {
const container = containerRef.value
if (!container) return
const container = containerRef.value;
if (!container) return;
const rect = container.getBoundingClientRect()
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1
const y = -(((e.clientY - rect.top) / rect.height) * 2 - 1)
mouseRef.value = { x, y }
}
const rect = container.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
const y = -(((e.clientY - rect.top) / rect.height) * 2 - 1);
mouseRef.value = { x, y };
};
const initParticles = () => {
const container = containerRef.value
if (!container) return
const container = containerRef.value;
if (!container) return;
renderer = new Renderer({ depth: false, alpha: true })
const gl = renderer.gl
container.appendChild(gl.canvas)
gl.clearColor(0, 0, 0, 0)
renderer = new Renderer({ depth: false, alpha: true });
const gl = renderer.gl;
container.appendChild(gl.canvas);
gl.clearColor(0, 0, 0, 0);
gl.canvas.style.width = '100%'
gl.canvas.style.height = '100%'
gl.canvas.style.display = 'block'
gl.canvas.style.position = 'absolute'
gl.canvas.style.top = '0'
gl.canvas.style.left = '0'
gl.canvas.style.width = '100%';
gl.canvas.style.height = '100%';
gl.canvas.style.display = 'block';
gl.canvas.style.position = 'absolute';
gl.canvas.style.top = '0';
gl.canvas.style.left = '0';
camera = new Camera(gl, { fov: 15 })
camera.position.set(0, 0, props.cameraDistance)
camera = new Camera(gl, { fov: 15 });
camera.position.set(0, 0, props.cameraDistance);
const resize = () => {
if (!container) return
const parentWidth = container.parentElement?.offsetWidth || container.offsetWidth || window.innerWidth
const parentHeight = container.parentElement?.offsetHeight || container.offsetHeight || window.innerHeight
const width = Math.max(parentWidth, 300)
const height = Math.max(parentHeight, 300)
renderer!.setSize(width, height)
camera!.perspective({ aspect: width / height })
if (!container) return;
gl.canvas.style.width = '100%'
gl.canvas.style.height = '100%'
gl.canvas.style.display = 'block'
gl.canvas.style.position = 'absolute'
gl.canvas.style.top = '0'
gl.canvas.style.left = '0'
}
window.addEventListener('resize', resize, false)
resize()
const parentWidth = container.parentElement?.offsetWidth || container.offsetWidth || window.innerWidth;
const parentHeight = container.parentElement?.offsetHeight || container.offsetHeight || window.innerHeight;
const width = Math.max(parentWidth, 300);
const height = Math.max(parentHeight, 300);
renderer!.setSize(width, height);
camera!.perspective({ aspect: width / height });
gl.canvas.style.width = '100%';
gl.canvas.style.height = '100%';
gl.canvas.style.display = 'block';
gl.canvas.style.position = 'absolute';
gl.canvas.style.top = '0';
gl.canvas.style.left = '0';
};
window.addEventListener('resize', resize, false);
resize();
if (props.moveParticlesOnHover) {
container.addEventListener('mousemove', handleMouseMove)
container.addEventListener('mousemove', handleMouseMove);
}
const count = props.particleCount
const positions = new Float32Array(count * 3)
const randoms = new Float32Array(count * 4)
const colors = new Float32Array(count * 3)
const palette = props.particleColors && props.particleColors.length > 0 ? props.particleColors : defaultColors
const count = props.particleCount;
const positions = new Float32Array(count * 3);
const randoms = new Float32Array(count * 4);
const colors = new Float32Array(count * 3);
const palette = props.particleColors && props.particleColors.length > 0 ? props.particleColors : defaultColors;
for (let i = 0; i < count; i++) {
let x: number, y: number, z: number, len: number
let x: number, y: number, z: number, len: number;
do {
x = Math.random() * 2 - 1
y = Math.random() * 2 - 1
z = Math.random() * 2 - 1
len = x * x + y * y + z * z
} while (len > 1 || len === 0)
const r = Math.cbrt(Math.random())
positions.set([x * r, y * r, z * r], i * 3)
randoms.set([Math.random(), Math.random(), Math.random(), Math.random()], i * 4)
const col = hexToRgb(palette[Math.floor(Math.random() * palette.length)])
colors.set(col, i * 3)
x = Math.random() * 2 - 1;
y = Math.random() * 2 - 1;
z = Math.random() * 2 - 1;
len = x * x + y * y + z * z;
} while (len > 1 || len === 0);
const r = Math.cbrt(Math.random());
positions.set([x * r, y * r, z * r], i * 3);
randoms.set([Math.random(), Math.random(), Math.random(), Math.random()], i * 4);
const col = hexToRgb(palette[Math.floor(Math.random() * palette.length)]);
colors.set(col, i * 3);
}
const geometry = new Geometry(gl, {
position: { size: 3, data: positions },
random: { size: 4, data: randoms },
color: { size: 3, data: colors },
})
color: { size: 3, data: colors }
});
program = new Program(gl, {
vertex,
@@ -210,105 +213,105 @@ const initParticles = () => {
uSpread: { value: props.particleSpread },
uBaseSize: { value: props.particleBaseSize },
uSizeRandomness: { value: props.sizeRandomness },
uAlphaParticles: { value: props.alphaParticles ? 1 : 0 },
uAlphaParticles: { value: props.alphaParticles ? 1 : 0 }
},
transparent: true,
depthTest: false,
})
depthTest: false
});
particles = new Mesh(gl, { mode: gl.POINTS, geometry, program })
particles = new Mesh(gl, { mode: gl.POINTS, geometry, program });
lastTime = performance.now()
elapsed = 0
lastTime = performance.now();
elapsed = 0;
const update = (t: number) => {
if (!animationFrameId) return
animationFrameId = requestAnimationFrame(update)
const delta = t - lastTime
lastTime = t
elapsed += delta * props.speed
if (!animationFrameId) return;
animationFrameId = requestAnimationFrame(update);
const delta = t - lastTime;
lastTime = t;
elapsed += delta * props.speed;
if (program) {
program.uniforms.uTime.value = elapsed * 0.001
program.uniforms.uSpread.value = props.particleSpread
program.uniforms.uBaseSize.value = props.particleBaseSize
program.uniforms.uSizeRandomness.value = props.sizeRandomness
program.uniforms.uAlphaParticles.value = props.alphaParticles ? 1 : 0
program.uniforms.uTime.value = elapsed * 0.001;
program.uniforms.uSpread.value = props.particleSpread;
program.uniforms.uBaseSize.value = props.particleBaseSize;
program.uniforms.uSizeRandomness.value = props.sizeRandomness;
program.uniforms.uAlphaParticles.value = props.alphaParticles ? 1 : 0;
}
if (particles) {
if (props.moveParticlesOnHover) {
particles.position.x = -mouseRef.value.x * props.particleHoverFactor
particles.position.y = -mouseRef.value.y * props.particleHoverFactor
particles.position.x = -mouseRef.value.x * props.particleHoverFactor;
particles.position.y = -mouseRef.value.y * props.particleHoverFactor;
} else {
particles.position.x = 0
particles.position.y = 0
particles.position.x = 0;
particles.position.y = 0;
}
if (!props.disableRotation) {
particles.rotation.x = Math.sin(elapsed * 0.0002) * 0.1
particles.rotation.y = Math.cos(elapsed * 0.0005) * 0.15
particles.rotation.z += 0.01 * props.speed
particles.rotation.x = Math.sin(elapsed * 0.0002) * 0.1;
particles.rotation.y = Math.cos(elapsed * 0.0005) * 0.15;
particles.rotation.z += 0.01 * props.speed;
}
}
if (renderer && camera && particles) {
renderer.render({ scene: particles, camera })
renderer.render({ scene: particles, camera });
}
}
};
animationFrameId = requestAnimationFrame(update)
animationFrameId = requestAnimationFrame(update);
return () => {
window.removeEventListener('resize', resize)
window.removeEventListener('resize', resize);
if (props.moveParticlesOnHover) {
container.removeEventListener('mousemove', handleMouseMove)
container.removeEventListener('mousemove', handleMouseMove);
}
if (animationFrameId) {
cancelAnimationFrame(animationFrameId)
animationFrameId = null
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
if (container.contains(gl.canvas)) {
container.removeChild(gl.canvas)
container.removeChild(gl.canvas);
}
}
}
};
};
const cleanup = () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId)
animationFrameId = null
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
if (renderer) {
const container = containerRef.value
const gl = renderer.gl
const container = containerRef.value;
const gl = renderer.gl;
if (container && gl.canvas.parentNode === container) {
container.removeChild(gl.canvas)
container.removeChild(gl.canvas);
}
gl.getExtension('WEBGL_lose_context')?.loseContext()
gl.getExtension('WEBGL_lose_context')?.loseContext();
}
renderer = null
camera = null
particles = null
program = null
}
renderer = null;
camera = null;
particles = null;
program = null;
};
onMounted(() => {
initParticles()
})
initParticles();
});
onUnmounted(() => {
cleanup()
})
cleanup();
});
watch(
() => [props.particleCount, props.particleColors],
() => {
cleanup()
initParticles()
cleanup();
initParticles();
},
{ deep: true }
)
);
watch(
() => [
@@ -322,7 +325,7 @@ watch(
props.disableRotation
],
() => {}
)
);
</script>
<style scoped>

View File

@@ -0,0 +1,334 @@
<template>
<div ref="shapeBlurContainer" class="shape-blur-container" />
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue';
import * as THREE from 'three';
interface ShapeBlurProps {
className?: string;
variation?: number;
pixelRatioProp?: number;
shapeSize?: number;
roundness?: number;
borderSize?: number;
circleSize?: number;
circleEdge?: number;
}
const props = withDefaults(defineProps<ShapeBlurProps>(), {
className: '',
variation: 0,
pixelRatioProp: 2,
shapeSize: 1.2,
roundness: 0.4,
borderSize: 0.05,
circleSize: 0.3,
circleEdge: 0.5
});
const shapeBlurContainer = ref<HTMLDivElement>();
let animationFrameId: number;
let time = 0;
let lastTime = 0;
let scene: THREE.Scene;
let camera: THREE.OrthographicCamera;
let renderer: THREE.WebGLRenderer;
let material: THREE.ShaderMaterial;
let quad: THREE.Mesh;
let resizeObserver: ResizeObserver | null = null;
const vMouse = new THREE.Vector2();
const vMouseDamp = new THREE.Vector2();
const vResolution = new THREE.Vector2();
let w = 1;
let h = 1;
const vertexShader = /* glsl */ `
varying vec2 v_texcoord;
void main() {
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
v_texcoord = uv;
}
`;
const fragmentShader = /* glsl */ `
varying vec2 v_texcoord;
uniform vec2 u_mouse;
uniform vec2 u_resolution;
uniform float u_pixelRatio;
uniform float u_shapeSize;
uniform float u_roundness;
uniform float u_borderSize;
uniform float u_circleSize;
uniform float u_circleEdge;
#ifndef PI
#define PI 3.1415926535897932384626433832795
#endif
#ifndef TWO_PI
#define TWO_PI 6.2831853071795864769252867665590
#endif
#ifndef VAR
#define VAR 0
#endif
#ifndef FNC_COORD
#define FNC_COORD
vec2 coord(in vec2 p) {
p = p / u_resolution.xy;
if (u_resolution.x > u_resolution.y) {
p.x *= u_resolution.x / u_resolution.y;
p.x += (u_resolution.y - u_resolution.x) / u_resolution.y / 2.0;
} else {
p.y *= u_resolution.y / u_resolution.x;
p.y += (u_resolution.x - u_resolution.y) / u_resolution.x / 2.0;
}
p -= 0.5;
p *= vec2(-1.0, 1.0);
return p;
}
#endif
#define st0 coord(gl_FragCoord.xy)
#define mx coord(u_mouse * u_pixelRatio)
float sdRoundRect(vec2 p, vec2 b, float r) {
vec2 d = abs(p - 0.5) * 4.2 - b + vec2(r);
return min(max(d.x, d.y), 0.0) + length(max(d, 0.0)) - r;
}
float sdCircle(in vec2 st, in vec2 center) {
return length(st - center) * 2.0;
}
float sdPoly(in vec2 p, in float w, in int sides) {
float a = atan(p.x, p.y) + PI;
float r = TWO_PI / float(sides);
float d = cos(floor(0.5 + a / r) * r - a) * length(max(abs(p) * 1.0, 0.0));
return d * 2.0 - w;
}
float aastep(float threshold, float value) {
float afwidth = length(vec2(dFdx(value), dFdy(value))) * 0.70710678118654757;
return smoothstep(threshold - afwidth, threshold + afwidth, value);
}
float fill(in float x) { return 1.0 - aastep(0.0, x); }
float fill(float x, float size, float edge) {
return 1.0 - smoothstep(size - edge, size + edge, x);
}
float stroke(in float d, in float t) { return (1.0 - aastep(t, abs(d))); }
float stroke(float x, float size, float w, float edge) {
float d = smoothstep(size - edge, size + edge, x + w * 0.5) - smoothstep(size - edge, size + edge, x - w * 0.5);
return clamp(d, 0.0, 1.0);
}
float strokeAA(float x, float size, float w, float edge) {
float afwidth = length(vec2(dFdx(x), dFdy(x))) * 0.70710678;
float d = smoothstep(size - edge - afwidth, size + edge + afwidth, x + w * 0.5)
- smoothstep(size - edge - afwidth, size + edge + afwidth, x - w * 0.5);
return clamp(d, 0.0, 1.0);
}
void main() {
vec2 st = st0 + 0.5;
vec2 posMouse = mx * vec2(1., -1.) + 0.5;
float size = u_shapeSize;
float roundness = u_roundness;
float borderSize = u_borderSize;
float circleSize = u_circleSize;
float circleEdge = u_circleEdge;
float sdfCircle = fill(
sdCircle(st, posMouse),
circleSize,
circleEdge
);
float sdf;
if (VAR == 0) {
sdf = sdRoundRect(st, vec2(size), roundness);
sdf = strokeAA(sdf, 0.0, borderSize, sdfCircle) * 4.0;
} else if (VAR == 1) {
sdf = sdCircle(st, vec2(0.5));
sdf = fill(sdf, 0.6, sdfCircle) * 1.2;
} else if (VAR == 2) {
sdf = sdCircle(st, vec2(0.5));
sdf = strokeAA(sdf, 0.58, 0.02, sdfCircle) * 4.0;
} else if (VAR == 3) {
sdf = sdPoly(st - vec2(0.5, 0.45), 0.3, 3);
sdf = fill(sdf, 0.05, sdfCircle) * 1.4;
}
vec3 color = vec3(1.0);
float alpha = sdf;
gl_FragColor = vec4(color.rgb, alpha);
}
`;
const onPointerMove = (e: PointerEvent | MouseEvent) => {
const mount = shapeBlurContainer.value;
if (!mount) return;
const rect = mount.getBoundingClientRect();
vMouse.set(e.clientX - rect.left, e.clientY - rect.top);
};
const resize = () => {
const container = shapeBlurContainer.value;
if (!container) return;
w = container.clientWidth;
h = container.clientHeight;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
renderer.setSize(w, h, false);
renderer.setPixelRatio(dpr);
camera.left = -w / 2;
camera.right = w / 2;
camera.top = h / 2;
camera.bottom = -h / 2;
camera.updateProjectionMatrix();
quad.scale.set(w, h, 1);
vResolution.set(w, h).multiplyScalar(dpr);
material.uniforms.u_pixelRatio.value = dpr;
};
const update = () => {
time = performance.now() * 0.001;
const dt = time - lastTime;
lastTime = time;
vMouseDamp.x = THREE.MathUtils.damp(vMouseDamp.x, vMouse.x, 8, dt);
vMouseDamp.y = THREE.MathUtils.damp(vMouseDamp.y, vMouse.y, 8, dt);
renderer.render(scene, camera);
animationFrameId = requestAnimationFrame(update);
};
const initShapeBlur = () => {
const mount = shapeBlurContainer.value;
if (!mount) return;
scene = new THREE.Scene();
camera = new THREE.OrthographicCamera();
camera.position.z = 1;
renderer = new THREE.WebGLRenderer({ alpha: true });
renderer.setClearColor(0x000000, 0);
renderer.domElement.style.width = '100%';
renderer.domElement.style.height = '100%';
renderer.domElement.style.display = 'block';
mount.appendChild(renderer.domElement);
const geo = new THREE.PlaneGeometry(1, 1);
material = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
uniforms: {
u_mouse: { value: vMouseDamp },
u_resolution: { value: vResolution },
u_pixelRatio: { value: props.pixelRatioProp },
u_shapeSize: { value: props.shapeSize },
u_roundness: { value: props.roundness },
u_borderSize: { value: props.borderSize },
u_circleSize: { value: props.circleSize },
u_circleEdge: { value: props.circleEdge }
},
defines: { VAR: props.variation },
transparent: true
});
quad = new THREE.Mesh(geo, material);
scene.add(quad);
document.addEventListener('mousemove', onPointerMove);
document.addEventListener('pointermove', onPointerMove);
resize();
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(mount);
} else {
window.addEventListener('resize', resize);
}
update();
};
const cleanup = () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
if (resizeObserver) {
resizeObserver.disconnect();
} else {
window.removeEventListener('resize', resize);
}
document.removeEventListener('mousemove', onPointerMove);
document.removeEventListener('pointermove', onPointerMove);
const mount = shapeBlurContainer.value;
if (mount && renderer) {
mount.removeChild(renderer.domElement);
renderer.dispose();
}
};
watch(
() => [
props.variation,
props.pixelRatioProp,
props.shapeSize,
props.roundness,
props.borderSize,
props.circleSize,
props.circleEdge
],
() => {
if (material) {
material.uniforms.u_pixelRatio.value = props.pixelRatioProp;
material.uniforms.u_shapeSize.value = props.shapeSize;
material.uniforms.u_roundness.value = props.roundness;
material.uniforms.u_borderSize.value = props.borderSize;
material.uniforms.u_circleSize.value = props.circleSize;
material.uniforms.u_circleEdge.value = props.circleEdge;
material.defines.VAR = props.variation;
material.needsUpdate = true;
}
},
{ deep: true }
);
onMounted(() => {
initShapeBlur();
});
onUnmounted(() => {
cleanup();
});
</script>
<style scoped>
.shape-blur-container {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
.shape-blur-container :deep(canvas) {
width: 100% !important;
height: 100% !important;
display: block;
}
</style>

View File

@@ -3,17 +3,17 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, type CSSProperties } from 'vue'
import { Renderer, Program, Mesh, Plane, Camera } from 'ogl'
import { ref, onMounted, onUnmounted, watch, type CSSProperties } from 'vue';
import { Renderer, Program, Mesh, Plane, Camera } from 'ogl';
interface SilkProps {
speed?: number
scale?: number
color?: string
noiseIntensity?: number
rotation?: number
className?: string
style?: CSSProperties
speed?: number;
scale?: number;
color?: string;
noiseIntensity?: number;
rotation?: number;
className?: string;
style?: CSSProperties;
}
const props = withDefaults(defineProps<SilkProps>(), {
@@ -24,17 +24,17 @@ const props = withDefaults(defineProps<SilkProps>(), {
rotation: 0,
className: '',
style: () => ({})
})
});
const containerRef = ref<HTMLDivElement>()
const containerRef = ref<HTMLDivElement>();
const hexToNormalizedRGB = (hex: string): [number, number, number] => {
const clean = hex.replace('#', '')
const r = parseInt(clean.slice(0, 2), 16) / 255
const g = parseInt(clean.slice(2, 4), 16) / 255
const b = parseInt(clean.slice(4, 6), 16) / 255
return [r, g, b]
}
const clean = hex.replace('#', '');
const r = parseInt(clean.slice(0, 2), 16) / 255;
const g = parseInt(clean.slice(2, 4), 16) / 255;
const b = parseInt(clean.slice(4, 6), 16) / 255;
return [r, g, b];
};
const vertexShader = `
attribute vec2 uv;
@@ -51,7 +51,7 @@ void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`
`;
const fragmentShader = `
precision highp float;
@@ -99,75 +99,75 @@ void main() {
col.a = 1.0;
gl_FragColor = col;
}
`
`;
let renderer: Renderer | null = null
let mesh: Mesh | null = null
let program: Program | null = null
let camera: Camera | null = null
let animateId = 0
let renderer: Renderer | null = null;
let mesh: Mesh | null = null;
let program: Program | null = null;
let camera: Camera | null = null;
let animateId = 0;
const initSilk = () => {
const container = containerRef.value
if (!container) return
const container = containerRef.value;
if (!container) return;
renderer = new Renderer({
alpha: true,
antialias: true,
})
antialias: true
});
const gl = renderer.gl
gl.clearColor(0, 0, 0, 0)
gl.canvas.style.backgroundColor = 'transparent'
const gl = renderer.gl;
gl.clearColor(0, 0, 0, 0);
gl.canvas.style.backgroundColor = 'transparent';
camera = new Camera(gl, { fov: 75 })
camera.position.z = 1
camera = new Camera(gl, { fov: 75 });
camera.position.z = 1;
const resize = () => {
if (!container || !camera) return
let width = container.offsetWidth
let height = container.offsetHeight
let parent = container.parentElement
if (!container || !camera) return;
let width = container.offsetWidth;
let height = container.offsetHeight;
let parent = container.parentElement;
while (parent && (!width || !height)) {
if (parent.offsetWidth && parent.offsetHeight) {
width = parent.offsetWidth
height = parent.offsetHeight
break
width = parent.offsetWidth;
height = parent.offsetHeight;
break;
}
parent = parent.parentElement
parent = parent.parentElement;
}
if (!width || !height) {
width = window.innerWidth
height = window.innerHeight
width = window.innerWidth;
height = window.innerHeight;
}
width = Math.max(width, 300)
height = Math.max(height, 300)
renderer!.setSize(width, height)
camera.perspective({ aspect: width / height })
width = Math.max(width, 300);
height = Math.max(height, 300);
renderer!.setSize(width, height);
camera.perspective({ aspect: width / height });
if (mesh) {
const distance = camera.position.z
const fov = camera.fov * (Math.PI / 180)
const height2 = 2 * Math.tan(fov / 2) * distance
const width2 = height2 * (width / height)
const distance = camera.position.z;
const fov = camera.fov * (Math.PI / 180);
const height2 = 2 * Math.tan(fov / 2) * distance;
const width2 = height2 * (width / height);
mesh.scale.set(width2, height2, 1)
mesh.scale.set(width2, height2, 1);
}
}
};
window.addEventListener('resize', resize)
window.addEventListener('resize', resize);
const geometry = new Plane(gl, {
width: 1,
height: 1,
})
height: 1
});
const colorRGB = hexToNormalizedRGB(props.color)
const colorRGB = hexToNormalizedRGB(props.color);
program = new Program(gl, {
vertex: vertexShader,
@@ -178,81 +178,81 @@ const initSilk = () => {
uNoiseIntensity: { value: props.noiseIntensity },
uColor: { value: colorRGB },
uRotation: { value: props.rotation },
uTime: { value: 0 },
},
})
uTime: { value: 0 }
}
});
mesh = new Mesh(gl, { geometry, program })
container.appendChild(gl.canvas)
mesh = new Mesh(gl, { geometry, program });
container.appendChild(gl.canvas);
gl.canvas.style.width = '100%'
gl.canvas.style.height = '100%'
gl.canvas.style.display = 'block'
gl.canvas.style.position = 'absolute'
gl.canvas.style.top = '0'
gl.canvas.style.left = '0'
gl.canvas.style.zIndex = '1'
gl.canvas.style.width = '100%';
gl.canvas.style.height = '100%';
gl.canvas.style.display = 'block';
gl.canvas.style.position = 'absolute';
gl.canvas.style.top = '0';
gl.canvas.style.left = '0';
gl.canvas.style.zIndex = '1';
let lastTime = 0
let lastTime = 0;
const update = (t: number) => {
animateId = requestAnimationFrame(update)
const deltaTime = (t - lastTime) / 1000
lastTime = t
animateId = requestAnimationFrame(update);
const deltaTime = (t - lastTime) / 1000;
lastTime = t;
if (program && mesh && camera) {
program.uniforms.uTime.value += 0.1 * deltaTime
program.uniforms.uSpeed.value = props.speed
program.uniforms.uScale.value = props.scale
program.uniforms.uNoiseIntensity.value = props.noiseIntensity
program.uniforms.uColor.value = hexToNormalizedRGB(props.color)
program.uniforms.uRotation.value = props.rotation
renderer!.render({ scene: mesh, camera })
program.uniforms.uTime.value += 0.1 * deltaTime;
program.uniforms.uSpeed.value = props.speed;
program.uniforms.uScale.value = props.scale;
program.uniforms.uNoiseIntensity.value = props.noiseIntensity;
program.uniforms.uColor.value = hexToNormalizedRGB(props.color);
program.uniforms.uRotation.value = props.rotation;
renderer!.render({ scene: mesh, camera });
}
}
animateId = requestAnimationFrame(update)
};
animateId = requestAnimationFrame(update);
resize()
resize();
return () => {
cancelAnimationFrame(animateId)
window.removeEventListener('resize', resize)
cancelAnimationFrame(animateId);
window.removeEventListener('resize', resize);
if (container && gl.canvas.parentNode === container) {
container.removeChild(gl.canvas)
container.removeChild(gl.canvas);
}
gl.getExtension('WEBGL_lose_context')?.loseContext()
}
}
gl.getExtension('WEBGL_lose_context')?.loseContext();
};
};
const cleanup = () => {
if (animateId) {
cancelAnimationFrame(animateId)
cancelAnimationFrame(animateId);
}
if (renderer) {
const gl = renderer.gl
const container = containerRef.value
const gl = renderer.gl;
const container = containerRef.value;
if (container && gl.canvas.parentNode === container) {
container.removeChild(gl.canvas)
container.removeChild(gl.canvas);
}
gl.getExtension('WEBGL_lose_context')?.loseContext()
gl.getExtension('WEBGL_lose_context')?.loseContext();
}
renderer = null
mesh = null
camera = null
program = null
}
renderer = null;
mesh = null;
camera = null;
program = null;
};
onMounted(() => {
initSilk()
})
initSilk();
});
onUnmounted(() => {
cleanup()
})
cleanup();
});
watch(
() => [props.speed, props.scale, props.color, props.noiseIntensity, props.rotation],
() => {}
)
);
</script>
<style scoped>

View File

@@ -3,75 +3,75 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { ref, onMounted, onUnmounted, watch } from 'vue';
type CanvasStrokeStyle = string | CanvasGradient | CanvasPattern
type CanvasStrokeStyle = string | CanvasGradient | CanvasPattern;
interface GridOffset {
x: number
y: number
x: number;
y: number;
}
interface Props {
direction?: "diagonal" | "up" | "right" | "down" | "left"
speed?: number
borderColor?: CanvasStrokeStyle
squareSize?: number
hoverFillColor?: CanvasStrokeStyle
direction?: 'diagonal' | 'up' | 'right' | 'down' | 'left';
speed?: number;
borderColor?: CanvasStrokeStyle;
squareSize?: number;
hoverFillColor?: CanvasStrokeStyle;
}
const props = withDefaults(defineProps<Props>(), {
direction: "right",
direction: 'right',
speed: 1,
borderColor: "#999",
borderColor: '#999',
squareSize: 40,
hoverFillColor: "#222"
})
hoverFillColor: '#222'
});
const canvasRef = ref<HTMLCanvasElement | null>(null)
const requestRef = ref<number | null>(null)
const numSquaresX = ref<number>(0)
const numSquaresY = ref<number>(0)
const gridOffset = ref<GridOffset>({ x: 0, y: 0 })
const hoveredSquareRef = ref<GridOffset | null>(null)
const canvasRef = ref<HTMLCanvasElement | null>(null);
const requestRef = ref<number | null>(null);
const numSquaresX = ref<number>(0);
const numSquaresY = ref<number>(0);
const gridOffset = ref<GridOffset>({ x: 0, y: 0 });
const hoveredSquareRef = ref<GridOffset | null>(null);
let ctx: CanvasRenderingContext2D | null = null
let ctx: CanvasRenderingContext2D | null = null;
const resizeCanvas = () => {
const canvas = canvasRef.value
if (!canvas) return
const canvas = canvasRef.value;
if (!canvas) return;
canvas.width = canvas.offsetWidth
canvas.height = canvas.offsetHeight
numSquaresX.value = Math.ceil(canvas.width / props.squareSize) + 1
numSquaresY.value = Math.ceil(canvas.height / props.squareSize) + 1
}
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
numSquaresX.value = Math.ceil(canvas.width / props.squareSize) + 1;
numSquaresY.value = Math.ceil(canvas.height / props.squareSize) + 1;
};
const drawGrid = () => {
const canvas = canvasRef.value
if (!ctx || !canvas) return
const canvas = canvasRef.value;
if (!ctx || !canvas) return;
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.clearRect(0, 0, canvas.width, canvas.height);
const startX = Math.floor(gridOffset.value.x / props.squareSize) * props.squareSize
const startY = Math.floor(gridOffset.value.y / props.squareSize) * props.squareSize
const startX = Math.floor(gridOffset.value.x / props.squareSize) * props.squareSize;
const startY = Math.floor(gridOffset.value.y / props.squareSize) * props.squareSize;
for (let x = startX; x < canvas.width + props.squareSize; x += props.squareSize) {
for (let y = startY; y < canvas.height + props.squareSize; y += props.squareSize) {
const squareX = x - (gridOffset.value.x % props.squareSize)
const squareY = y - (gridOffset.value.y % props.squareSize)
const squareX = x - (gridOffset.value.x % props.squareSize);
const squareY = y - (gridOffset.value.y % props.squareSize);
if (
hoveredSquareRef.value &&
Math.floor((x - startX) / props.squareSize) === hoveredSquareRef.value.x &&
Math.floor((y - startY) / props.squareSize) === hoveredSquareRef.value.y
) {
ctx.fillStyle = props.hoverFillColor
ctx.fillRect(squareX, squareY, props.squareSize, props.squareSize)
ctx.fillStyle = props.hoverFillColor;
ctx.fillRect(squareX, squareY, props.squareSize, props.squareSize);
}
ctx.strokeStyle = props.borderColor
ctx.strokeRect(squareX, squareY, props.squareSize, props.squareSize)
ctx.strokeStyle = props.borderColor;
ctx.strokeRect(squareX, squareY, props.squareSize, props.squareSize);
}
}
@@ -82,116 +82,118 @@ const drawGrid = () => {
canvas.width / 2,
canvas.height / 2,
Math.sqrt(canvas.width ** 2 + canvas.height ** 2) / 2
)
gradient.addColorStop(0, "rgba(0, 0, 0, 0)")
gradient.addColorStop(1, "#0b0b0b")
);
gradient.addColorStop(0, 'rgba(0, 0, 0, 0)');
gradient.addColorStop(1, '#0b0b0b');
ctx.fillStyle = gradient
ctx.fillRect(0, 0, canvas.width, canvas.height)
}
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, canvas.width, canvas.height);
};
const updateAnimation = () => {
const effectiveSpeed = Math.max(props.speed, 0.1)
const effectiveSpeed = Math.max(props.speed, 0.1);
switch (props.direction) {
case "right":
gridOffset.value.x = (gridOffset.value.x - effectiveSpeed + props.squareSize) % props.squareSize
break
case "left":
gridOffset.value.x = (gridOffset.value.x + effectiveSpeed + props.squareSize) % props.squareSize
break
case "up":
gridOffset.value.y = (gridOffset.value.y + effectiveSpeed + props.squareSize) % props.squareSize
break
case "down":
gridOffset.value.y = (gridOffset.value.y - effectiveSpeed + props.squareSize) % props.squareSize
break
case "diagonal":
gridOffset.value.x = (gridOffset.value.x - effectiveSpeed + props.squareSize) % props.squareSize
gridOffset.value.y = (gridOffset.value.y - effectiveSpeed + props.squareSize) % props.squareSize
break
case 'right':
gridOffset.value.x = (gridOffset.value.x - effectiveSpeed + props.squareSize) % props.squareSize;
break;
case 'left':
gridOffset.value.x = (gridOffset.value.x + effectiveSpeed + props.squareSize) % props.squareSize;
break;
case 'up':
gridOffset.value.y = (gridOffset.value.y + effectiveSpeed + props.squareSize) % props.squareSize;
break;
case 'down':
gridOffset.value.y = (gridOffset.value.y - effectiveSpeed + props.squareSize) % props.squareSize;
break;
case 'diagonal':
gridOffset.value.x = (gridOffset.value.x - effectiveSpeed + props.squareSize) % props.squareSize;
gridOffset.value.y = (gridOffset.value.y - effectiveSpeed + props.squareSize) % props.squareSize;
break;
default:
break
break;
}
drawGrid()
requestRef.value = requestAnimationFrame(updateAnimation)
}
drawGrid();
requestRef.value = requestAnimationFrame(updateAnimation);
};
const handleMouseMove = (event: MouseEvent) => {
const canvas = canvasRef.value
if (!canvas) return
const canvas = canvasRef.value;
if (!canvas) return;
const rect = canvas.getBoundingClientRect()
const mouseX = event.clientX - rect.left
const mouseY = event.clientY - rect.top
const rect = canvas.getBoundingClientRect();
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;
const startX = Math.floor(gridOffset.value.x / props.squareSize) * props.squareSize
const startY = Math.floor(gridOffset.value.y / props.squareSize) * props.squareSize
const startX = Math.floor(gridOffset.value.x / props.squareSize) * props.squareSize;
const startY = Math.floor(gridOffset.value.y / props.squareSize) * props.squareSize;
const hoveredSquareX = Math.floor(
(mouseX + gridOffset.value.x - startX) / props.squareSize
)
const hoveredSquareY = Math.floor(
(mouseY + gridOffset.value.y - startY) / props.squareSize
)
const hoveredSquareX = Math.floor((mouseX + gridOffset.value.x - startX) / props.squareSize);
const hoveredSquareY = Math.floor((mouseY + gridOffset.value.y - startY) / props.squareSize);
if (
!hoveredSquareRef.value ||
hoveredSquareRef.value.x !== hoveredSquareX ||
hoveredSquareRef.value.y !== hoveredSquareY
) {
hoveredSquareRef.value = { x: hoveredSquareX, y: hoveredSquareY }
hoveredSquareRef.value = { x: hoveredSquareX, y: hoveredSquareY };
}
}
};
const handleMouseLeave = () => {
hoveredSquareRef.value = null
}
hoveredSquareRef.value = null;
};
const initializeCanvas = () => {
const canvas = canvasRef.value
if (!canvas) return
const canvas = canvasRef.value;
if (!canvas) return;
ctx = canvas.getContext("2d")
resizeCanvas()
ctx = canvas.getContext('2d');
resizeCanvas();
canvas.addEventListener("mousemove", handleMouseMove)
canvas.addEventListener("mouseleave", handleMouseLeave)
window.addEventListener("resize", resizeCanvas)
canvas.addEventListener('mousemove', handleMouseMove);
canvas.addEventListener('mouseleave', handleMouseLeave);
window.addEventListener('resize', resizeCanvas);
requestRef.value = requestAnimationFrame(updateAnimation)
}
requestRef.value = requestAnimationFrame(updateAnimation);
};
const cleanup = () => {
const canvas = canvasRef.value
const canvas = canvasRef.value;
if (requestRef.value) {
cancelAnimationFrame(requestRef.value)
requestRef.value = null
cancelAnimationFrame(requestRef.value);
requestRef.value = null;
}
if (canvas) {
canvas.removeEventListener("mousemove", handleMouseMove)
canvas.removeEventListener("mouseleave", handleMouseLeave)
canvas.removeEventListener('mousemove', handleMouseMove);
canvas.removeEventListener('mouseleave', handleMouseLeave);
}
window.removeEventListener("resize", resizeCanvas)
}
window.removeEventListener('resize', resizeCanvas);
};
onMounted(() => {
initializeCanvas()
})
initializeCanvas();
});
onUnmounted(() => {
cleanup()
})
cleanup();
});
watch(
[() => props.direction, () => props.speed, () => props.borderColor, () => props.hoverFillColor, () => props.squareSize],
[
() => props.direction,
() => props.speed,
() => props.borderColor,
() => props.hoverFillColor,
() => props.squareSize
],
() => {
cleanup()
initializeCanvas()
cleanup();
initializeCanvas();
}
)
</script>
);
</script>

View File

@@ -3,15 +3,15 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { Renderer, Program, Mesh, Triangle, Color } from 'ogl'
import type { OGLRenderingContext } from 'ogl'
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { Renderer, Program, Mesh, Triangle, Color } from 'ogl';
import type { OGLRenderingContext } from 'ogl';
interface Props {
color?: [number, number, number]
amplitude?: number
distance?: number
enableMouseInteraction?: boolean
color?: [number, number, number];
amplitude?: number;
distance?: number;
enableMouseInteraction?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
@@ -19,17 +19,17 @@ const props = withDefaults(defineProps<Props>(), {
amplitude: 1,
distance: 0,
enableMouseInteraction: false
})
});
const containerRef = ref<HTMLDivElement | null>(null)
const containerRef = ref<HTMLDivElement | null>(null);
let renderer: Renderer | null = null
let gl: OGLRenderingContext | null = null
let program: Program | null = null
let mesh: Mesh | null = null
let animationId: number | null = null
let currentMouse = [0.5, 0.5]
let targetMouse = [0.5, 0.5]
let renderer: Renderer | null = null;
let gl: OGLRenderingContext | null = null;
let program: Program | null = null;
let mesh: Mesh | null = null;
let animationId: number | null = null;
let currentMouse = [0.5, 0.5];
let targetMouse = [0.5, 0.5];
const vertexShader = `
attribute vec2 position;
@@ -39,7 +39,7 @@ void main() {
vUv = uv;
gl_Position = vec4(position, 0.0, 1.0);
}
`
`;
const fragmentShader = `
precision highp float;
@@ -146,145 +146,141 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
void main() {
mainImage(gl_FragColor, gl_FragCoord.xy);
}
`
`;
const resize = () => {
if (!containerRef.value || !renderer || !program) return
if (!containerRef.value || !renderer || !program) return;
const container = containerRef.value
const { clientWidth, clientHeight } = container
renderer.setSize(clientWidth, clientHeight)
program.uniforms.iResolution.value.r = clientWidth
program.uniforms.iResolution.value.g = clientHeight
program.uniforms.iResolution.value.b = clientWidth / clientHeight
}
const container = containerRef.value;
const { clientWidth, clientHeight } = container;
renderer.setSize(clientWidth, clientHeight);
program.uniforms.iResolution.value.r = clientWidth;
program.uniforms.iResolution.value.g = clientHeight;
program.uniforms.iResolution.value.b = clientWidth / clientHeight;
};
const handleMouseMove = (e: MouseEvent) => {
if (!containerRef.value) return
if (!containerRef.value) return;
const rect = containerRef.value.getBoundingClientRect()
const x = (e.clientX - rect.left) / rect.width
const y = 1.0 - (e.clientY - rect.top) / rect.height
targetMouse = [x, y]
}
const rect = containerRef.value.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = 1.0 - (e.clientY - rect.top) / rect.height;
targetMouse = [x, y];
};
const handleMouseLeave = () => {
targetMouse = [0.5, 0.5]
}
targetMouse = [0.5, 0.5];
};
const update = (t: number) => {
if (!program || !renderer || !mesh) return
if (!program || !renderer || !mesh) return;
if (props.enableMouseInteraction) {
const smoothing = 0.05
currentMouse[0] += smoothing * (targetMouse[0] - currentMouse[0])
currentMouse[1] += smoothing * (targetMouse[1] - currentMouse[1])
program.uniforms.uMouse.value[0] = currentMouse[0]
program.uniforms.uMouse.value[1] = currentMouse[1]
const smoothing = 0.05;
currentMouse[0] += smoothing * (targetMouse[0] - currentMouse[0]);
currentMouse[1] += smoothing * (targetMouse[1] - currentMouse[1]);
program.uniforms.uMouse.value[0] = currentMouse[0];
program.uniforms.uMouse.value[1] = currentMouse[1];
} else {
program.uniforms.uMouse.value[0] = 0.5
program.uniforms.uMouse.value[1] = 0.5
program.uniforms.uMouse.value[0] = 0.5;
program.uniforms.uMouse.value[1] = 0.5;
}
program.uniforms.iTime.value = t * 0.001
renderer.render({ scene: mesh })
animationId = requestAnimationFrame(update)
}
program.uniforms.iTime.value = t * 0.001;
renderer.render({ scene: mesh });
animationId = requestAnimationFrame(update);
};
const initializeScene = () => {
if (!containerRef.value) return
if (!containerRef.value) return;
cleanup()
cleanup();
const container = containerRef.value
renderer = new Renderer({ alpha: true })
gl = renderer.gl
gl.clearColor(0, 0, 0, 0)
gl.enable(gl.BLEND)
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
const container = containerRef.value;
renderer = new Renderer({ alpha: true });
gl = renderer.gl;
gl.clearColor(0, 0, 0, 0);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
const geometry = new Triangle(gl)
const geometry = new Triangle(gl);
program = new Program(gl, {
vertex: vertexShader,
fragment: fragmentShader,
uniforms: {
iTime: { value: 0 },
iResolution: {
value: new Color(
gl.canvas.width,
gl.canvas.height,
gl.canvas.width / gl.canvas.height
)
value: new Color(gl.canvas.width, gl.canvas.height, gl.canvas.width / gl.canvas.height)
},
uColor: { value: new Color(...props.color) },
uAmplitude: { value: props.amplitude },
uDistance: { value: props.distance },
uMouse: { value: new Float32Array([0.5, 0.5]) }
}
})
});
mesh = new Mesh(gl, { geometry, program })
mesh = new Mesh(gl, { geometry, program });
const canvas = gl.canvas as HTMLCanvasElement
canvas.style.width = '100%'
canvas.style.height = '100%'
canvas.style.display = 'block'
const canvas = gl.canvas as HTMLCanvasElement;
canvas.style.width = '100%';
canvas.style.height = '100%';
canvas.style.display = 'block';
container.appendChild(canvas)
container.appendChild(canvas);
window.addEventListener('resize', resize)
window.addEventListener('resize', resize);
if (props.enableMouseInteraction) {
container.addEventListener('mousemove', handleMouseMove)
container.addEventListener('mouseleave', handleMouseLeave)
container.addEventListener('mousemove', handleMouseMove);
container.addEventListener('mouseleave', handleMouseLeave);
}
resize()
animationId = requestAnimationFrame(update)
}
resize();
animationId = requestAnimationFrame(update);
};
const cleanup = () => {
if (animationId) {
cancelAnimationFrame(animationId)
animationId = null
cancelAnimationFrame(animationId);
animationId = null;
}
window.removeEventListener('resize', resize)
window.removeEventListener('resize', resize);
if (containerRef.value) {
containerRef.value.removeEventListener('mousemove', handleMouseMove)
containerRef.value.removeEventListener('mouseleave', handleMouseLeave)
containerRef.value.removeEventListener('mousemove', handleMouseMove);
containerRef.value.removeEventListener('mouseleave', handleMouseLeave);
const canvas = containerRef.value.querySelector('canvas')
const canvas = containerRef.value.querySelector('canvas');
if (canvas) {
containerRef.value.removeChild(canvas)
containerRef.value.removeChild(canvas);
}
}
if (gl) {
gl.getExtension('WEBGL_lose_context')?.loseContext()
gl.getExtension('WEBGL_lose_context')?.loseContext();
}
renderer = null
gl = null
program = null
mesh = null
currentMouse = [0.5, 0.5]
targetMouse = [0.5, 0.5]
}
renderer = null;
gl = null;
program = null;
mesh = null;
currentMouse = [0.5, 0.5];
targetMouse = [0.5, 0.5];
};
onMounted(() => {
initializeScene()
})
initializeScene();
});
onUnmounted(() => {
cleanup()
})
cleanup();
});
watch(
[() => props.color, () => props.amplitude, () => props.distance, () => props.enableMouseInteraction],
() => {
initializeScene()
initializeScene();
},
{ deep: true }
)
</script>
);
</script>

View File

@@ -1,38 +1,46 @@
<template>
<div ref="containerRef" :class="className" :style="{ backgroundColor, ...style }"
class="absolute top-0 left-0 w-full h-full overflow-hidden">
<div class="absolute top-0 left-0 bg-[#160000] rounded-full w-[0.5rem] h-[0.5rem]" :style="{
transform: 'translate3d(calc(var(--x) - 50%), calc(var(--y) - 50%), 0)',
willChange: 'transform',
}" />
<div
ref="containerRef"
:class="className"
:style="{ backgroundColor, ...style }"
class="absolute top-0 left-0 w-full h-full overflow-hidden"
>
<div
class="absolute top-0 left-0 bg-[#160000] rounded-full w-[0.5rem] h-[0.5rem]"
:style="{
transform: 'translate3d(calc(var(--x) - 50%), calc(var(--y) - 50%), 0)',
willChange: 'transform'
}"
/>
<canvas ref="canvasRef" class="block w-full h-full" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, type CSSProperties } from 'vue'
import { ref, onMounted, onUnmounted, watch, type CSSProperties } from 'vue';
class Grad {
x: number
y: number
z: number
x: number;
y: number;
z: number;
constructor(x: number, y: number, z: number) {
this.x = x
this.y = y
this.z = z
this.x = x;
this.y = y;
this.z = z;
}
dot2(x: number, y: number): number {
return this.x * x + this.y * y
return this.x * x + this.y * y;
}
}
class Noise {
grad3: Grad[]
p: number[]
perm: number[]
gradP: Grad[]
grad3: Grad[];
p: number[];
perm: number[];
gradP: Grad[];
constructor(seed = 0) {
this.grad3 = [
@@ -47,122 +55,113 @@ class Noise {
new Grad(0, 1, 1),
new Grad(0, -1, 1),
new Grad(0, 1, -1),
new Grad(0, -1, -1),
]
new Grad(0, -1, -1)
];
this.p = [
151, 160, 137, 91, 90, 15, 131, 13, 201, 95, 96, 53, 194, 233, 7, 225,
140, 36, 103, 30, 69, 142, 8, 99, 37, 240, 21, 10, 23, 190, 6, 148, 247,
120, 234, 75, 0, 26, 197, 62, 94, 252, 219, 203, 117, 35, 11, 32, 57, 177,
33, 88, 237, 149, 56, 87, 174, 20, 125, 136, 171, 168, 68, 175, 74, 165,
71, 134, 139, 48, 27, 166, 77, 146, 158, 231, 83, 111, 229, 122, 60, 211,
133, 230, 220, 105, 92, 41, 55, 46, 245, 40, 244, 102, 143, 54, 65, 25,
63, 161, 1, 216, 80, 73, 209, 76, 132, 187, 208, 89, 18, 169, 200, 196,
135, 130, 116, 188, 159, 86, 164, 100, 109, 198, 173, 186, 3, 64, 52, 217,
226, 250, 124, 123, 5, 202, 38, 147, 118, 126, 255, 82, 85, 212, 207, 206,
59, 227, 47, 16, 58, 17, 182, 189, 28, 42, 223, 183, 170, 213, 119, 248,
152, 2, 44, 154, 163, 70, 221, 153, 101, 155, 167, 43, 172, 9, 129, 22,
39, 253, 19, 98, 108, 110, 79, 113, 224, 232, 178, 185, 112, 104, 218,
246, 97, 228, 251, 34, 242, 193, 238, 210, 144, 12, 191, 179, 162, 241,
81, 51, 145, 235, 249, 14, 239, 107, 49, 192, 214, 31, 181, 199, 106, 157,
184, 84, 204, 176, 115, 121, 50, 45, 127, 4, 150, 254, 138, 236, 205, 93,
222, 114, 67, 29, 24, 72, 243, 141, 128, 195, 78, 66, 215, 61, 156, 180,
]
151, 160, 137, 91, 90, 15, 131, 13, 201, 95, 96, 53, 194, 233, 7, 225, 140, 36, 103, 30, 69, 142, 8, 99, 37, 240,
21, 10, 23, 190, 6, 148, 247, 120, 234, 75, 0, 26, 197, 62, 94, 252, 219, 203, 117, 35, 11, 32, 57, 177, 33, 88,
237, 149, 56, 87, 174, 20, 125, 136, 171, 168, 68, 175, 74, 165, 71, 134, 139, 48, 27, 166, 77, 146, 158, 231, 83,
111, 229, 122, 60, 211, 133, 230, 220, 105, 92, 41, 55, 46, 245, 40, 244, 102, 143, 54, 65, 25, 63, 161, 1, 216,
80, 73, 209, 76, 132, 187, 208, 89, 18, 169, 200, 196, 135, 130, 116, 188, 159, 86, 164, 100, 109, 198, 173, 186,
3, 64, 52, 217, 226, 250, 124, 123, 5, 202, 38, 147, 118, 126, 255, 82, 85, 212, 207, 206, 59, 227, 47, 16, 58,
17, 182, 189, 28, 42, 223, 183, 170, 213, 119, 248, 152, 2, 44, 154, 163, 70, 221, 153, 101, 155, 167, 43, 172, 9,
129, 22, 39, 253, 19, 98, 108, 110, 79, 113, 224, 232, 178, 185, 112, 104, 218, 246, 97, 228, 251, 34, 242, 193,
238, 210, 144, 12, 191, 179, 162, 241, 81, 51, 145, 235, 249, 14, 239, 107, 49, 192, 214, 31, 181, 199, 106, 157,
184, 84, 204, 176, 115, 121, 50, 45, 127, 4, 150, 254, 138, 236, 205, 93, 222, 114, 67, 29, 24, 72, 243, 141, 128,
195, 78, 66, 215, 61, 156, 180
];
this.perm = new Array(512)
this.gradP = new Array(512)
this.seed(seed)
this.perm = new Array(512);
this.gradP = new Array(512);
this.seed(seed);
}
seed(seed: number) {
if (seed > 0 && seed < 1) seed *= 65536
seed = Math.floor(seed)
if (seed < 256) seed |= seed << 8
if (seed > 0 && seed < 1) seed *= 65536;
seed = Math.floor(seed);
if (seed < 256) seed |= seed << 8;
for (let i = 0; i < 256; i++) {
const v = i & 1 ? this.p[i] ^ (seed & 255) : this.p[i] ^ ((seed >> 8) & 255)
this.perm[i] = this.perm[i + 256] = v
this.gradP[i] = this.gradP[i + 256] = this.grad3[v % 12]
const v = i & 1 ? this.p[i] ^ (seed & 255) : this.p[i] ^ ((seed >> 8) & 255);
this.perm[i] = this.perm[i + 256] = v;
this.gradP[i] = this.gradP[i + 256] = this.grad3[v % 12];
}
}
fade(t: number): number {
return t * t * t * (t * (t * 6 - 15) + 10)
return t * t * t * (t * (t * 6 - 15) + 10);
}
lerp(a: number, b: number, t: number): number {
return (1 - t) * a + t * b
return (1 - t) * a + t * b;
}
perlin2(x: number, y: number): number {
let X = Math.floor(x),
Y = Math.floor(y)
x -= X
y -= Y
X &= 255
Y &= 255
Y = Math.floor(y);
x -= X;
y -= Y;
X &= 255;
Y &= 255;
const n00 = this.gradP[X + this.perm[Y]].dot2(x, y)
const n01 = this.gradP[X + this.perm[Y + 1]].dot2(x, y - 1)
const n10 = this.gradP[X + 1 + this.perm[Y]].dot2(x - 1, y)
const n11 = this.gradP[X + 1 + this.perm[Y + 1]].dot2(x - 1, y - 1)
const u = this.fade(x)
const n00 = this.gradP[X + this.perm[Y]].dot2(x, y);
const n01 = this.gradP[X + this.perm[Y + 1]].dot2(x, y - 1);
const n10 = this.gradP[X + 1 + this.perm[Y]].dot2(x - 1, y);
const n11 = this.gradP[X + 1 + this.perm[Y + 1]].dot2(x - 1, y - 1);
const u = this.fade(x);
return this.lerp(
this.lerp(n00, n10, u),
this.lerp(n01, n11, u),
this.fade(y)
)
return this.lerp(this.lerp(n00, n10, u), this.lerp(n01, n11, u), this.fade(y));
}
}
interface Point {
x: number
y: number
wave: { x: number; y: number }
cursor: { x: number; y: number; vx: number; vy: number }
x: number;
y: number;
wave: { x: number; y: number };
cursor: { x: number; y: number; vx: number; vy: number };
}
interface Mouse {
x: number
y: number
lx: number
ly: number
sx: number
sy: number
v: number
vs: number
a: number
set: boolean
x: number;
y: number;
lx: number;
ly: number;
sx: number;
sy: number;
v: number;
vs: number;
a: number;
set: boolean;
}
interface Config {
lineColor: string
waveSpeedX: number
waveSpeedY: number
waveAmpX: number
waveAmpY: number
friction: number
tension: number
maxCursorMove: number
xGap: number
yGap: number
lineColor: string;
waveSpeedX: number;
waveSpeedY: number;
waveAmpX: number;
waveAmpY: number;
friction: number;
tension: number;
maxCursorMove: number;
xGap: number;
yGap: number;
}
interface WavesProps {
lineColor?: string
backgroundColor?: string
waveSpeedX?: number
waveSpeedY?: number
waveAmpX?: number
waveAmpY?: number
xGap?: number
yGap?: number
friction?: number
tension?: number
maxCursorMove?: number
style?: CSSProperties
className?: string
lineColor?: string;
backgroundColor?: string;
waveSpeedX?: number;
waveSpeedY?: number;
waveAmpX?: number;
waveAmpY?: number;
xGap?: number;
yGap?: number;
friction?: number;
tension?: number;
maxCursorMove?: number;
style?: CSSProperties;
className?: string;
}
const props = withDefaults(defineProps<WavesProps>(), {
@@ -179,15 +178,15 @@ const props = withDefaults(defineProps<WavesProps>(), {
maxCursorMove: 100,
style: () => ({}),
className: ''
})
});
const containerRef = ref<HTMLDivElement>()
const canvasRef = ref<HTMLCanvasElement>()
const containerRef = ref<HTMLDivElement>();
const canvasRef = ref<HTMLCanvasElement>();
let ctx: CanvasRenderingContext2D | null = null
let bounding = { width: 0, height: 0, left: 0, top: 0 }
let noise: Noise | null = null
let lines: Point[][] = []
let ctx: CanvasRenderingContext2D | null = null;
let bounding = { width: 0, height: 0, left: 0, top: 0 };
let noise: Noise | null = null;
let lines: Point[][] = [];
const mouse: Mouse = {
x: -10,
y: 0,
@@ -198,8 +197,8 @@ const mouse: Mouse = {
v: 0,
vs: 0,
a: 0,
set: false,
}
set: false
};
let config: Config = {
lineColor: props.lineColor,
waveSpeedX: props.waveSpeedX,
@@ -210,207 +209,187 @@ let config: Config = {
tension: props.tension,
maxCursorMove: props.maxCursorMove,
xGap: props.xGap,
yGap: props.yGap,
}
let frameId: number | null = null
yGap: props.yGap
};
let frameId: number | null = null;
const setSize = () => {
const container = containerRef.value
const canvas = canvasRef.value
if (!container || !canvas) return
const container = containerRef.value;
const canvas = canvasRef.value;
if (!container || !canvas) return;
const rect = container.getBoundingClientRect()
const rect = container.getBoundingClientRect();
bounding = {
width: rect.width,
height: rect.height,
left: rect.left,
top: rect.top,
}
canvas.width = rect.width
canvas.height = rect.height
}
top: rect.top
};
canvas.width = rect.width;
canvas.height = rect.height;
};
const setLines = () => {
const { width, height } = bounding
lines = []
const { width, height } = bounding;
lines = [];
const oWidth = width + 200,
oHeight = height + 30
const { xGap, yGap } = config
const totalLines = Math.ceil(oWidth / xGap)
const totalPoints = Math.ceil(oHeight / yGap)
const xStart = (width - xGap * totalLines) / 2
const yStart = (height - yGap * totalPoints) / 2
oHeight = height + 30;
const { xGap, yGap } = config;
const totalLines = Math.ceil(oWidth / xGap);
const totalPoints = Math.ceil(oHeight / yGap);
const xStart = (width - xGap * totalLines) / 2;
const yStart = (height - yGap * totalPoints) / 2;
for (let i = 0; i <= totalLines; i++) {
const pts: Point[] = []
const pts: Point[] = [];
for (let j = 0; j <= totalPoints; j++) {
pts.push({
x: xStart + xGap * i,
y: yStart + yGap * j,
wave: { x: 0, y: 0 },
cursor: { x: 0, y: 0, vx: 0, vy: 0 },
})
cursor: { x: 0, y: 0, vx: 0, vy: 0 }
});
}
lines.push(pts)
lines.push(pts);
}
}
};
const movePoints = (time: number) => {
if (!noise) return
if (!noise) return;
const {
waveSpeedX,
waveSpeedY,
waveAmpX,
waveAmpY,
friction,
tension,
maxCursorMove,
} = config
const { waveSpeedX, waveSpeedY, waveAmpX, waveAmpY, friction, tension, maxCursorMove } = config;
lines.forEach((pts) => {
pts.forEach((p) => {
const move = noise!.perlin2(
(p.x + time * waveSpeedX) * 0.002,
(p.y + time * waveSpeedY) * 0.0015
) * 12
p.wave.x = Math.cos(move) * waveAmpX
p.wave.y = Math.sin(move) * waveAmpY
lines.forEach(pts => {
pts.forEach(p => {
const move = noise!.perlin2((p.x + time * waveSpeedX) * 0.002, (p.y + time * waveSpeedY) * 0.0015) * 12;
p.wave.x = Math.cos(move) * waveAmpX;
p.wave.y = Math.sin(move) * waveAmpY;
const dx = p.x - mouse.sx,
dy = p.y - mouse.sy
const dist = Math.hypot(dx, dy)
const l = Math.max(175, mouse.vs)
dy = p.y - mouse.sy;
const dist = Math.hypot(dx, dy);
const l = Math.max(175, mouse.vs);
if (dist < l) {
const s = 1 - dist / l
const f = Math.cos(dist * 0.001) * s
p.cursor.vx += Math.cos(mouse.a) * f * l * mouse.vs * 0.00065
p.cursor.vy += Math.sin(mouse.a) * f * l * mouse.vs * 0.00065
const s = 1 - dist / l;
const f = Math.cos(dist * 0.001) * s;
p.cursor.vx += Math.cos(mouse.a) * f * l * mouse.vs * 0.00065;
p.cursor.vy += Math.sin(mouse.a) * f * l * mouse.vs * 0.00065;
}
p.cursor.vx += (0 - p.cursor.x) * tension
p.cursor.vy += (0 - p.cursor.y) * tension
p.cursor.vx *= friction
p.cursor.vy *= friction
p.cursor.x += p.cursor.vx * 2
p.cursor.y += p.cursor.vy * 2
p.cursor.x = Math.min(
maxCursorMove,
Math.max(-maxCursorMove, p.cursor.x)
)
p.cursor.y = Math.min(
maxCursorMove,
Math.max(-maxCursorMove, p.cursor.y)
)
})
})
}
p.cursor.vx += (0 - p.cursor.x) * tension;
p.cursor.vy += (0 - p.cursor.y) * tension;
p.cursor.vx *= friction;
p.cursor.vy *= friction;
p.cursor.x += p.cursor.vx * 2;
p.cursor.y += p.cursor.vy * 2;
p.cursor.x = Math.min(maxCursorMove, Math.max(-maxCursorMove, p.cursor.x));
p.cursor.y = Math.min(maxCursorMove, Math.max(-maxCursorMove, p.cursor.y));
});
});
};
const moved = (point: Point, withCursor = true): { x: number; y: number } => {
const x = point.x + point.wave.x + (withCursor ? point.cursor.x : 0)
const y = point.y + point.wave.y + (withCursor ? point.cursor.y : 0)
return { x: Math.round(x * 10) / 10, y: Math.round(y * 10) / 10 }
}
const x = point.x + point.wave.x + (withCursor ? point.cursor.x : 0);
const y = point.y + point.wave.y + (withCursor ? point.cursor.y : 0);
return { x: Math.round(x * 10) / 10, y: Math.round(y * 10) / 10 };
};
const drawLines = () => {
const { width, height } = bounding
if (!ctx) return
const { width, height } = bounding;
if (!ctx) return;
ctx.clearRect(0, 0, width, height)
ctx.beginPath()
ctx.strokeStyle = config.lineColor
ctx.clearRect(0, 0, width, height);
ctx.beginPath();
ctx.strokeStyle = config.lineColor;
lines.forEach((points) => {
let p1 = moved(points[0], false)
ctx!.moveTo(p1.x, p1.y)
lines.forEach(points => {
let p1 = moved(points[0], false);
ctx!.moveTo(p1.x, p1.y);
points.forEach((p, idx) => {
const isLast = idx === points.length - 1
p1 = moved(p, !isLast)
const p2 = moved(
points[idx + 1] || points[points.length - 1],
!isLast
)
ctx!.lineTo(p1.x, p1.y)
if (isLast) ctx!.moveTo(p2.x, p2.y)
})
})
ctx.stroke()
}
const isLast = idx === points.length - 1;
p1 = moved(p, !isLast);
const p2 = moved(points[idx + 1] || points[points.length - 1], !isLast);
ctx!.lineTo(p1.x, p1.y);
if (isLast) ctx!.moveTo(p2.x, p2.y);
});
});
ctx.stroke();
};
const tick = (t: number) => {
const container = containerRef.value
if (!container) return
const container = containerRef.value;
if (!container) return;
mouse.sx += (mouse.x - mouse.sx) * 0.1
mouse.sy += (mouse.y - mouse.sy) * 0.1
mouse.sx += (mouse.x - mouse.sx) * 0.1;
mouse.sy += (mouse.y - mouse.sy) * 0.1;
const dx = mouse.x - mouse.lx,
dy = mouse.y - mouse.ly
const d = Math.hypot(dx, dy)
mouse.v = d
mouse.vs += (d - mouse.vs) * 0.1
mouse.vs = Math.min(100, mouse.vs)
mouse.lx = mouse.x
mouse.ly = mouse.y
mouse.a = Math.atan2(dy, dx)
container.style.setProperty('--x', `${mouse.sx}px`)
container.style.setProperty('--y', `${mouse.sy}px`)
dy = mouse.y - mouse.ly;
const d = Math.hypot(dx, dy);
mouse.v = d;
mouse.vs += (d - mouse.vs) * 0.1;
mouse.vs = Math.min(100, mouse.vs);
mouse.lx = mouse.x;
mouse.ly = mouse.y;
mouse.a = Math.atan2(dy, dx);
container.style.setProperty('--x', `${mouse.sx}px`);
container.style.setProperty('--y', `${mouse.sy}px`);
movePoints(t)
drawLines()
frameId = requestAnimationFrame(tick)
}
movePoints(t);
drawLines();
frameId = requestAnimationFrame(tick);
};
const onResize = () => {
setSize()
setLines()
}
setSize();
setLines();
};
const updateMouse = (x: number, y: number) => {
mouse.x = x - bounding.left
mouse.y = y - bounding.top
mouse.x = x - bounding.left;
mouse.y = y - bounding.top;
if (!mouse.set) {
mouse.sx = mouse.x
mouse.sy = mouse.y
mouse.lx = mouse.x
mouse.ly = mouse.y
mouse.set = true
mouse.sx = mouse.x;
mouse.sy = mouse.y;
mouse.lx = mouse.x;
mouse.ly = mouse.y;
mouse.set = true;
}
}
};
const onMouseMove = (e: MouseEvent) => {
updateMouse(e.clientX, e.clientY)
}
updateMouse(e.clientX, e.clientY);
};
const onTouchMove = (e: TouchEvent) => {
const touch = e.touches[0]
updateMouse(touch.clientX, touch.clientY)
}
const touch = e.touches[0];
updateMouse(touch.clientX, touch.clientY);
};
onMounted(() => {
const canvas = canvasRef.value
const container = containerRef.value
if (!canvas || !container) return
const canvas = canvasRef.value;
const container = containerRef.value;
if (!canvas || !container) return;
ctx = canvas.getContext('2d')
noise = new Noise(Math.random())
ctx = canvas.getContext('2d');
noise = new Noise(Math.random());
setSize()
setLines()
frameId = requestAnimationFrame(tick)
setSize();
setLines();
frameId = requestAnimationFrame(tick);
window.addEventListener('resize', onResize)
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('touchmove', onTouchMove, { passive: false })
})
window.addEventListener('resize', onResize);
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('touchmove', onTouchMove, { passive: false });
});
onUnmounted(() => {
window.removeEventListener('resize', onResize)
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('touchmove', onTouchMove)
window.removeEventListener('resize', onResize);
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('touchmove', onTouchMove);
if (frameId !== null) {
cancelAnimationFrame(frameId)
cancelAnimationFrame(frameId);
}
})
});
watch(
() => [
@@ -423,7 +402,7 @@ watch(
props.tension,
props.maxCursorMove,
props.xGap,
props.yGap,
props.yGap
],
() => {
config = {
@@ -436,8 +415,8 @@ watch(
tension: props.tension,
maxCursorMove: props.maxCursorMove,
xGap: props.xGap,
yGap: props.yGap,
}
yGap: props.yGap
};
}
)
);
</script>

View File

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

View File

@@ -1,62 +1,86 @@
<template>
<div ref="containerRef" :class="[
'relative overflow-hidden p-4',
round
? 'rounded-full border border-[#333]'
: 'rounded-[24px] border border-[#333]'
]" :style="{
width: `${baseWidth}px`,
...(round && { height: `${baseWidth}px` }),
}">
<Motion tag="div" class="flex" drag="x" :dragConstraints="dragConstraints" :style="{
width: itemWidth + 'px',
gap: `${GAP}px`,
perspective: 1000,
perspectiveOrigin: `${currentIndex * trackItemOffset + itemWidth / 2}px 50%`,
x: motionX,
}" @dragEnd="handleDragEnd" :animate="{ x: -(currentIndex * trackItemOffset) }" :transition="effectiveTransition"
@animationComplete="handleAnimationComplete">
<Motion v-for="(item, index) in carouselItems" :key="index" tag="div" :class="[
'relative shrink-0 flex flex-col overflow-hidden cursor-grab active:cursor-grabbing',
round
? 'items-center justify-center text-center bg-[#111] border border-[#333] rounded-full'
: 'items-start justify-between bg-[#111] border border-[#333] rounded-[12px]'
]" :style="{
<div
ref="containerRef"
:class="[
'relative overflow-hidden p-4',
round ? 'rounded-full border border-[#333]' : 'rounded-[24px] border border-[#333]'
]"
:style="{
width: `${baseWidth}px`,
...(round && { height: `${baseWidth}px` })
}"
>
<Motion
tag="div"
class="flex"
drag="x"
:dragConstraints="dragConstraints"
:style="{
width: itemWidth + 'px',
height: round ? itemWidth + 'px' : '100%',
rotateY: getRotateY(index),
...(round && { borderRadius: '50%' }),
}" :transition="effectiveTransition">
gap: `${GAP}px`,
perspective: 1000,
perspectiveOrigin: `${currentIndex * trackItemOffset + itemWidth / 2}px 50%`,
x: motionX
}"
@dragEnd="handleDragEnd"
:animate="{ x: -(currentIndex * trackItemOffset) }"
:transition="effectiveTransition"
@animationComplete="handleAnimationComplete"
>
<Motion
v-for="(item, index) in carouselItems"
:key="index"
tag="div"
:class="[
'relative shrink-0 flex flex-col overflow-hidden cursor-grab active:cursor-grabbing',
round
? 'items-center justify-center text-center bg-[#111] border border-[#333] rounded-full'
: 'items-start justify-between bg-[#111] border border-[#333] rounded-[12px]'
]"
:style="{
width: itemWidth + 'px',
height: round ? itemWidth + 'px' : '100%',
rotateY: getRotateY(index),
...(round && { borderRadius: '50%' })
}"
:transition="effectiveTransition"
>
<div :class="round ? 'p-0 m-0' : 'mb-4 p-5'">
<span class="flex h-[28px] w-[28px] items-center justify-center rounded-full bg-[#060010]">
<i :class="item.icon" class="text-white text-base"></i>
</span>
</div>
<div class="p-5">
<div class="mb-1 font-black text-lg text-white">
{{ item.title }}
</div>
<div class="mb-1 font-black text-lg text-white">{{ item.title }}</div>
<p class="text-sm text-white">{{ item.description }}</p>
</div>
</Motion>
</Motion>
<div :class="[
'flex w-full justify-center',
round ? 'absolute z-20 bottom-12 left-1/2 -translate-x-1/2' : ''
]">
<div :class="['flex w-full justify-center', round ? 'absolute z-20 bottom-12 left-1/2 -translate-x-1/2' : '']">
<div class="mt-4 flex w-[150px] justify-between px-8">
<Motion v-for="(_, index) in items" :key="index" tag="div" :class="[
'h-2 w-2 rounded-full cursor-pointer transition-colors duration-150',
currentIndex % items.length === index
? round
? 'bg-white'
: 'bg-[#333333]'
: round
? 'bg-[#555]'
: 'bg-[rgba(51,51,51,0.4)]'
]" :animate="{
scale: currentIndex % items.length === index ? 1.2 : 1,
}" @click="() => setCurrentIndex(index)" :transition="{ duration: 0.15 }" />
<Motion
v-for="(_, index) in items"
:key="index"
tag="div"
:class="[
'h-2 w-2 rounded-full cursor-pointer transition-colors duration-150',
currentIndex % items.length === index
? round
? 'bg-white'
: 'bg-[#333333]'
: round
? 'bg-[#555]'
: 'bg-[rgba(51,51,51,0.4)]'
]"
:animate="{
scale: currentIndex % items.length === index ? 1.2 : 1
}"
@click="() => setCurrentIndex(index)"
:transition="{ duration: 0.15 }"
/>
</div>
</div>
</div>
@@ -64,64 +88,64 @@
<script lang="ts">
export interface CarouselItem {
title: string
description: string
id: number
icon: string
title: string;
description: string;
id: number;
icon: string;
}
export interface CarouselProps {
items?: CarouselItem[]
baseWidth?: number
autoplay?: boolean
autoplayDelay?: number
pauseOnHover?: boolean
loop?: boolean
round?: boolean
items?: CarouselItem[];
baseWidth?: number;
autoplay?: boolean;
autoplayDelay?: number;
pauseOnHover?: boolean;
loop?: boolean;
round?: boolean;
}
export const DEFAULT_ITEMS: CarouselItem[] = [
{
title: "Text Animations",
description: "Cool text animations for your projects.",
title: 'Text Animations',
description: 'Cool text animations for your projects.',
id: 1,
icon: "pi pi-file",
icon: 'pi pi-file'
},
{
title: "Animations",
description: "Smooth animations for your projects.",
title: 'Animations',
description: 'Smooth animations for your projects.',
id: 2,
icon: "pi pi-circle",
icon: 'pi pi-circle'
},
{
title: "Components",
description: "Reusable components for your projects.",
title: 'Components',
description: 'Reusable components for your projects.',
id: 3,
icon: "pi pi-objects-column",
icon: 'pi pi-objects-column'
},
{
title: "Backgrounds",
description: "Beautiful backgrounds and patterns for your projects.",
title: 'Backgrounds',
description: 'Beautiful backgrounds and patterns for your projects.',
id: 4,
icon: "pi pi-table",
icon: 'pi pi-table'
},
{
title: "Common UI",
description: "Common UI components are coming soon!",
title: 'Common UI',
description: 'Common UI components are coming soon!',
id: 5,
icon: "pi pi-code",
},
]
icon: 'pi pi-code'
}
];
</script>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { Motion, useMotionValue, useTransform } from 'motion-v'
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { Motion, useMotionValue, useTransform } from 'motion-v';
const DRAG_BUFFER = 0
const VELOCITY_THRESHOLD = 500
const GAP = 16
const SPRING_OPTIONS = { type: "spring" as const, stiffness: 300, damping: 30 }
const DRAG_BUFFER = 0;
const VELOCITY_THRESHOLD = 500;
const GAP = 16;
const SPRING_OPTIONS = { type: 'spring' as const, stiffness: 300, damping: 30 };
const props = withDefaults(defineProps<CarouselProps>(), {
items: () => DEFAULT_ITEMS,
@@ -130,148 +154,154 @@ const props = withDefaults(defineProps<CarouselProps>(), {
autoplayDelay: 3000,
pauseOnHover: false,
loop: false,
round: false,
})
round: false
});
const containerPadding = 16
const itemWidth = computed(() => props.baseWidth - containerPadding * 2)
const trackItemOffset = computed(() => itemWidth.value + GAP)
const containerPadding = 16;
const itemWidth = computed(() => props.baseWidth - containerPadding * 2);
const trackItemOffset = computed(() => itemWidth.value + GAP);
const carouselItems = computed(() => props.loop ? [...props.items, props.items[0]] : props.items)
const currentIndex = ref<number>(0)
const motionX = useMotionValue(0)
const isHovered = ref<boolean>(false)
const isResetting = ref<boolean>(false)
const carouselItems = computed(() => (props.loop ? [...props.items, props.items[0]] : props.items));
const currentIndex = ref<number>(0);
const motionX = useMotionValue(0);
const isHovered = ref<boolean>(false);
const isResetting = ref<boolean>(false);
const containerRef = ref<HTMLDivElement>()
let autoplayTimer: number | null = null
const containerRef = ref<HTMLDivElement>();
let autoplayTimer: number | null = null;
const dragConstraints = computed(() => {
return props.loop
? {}
: {
left: -trackItemOffset.value * (carouselItems.value.length - 1),
right: 0,
}
})
left: -trackItemOffset.value * (carouselItems.value.length - 1),
right: 0
};
});
const effectiveTransition = computed(() =>
isResetting.value ? { duration: 0 } : SPRING_OPTIONS
)
const effectiveTransition = computed(() => (isResetting.value ? { duration: 0 } : SPRING_OPTIONS));
const maxItems = Math.max(props.items.length + 1, 10)
const maxItems = Math.max(props.items.length + 1, 10);
const rotateYTransforms = Array.from({ length: maxItems }, (_, index) => {
const range = computed(() => [
-(index + 1) * trackItemOffset.value,
-index * trackItemOffset.value,
-(index - 1) * trackItemOffset.value,
])
const outputRange = [90, 0, -90]
return useTransform(motionX, range, outputRange, { clamp: false })
})
-(index - 1) * trackItemOffset.value
]);
const outputRange = [90, 0, -90];
return useTransform(motionX, range, outputRange, { clamp: false });
});
const getRotateY = (index: number) => {
return rotateYTransforms[index] || rotateYTransforms[0]
}
return rotateYTransforms[index] || rotateYTransforms[0];
};
const setCurrentIndex = (index: number) => {
currentIndex.value = index
}
currentIndex.value = index;
};
const handleAnimationComplete = () => {
if (props.loop && currentIndex.value === carouselItems.value.length - 1) {
isResetting.value = true
motionX.set(0)
currentIndex.value = 0
isResetting.value = true;
motionX.set(0);
currentIndex.value = 0;
setTimeout(() => {
isResetting.value = false
}, 50)
isResetting.value = false;
}, 50);
}
}
};
interface DragInfo {
offset: { x: number; y: number }
velocity: { x: number; y: number }
offset: { x: number; y: number };
velocity: { x: number; y: number };
}
const handleDragEnd = (event: Event, info: DragInfo) => {
const offset = info.offset.x
const velocity = info.velocity.x
const offset = info.offset.x;
const velocity = info.velocity.x;
if (offset < -DRAG_BUFFER || velocity < -VELOCITY_THRESHOLD) {
if (props.loop && currentIndex.value === props.items.length - 1) {
currentIndex.value = currentIndex.value + 1
currentIndex.value = currentIndex.value + 1;
} else {
currentIndex.value = Math.min(currentIndex.value + 1, carouselItems.value.length - 1)
currentIndex.value = Math.min(currentIndex.value + 1, carouselItems.value.length - 1);
}
} else if (offset > DRAG_BUFFER || velocity > VELOCITY_THRESHOLD) {
if (props.loop && currentIndex.value === 0) {
currentIndex.value = props.items.length - 1
currentIndex.value = props.items.length - 1;
} else {
currentIndex.value = Math.max(currentIndex.value - 1, 0)
currentIndex.value = Math.max(currentIndex.value - 1, 0);
}
}
}
};
const startAutoplay = () => {
if (props.autoplay && (!props.pauseOnHover || !isHovered.value)) {
autoplayTimer = window.setInterval(() => {
currentIndex.value = (() => {
const prev = currentIndex.value
const prev = currentIndex.value;
if (prev === props.items.length - 1 && props.loop) {
return prev + 1
return prev + 1;
}
if (prev === carouselItems.value.length - 1) {
return props.loop ? 0 : prev
return props.loop ? 0 : prev;
}
return prev + 1
})()
}, props.autoplayDelay)
return prev + 1;
})();
}, props.autoplayDelay);
}
}
};
const stopAutoplay = () => {
if (autoplayTimer) {
clearInterval(autoplayTimer)
autoplayTimer = null
clearInterval(autoplayTimer);
autoplayTimer = null;
}
}
};
const handleMouseEnter = () => {
isHovered.value = true
isHovered.value = true;
if (props.pauseOnHover) {
stopAutoplay()
stopAutoplay();
}
}
};
const handleMouseLeave = () => {
isHovered.value = false
isHovered.value = false;
if (props.pauseOnHover) {
startAutoplay()
startAutoplay();
}
}
};
watch(
[() => props.autoplay, () => props.autoplayDelay, isHovered, () => props.loop, () => props.items.length, () => carouselItems.value.length, () => props.pauseOnHover],
[
() => props.autoplay,
() => props.autoplayDelay,
isHovered,
() => props.loop,
() => props.items.length,
() => carouselItems.value.length,
() => props.pauseOnHover
],
() => {
stopAutoplay()
startAutoplay()
stopAutoplay();
startAutoplay();
}
)
);
onMounted(() => {
if (props.pauseOnHover && containerRef.value) {
containerRef.value.addEventListener('mouseenter', handleMouseEnter)
containerRef.value.addEventListener('mouseleave', handleMouseLeave)
containerRef.value.addEventListener('mouseenter', handleMouseEnter);
containerRef.value.addEventListener('mouseleave', handleMouseLeave);
}
startAutoplay()
})
startAutoplay();
});
onUnmounted(() => {
if (containerRef.value) {
containerRef.value.removeEventListener('mouseenter', handleMouseEnter)
containerRef.value.removeEventListener('mouseleave', handleMouseLeave)
containerRef.value.removeEventListener('mouseenter', handleMouseEnter);
containerRef.value.removeEventListener('mouseleave', handleMouseLeave);
}
stopAutoplay()
})
</script>
stopAutoplay();
});
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,20 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { Motion } from 'motion-v'
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { Motion } from 'motion-v';
interface BlurTextProps {
text?: string
delay?: number
className?: string
animateBy?: 'words' | 'letters'
direction?: 'top' | 'bottom'
threshold?: number
rootMargin?: string
animationFrom?: Record<string, string | number>
animationTo?: Array<Record<string, string | number>>
easing?: (t: number) => number
onAnimationComplete?: () => void
stepDuration?: number
text?: string;
delay?: number;
className?: string;
animateBy?: 'words' | 'letters';
direction?: 'top' | 'bottom';
threshold?: number;
rootMargin?: string;
animationFrom?: Record<string, string | number>;
animationTo?: Array<Record<string, string | number>>;
easing?: (t: number) => number;
onAnimationComplete?: () => void;
stepDuration?: number;
}
const props = withDefaults(defineProps<BlurTextProps>(), {
@@ -27,37 +27,30 @@ const props = withDefaults(defineProps<BlurTextProps>(), {
rootMargin: '0px',
easing: (t: number) => t,
stepDuration: 0.35
})
});
const buildKeyframes = (
from: Record<string, string | number>,
steps: Array<Record<string, string | number>>
): Record<string, Array<string | number>> => {
const keys = new Set<string>([
...Object.keys(from),
...steps.flatMap((s) => Object.keys(s))
])
const keys = new Set<string>([...Object.keys(from), ...steps.flatMap(s => Object.keys(s))]);
const keyframes: Record<string, Array<string | number>> = {}
keys.forEach((k) => {
keyframes[k] = [from[k], ...steps.map((s) => s[k])]
})
return keyframes
}
const keyframes: Record<string, Array<string | number>> = {};
keys.forEach(k => {
keyframes[k] = [from[k], ...steps.map(s => s[k])];
});
return keyframes;
};
const elements = computed(() =>
props.animateBy === 'words' ? props.text.split(' ') : props.text.split('')
)
const elements = computed(() => (props.animateBy === 'words' ? props.text.split(' ') : props.text.split('')));
const inView = ref(false)
const rootRef = ref<HTMLParagraphElement | null>(null)
let observer: IntersectionObserver | null = null
const inView = ref(false);
const rootRef = ref<HTMLParagraphElement | null>(null);
let observer: IntersectionObserver | null = null;
const defaultFrom = computed(() =>
props.direction === 'top'
? { filter: 'blur(10px)', opacity: 0, y: -50 }
: { filter: 'blur(10px)', opacity: 0, y: 50 }
)
props.direction === 'top' ? { filter: 'blur(10px)', opacity: 0, y: -50 } : { filter: 'blur(10px)', opacity: 0, y: 50 }
);
const defaultTo = computed(() => [
{
@@ -66,51 +59,49 @@ const defaultTo = computed(() => [
y: props.direction === 'top' ? 5 : -5
},
{ filter: 'blur(0px)', opacity: 1, y: 0 }
])
]);
const fromSnapshot = computed(() => props.animationFrom ?? defaultFrom.value)
const toSnapshots = computed(() => props.animationTo ?? defaultTo.value)
const fromSnapshot = computed(() => props.animationFrom ?? defaultFrom.value);
const toSnapshots = computed(() => props.animationTo ?? defaultTo.value);
const stepCount = computed(() => toSnapshots.value.length + 1)
const totalDuration = computed(() => props.stepDuration * (stepCount.value - 1))
const stepCount = computed(() => toSnapshots.value.length + 1);
const totalDuration = computed(() => props.stepDuration * (stepCount.value - 1));
const times = computed(() =>
Array.from({ length: stepCount.value }, (_, i) =>
stepCount.value === 1 ? 0 : i / (stepCount.value - 1)
)
)
Array.from({ length: stepCount.value }, (_, i) => (stepCount.value === 1 ? 0 : i / (stepCount.value - 1)))
);
const setupObserver = () => {
if (!rootRef.value) return
if (!rootRef.value) return;
observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
inView.value = true
observer?.unobserve(rootRef.value as Element)
inView.value = true;
observer?.unobserve(rootRef.value as Element);
}
},
{ threshold: props.threshold, rootMargin: props.rootMargin }
)
);
observer.observe(rootRef.value)
}
observer.observe(rootRef.value);
};
onMounted(() => {
setupObserver()
})
setupObserver();
});
onUnmounted(() => {
observer?.disconnect()
})
observer?.disconnect();
});
watch([() => props.threshold, () => props.rootMargin], () => {
observer?.disconnect()
setupObserver()
})
observer?.disconnect();
setupObserver();
});
const getAnimateKeyframes = () => {
return buildKeyframes(fromSnapshot.value, toSnapshots.value)
}
return buildKeyframes(fromSnapshot.value, toSnapshots.value);
};
const getTransition = (index: number) => {
return {
@@ -118,24 +109,33 @@ const getTransition = (index: number) => {
times: times.value,
delay: (index * props.delay) / 1000,
ease: props.easing
}
}
};
};
const handleAnimationComplete = (index: number) => {
if (index === elements.value.length - 1 && props.onAnimationComplete) {
props.onAnimationComplete()
props.onAnimationComplete();
}
}
};
</script>
<template>
<p ref="rootRef" :class="`blur-text ${className} flex flex-wrap`">
<Motion v-for="(segment, index) in elements" :key="index" tag="span" :initial="fromSnapshot"
:animate="inView ? getAnimateKeyframes() : fromSnapshot" :transition="getTransition(index)" :style="{
<Motion
v-for="(segment, index) in elements"
:key="index"
tag="span"
:initial="fromSnapshot"
:animate="inView ? getAnimateKeyframes() : fromSnapshot"
:transition="getTransition(index)"
:style="{
display: 'inline-block',
willChange: 'transform, filter, opacity'
}" @animation-complete="handleAnimationComplete(index)">
{{ segment === ' ' ? '\u00A0' : segment }}{{ animateBy === 'words' && index < elements.length - 1 ? '\u00A0' : ''
}} </Motion>
}"
@animation-complete="handleAnimationComplete(index)"
>
{{ segment === ' ' ? '\u00A0' : segment
}}{{ animateBy === 'words' && index < elements.length - 1 ? '\u00A0' : '' }}
</Motion>
</p>
</template>

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
import { computed, ref, watchEffect, onUnmounted } from 'vue'
import { Motion } from 'motion-v'
import { computed, ref, watchEffect, onUnmounted } from 'vue';
import { Motion } from 'motion-v';
interface CircularTextProps {
text: string
spinDuration?: number
onHover?: 'slowDown' | 'speedUp' | 'pause' | 'goBonkers'
className?: string
text: string;
spinDuration?: number;
onHover?: 'slowDown' | 'speedUp' | 'pause' | 'goBonkers';
className?: string;
}
const props = withDefaults(defineProps<CircularTextProps>(), {
@@ -14,99 +14,101 @@ const props = withDefaults(defineProps<CircularTextProps>(), {
spinDuration: 20,
onHover: 'speedUp',
className: ''
})
});
const letters = computed(() => Array.from(props.text))
const isHovered = ref(false)
const letters = computed(() => Array.from(props.text));
const isHovered = ref(false);
const currentRotation = ref(0)
const animationId = ref<number | null>(null)
const lastTime = ref<number>(Date.now())
const rotationSpeed = ref<number>(0)
const currentRotation = ref(0);
const animationId = ref<number | null>(null);
const lastTime = ref<number>(Date.now());
const rotationSpeed = ref<number>(0);
const getCurrentSpeed = () => {
if (isHovered.value && props.onHover === 'pause') return 0
if (isHovered.value && props.onHover === 'pause') return 0;
const baseDuration = props.spinDuration
const baseSpeed = 360 / baseDuration
const baseDuration = props.spinDuration;
const baseSpeed = 360 / baseDuration;
if (!isHovered.value) return baseSpeed
if (!isHovered.value) return baseSpeed;
switch (props.onHover) {
case 'slowDown':
return baseSpeed / 2
return baseSpeed / 2;
case 'speedUp':
return baseSpeed * 4
return baseSpeed * 4;
case 'goBonkers':
return baseSpeed * 20
return baseSpeed * 20;
default:
return baseSpeed
return baseSpeed;
}
}
};
const getCurrentScale = () => {
return (isHovered.value && props.onHover === 'goBonkers') ? 0.8 : 1
}
return isHovered.value && props.onHover === 'goBonkers' ? 0.8 : 1;
};
const animate = () => {
const now = Date.now()
const deltaTime = (now - lastTime.value) / 1000
lastTime.value = now
const now = Date.now();
const deltaTime = (now - lastTime.value) / 1000;
lastTime.value = now;
const targetSpeed = getCurrentSpeed()
const targetSpeed = getCurrentSpeed();
const speedDiff = targetSpeed - rotationSpeed.value
const smoothingFactor = Math.min(1, deltaTime * 5)
rotationSpeed.value += speedDiff * smoothingFactor
const speedDiff = targetSpeed - rotationSpeed.value;
const smoothingFactor = Math.min(1, deltaTime * 5);
rotationSpeed.value += speedDiff * smoothingFactor;
currentRotation.value = (currentRotation.value + rotationSpeed.value * deltaTime) % 360
currentRotation.value = (currentRotation.value + rotationSpeed.value * deltaTime) % 360;
animationId.value = requestAnimationFrame(animate)
}
animationId.value = requestAnimationFrame(animate);
};
const startAnimation = () => {
if (animationId.value) {
cancelAnimationFrame(animationId.value)
cancelAnimationFrame(animationId.value);
}
lastTime.value = Date.now()
rotationSpeed.value = getCurrentSpeed()
animate()
}
lastTime.value = Date.now();
rotationSpeed.value = getCurrentSpeed();
animate();
};
watchEffect(() => {
startAnimation()
})
startAnimation();
});
startAnimation()
startAnimation();
onUnmounted(() => {
if (animationId.value) {
cancelAnimationFrame(animationId.value)
cancelAnimationFrame(animationId.value);
}
})
});
const handleHoverStart = () => {
isHovered.value = true
}
isHovered.value = true;
};
const handleHoverEnd = () => {
isHovered.value = false
}
isHovered.value = false;
};
const getLetterTransform = (index: number) => {
const rotationDeg = (360 / letters.value.length) * index
const factor = Math.PI / letters.value.length
const x = factor * index
const y = factor * index
return `rotateZ(${rotationDeg}deg) translate3d(${x}px, ${y}px, 0)`
}
const rotationDeg = (360 / letters.value.length) * index;
const factor = Math.PI / letters.value.length;
const x = factor * index;
const y = factor * index;
return `rotateZ(${rotationDeg}deg) translate3d(${x}px, ${y}px, 0)`;
};
</script>
<template>
<Motion :animate="{
rotate: currentRotation,
scale: getCurrentScale()
}" :transition="{
<Motion
:animate="{
rotate: currentRotation,
scale: getCurrentScale()
}"
:transition="{
rotate: {
duration: 0
},
@@ -117,13 +119,19 @@ const getLetterTransform = (index: number) => {
}
}"
:class="`m-0 mx-auto rounded-full w-[200px] h-[200px] relative font-black text-white text-center cursor-pointer origin-center ${props.className}`"
@mouseenter="handleHoverStart" @mouseleave="handleHoverEnd">
<span v-for="(letter, i) in letters" :key="i"
class="absolute inline-block inset-0 text-2xl transition-all duration-500 ease-[cubic-bezier(0,0,0,1)]" :style="{
@mouseenter="handleHoverStart"
@mouseleave="handleHoverEnd"
>
<span
v-for="(letter, i) in letters"
:key="i"
class="absolute inline-block inset-0 text-2xl transition-all duration-500 ease-[cubic-bezier(0,0,0,1)]"
:style="{
transform: getLetterTransform(i),
WebkitTransform: getLetterTransform(i)
}">
}"
>
{{ letter }}
</span>
</Motion>
</template>
</template>

View File

@@ -1,13 +1,13 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, computed, nextTick } from 'vue'
import { ref, onMounted, onUnmounted, watch, computed, nextTick } from 'vue';
interface CurvedLoopProps {
marqueeText?: string
speed?: number
className?: string
curveAmount?: number
direction?: 'left' | 'right'
interactive?: boolean
marqueeText?: string;
speed?: number;
className?: string;
curveAmount?: number;
direction?: 'left' | 'right';
interactive?: boolean;
}
const props = withDefaults(defineProps<CurvedLoopProps>(), {
@@ -17,161 +17,166 @@ const props = withDefaults(defineProps<CurvedLoopProps>(), {
curveAmount: 400,
direction: 'left',
interactive: true
})
});
const text = computed(() => {
const hasTrailing = /\s|\u00A0$/.test(props.marqueeText)
return (
(hasTrailing ? props.marqueeText.replace(/\s+$/, '') : props.marqueeText) + '\u00A0'
)
})
const hasTrailing = /\s|\u00A0$/.test(props.marqueeText);
return (hasTrailing ? props.marqueeText.replace(/\s+$/, '') : props.marqueeText) + '\u00A0';
});
const measureRef = ref<SVGTextElement | null>(null)
const tspansRef = ref<SVGTSpanElement[]>([])
const pathRef = ref<SVGPathElement | null>(null)
const pathLength = ref(0)
const spacing = ref(0)
const uid = Math.random().toString(36).substr(2, 9)
const pathId = `curve-${uid}`
const measureRef = ref<SVGTextElement | null>(null);
const tspansRef = ref<SVGTSpanElement[]>([]);
const pathRef = ref<SVGPathElement | null>(null);
const pathLength = ref(0);
const spacing = ref(0);
const uid = Math.random().toString(36).substr(2, 9);
const pathId = `curve-${uid}`;
const pathD = computed(() => `M-100,40 Q500,${40 + props.curveAmount} 1540,40`)
const pathD = computed(() => `M-100,40 Q500,${40 + props.curveAmount} 1540,40`);
const dragRef = ref(false)
const lastXRef = ref(0)
const dirRef = ref<'left' | 'right'>(props.direction)
const velRef = ref(0)
const dragRef = ref(false);
const lastXRef = ref(0);
const dirRef = ref<'left' | 'right'>(props.direction);
const velRef = ref(0);
let animationFrame: number | null = null
let animationFrame: number | null = null;
const updateSpacing = () => {
if (measureRef.value) {
spacing.value = measureRef.value.getComputedTextLength()
spacing.value = measureRef.value.getComputedTextLength();
}
}
};
const updatePathLength = () => {
if (pathRef.value) {
pathLength.value = pathRef.value.getTotalLength()
pathLength.value = pathRef.value.getTotalLength();
}
}
};
const animate = () => {
if (!spacing.value) return
if (!spacing.value) return;
const step = () => {
tspansRef.value.forEach((t) => {
if (!t) return
let x = parseFloat(t.getAttribute('x') || '0')
tspansRef.value.forEach(t => {
if (!t) return;
let x = parseFloat(t.getAttribute('x') || '0');
if (!dragRef.value) {
const delta = dirRef.value === 'right' ? Math.abs(props.speed) : -Math.abs(props.speed)
x += delta
const delta = dirRef.value === 'right' ? Math.abs(props.speed) : -Math.abs(props.speed);
x += delta;
}
const maxX = (tspansRef.value.length - 1) * spacing.value
if (x < -spacing.value) x = maxX
if (x > maxX) x = -spacing.value
t.setAttribute('x', x.toString())
})
animationFrame = requestAnimationFrame(step)
}
step()
}
const maxX = (tspansRef.value.length - 1) * spacing.value;
if (x < -spacing.value) x = maxX;
if (x > maxX) x = -spacing.value;
t.setAttribute('x', x.toString());
});
animationFrame = requestAnimationFrame(step);
};
step();
};
const stopAnimation = () => {
if (animationFrame) {
cancelAnimationFrame(animationFrame)
animationFrame = null
cancelAnimationFrame(animationFrame);
animationFrame = null;
}
}
};
const repeats = computed(() => {
return pathLength.value && spacing.value ? Math.ceil(pathLength.value / spacing.value) + 2 : 0
})
return pathLength.value && spacing.value ? Math.ceil(pathLength.value / spacing.value) + 2 : 0;
});
const ready = computed(() => pathLength.value > 0 && spacing.value > 0)
const ready = computed(() => pathLength.value > 0 && spacing.value > 0);
const onPointerDown = (e: PointerEvent) => {
if (!props.interactive) return
dragRef.value = true
lastXRef.value = e.clientX
velRef.value = 0
; (e.target as HTMLElement).setPointerCapture(e.pointerId)
}
if (!props.interactive) return;
dragRef.value = true;
lastXRef.value = e.clientX;
velRef.value = 0;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
};
const onPointerMove = (e: PointerEvent) => {
if (!props.interactive || !dragRef.value) return
const dx = e.clientX - lastXRef.value
lastXRef.value = e.clientX
velRef.value = dx
tspansRef.value.forEach((t) => {
if (!t) return
let x = parseFloat(t.getAttribute('x') || '0')
x += dx
const maxX = (tspansRef.value.length - 1) * spacing.value
if (x < -spacing.value) x = maxX
if (x > maxX) x = -spacing.value
t.setAttribute('x', x.toString())
})
}
if (!props.interactive || !dragRef.value) return;
const dx = e.clientX - lastXRef.value;
lastXRef.value = e.clientX;
velRef.value = dx;
tspansRef.value.forEach(t => {
if (!t) return;
let x = parseFloat(t.getAttribute('x') || '0');
x += dx;
const maxX = (tspansRef.value.length - 1) * spacing.value;
if (x < -spacing.value) x = maxX;
if (x > maxX) x = -spacing.value;
t.setAttribute('x', x.toString());
});
};
const endDrag = () => {
if (!props.interactive) return
dragRef.value = false
dirRef.value = velRef.value > 0 ? 'right' : 'left'
}
if (!props.interactive) return;
dragRef.value = false;
dirRef.value = velRef.value > 0 ? 'right' : 'left';
};
const cursorStyle = computed(() => {
return props.interactive
? dragRef.value
? 'grabbing'
: 'grab'
: 'auto'
})
return props.interactive ? (dragRef.value ? 'grabbing' : 'grab') : 'auto';
});
onMounted(() => {
nextTick(() => {
updateSpacing()
updatePathLength()
animate()
})
})
updateSpacing();
updatePathLength();
animate();
});
});
onUnmounted(() => {
stopAnimation()
})
stopAnimation();
});
watch([text, () => props.className], () => {
nextTick(() => {
updateSpacing()
})
})
updateSpacing();
});
});
watch(() => props.curveAmount, () => {
nextTick(() => {
updatePathLength()
})
})
watch(
() => props.curveAmount,
() => {
nextTick(() => {
updatePathLength();
});
}
);
watch([spacing, () => props.speed], () => {
stopAnimation()
stopAnimation();
if (spacing.value) {
animate()
animate();
}
})
});
watch(repeats, () => {
tspansRef.value = []
})
tspansRef.value = [];
});
</script>
<template>
<div class="min-h-screen flex items-center justify-center w-full" :style="{
visibility: ready ? 'visible' : 'hidden',
cursor: cursorStyle
}" @pointerdown="onPointerDown" @pointermove="onPointerMove" @pointerup="endDrag" @pointerleave="endDrag">
<div
class="min-h-screen flex items-center justify-center w-full"
:style="{
visibility: ready ? 'visible' : 'hidden',
cursor: cursorStyle
}"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="endDrag"
@pointerleave="endDrag"
>
<svg
class="select-none w-full overflow-visible block aspect-[100/12] text-[6rem] font-bold tracking-[5px] uppercase leading-none"
viewBox="0 0 1440 120">
<text ref="measureRef" xml:space="preserve" style="visibility: hidden; opacity: 0; pointer-events: none;">
viewBox="0 0 1440 120"
>
<text ref="measureRef" xml:space="preserve" style="visibility: hidden; opacity: 0; pointer-events: none">
{{ text }}
</text>
@@ -181,9 +186,16 @@ watch(repeats, () => {
<text v-if="ready" xml:space="preserve" :class="`fill-white ${className}`">
<textPath :href="`#${pathId}`" xml:space="preserve">
<tspan v-for="i in repeats" :key="i" :x="(i - 1) * spacing" :ref="(el) => {
if (el) tspansRef[i - 1] = el as SVGTSpanElement
}">
<tspan
v-for="i in repeats"
:key="i"
:x="(i - 1) * spacing"
:ref="
el => {
if (el) tspansRef[i - 1] = el as SVGTSpanElement;
}
"
>
{{ text }}
</tspan>
</textPath>

View File

@@ -1,18 +1,18 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
interface DecryptedTextProps {
text: string
speed?: number
maxIterations?: number
sequential?: boolean
revealDirection?: 'start' | 'end' | 'center'
useOriginalCharsOnly?: boolean
characters?: string
className?: string
encryptedClassName?: string
parentClassName?: string
animateOn?: 'view' | 'hover'
text: string;
speed?: number;
maxIterations?: number;
sequential?: boolean;
revealDirection?: 'start' | 'end' | 'center';
useOriginalCharsOnly?: boolean;
characters?: string;
className?: string;
encryptedClassName?: string;
parentClassName?: string;
animateOn?: 'view' | 'hover';
}
const props = withDefaults(defineProps<DecryptedTextProps>(), {
@@ -27,201 +27,205 @@ const props = withDefaults(defineProps<DecryptedTextProps>(), {
parentClassName: '',
encryptedClassName: '',
animateOn: 'hover'
})
});
const emit = defineEmits<{
animationComplete: []
}>()
animationComplete: [];
}>();
const containerRef = ref<HTMLSpanElement>()
const displayText = ref(props.text)
const isHovering = ref(false)
const isScrambling = ref(false)
const revealedIndices = ref(new Set<number>())
const hasAnimated = ref(false)
const containerRef = ref<HTMLSpanElement>();
const displayText = ref(props.text);
const isHovering = ref(false);
const isScrambling = ref(false);
const revealedIndices = ref(new Set<number>());
const hasAnimated = ref(false);
let interval: number | null = null
let intersectionObserver: IntersectionObserver | null = null
let interval: number | null = null;
let intersectionObserver: IntersectionObserver | null = null;
watch([
() => isHovering.value,
() => props.text,
() => props.speed,
() => props.maxIterations,
() => props.sequential,
() => props.revealDirection,
() => props.characters,
() => props.useOriginalCharsOnly
], () => {
let currentIteration = 0
watch(
[
() => isHovering.value,
() => props.text,
() => props.speed,
() => props.maxIterations,
() => props.sequential,
() => props.revealDirection,
() => props.characters,
() => props.useOriginalCharsOnly
],
() => {
let currentIteration = 0;
const getNextIndex = (revealedSet: Set<number>): number => {
const textLength = props.text.length
switch (props.revealDirection) {
case 'start':
return revealedSet.size
case 'end':
return textLength - 1 - revealedSet.size
case 'center': {
const middle = Math.floor(textLength / 2)
const offset = Math.floor(revealedSet.size / 2)
const nextIndex =
revealedSet.size % 2 === 0
? middle + offset
: middle - offset - 1
const getNextIndex = (revealedSet: Set<number>): number => {
const textLength = props.text.length;
switch (props.revealDirection) {
case 'start':
return revealedSet.size;
case 'end':
return textLength - 1 - revealedSet.size;
case 'center': {
const middle = Math.floor(textLength / 2);
const offset = Math.floor(revealedSet.size / 2);
const nextIndex = revealedSet.size % 2 === 0 ? middle + offset : middle - offset - 1;
if (nextIndex >= 0 && nextIndex < textLength && !revealedSet.has(nextIndex)) {
return nextIndex
if (nextIndex >= 0 && nextIndex < textLength && !revealedSet.has(nextIndex)) {
return nextIndex;
}
for (let i = 0; i < textLength; i++) {
if (!revealedSet.has(i)) return i;
}
return 0;
}
for (let i = 0; i < textLength; i++) {
if (!revealedSet.has(i)) return i
}
return 0
default:
return revealedSet.size;
}
default:
return revealedSet.size
}
}
};
const availableChars = props.useOriginalCharsOnly
? Array.from(new Set(props.text.split(''))).filter((char) => char !== ' ')
: props.characters.split('')
const availableChars = props.useOriginalCharsOnly
? Array.from(new Set(props.text.split(''))).filter(char => char !== ' ')
: props.characters.split('');
const shuffleText = (originalText: string, currentRevealed: Set<number>): string => {
if (props.useOriginalCharsOnly) {
const positions = originalText.split('').map((char, i) => ({
char,
isSpace: char === ' ',
index: i,
isRevealed: currentRevealed.has(i)
}))
const shuffleText = (originalText: string, currentRevealed: Set<number>): string => {
if (props.useOriginalCharsOnly) {
const positions = originalText.split('').map((char, i) => ({
char,
isSpace: char === ' ',
index: i,
isRevealed: currentRevealed.has(i)
}));
const nonSpaceChars = positions
.filter((p) => !p.isSpace && !p.isRevealed)
.map((p) => p.char)
const nonSpaceChars = positions.filter(p => !p.isSpace && !p.isRevealed).map(p => p.char);
for (let i = nonSpaceChars.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[nonSpaceChars[i], nonSpaceChars[j]] = [nonSpaceChars[j], nonSpaceChars[i]]
}
let charIndex = 0
return positions
.map((p) => {
if (p.isSpace) return ' '
if (p.isRevealed) return originalText[p.index]
return nonSpaceChars[charIndex++]
})
.join('')
} else {
return originalText
.split('')
.map((char, i) => {
if (char === ' ') return ' '
if (currentRevealed.has(i)) return originalText[i]
return availableChars[Math.floor(Math.random() * availableChars.length)]
})
.join('')
}
}
if (interval) {
clearInterval(interval)
interval = null
}
if (isHovering.value) {
isScrambling.value = true
interval = setInterval(() => {
if (props.sequential) {
if (revealedIndices.value.size < props.text.length) {
const nextIndex = getNextIndex(revealedIndices.value)
const newRevealed = new Set(revealedIndices.value)
newRevealed.add(nextIndex)
revealedIndices.value = newRevealed
displayText.value = shuffleText(props.text, newRevealed)
} else {
clearInterval(interval!)
interval = null
isScrambling.value = false
emit('animationComplete')
for (let i = nonSpaceChars.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[nonSpaceChars[i], nonSpaceChars[j]] = [nonSpaceChars[j], nonSpaceChars[i]];
}
let charIndex = 0;
return positions
.map(p => {
if (p.isSpace) return ' ';
if (p.isRevealed) return originalText[p.index];
return nonSpaceChars[charIndex++];
})
.join('');
} else {
displayText.value = shuffleText(props.text, revealedIndices.value)
currentIteration++
if (currentIteration >= props.maxIterations) {
clearInterval(interval!)
interval = null
isScrambling.value = false
displayText.value = props.text
emit('animationComplete')
}
return originalText
.split('')
.map((char, i) => {
if (char === ' ') return ' ';
if (currentRevealed.has(i)) return originalText[i];
return availableChars[Math.floor(Math.random() * availableChars.length)];
})
.join('');
}
}, props.speed)
} else {
displayText.value = props.text
revealedIndices.value = new Set()
isScrambling.value = false
};
if (interval) {
clearInterval(interval);
interval = null;
}
if (isHovering.value) {
isScrambling.value = true;
interval = setInterval(() => {
if (props.sequential) {
if (revealedIndices.value.size < props.text.length) {
const nextIndex = getNextIndex(revealedIndices.value);
const newRevealed = new Set(revealedIndices.value);
newRevealed.add(nextIndex);
revealedIndices.value = newRevealed;
displayText.value = shuffleText(props.text, newRevealed);
} else {
clearInterval(interval!);
interval = null;
isScrambling.value = false;
emit('animationComplete');
}
} else {
displayText.value = shuffleText(props.text, revealedIndices.value);
currentIteration++;
if (currentIteration >= props.maxIterations) {
clearInterval(interval!);
interval = null;
isScrambling.value = false;
displayText.value = props.text;
emit('animationComplete');
}
}
}, props.speed);
} else {
displayText.value = props.text;
revealedIndices.value = new Set();
isScrambling.value = false;
}
}
})
);
const handleMouseEnter = () => {
if (props.animateOn === 'hover') {
isHovering.value = true
isHovering.value = true;
}
}
};
const handleMouseLeave = () => {
if (props.animateOn === 'hover') {
isHovering.value = false
isHovering.value = false;
}
}
};
onMounted(async () => {
if (props.animateOn === 'view') {
await nextTick()
await nextTick();
const observerCallback = (entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
entries.forEach(entry => {
if (entry.isIntersecting && !hasAnimated.value) {
isHovering.value = true
hasAnimated.value = true
isHovering.value = true;
hasAnimated.value = true;
}
})
}
});
};
const observerOptions = {
root: null,
rootMargin: '0px',
threshold: 0.1
}
};
intersectionObserver = new IntersectionObserver(observerCallback, observerOptions)
intersectionObserver = new IntersectionObserver(observerCallback, observerOptions);
if (containerRef.value) {
intersectionObserver.observe(containerRef.value)
intersectionObserver.observe(containerRef.value);
}
}
})
});
onUnmounted(() => {
if (interval) {
clearInterval(interval)
clearInterval(interval);
}
if (intersectionObserver && containerRef.value) {
intersectionObserver.unobserve(containerRef.value)
intersectionObserver.unobserve(containerRef.value);
}
})
});
</script>
<template>
<span ref="containerRef" :class="`inline-block whitespace-pre-wrap ${props.parentClassName}`"
@mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave">
<span
ref="containerRef"
:class="`inline-block whitespace-pre-wrap ${props.parentClassName}`"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<span class="sr-only">{{ displayText }}</span>
<span aria-hidden="true">
<span v-for="(char, index) in displayText.split('')" :key="index" :class="(revealedIndices.has(index) || !isScrambling || !isHovering)
? props.className
: props.encryptedClassName">
<span
v-for="(char, index) in displayText.split('')"
:key="index"
:class="revealedIndices.has(index) || !isScrambling || !isHovering ? props.className : props.encryptedClassName"
>
{{ char }}
</span>
</span>

View File

@@ -1,16 +1,16 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import Matter from 'matter-js'
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
import Matter from 'matter-js';
interface FallingTextProps {
text?: string
highlightWords?: string[]
trigger?: 'auto' | 'scroll' | 'click' | 'hover'
backgroundColor?: string
wireframes?: boolean
gravity?: number
mouseConstraintStiffness?: number
fontSize?: string
text?: string;
highlightWords?: string[];
trigger?: 'auto' | 'scroll' | 'click' | 'hover';
backgroundColor?: string;
wireframes?: boolean;
gravity?: number;
mouseConstraintStiffness?: number;
fontSize?: string;
}
const props = withDefaults(defineProps<FallingTextProps>(), {
@@ -22,80 +22,79 @@ const props = withDefaults(defineProps<FallingTextProps>(), {
gravity: 1,
mouseConstraintStiffness: 0.2,
fontSize: '1rem'
})
});
const containerRef = ref<HTMLDivElement>()
const textRef = ref<HTMLDivElement>()
const canvasContainerRef = ref<HTMLDivElement>()
const containerRef = ref<HTMLDivElement>();
const textRef = ref<HTMLDivElement>();
const canvasContainerRef = ref<HTMLDivElement>();
const effectStarted = ref(false)
const effectStarted = ref(false);
let engine: Matter.Engine | null = null
let render: Matter.Render | null = null
let runner: Matter.Runner | null = null
let mouseConstraint: Matter.MouseConstraint | null = null
let wordBodies: Array<{ elem: HTMLElement; body: Matter.Body }> = []
let intersectionObserver: IntersectionObserver | null = null
let animationFrameId: number | null = null
let engine: Matter.Engine | null = null;
let render: Matter.Render | null = null;
let runner: Matter.Runner | null = null;
let mouseConstraint: Matter.MouseConstraint | null = null;
let wordBodies: Array<{ elem: HTMLElement; body: Matter.Body }> = [];
let intersectionObserver: IntersectionObserver | null = null;
let animationFrameId: number | null = null;
const createTextHTML = () => {
if (!textRef.value) return
if (!textRef.value) return;
const words = props.text.split(' ')
const words = props.text.split(' ');
const newHTML = words
.map((word) => {
const isHighlighted = props.highlightWords.some((hw) => word.startsWith(hw))
return `<span class="inline-block mx-[2px] select-none ${isHighlighted ? 'text-green-500 font-bold' : ''
}">${word}</span>`
.map(word => {
const isHighlighted = props.highlightWords.some(hw => word.startsWith(hw));
return `<span class="inline-block mx-[2px] select-none ${isHighlighted ? 'text-green-500 font-bold' : ''}">${word}</span>`;
})
.join(' ')
.join(' ');
textRef.value.innerHTML = newHTML
}
textRef.value.innerHTML = newHTML;
};
const setupTrigger = () => {
if (props.trigger === 'auto') {
setTimeout(() => {
effectStarted.value = true
}, 100)
return
effectStarted.value = true;
}, 100);
return;
}
if (props.trigger === 'scroll' && containerRef.value) {
intersectionObserver = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
effectStarted.value = true
intersectionObserver?.disconnect()
effectStarted.value = true;
intersectionObserver?.disconnect();
}
},
{ threshold: 0.1 }
)
intersectionObserver.observe(containerRef.value)
);
intersectionObserver.observe(containerRef.value);
}
}
};
const handleTrigger = () => {
if (!effectStarted.value && (props.trigger === 'click' || props.trigger === 'hover')) {
effectStarted.value = true
effectStarted.value = true;
}
}
};
const startPhysics = async () => {
if (!containerRef.value || !canvasContainerRef.value || !textRef.value) return
if (!containerRef.value || !canvasContainerRef.value || !textRef.value) return;
await nextTick()
await nextTick();
const { Engine, Render, World, Bodies, Runner, Mouse, MouseConstraint } = Matter
const { Engine, Render, World, Bodies, Runner, Mouse, MouseConstraint } = Matter;
const containerRect = containerRef.value.getBoundingClientRect()
const width = containerRect.width
const height = containerRect.height
const containerRect = containerRef.value.getBoundingClientRect();
const width = containerRect.width;
const height = containerRect.height;
if (width <= 0 || height <= 0) return
if (width <= 0 || height <= 0) return;
engine = Engine.create()
engine.world.gravity.y = props.gravity
engine = Engine.create();
engine.world.gravity.y = props.gravity;
render = Render.create({
element: canvasContainerRef.value,
@@ -106,180 +105,175 @@ const startPhysics = async () => {
background: props.backgroundColor,
wireframes: props.wireframes
}
})
});
const boundaryOptions = {
isStatic: true,
render: { fillStyle: 'transparent' }
}
};
const floor = Bodies.rectangle(width / 2, height + 25, width, 50, boundaryOptions)
const leftWall = Bodies.rectangle(-25, height / 2, 50, height, boundaryOptions)
const rightWall = Bodies.rectangle(width + 25, height / 2, 50, height, boundaryOptions)
const ceiling = Bodies.rectangle(width / 2, -25, width, 50, boundaryOptions)
const floor = Bodies.rectangle(width / 2, height + 25, width, 50, boundaryOptions);
const leftWall = Bodies.rectangle(-25, height / 2, 50, height, boundaryOptions);
const rightWall = Bodies.rectangle(width + 25, height / 2, 50, height, boundaryOptions);
const ceiling = Bodies.rectangle(width / 2, -25, width, 50, boundaryOptions);
const wordSpans = textRef.value.querySelectorAll('span') as NodeListOf<HTMLElement>
wordBodies = Array.from(wordSpans).map((elem) => {
const rect = elem.getBoundingClientRect()
const containerRect = containerRef.value!.getBoundingClientRect()
const wordSpans = textRef.value.querySelectorAll('span') as NodeListOf<HTMLElement>;
wordBodies = Array.from(wordSpans).map(elem => {
const rect = elem.getBoundingClientRect();
const containerRect = containerRef.value!.getBoundingClientRect();
const x = rect.left - containerRect.left + rect.width / 2
const y = rect.top - containerRect.top + rect.height / 2
const x = rect.left - containerRect.left + rect.width / 2;
const y = rect.top - containerRect.top + rect.height / 2;
const body = Bodies.rectangle(x, y, rect.width, rect.height, {
render: { fillStyle: 'transparent' },
restitution: 0.8,
frictionAir: 0.01,
friction: 0.2
})
});
Matter.Body.setVelocity(body, {
x: (Math.random() - 0.5) * 5,
y: 0
})
Matter.Body.setAngularVelocity(body, (Math.random() - 0.5) * 0.05)
});
Matter.Body.setAngularVelocity(body, (Math.random() - 0.5) * 0.05);
return { elem, body }
})
return { elem, body };
});
wordBodies.forEach(({ elem, body }) => {
elem.style.position = 'absolute'
elem.style.left = `${body.position.x - (body.bounds.max.x - body.bounds.min.x) / 2}px`
elem.style.top = `${body.position.y - (body.bounds.max.y - body.bounds.min.y) / 2}px`
elem.style.transform = 'none'
})
elem.style.position = 'absolute';
elem.style.left = `${body.position.x - (body.bounds.max.x - body.bounds.min.x) / 2}px`;
elem.style.top = `${body.position.y - (body.bounds.max.y - body.bounds.min.y) / 2}px`;
elem.style.transform = 'none';
});
const mouse = Mouse.create(containerRef.value)
const mouse = Mouse.create(containerRef.value);
mouseConstraint = MouseConstraint.create(engine, {
mouse,
constraint: {
stiffness: props.mouseConstraintStiffness,
render: { visible: false }
}
})
render.mouse = mouse
});
render.mouse = mouse;
World.add(engine.world, [
floor,
leftWall,
rightWall,
ceiling,
mouseConstraint,
...wordBodies.map((wb) => wb.body)
])
World.add(engine.world, [floor, leftWall, rightWall, ceiling, mouseConstraint, ...wordBodies.map(wb => wb.body)]);
runner = Runner.create()
Runner.run(runner, engine)
Render.run(render)
runner = Runner.create();
Runner.run(runner, engine);
Render.run(render);
const updateLoop = () => {
wordBodies.forEach(({ body, elem }) => {
const { x, y } = body.position
elem.style.left = `${x}px`
elem.style.top = `${y}px`
elem.style.transform = `translate(-50%, -50%) rotate(${body.angle}rad)`
})
Matter.Engine.update(engine!)
animationFrameId = requestAnimationFrame(updateLoop)
}
updateLoop()
}
const { x, y } = body.position;
elem.style.left = `${x}px`;
elem.style.top = `${y}px`;
elem.style.transform = `translate(-50%, -50%) rotate(${body.angle}rad)`;
});
Matter.Engine.update(engine!);
animationFrameId = requestAnimationFrame(updateLoop);
};
updateLoop();
};
const cleanup = () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId)
animationFrameId = null
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
if (render) {
Matter.Render.stop(render)
Matter.Render.stop(render);
if (render.canvas && canvasContainerRef.value) {
canvasContainerRef.value.removeChild(render.canvas)
canvasContainerRef.value.removeChild(render.canvas);
}
render = null
render = null;
}
if (runner && engine) {
Matter.Runner.stop(runner)
runner = null
Matter.Runner.stop(runner);
runner = null;
}
if (engine) {
Matter.World.clear(engine.world, false)
Matter.Engine.clear(engine)
engine = null
Matter.World.clear(engine.world, false);
Matter.Engine.clear(engine);
engine = null;
}
if (intersectionObserver) {
intersectionObserver.disconnect()
intersectionObserver = null
intersectionObserver.disconnect();
intersectionObserver = null;
}
mouseConstraint = null
wordBodies = []
}
mouseConstraint = null;
wordBodies = [];
};
watch(
() => [props.text, props.highlightWords],
() => {
createTextHTML()
createTextHTML();
},
{ immediate: true, deep: true }
)
);
watch(
() => props.trigger,
() => {
effectStarted.value = false
cleanup()
setupTrigger()
effectStarted.value = false;
cleanup();
setupTrigger();
},
{ immediate: true }
)
);
watch(
() => effectStarted.value,
(started) => {
started => {
if (started) {
startPhysics()
startPhysics();
}
}
)
);
watch(
() => [
props.gravity,
props.wireframes,
props.backgroundColor,
props.mouseConstraintStiffness
],
() => [props.gravity, props.wireframes, props.backgroundColor, props.mouseConstraintStiffness],
() => {
if (effectStarted.value) {
cleanup()
startPhysics()
cleanup();
startPhysics();
}
},
{ deep: true }
)
);
onMounted(() => {
createTextHTML()
setupTrigger()
})
createTextHTML();
setupTrigger();
});
onUnmounted(() => {
cleanup()
})
cleanup();
});
</script>
<template>
<div ref="containerRef" class="relative z-[1] w-full h-full cursor-pointer text-center pt-8 overflow-hidden"
<div
ref="containerRef"
class="relative z-[1] w-full h-full cursor-pointer text-center pt-8 overflow-hidden"
@click="props.trigger === 'click' ? handleTrigger() : undefined"
@mouseenter="props.trigger === 'hover' ? handleTrigger() : undefined">
<div ref="textRef" class="inline-block" :style="{
fontSize: props.fontSize,
lineHeight: 1.4
}" />
@mouseenter="props.trigger === 'hover' ? handleTrigger() : undefined"
>
<div
ref="textRef"
class="inline-block"
:style="{
fontSize: props.fontSize,
lineHeight: 1.4
}"
/>
<div class="absolute top-0 left-0 z-0" ref="canvasContainerRef" />
</div>

View File

@@ -1,15 +1,15 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
interface FuzzyTextProps {
text: string
fontSize?: number | string
fontWeight?: string | number
fontFamily?: string
color?: string
enableHover?: boolean
baseIntensity?: number
hoverIntensity?: number
text: string;
fontSize?: number | string;
fontWeight?: string | number;
fontFamily?: string;
color?: string;
enableHover?: boolean;
baseIntensity?: number;
hoverIntensity?: number;
}
const props = withDefaults(defineProps<FuzzyTextProps>(), {
@@ -21,246 +21,227 @@ const props = withDefaults(defineProps<FuzzyTextProps>(), {
enableHover: true,
baseIntensity: 0.18,
hoverIntensity: 0.5
})
});
const canvasRef = ref<HTMLCanvasElement | null>(null)
let animationFrameId: number
let isCancelled = false
let cleanup: (() => void) | null = null
const canvasRef = ref<HTMLCanvasElement | null>(null);
let animationFrameId: number;
let isCancelled = false;
let cleanup: (() => void) | null = null;
const waitForFont = async (fontFamily: string, fontWeight: string | number, fontSize: string): Promise<boolean> => {
if (document.fonts?.check) {
const fontString = `${fontWeight} ${fontSize} ${fontFamily}`
const fontString = `${fontWeight} ${fontSize} ${fontFamily}`;
if (document.fonts.check(fontString)) {
return true
return true;
}
try {
await document.fonts.load(fontString)
return document.fonts.check(fontString)
await document.fonts.load(fontString);
return document.fonts.check(fontString);
} catch (error) {
console.warn('Font loading failed:', error)
return false
console.warn('Font loading failed:', error);
return false;
}
}
return new Promise((resolve) => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
return new Promise(resolve => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
resolve(false)
return
resolve(false);
return;
}
ctx.font = `${fontWeight} ${fontSize} ${fontFamily}`
const testWidth = ctx.measureText('M').width
ctx.font = `${fontWeight} ${fontSize} ${fontFamily}`;
const testWidth = ctx.measureText('M').width;
let attempts = 0
let attempts = 0;
const checkFont = () => {
ctx.font = `${fontWeight} ${fontSize} ${fontFamily}`
const newWidth = ctx.measureText('M').width
ctx.font = `${fontWeight} ${fontSize} ${fontFamily}`;
const newWidth = ctx.measureText('M').width;
if (newWidth !== testWidth && newWidth > 0) {
resolve(true)
resolve(true);
} else if (attempts < 20) {
attempts++
setTimeout(checkFont, 50)
attempts++;
setTimeout(checkFont, 50);
} else {
resolve(false)
resolve(false);
}
}
};
setTimeout(checkFont, 10)
})
}
setTimeout(checkFont, 10);
});
};
const initCanvas = async () => {
if (document.fonts?.ready) {
await document.fonts.ready
await document.fonts.ready;
}
if (isCancelled) return
if (isCancelled) return;
const canvas = canvasRef.value
if (!canvas) return
const canvas = canvasRef.value;
if (!canvas) return;
const ctx = canvas.getContext('2d')
if (!ctx) return
const ctx = canvas.getContext('2d');
if (!ctx) return;
const computedFontFamily = props.fontFamily === 'inherit'
? window.getComputedStyle(canvas).fontFamily || 'sans-serif'
: props.fontFamily
const computedFontFamily =
props.fontFamily === 'inherit' ? window.getComputedStyle(canvas).fontFamily || 'sans-serif' : props.fontFamily;
const fontSizeStr = typeof props.fontSize === 'number' ? `${props.fontSize}px` : props.fontSize
let numericFontSize: number
const fontSizeStr = typeof props.fontSize === 'number' ? `${props.fontSize}px` : props.fontSize;
let numericFontSize: number;
if (typeof props.fontSize === 'number') {
numericFontSize = props.fontSize
numericFontSize = props.fontSize;
} else {
const temp = document.createElement('span')
temp.style.fontSize = props.fontSize
temp.style.fontFamily = computedFontFamily
document.body.appendChild(temp)
const computedSize = window.getComputedStyle(temp).fontSize
numericFontSize = parseFloat(computedSize)
document.body.removeChild(temp)
const temp = document.createElement('span');
temp.style.fontSize = props.fontSize;
temp.style.fontFamily = computedFontFamily;
document.body.appendChild(temp);
const computedSize = window.getComputedStyle(temp).fontSize;
numericFontSize = parseFloat(computedSize);
document.body.removeChild(temp);
}
const fontLoaded = await waitForFont(computedFontFamily, props.fontWeight, fontSizeStr)
const fontLoaded = await waitForFont(computedFontFamily, props.fontWeight, fontSizeStr);
if (!fontLoaded) {
console.warn(`Font not loaded: ${computedFontFamily}`)
console.warn(`Font not loaded: ${computedFontFamily}`);
}
const text = props.text
const text = props.text;
const offscreen = document.createElement('canvas')
const offCtx = offscreen.getContext('2d')
if (!offCtx) return
const offscreen = document.createElement('canvas');
const offCtx = offscreen.getContext('2d');
if (!offCtx) return;
const fontString = `${props.fontWeight} ${fontSizeStr} ${computedFontFamily}`
offCtx.font = fontString
const fontString = `${props.fontWeight} ${fontSizeStr} ${computedFontFamily}`;
offCtx.font = fontString;
const testMetrics = offCtx.measureText('M')
const testMetrics = offCtx.measureText('M');
if (testMetrics.width === 0) {
setTimeout(() => {
if (!isCancelled) {
initCanvas()
initCanvas();
}
}, 100)
return
}, 100);
return;
}
offCtx.textBaseline = 'alphabetic'
const metrics = offCtx.measureText(text)
offCtx.textBaseline = 'alphabetic';
const metrics = offCtx.measureText(text);
const actualLeft = metrics.actualBoundingBoxLeft ?? 0
const actualRight = metrics.actualBoundingBoxRight ?? metrics.width
const actualAscent = metrics.actualBoundingBoxAscent ?? numericFontSize
const actualDescent = metrics.actualBoundingBoxDescent ?? numericFontSize * 0.2
const actualLeft = metrics.actualBoundingBoxLeft ?? 0;
const actualRight = metrics.actualBoundingBoxRight ?? metrics.width;
const actualAscent = metrics.actualBoundingBoxAscent ?? numericFontSize;
const actualDescent = metrics.actualBoundingBoxDescent ?? numericFontSize * 0.2;
const textBoundingWidth = Math.ceil(actualLeft + actualRight)
const tightHeight = Math.ceil(actualAscent + actualDescent)
const textBoundingWidth = Math.ceil(actualLeft + actualRight);
const tightHeight = Math.ceil(actualAscent + actualDescent);
const extraWidthBuffer = 10
const offscreenWidth = textBoundingWidth + extraWidthBuffer
const extraWidthBuffer = 10;
const offscreenWidth = textBoundingWidth + extraWidthBuffer;
offscreen.width = offscreenWidth
offscreen.height = tightHeight
offscreen.width = offscreenWidth;
offscreen.height = tightHeight;
const xOffset = extraWidthBuffer / 2
offCtx.font = `${props.fontWeight} ${fontSizeStr} ${computedFontFamily}`
offCtx.textBaseline = 'alphabetic'
offCtx.fillStyle = props.color
offCtx.fillText(text, xOffset - actualLeft, actualAscent)
const xOffset = extraWidthBuffer / 2;
offCtx.font = `${props.fontWeight} ${fontSizeStr} ${computedFontFamily}`;
offCtx.textBaseline = 'alphabetic';
offCtx.fillStyle = props.color;
offCtx.fillText(text, xOffset - actualLeft, actualAscent);
const horizontalMargin = 50
const verticalMargin = 0
canvas.width = offscreenWidth + horizontalMargin * 2
canvas.height = tightHeight + verticalMargin * 2
ctx.translate(horizontalMargin, verticalMargin)
const horizontalMargin = 50;
const verticalMargin = 0;
canvas.width = offscreenWidth + horizontalMargin * 2;
canvas.height = tightHeight + verticalMargin * 2;
ctx.translate(horizontalMargin, verticalMargin);
const interactiveLeft = horizontalMargin + xOffset
const interactiveTop = verticalMargin
const interactiveRight = interactiveLeft + textBoundingWidth
const interactiveBottom = interactiveTop + tightHeight
const interactiveLeft = horizontalMargin + xOffset;
const interactiveTop = verticalMargin;
const interactiveRight = interactiveLeft + textBoundingWidth;
const interactiveBottom = interactiveTop + tightHeight;
let isHovering = false
const fuzzRange = 30
let isHovering = false;
const fuzzRange = 30;
const run = () => {
if (isCancelled) return
ctx.clearRect(
-fuzzRange,
-fuzzRange,
offscreenWidth + 2 * fuzzRange,
tightHeight + 2 * fuzzRange
)
const intensity = isHovering ? props.hoverIntensity : props.baseIntensity
if (isCancelled) return;
ctx.clearRect(-fuzzRange, -fuzzRange, offscreenWidth + 2 * fuzzRange, tightHeight + 2 * fuzzRange);
const intensity = isHovering ? props.hoverIntensity : props.baseIntensity;
for (let j = 0; j < tightHeight; j++) {
const dx = Math.floor(intensity * (Math.random() - 0.5) * fuzzRange)
ctx.drawImage(
offscreen,
0,
j,
offscreenWidth,
1,
dx,
j,
offscreenWidth,
1
)
const dx = Math.floor(intensity * (Math.random() - 0.5) * fuzzRange);
ctx.drawImage(offscreen, 0, j, offscreenWidth, 1, dx, j, offscreenWidth, 1);
}
animationFrameId = window.requestAnimationFrame(run)
}
animationFrameId = window.requestAnimationFrame(run);
};
run()
run();
const isInsideTextArea = (x: number, y: number) =>
x >= interactiveLeft &&
x <= interactiveRight &&
y >= interactiveTop &&
y <= interactiveBottom
x >= interactiveLeft && x <= interactiveRight && y >= interactiveTop && y <= interactiveBottom;
const handleMouseMove = (e: MouseEvent) => {
if (!props.enableHover) return
const rect = canvas.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
isHovering = isInsideTextArea(x, y)
}
if (!props.enableHover) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
isHovering = isInsideTextArea(x, y);
};
const handleMouseLeave = () => {
isHovering = false
}
isHovering = false;
};
const handleTouchMove = (e: TouchEvent) => {
if (!props.enableHover) return
e.preventDefault()
const rect = canvas.getBoundingClientRect()
const touch = e.touches[0]
const x = touch.clientX - rect.left
const y = touch.clientY - rect.top
isHovering = isInsideTextArea(x, y)
}
if (!props.enableHover) return;
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const touch = e.touches[0];
const x = touch.clientX - rect.left;
const y = touch.clientY - rect.top;
isHovering = isInsideTextArea(x, y);
};
const handleTouchEnd = () => {
isHovering = false
}
isHovering = false;
};
if (props.enableHover) {
canvas.addEventListener('mousemove', handleMouseMove)
canvas.addEventListener('mouseleave', handleMouseLeave)
canvas.addEventListener('touchmove', handleTouchMove, { passive: false })
canvas.addEventListener('touchend', handleTouchEnd)
canvas.addEventListener('mousemove', handleMouseMove);
canvas.addEventListener('mouseleave', handleMouseLeave);
canvas.addEventListener('touchmove', handleTouchMove, { passive: false });
canvas.addEventListener('touchend', handleTouchEnd);
}
cleanup = () => {
window.cancelAnimationFrame(animationFrameId)
window.cancelAnimationFrame(animationFrameId);
if (props.enableHover) {
canvas.removeEventListener('mousemove', handleMouseMove)
canvas.removeEventListener('mouseleave', handleMouseLeave)
canvas.removeEventListener('touchmove', handleTouchMove)
canvas.removeEventListener('touchend', handleTouchEnd)
canvas.removeEventListener('mousemove', handleMouseMove);
canvas.removeEventListener('mouseleave', handleMouseLeave);
canvas.removeEventListener('touchmove', handleTouchMove);
canvas.removeEventListener('touchend', handleTouchEnd);
}
}
}
};
};
onMounted(() => {
nextTick(() => {
initCanvas()
})
})
initCanvas();
});
});
onUnmounted(() => {
isCancelled = true
isCancelled = true;
if (animationFrameId) {
window.cancelAnimationFrame(animationFrameId)
window.cancelAnimationFrame(animationFrameId);
}
if (cleanup) {
cleanup()
cleanup();
}
})
});
watch(
[
@@ -274,19 +255,19 @@ watch(
() => props.hoverIntensity
],
() => {
isCancelled = true
isCancelled = true;
if (animationFrameId) {
window.cancelAnimationFrame(animationFrameId)
window.cancelAnimationFrame(animationFrameId);
}
if (cleanup) {
cleanup()
cleanup();
}
isCancelled = false
isCancelled = false;
nextTick(() => {
initCanvas()
})
initCanvas();
});
}
)
);
</script>
<template>

View File

@@ -0,0 +1,183 @@
<template>
<div :class="computedClasses" :style="inlineStyles" :data-text="children">
{{ children }}
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { CSSProperties } from 'vue';
interface GlitchTextProps {
children: string;
speed?: number;
enableShadows?: boolean;
enableOnHover?: boolean;
className?: string;
}
interface CustomCSSProperties extends CSSProperties {
'--after-duration': string;
'--before-duration': string;
'--after-shadow': string;
'--before-shadow': string;
}
const props = withDefaults(defineProps<GlitchTextProps>(), {
speed: 0.5,
enableShadows: true,
enableOnHover: false,
className: ''
});
const inlineStyles = computed(
(): CustomCSSProperties => ({
'--after-duration': `${props.speed * 3}s`,
'--before-duration': `${props.speed * 2}s`,
'--after-shadow': props.enableShadows ? '-5px 0 red' : 'none',
'--before-shadow': props.enableShadows ? '5px 0 cyan' : 'none'
})
);
const baseClasses = [
'text-white',
'font-black',
'whitespace-nowrap',
'relative',
'mx-auto',
'select-none',
'cursor-pointer',
'text-[clamp(2rem,10vw,8rem)]',
'before:content-[attr(data-text)]',
'before:absolute',
'before:top-0',
'before:text-white',
'before:bg-[#060010]',
'before:overflow-hidden',
'before:[clip-path:inset(0_0_0_0)]',
'after:content-[attr(data-text)]',
'after:absolute',
'after:top-0',
'after:text-white',
'after:bg-[#060010]',
'after:overflow-hidden',
'after:[clip-path:inset(0_0_0_0)]'
];
const normalGlitchClasses = [
'after:left-[10px]',
'after:[text-shadow:var(--after-shadow,-10px_0_red)]',
'after:[animation:animate-glitch_var(--after-duration,3s)_infinite_linear_alternate-reverse]',
'before:left-[-10px]',
'before:[text-shadow:var(--before-shadow,10px_0_cyan)]',
'before:[animation:animate-glitch_var(--before-duration,2s)_infinite_linear_alternate-reverse]'
];
const hoverOnlyClasses = [
'before:content-[""]',
'before:opacity-0',
'before:[animation:none]',
'after:content-[""]',
'after:opacity-0',
'after:[animation:none]',
'hover:before:content-[attr(data-text)]',
'hover:before:opacity-100',
'hover:before:left-[-10px]',
'hover:before:[text-shadow:var(--before-shadow,10px_0_cyan)]',
'hover:before:[animation:animate-glitch_var(--before-duration,2s)_infinite_linear_alternate-reverse]',
'hover:after:content-[attr(data-text)]',
'hover:after:opacity-100',
'hover:after:left-[10px]',
'hover:after:[text-shadow:var(--after-shadow,-10px_0_red)]',
'hover:after:[animation:animate-glitch_var(--after-duration,3s)_infinite_linear_alternate-reverse]'
];
const computedClasses = computed(() => {
const classes = [...baseClasses];
if (props.enableOnHover) {
classes.push(...hoverOnlyClasses);
} else {
classes.push(...normalGlitchClasses);
}
if (props.className) {
classes.push(props.className);
}
return classes.join(' ');
});
</script>
<style>
@keyframes animate-glitch {
0% {
clip-path: inset(20% 0 50% 0);
}
5% {
clip-path: inset(10% 0 60% 0);
}
10% {
clip-path: inset(15% 0 55% 0);
}
15% {
clip-path: inset(25% 0 35% 0);
}
20% {
clip-path: inset(30% 0 40% 0);
}
25% {
clip-path: inset(40% 0 20% 0);
}
30% {
clip-path: inset(10% 0 60% 0);
}
35% {
clip-path: inset(15% 0 55% 0);
}
40% {
clip-path: inset(25% 0 35% 0);
}
45% {
clip-path: inset(30% 0 40% 0);
}
50% {
clip-path: inset(20% 0 50% 0);
}
55% {
clip-path: inset(10% 0 60% 0);
}
60% {
clip-path: inset(15% 0 55% 0);
}
65% {
clip-path: inset(25% 0 35% 0);
}
70% {
clip-path: inset(30% 0 40% 0);
}
75% {
clip-path: inset(40% 0 20% 0);
}
80% {
clip-path: inset(20% 0 50% 0);
}
85% {
clip-path: inset(10% 0 60% 0);
}
90% {
clip-path: inset(15% 0 55% 0);
}
95% {
clip-path: inset(25% 0 35% 0);
}
100% {
clip-path: inset(30% 0 40% 0);
}
}
</style>

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed } from 'vue';
interface GradientTextProps {
text: string
className?: string
colors?: string[]
animationSpeed?: number
showBorder?: boolean
text: string;
className?: string;
colors?: string[];
animationSpeed?: number;
showBorder?: boolean;
}
const props = withDefaults(defineProps<GradientTextProps>(), {
@@ -15,24 +15,24 @@ const props = withDefaults(defineProps<GradientTextProps>(), {
colors: () => ['#ffaa40', '#9c40ff', '#ffaa40'],
animationSpeed: 8,
showBorder: false
})
});
const gradientStyle = computed(() => ({
backgroundImage: `linear-gradient(to right, ${props.colors.join(', ')})`,
animationDuration: `${props.animationSpeed}s`,
backgroundSize: '300% 100%',
'--animation-duration': `${props.animationSpeed}s`
}))
}));
const borderStyle = computed(() => ({
...gradientStyle.value
}))
}));
const textStyle = computed(() => ({
...gradientStyle.value,
backgroundClip: 'text',
WebkitBackgroundClip: 'text'
}))
}));
</script>
<template>
@@ -46,20 +46,11 @@ const textStyle = computed(() => ({
>
<div
class="absolute inset-0 bg-black rounded-[1.25rem] z-[-1]"
style="
width: calc(100% - 2px);
height: calc(100% - 2px);
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
"
style="width: calc(100% - 2px); height: calc(100% - 2px); left: 50%; top: 50%; transform: translate(-50%, -50%)"
/>
</div>
<div
class="inline-block relative z-2 text-transparent bg-cover animate-gradient"
:style="textStyle"
>
<div class="inline-block relative z-2 text-transparent bg-cover animate-gradient" :style="textStyle">
{{ text }}
</div>
</div>

View File

@@ -0,0 +1,248 @@
<script setup lang="ts">
import { AnimatePresence, Motion, type Target, type Transition, type VariantLabels } from 'motion-v';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
type StaggerFrom = 'first' | 'last' | 'center' | 'random' | number;
type SplitBy = 'characters' | 'words' | 'lines';
interface WordElement {
characters: string[];
needsSpace: boolean;
}
interface RotatingTextProps {
texts: string[];
transition?: Transition;
initial?: boolean | Target | VariantLabels;
animate?: Target | VariantLabels;
exit?: Target | VariantLabels;
animatePresenceMode?: 'sync' | 'wait';
animatePresenceInitial?: boolean;
rotationInterval?: number;
staggerDuration?: number;
staggerFrom?: StaggerFrom;
loop?: boolean;
auto?: boolean;
splitBy?: SplitBy;
onNext?: (index: number) => void;
mainClassName?: string;
splitLevelClassName?: string;
elementLevelClassName?: string;
}
const cn = (...classes: (string | undefined | null | boolean)[]): string => {
return classes.filter(Boolean).join(' ');
};
const props = withDefaults(defineProps<RotatingTextProps>(), {
transition: () =>
({
type: 'spring',
damping: 25,
stiffness: 300
}) as Transition,
initial: () => ({ y: '100%', opacity: 0 }) as Target,
animate: () => ({ y: 0, opacity: 1 }) as Target,
exit: () => ({ y: '-120%', opacity: 0 }) as Target,
animatePresenceMode: 'wait',
animatePresenceInitial: false,
rotationInterval: 2000,
staggerDuration: 0,
staggerFrom: 'first',
loop: true,
auto: true,
splitBy: 'characters'
});
const currentTextIndex = ref(0);
let intervalId: ReturnType<typeof setInterval> | null = null;
const splitIntoCharacters = (text: string): string[] => {
if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const segmenter = new (Intl as any).Segmenter('en', { granularity: 'grapheme' });
return [...segmenter.segment(text)].map(({ segment }) => segment);
}
return [...text];
};
const elements = computed((): WordElement[] => {
const currentText = props.texts[currentTextIndex.value];
switch (props.splitBy) {
case 'characters': {
const words = currentText.split(' ');
return words.map((word, i) => ({
characters: splitIntoCharacters(word),
needsSpace: i !== words.length - 1
}));
}
case 'words': {
const words = currentText.split(' ');
return words.map((word, i) => ({
characters: [word],
needsSpace: i !== words.length - 1
}));
}
case 'lines': {
const lines = currentText.split('\n');
return lines.map((line, i) => ({
characters: [line],
needsSpace: i !== lines.length - 1
}));
}
default: {
const parts = currentText.split(props.splitBy!);
return parts.map((part, i) => ({
characters: [part],
needsSpace: i !== parts.length - 1
}));
}
}
});
const getStaggerDelay = (index: number, totalChars: number): number => {
const { staggerDuration, staggerFrom } = props;
switch (staggerFrom) {
case 'first':
return index * staggerDuration;
case 'last':
return (totalChars - 1 - index) * staggerDuration;
case 'center': {
const center = Math.floor(totalChars / 2);
return Math.abs(center - index) * staggerDuration;
}
case 'random': {
const randomIndex = Math.floor(Math.random() * totalChars);
return Math.abs(randomIndex - index) * staggerDuration;
}
default:
return Math.abs((staggerFrom as number) - index) * staggerDuration;
}
};
const handleIndexChange = (newIndex: number): void => {
currentTextIndex.value = newIndex;
props.onNext?.(newIndex);
};
const next = (): void => {
const isAtEnd = currentTextIndex.value === props.texts.length - 1;
const nextIndex = isAtEnd ? (props.loop ? 0 : currentTextIndex.value) : currentTextIndex.value + 1;
if (nextIndex !== currentTextIndex.value) {
handleIndexChange(nextIndex);
}
};
const previous = (): void => {
const isAtStart = currentTextIndex.value === 0;
const prevIndex = isAtStart
? props.loop
? props.texts.length - 1
: currentTextIndex.value
: currentTextIndex.value - 1;
if (prevIndex !== currentTextIndex.value) {
handleIndexChange(prevIndex);
}
};
const jumpTo = (index: number): void => {
const validIndex = Math.max(0, Math.min(index, props.texts.length - 1));
if (validIndex !== currentTextIndex.value) {
handleIndexChange(validIndex);
}
};
const reset = (): void => {
if (currentTextIndex.value !== 0) {
handleIndexChange(0);
}
};
const cleanupInterval = (): void => {
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
};
const startInterval = (): void => {
if (props.auto) {
intervalId = setInterval(next, props.rotationInterval);
}
};
defineExpose({
next,
previous,
jumpTo,
reset
});
watch(
() => [props.auto, props.rotationInterval] as const,
() => {
cleanupInterval();
startInterval();
},
{ immediate: true }
);
onMounted(() => {
startInterval();
});
onUnmounted(() => {
cleanupInterval();
});
</script>
<template>
<Motion
tag="span"
:class="cn('flex flex-wrap whitespace-pre-wrap relative', mainClassName)"
v-bind="$attrs"
:transition="transition"
layout
>
<span class="sr-only">
{{ texts[currentTextIndex] }}
</span>
<AnimatePresence :mode="animatePresenceMode" :initial="animatePresenceInitial">
<Motion
:key="currentTextIndex"
tag="span"
:class="cn(splitBy === 'lines' ? 'flex flex-col w-full' : 'flex flex-wrap whitespace-pre-wrap relative')"
aria-hidden="true"
layout
>
<span v-for="(wordObj, wordIndex) in elements" :key="wordIndex" :class="cn('inline-flex', splitLevelClassName)">
<Motion
v-for="(char, charIndex) in wordObj.characters"
:key="charIndex"
tag="span"
:initial="initial"
:animate="animate"
:exit="exit"
:transition="{
...transition,
delay: getStaggerDelay(
elements.slice(0, wordIndex).reduce((sum, word) => sum + word.characters.length, 0) + charIndex,
elements.reduce((sum, word) => sum + word.characters.length, 0)
)
}"
:class="cn('inline-block', elementLevelClassName)"
>
{{ char }}
</Motion>
<span v-if="wordObj.needsSpace" class="whitespace-pre"></span>
</span>
</Motion>
</AnimatePresence>
</Motion>
</template>

View File

@@ -0,0 +1,120 @@
<template>
<h2 ref="containerRef" :class="`overflow-hidden ${containerClassName}`">
<span
:class="`inline-block text-center leading-relaxed font-black ${textClassName}`"
style="font-size: clamp(1.6rem, 8vw, 10rem)"
>
<span v-for="(char, index) in splitText" :key="index" class="inline-block char">
{{ char === ' ' ? '\u00A0' : char }}
</span>
</span>
</h2>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);
interface Props {
children: string;
scrollContainerRef?: { current: HTMLElement | null };
containerClassName?: string;
textClassName?: string;
animationDuration?: number;
ease?: string;
scrollStart?: string;
scrollEnd?: string;
stagger?: number;
}
const props = withDefaults(defineProps<Props>(), {
containerClassName: '',
textClassName: '',
animationDuration: 1,
ease: 'back.inOut(2)',
scrollStart: 'center bottom+=50%',
scrollEnd: 'bottom bottom-=40%',
stagger: 0.03
});
const containerRef = ref<HTMLElement | null>(null);
let scrollTriggerInstance: ScrollTrigger | null = null;
const splitText = computed(() => {
const text = typeof props.children === 'string' ? props.children : '';
return text.split('');
});
const initializeAnimation = () => {
const el = containerRef.value;
if (!el) return;
const scroller =
props.scrollContainerRef && props.scrollContainerRef.current ? props.scrollContainerRef.current : window;
const charElements = el.querySelectorAll('.char');
if (scrollTriggerInstance) {
scrollTriggerInstance.kill();
}
const tl = gsap.fromTo(
charElements,
{
willChange: 'opacity, transform',
opacity: 0,
yPercent: 120,
scaleY: 2.3,
scaleX: 0.7,
transformOrigin: '50% 0%'
},
{
duration: props.animationDuration,
ease: props.ease,
opacity: 1,
yPercent: 0,
scaleY: 1,
scaleX: 1,
stagger: props.stagger,
scrollTrigger: {
trigger: el,
scroller,
start: props.scrollStart,
end: props.scrollEnd,
scrub: true
}
}
);
scrollTriggerInstance = tl.scrollTrigger || null;
};
onMounted(() => {
initializeAnimation();
});
onUnmounted(() => {
if (scrollTriggerInstance) {
scrollTriggerInstance.kill();
}
});
watch(
[
() => props.children,
() => props.scrollContainerRef,
() => props.animationDuration,
() => props.ease,
() => props.scrollStart,
() => props.scrollEnd,
() => props.stagger
],
() => {
initializeAnimation();
},
{ deep: true }
);
</script>

View File

@@ -0,0 +1,155 @@
<template>
<h2 ref="containerRef" :class="`my-5 ${containerClassName}`">
<p :class="`leading-relaxed font-semibold ${textClassName}`" style="font-size: clamp(1.6rem, 4vw, 3rem)">
<span v-for="(word, index) in splitText" :key="index" :class="word.isWhitespace ? '' : 'inline-block word'">
{{ word.text }}
</span>
</p>
</h2>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);
interface Props {
children: string;
scrollContainerRef?: { current: HTMLElement | null };
enableBlur?: boolean;
baseOpacity?: number;
baseRotation?: number;
blurStrength?: number;
containerClassName?: string;
textClassName?: string;
rotationEnd?: string;
wordAnimationEnd?: string;
}
const props = withDefaults(defineProps<Props>(), {
enableBlur: true,
baseOpacity: 0.1,
baseRotation: 3,
blurStrength: 4,
containerClassName: '',
textClassName: '',
rotationEnd: 'bottom bottom',
wordAnimationEnd: 'bottom bottom'
});
const containerRef = ref<HTMLElement | null>(null);
let scrollTriggerInstances: ScrollTrigger[] = [];
const splitText = computed(() => {
const text = typeof props.children === 'string' ? props.children : '';
return text.split(/(\s+)/).map((word, index) => ({
text: word,
isWhitespace: word.match(/^\s+$/) !== null,
key: index
}));
});
const initializeAnimation = () => {
const el = containerRef.value;
if (!el) return;
scrollTriggerInstances.forEach(trigger => trigger.kill());
scrollTriggerInstances = [];
const scroller =
props.scrollContainerRef && props.scrollContainerRef.current ? props.scrollContainerRef.current : window;
const rotationTl = gsap.fromTo(
el,
{ transformOrigin: '0% 50%', rotate: props.baseRotation },
{
ease: 'none',
rotate: 0,
scrollTrigger: {
trigger: el,
scroller,
start: 'top bottom',
end: props.rotationEnd,
scrub: true
}
}
);
if (rotationTl.scrollTrigger) {
scrollTriggerInstances.push(rotationTl.scrollTrigger);
}
const wordElements = el.querySelectorAll('.word');
const opacityTl = gsap.fromTo(
wordElements,
{ opacity: props.baseOpacity, willChange: 'opacity' },
{
ease: 'none',
opacity: 1,
stagger: 0.05,
scrollTrigger: {
trigger: el,
scroller,
start: 'top bottom-=20%',
end: props.wordAnimationEnd,
scrub: true
}
}
);
if (opacityTl.scrollTrigger) {
scrollTriggerInstances.push(opacityTl.scrollTrigger);
}
if (props.enableBlur) {
const blurTl = gsap.fromTo(
wordElements,
{ filter: `blur(${props.blurStrength}px)` },
{
ease: 'none',
filter: 'blur(0px)',
stagger: 0.05,
scrollTrigger: {
trigger: el,
scroller,
start: 'top bottom-=20%',
end: props.wordAnimationEnd,
scrub: true
}
}
);
if (blurTl.scrollTrigger) {
scrollTriggerInstances.push(blurTl.scrollTrigger);
}
}
};
onMounted(() => {
initializeAnimation();
});
onUnmounted(() => {
scrollTriggerInstances.forEach(trigger => trigger.kill());
});
watch(
[
() => props.children,
() => props.scrollContainerRef,
() => props.enableBlur,
() => props.baseRotation,
() => props.baseOpacity,
() => props.rotationEnd,
() => props.wordAnimationEnd,
() => props.blurStrength
],
() => {
initializeAnimation();
},
{ deep: true }
);
</script>

View File

@@ -1,11 +1,11 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed } from 'vue';
interface ShinyTextProps {
text: string
disabled?: boolean
speed?: number
className?: string
text: string;
disabled?: boolean;
speed?: number;
className?: string;
}
const props = withDefaults(defineProps<ShinyTextProps>(), {
@@ -13,16 +13,17 @@ const props = withDefaults(defineProps<ShinyTextProps>(), {
disabled: false,
speed: 5,
className: ''
})
});
const animationDuration = computed(() => `${props.speed}s`)
const animationDuration = computed(() => `${props.speed}s`);
</script>
<template>
<div
:class="`text-[#b5b5b5a4] bg-clip-text inline-block ${!props.disabled ? 'animate-shine' : ''} ${props.className}`"
:style="{
backgroundImage: 'linear-gradient(120deg, rgba(255, 255, 255, 0) 40%, rgba(255, 255, 255, 0.8) 50%, rgba(255, 255, 255, 0) 60%)',
backgroundImage:
'linear-gradient(120deg, rgba(255, 255, 255, 0) 40%, rgba(255, 255, 255, 0.8) 50%, rgba(255, 255, 255, 0) 60%)',
backgroundSize: '200% 100%',
WebkitBackgroundClip: 'text',
animationDuration: animationDuration

View File

@@ -4,7 +4,7 @@
:class="`split-parent overflow-hidden inline-block whitespace-normal ${className}`"
:style="{
textAlign,
wordWrap: 'break-word',
wordWrap: 'break-word'
}"
>
{{ text }}
@@ -12,26 +12,26 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { gsap } from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { SplitText as GSAPSplitText } from 'gsap/SplitText'
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { SplitText as GSAPSplitText } from 'gsap/SplitText';
gsap.registerPlugin(ScrollTrigger, GSAPSplitText)
gsap.registerPlugin(ScrollTrigger, GSAPSplitText);
export interface SplitTextProps {
text: string
className?: string
delay?: number
duration?: number
ease?: string | ((t: number) => number)
splitType?: 'chars' | 'words' | 'lines' | 'words, chars'
from?: gsap.TweenVars
to?: gsap.TweenVars
threshold?: number
rootMargin?: string
textAlign?: 'left' | 'center' | 'right' | 'justify'
onLetterAnimationComplete?: () => void
text: string;
className?: string;
delay?: number;
duration?: number;
ease?: string | ((t: number) => number);
splitType?: 'chars' | 'words' | 'lines' | 'words, chars';
from?: gsap.TweenVars;
to?: gsap.TweenVars;
threshold?: number;
rootMargin?: string;
textAlign?: 'left' | 'center' | 'right' | 'justify';
onLetterAnimationComplete?: () => void;
}
const props = withDefaults(defineProps<SplitTextProps>(), {
@@ -45,74 +45,74 @@ const props = withDefaults(defineProps<SplitTextProps>(), {
threshold: 0.1,
rootMargin: '-100px',
textAlign: 'center'
})
});
const emit = defineEmits<{
'animation-complete': []
}>()
'animation-complete': [];
}>();
const textRef = ref<HTMLParagraphElement | null>(null)
const animationCompletedRef = ref(false)
const scrollTriggerRef = ref<ScrollTrigger | null>(null)
const timelineRef = ref<gsap.core.Timeline | null>(null)
const splitterRef = ref<GSAPSplitText | null>(null)
const textRef = ref<HTMLParagraphElement | null>(null);
const animationCompletedRef = ref(false);
const scrollTriggerRef = ref<ScrollTrigger | null>(null);
const timelineRef = ref<gsap.core.Timeline | null>(null);
const splitterRef = ref<GSAPSplitText | null>(null);
const initializeAnimation = async () => {
if (typeof window === 'undefined' || !textRef.value || !props.text) return
if (typeof window === 'undefined' || !textRef.value || !props.text) return;
await nextTick()
const el = textRef.value
animationCompletedRef.value = false
await nextTick();
const absoluteLines = props.splitType === 'lines'
if (absoluteLines) el.style.position = 'relative'
const el = textRef.value;
let splitter: GSAPSplitText
animationCompletedRef.value = false;
const absoluteLines = props.splitType === 'lines';
if (absoluteLines) el.style.position = 'relative';
let splitter: GSAPSplitText;
try {
splitter = new GSAPSplitText(el, {
type: props.splitType,
absolute: absoluteLines,
linesClass: 'split-line',
})
splitterRef.value = splitter
linesClass: 'split-line'
});
splitterRef.value = splitter;
} catch (error) {
console.error('Failed to create SplitText:', error)
return
console.error('Failed to create SplitText:', error);
return;
}
let targets: Element[]
let targets: Element[];
switch (props.splitType) {
case 'lines':
targets = splitter.lines
break
targets = splitter.lines;
break;
case 'words':
targets = splitter.words
break
targets = splitter.words;
break;
case 'chars':
targets = splitter.chars
break
targets = splitter.chars;
break;
default:
targets = splitter.chars
targets = splitter.chars;
}
if (!targets || targets.length === 0) {
console.warn('No targets found for SplitText animation')
splitter.revert()
return
console.warn('No targets found for SplitText animation');
splitter.revert();
return;
}
targets.forEach((t) => {
;(t as HTMLElement).style.willChange = 'transform, opacity'
})
targets.forEach(t => {
(t as HTMLElement).style.willChange = 'transform, opacity';
});
const startPct = (1 - props.threshold) * 100
const marginMatch = /^(-?\d+(?:\.\d+)?)(px|em|rem|%)?$/.exec(props.rootMargin)
const marginValue = marginMatch ? parseFloat(marginMatch[1]) : 0
const marginUnit = marginMatch ? (marginMatch[2] || 'px') : 'px'
const sign = marginValue < 0 ? `-=${Math.abs(marginValue)}${marginUnit}` : `+=${marginValue}${marginUnit}`
const start = `top ${startPct}%${sign}`
const startPct = (1 - props.threshold) * 100;
const marginMatch = /^(-?\d+(?:\.\d+)?)(px|em|rem|%)?$/.exec(props.rootMargin);
const marginValue = marginMatch ? parseFloat(marginMatch[1]) : 0;
const marginUnit = marginMatch ? marginMatch[2] || 'px' : 'px';
const sign = marginValue < 0 ? `-=${Math.abs(marginValue)}${marginUnit}` : `+=${marginValue}${marginUnit}`;
const start = `top ${startPct}%${sign}`;
const tl = gsap.timeline({
scrollTrigger: {
@@ -120,58 +120,58 @@ const initializeAnimation = async () => {
start,
toggleActions: 'play none none none',
once: true,
onToggle: (self) => {
scrollTriggerRef.value = self
},
onToggle: self => {
scrollTriggerRef.value = self;
}
},
smoothChildTiming: true,
onComplete: () => {
animationCompletedRef.value = true
animationCompletedRef.value = true;
gsap.set(targets, {
...props.to,
clearProps: 'willChange',
immediateRender: true,
})
props.onLetterAnimationComplete?.()
emit('animation-complete')
},
})
immediateRender: true
});
props.onLetterAnimationComplete?.();
emit('animation-complete');
}
});
timelineRef.value = tl
timelineRef.value = tl;
tl.set(targets, { ...props.from, immediateRender: false, force3D: true })
tl.set(targets, { ...props.from, immediateRender: false, force3D: true });
tl.to(targets, {
...props.to,
duration: props.duration,
ease: props.ease,
stagger: props.delay / 1000,
force3D: true,
})
}
force3D: true
});
};
const cleanup = () => {
if (timelineRef.value) {
timelineRef.value.kill()
timelineRef.value = null
timelineRef.value.kill();
timelineRef.value = null;
}
if (scrollTriggerRef.value) {
scrollTriggerRef.value.kill()
scrollTriggerRef.value = null
scrollTriggerRef.value.kill();
scrollTriggerRef.value = null;
}
if (splitterRef.value) {
gsap.killTweensOf(textRef.value)
splitterRef.value.revert()
splitterRef.value = null
gsap.killTweensOf(textRef.value);
splitterRef.value.revert();
splitterRef.value = null;
}
}
};
onMounted(() => {
initializeAnimation()
})
initializeAnimation();
});
onUnmounted(() => {
cleanup()
})
cleanup();
});
watch(
[
@@ -184,12 +184,11 @@ watch(
() => props.to,
() => props.threshold,
() => props.rootMargin,
() => props.onLetterAnimationComplete,
() => props.onLetterAnimationComplete
],
() => {
cleanup()
initializeAnimation()
cleanup();
initializeAnimation();
}
)
);
</script>

View File

@@ -1,26 +1,26 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { Motion } from 'motion-v'
import { ref, onMounted, onUnmounted } from 'vue';
import { Motion } from 'motion-v';
interface TextCursorProps {
text?: string
delay?: number
spacing?: number
followMouseDirection?: boolean
randomFloat?: boolean
exitDuration?: number
removalInterval?: number
maxPoints?: number
text?: string;
delay?: number;
spacing?: number;
followMouseDirection?: boolean;
randomFloat?: boolean;
exitDuration?: number;
removalInterval?: number;
maxPoints?: number;
}
interface TrailItem {
id: number
x: number
y: number
angle: number
randomX?: number
randomY?: number
randomRotate?: number
id: number;
x: number;
y: number;
angle: number;
randomX?: number;
randomY?: number;
randomRotate?: number;
}
const props = withDefaults(defineProps<TextCursorProps>(), {
@@ -32,24 +32,24 @@ const props = withDefaults(defineProps<TextCursorProps>(), {
exitDuration: 0.5,
removalInterval: 30,
maxPoints: 5
})
});
const containerRef = ref<HTMLDivElement>()
const trail = ref<TrailItem[]>([])
const lastMoveTime = ref(Date.now())
const idCounter = ref(0)
const containerRef = ref<HTMLDivElement>();
const trail = ref<TrailItem[]>([]);
const lastMoveTime = ref(Date.now());
const idCounter = ref(0);
let removalIntervalId: number | null = null
let removalIntervalId: number | null = null;
const handleMouseMove = (e: MouseEvent) => {
if (!containerRef.value) return
const rect = containerRef.value.getBoundingClientRect()
const mouseX = e.clientX - rect.left
const mouseY = e.clientY - rect.top
if (!containerRef.value) return;
const rect = containerRef.value.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
let newTrail = [...trail.value];
let newTrail = [...trail.value]
if (newTrail.length === 0) {
newTrail.push({
id: idCounter.value++,
@@ -61,24 +61,24 @@ const handleMouseMove = (e: MouseEvent) => {
randomY: Math.random() * 10 - 5,
randomRotate: Math.random() * 10 - 5
})
})
});
} else {
const last = newTrail[newTrail.length - 1]
const dx = mouseX - last.x
const dy = mouseY - last.y
const distance = Math.sqrt(dx * dx + dy * dy)
const last = newTrail[newTrail.length - 1];
const dx = mouseX - last.x;
const dy = mouseY - last.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance >= props.spacing) {
let rawAngle = (Math.atan2(dy, dx) * 180) / Math.PI
if (rawAngle > 90) rawAngle -= 180
else if (rawAngle < -90) rawAngle += 180
const computedAngle = props.followMouseDirection ? rawAngle : 0
const steps = Math.floor(distance / props.spacing)
let rawAngle = (Math.atan2(dy, dx) * 180) / Math.PI;
if (rawAngle > 90) rawAngle -= 180;
else if (rawAngle < -90) rawAngle += 180;
const computedAngle = props.followMouseDirection ? rawAngle : 0;
const steps = Math.floor(distance / props.spacing);
for (let i = 1; i <= steps; i++) {
const t = (props.spacing * i) / distance
const newX = last.x + dx * t
const newY = last.y + dy * t
const t = (props.spacing * i) / distance;
const newX = last.x + dx * t;
const newY = last.y + dy * t;
newTrail.push({
id: idCounter.value++,
x: newX,
@@ -89,48 +89,48 @@ const handleMouseMove = (e: MouseEvent) => {
randomY: Math.random() * 10 - 5,
randomRotate: Math.random() * 10 - 5
})
})
});
}
}
}
if (newTrail.length > props.maxPoints) {
newTrail = newTrail.slice(newTrail.length - props.maxPoints)
newTrail = newTrail.slice(newTrail.length - props.maxPoints);
}
trail.value = newTrail
lastMoveTime.value = Date.now()
}
trail.value = newTrail;
lastMoveTime.value = Date.now();
};
const startRemovalInterval = () => {
if (removalIntervalId) {
clearInterval(removalIntervalId)
clearInterval(removalIntervalId);
}
removalIntervalId = setInterval(() => {
if (Date.now() - lastMoveTime.value > 100) {
if (trail.value.length > 0) {
trail.value = trail.value.slice(1)
trail.value = trail.value.slice(1);
}
}
}, props.removalInterval)
}
}, props.removalInterval);
};
onMounted(() => {
if (containerRef.value) {
containerRef.value.addEventListener('mousemove', handleMouseMove)
startRemovalInterval()
containerRef.value.addEventListener('mousemove', handleMouseMove);
startRemovalInterval();
}
})
});
onUnmounted(() => {
if (containerRef.value) {
containerRef.value.removeEventListener('mousemove', handleMouseMove)
containerRef.value.removeEventListener('mousemove', handleMouseMove);
}
if (removalIntervalId) {
clearInterval(removalIntervalId)
clearInterval(removalIntervalId);
}
})
});
</script>
<template>
@@ -140,16 +140,14 @@ onUnmounted(() => {
v-for="item in trail"
:key="item.id"
:initial="{ opacity: 0, scale: 1, rotate: item.angle }"
:animate="{
opacity: 1,
:animate="{
opacity: 1,
scale: 1,
x: props.randomFloat ? [0, item.randomX || 0, 0] : 0,
y: props.randomFloat ? [0, item.randomY || 0, 0] : 0,
rotate: props.randomFloat
? [item.angle, item.angle + (item.randomRotate || 0), item.angle]
: item.angle
rotate: props.randomFloat ? [item.angle, item.angle + (item.randomRotate || 0), item.angle] : item.angle
}"
:transition="{
:transition="{
duration: props.randomFloat ? 2 : props.exitDuration,
repeat: props.randomFloat ? Infinity : 0,
repeatType: props.randomFloat ? 'mirror' : 'loop'

View File

@@ -1,22 +1,22 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick, computed, watch } from 'vue'
import { ref, onMounted, onUnmounted, nextTick, computed, watch } from 'vue';
interface TextPressureProps {
text?: string
fontFamily?: string
fontUrl?: string
width?: boolean
weight?: boolean
italic?: boolean
alpha?: boolean
flex?: boolean
stroke?: boolean
scale?: boolean
textColor?: string
strokeColor?: string
strokeWidth?: number
className?: string
minFontSize?: number
text?: string;
fontFamily?: string;
fontUrl?: string;
width?: boolean;
weight?: boolean;
italic?: boolean;
alpha?: boolean;
flex?: boolean;
stroke?: boolean;
scale?: boolean;
textColor?: string;
strokeColor?: string;
strokeWidth?: number;
className?: string;
minFontSize?: number;
}
const props = withDefaults(defineProps<TextPressureProps>(), {
@@ -35,141 +35,141 @@ const props = withDefaults(defineProps<TextPressureProps>(), {
strokeWidth: 2,
className: '',
minFontSize: 24
})
});
const containerRef = ref<HTMLDivElement | null>(null)
const titleRef = ref<HTMLHeadingElement | null>(null)
const spansRef = ref<(HTMLSpanElement | null)[]>([])
const containerRef = ref<HTMLDivElement | null>(null);
const titleRef = ref<HTMLHeadingElement | null>(null);
const spansRef = ref<(HTMLSpanElement | null)[]>([]);
const mouseRef = ref({ x: 0, y: 0 })
const cursorRef = ref({ x: 0, y: 0 })
const mouseRef = ref({ x: 0, y: 0 });
const cursorRef = ref({ x: 0, y: 0 });
const fontSize = ref(props.minFontSize)
const scaleY = ref(1)
const lineHeight = ref(1)
const fontSize = ref(props.minFontSize);
const scaleY = ref(1);
const lineHeight = ref(1);
const chars = computed(() => props.text.split(''))
const chars = computed(() => props.text.split(''));
const dist = (a: { x: number; y: number }, b: { x: number; y: number }) => {
const dx = b.x - a.x
const dy = b.y - a.y
return Math.sqrt(dx * dx + dy * dy)
}
const dx = b.x - a.x;
const dy = b.y - a.y;
return Math.sqrt(dx * dx + dy * dy);
};
const handleMouseMove = (e: MouseEvent) => {
cursorRef.value.x = e.clientX
cursorRef.value.y = e.clientY
}
cursorRef.value.x = e.clientX;
cursorRef.value.y = e.clientY;
};
const handleTouchMove = (e: TouchEvent) => {
const t = e.touches[0]
cursorRef.value.x = t.clientX
cursorRef.value.y = t.clientY
}
const t = e.touches[0];
cursorRef.value.x = t.clientX;
cursorRef.value.y = t.clientY;
};
const setSize = () => {
if (!containerRef.value || !titleRef.value) return
if (!containerRef.value || !titleRef.value) return;
const { width: containerW, height: containerH } = containerRef.value.getBoundingClientRect()
const { width: containerW, height: containerH } = containerRef.value.getBoundingClientRect();
let newFontSize = containerW / (chars.value.length / 2)
newFontSize = Math.max(newFontSize, props.minFontSize)
let newFontSize = containerW / (chars.value.length / 2);
newFontSize = Math.max(newFontSize, props.minFontSize);
fontSize.value = newFontSize
scaleY.value = 1
lineHeight.value = 1
fontSize.value = newFontSize;
scaleY.value = 1;
lineHeight.value = 1;
nextTick(() => {
if (!titleRef.value) return
const textRect = titleRef.value.getBoundingClientRect()
if (!titleRef.value) return;
const textRect = titleRef.value.getBoundingClientRect();
if (props.scale && textRect.height > 0) {
const yRatio = containerH / textRect.height
scaleY.value = yRatio
lineHeight.value = yRatio
const yRatio = containerH / textRect.height;
scaleY.value = yRatio;
lineHeight.value = yRatio;
}
})
}
});
};
let rafId: number
let rafId: number;
const animate = () => {
mouseRef.value.x += (cursorRef.value.x - mouseRef.value.x) / 15
mouseRef.value.y += (cursorRef.value.y - mouseRef.value.y) / 15
mouseRef.value.x += (cursorRef.value.x - mouseRef.value.x) / 15;
mouseRef.value.y += (cursorRef.value.y - mouseRef.value.y) / 15;
if (titleRef.value) {
const titleRect = titleRef.value.getBoundingClientRect()
const maxDist = titleRect.width / 2
const titleRect = titleRef.value.getBoundingClientRect();
const maxDist = titleRect.width / 2;
spansRef.value.forEach((span) => {
if (!span) return
spansRef.value.forEach(span => {
if (!span) return;
const rect = span.getBoundingClientRect()
const rect = span.getBoundingClientRect();
const charCenter = {
x: rect.x + rect.width / 2,
y: rect.y + rect.height / 2,
}
y: rect.y + rect.height / 2
};
const d = dist(mouseRef.value, charCenter)
const d = dist(mouseRef.value, charCenter);
const getAttr = (distance: number, minVal: number, maxVal: number) => {
const val = maxVal - Math.abs((maxVal * distance) / maxDist)
return Math.max(minVal, val + minVal)
}
const val = maxVal - Math.abs((maxVal * distance) / maxDist);
return Math.max(minVal, val + minVal);
};
const wdth = props.width ? Math.floor(getAttr(d, 5, 200)) : 100
const wght = props.weight ? Math.floor(getAttr(d, 100, 900)) : 400
const italVal = props.italic ? getAttr(d, 0, 1).toFixed(2) : '0'
const alphaVal = props.alpha ? getAttr(d, 0, 1).toFixed(2) : '1'
const wdth = props.width ? Math.floor(getAttr(d, 5, 200)) : 100;
const wght = props.weight ? Math.floor(getAttr(d, 100, 900)) : 400;
const italVal = props.italic ? getAttr(d, 0, 1).toFixed(2) : '0';
const alphaVal = props.alpha ? getAttr(d, 0, 1).toFixed(2) : '1';
span.style.opacity = alphaVal
span.style.fontVariationSettings = `'wght' ${wght}, 'wdth' ${wdth}, 'ital' ${italVal}`
})
span.style.opacity = alphaVal;
span.style.fontVariationSettings = `'wght' ${wght}, 'wdth' ${wdth}, 'ital' ${italVal}`;
});
}
rafId = requestAnimationFrame(animate)
}
rafId = requestAnimationFrame(animate);
};
onMounted(() => {
const styleElement = document.createElement('style')
styleElement.textContent = dynamicStyles.value
document.head.appendChild(styleElement)
styleElement.setAttribute('data-text-pressure', 'true')
const styleElement = document.createElement('style');
styleElement.textContent = dynamicStyles.value;
document.head.appendChild(styleElement);
styleElement.setAttribute('data-text-pressure', 'true');
setSize()
setSize();
window.addEventListener('mousemove', handleMouseMove)
window.addEventListener('touchmove', handleTouchMove, { passive: false })
window.addEventListener('resize', setSize)
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('touchmove', handleTouchMove, { passive: false });
window.addEventListener('resize', setSize);
if (containerRef.value) {
const { left, top, width, height } = containerRef.value.getBoundingClientRect()
mouseRef.value.x = left + width / 2
mouseRef.value.y = top + height / 2
cursorRef.value.x = mouseRef.value.x
cursorRef.value.y = mouseRef.value.y
const { left, top, width, height } = containerRef.value.getBoundingClientRect();
mouseRef.value.x = left + width / 2;
mouseRef.value.y = top + height / 2;
cursorRef.value.x = mouseRef.value.x;
cursorRef.value.y = mouseRef.value.y;
}
animate()
})
animate();
});
onUnmounted(() => {
const styleElements = document.querySelectorAll('style[data-text-pressure="true"]')
styleElements.forEach(el => el.remove())
const styleElements = document.querySelectorAll('style[data-text-pressure="true"]');
styleElements.forEach(el => el.remove());
window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('touchmove', handleTouchMove)
window.removeEventListener('resize', setSize)
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('touchmove', handleTouchMove);
window.removeEventListener('resize', setSize);
if (rafId) {
cancelAnimationFrame(rafId)
cancelAnimationFrame(rafId);
}
})
});
watch([() => props.scale, () => props.text], () => {
setSize()
})
setSize();
});
watch([() => props.width, () => props.weight, () => props.italic, () => props.alpha], () => {})
watch([() => props.width, () => props.weight, () => props.italic, () => props.alpha], () => {});
const titleStyle = computed(() => ({
fontFamily: props.fontFamily,
@@ -179,10 +179,11 @@ const titleStyle = computed(() => ({
transformOrigin: 'center top',
margin: 0,
fontWeight: 100,
color: props.stroke ? undefined : props.textColor,
}))
color: props.stroke ? undefined : props.textColor
}));
const dynamicStyles = computed(() => `
const dynamicStyles = computed(
() => `
@font-face {
font-family: '${props.fontFamily}';
src: url('${props.fontUrl}');
@@ -202,29 +203,37 @@ const dynamicStyles = computed(() => `
-webkit-text-stroke-width: ${props.strokeWidth}px;
-webkit-text-stroke-color: ${props.strokeColor};
}
`)
`
);
onMounted(() => {
const styleElement = document.createElement('style')
styleElement.textContent = dynamicStyles.value
document.head.appendChild(styleElement)
const styleElement = document.createElement('style');
styleElement.textContent = dynamicStyles.value;
document.head.appendChild(styleElement);
styleElement.setAttribute('data-text-pressure', 'true')
})
styleElement.setAttribute('data-text-pressure', 'true');
});
onUnmounted(() => {
const styleElements = document.querySelectorAll('style[data-text-pressure="true"]')
styleElements.forEach(el => el.remove())
})
const styleElements = document.querySelectorAll('style[data-text-pressure="true"]');
styleElements.forEach(el => el.remove());
});
</script>
<template>
<div ref="containerRef" class="relative w-full h-full overflow-hidden bg-transparent">
<h1 ref="titleRef"
<h1
ref="titleRef"
:class="`text-pressure-title ${className} ${flex ? 'flex justify-between' : ''} ${stroke ? 'stroke' : ''} uppercase text-center`"
:style="titleStyle">
<span v-for="(char, i) in chars" :key="i" :ref="(el) => spansRef[i] = el as HTMLSpanElement" :data-char="char"
class="inline-block">
:style="titleStyle"
>
<span
v-for="(char, i) in chars"
:key="i"
:ref="el => (spansRef[i] = el as HTMLSpanElement)"
:data-char="char"
class="inline-block"
>
{{ char }}
</span>
</h1>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { ref, onMounted, onUnmounted, watch } from 'vue';
import {
CanvasTexture,
Clock,
@@ -15,22 +15,22 @@ import {
Vector3,
WebGLRenderer,
WebGLRenderTarget
} from 'three'
} from 'three';
interface TextTrailProps {
text?: string
fontFamily?: string
fontWeight?: string | number
noiseFactor?: number
noiseScale?: number
rgbPersistFactor?: number
alphaPersistFactor?: number
animateColor?: boolean
startColor?: string
textColor?: string
backgroundColor?: number | string
colorCycleInterval?: number
supersample?: number
text?: string;
fontFamily?: string;
fontWeight?: string | number;
noiseFactor?: number;
noiseScale?: number;
rgbPersistFactor?: number;
alphaPersistFactor?: number;
animateColor?: boolean;
startColor?: string;
textColor?: string;
backgroundColor?: number | string;
colorCycleInterval?: number;
supersample?: number;
}
const props = withDefaults(defineProps<TextTrailProps>(), {
@@ -47,31 +47,31 @@ const props = withDefaults(defineProps<TextTrailProps>(), {
backgroundColor: 0x151515,
colorCycleInterval: 3000,
supersample: 2
})
});
const containerRef = ref<HTMLDivElement>()
const containerRef = ref<HTMLDivElement>();
const hexToRgb = (hex: string): [number, number, number] => {
let h = hex.replace('#', '')
let h = hex.replace('#', '');
if (h.length === 3)
h = h
.split('')
.map((c) => c + c)
.join('')
const n = parseInt(h, 16)
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
}
.map(c => c + c)
.join('');
const n = parseInt(h, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
};
const loadFont = async (fam: string) => {
if ('fonts' in document) {
const fonts = (document as Document & { fonts: { load: (font: string) => Promise<void> } }).fonts
await fonts.load(`64px "${fam}"`)
const fonts = (document as Document & { fonts: { load: (font: string) => Promise<void> } }).fonts;
await fonts.load(`64px "${fam}"`);
}
}
};
const BASE_VERT = `
varying vec2 v_uv;
void main(){gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);v_uv=uv;}`
void main(){gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);v_uv=uv;}`;
const SIMPLEX = `
vec3 mod289(vec3 x){return x-floor(x*(1./289.))*289.;}
@@ -114,7 +114,7 @@ float snoise3(vec3 v){
vec4 m=max(.6-vec4(dot(x0,x0),dot(x1,x1),dot(x2,x2),dot(x3,x3)),0.);
m*=m;
return 42.*dot(m*m,vec4(dot(p0,x0),dot(p1,x1),dot(p2,x2),dot(p3,x3)));
}`
}`;
const PERSIST_FRAG = `
uniform sampler2D sampler;
@@ -128,7 +128,7 @@ void main(){
float b=snoise3(vec3(v_uv*noiseFactor,time*.1+100.))*noiseScale;
vec4 t=texture2D(sampler,v_uv+vec2(a,b)+mousePos*.005);
gl_FragColor=vec4(t.xyz*rgbPersistFactor,alphaPersistFactor);
}`
}`;
const TEXT_FRAG = `
uniform sampler2D sampler;uniform vec3 color;varying vec2 v_uv;
@@ -137,77 +137,77 @@ void main(){
float alpha=smoothstep(0.1,0.9,t.a);
if(alpha<0.01)discard;
gl_FragColor=vec4(color,alpha);
}`
}`;
let renderer: WebGLRenderer | null = null
let scene: Scene | null = null
let fluidScene: Scene | null = null
let clock: Clock | null = null
let cam: OrthographicCamera | null = null
let rt0: WebGLRenderTarget | null = null
let rt1: WebGLRenderTarget | null = null
let quadMat: ShaderMaterial | null = null
let quad: Mesh | null = null
let labelMat: ShaderMaterial | null = null
let label: Mesh | null = null
let resizeObserver: ResizeObserver | null = null
let colorTimer: number | null = null
let renderer: WebGLRenderer | null = null;
let scene: Scene | null = null;
let fluidScene: Scene | null = null;
let clock: Clock | null = null;
let cam: OrthographicCamera | null = null;
let rt0: WebGLRenderTarget | null = null;
let rt1: WebGLRenderTarget | null = null;
let quadMat: ShaderMaterial | null = null;
let quad: Mesh | null = null;
let labelMat: ShaderMaterial | null = null;
let label: Mesh | null = null;
let resizeObserver: ResizeObserver | null = null;
let colorTimer: number | null = null;
const persistColor = ref<[number, number, number]>(
hexToRgb(props.textColor || props.startColor).map((c) => c / 255) as [number, number, number]
)
const targetColor = ref<[number, number, number]>([...persistColor.value])
hexToRgb(props.textColor || props.startColor).map(c => c / 255) as [number, number, number]
);
const targetColor = ref<[number, number, number]>([...persistColor.value]);
const mouse = [0, 0]
const target = [0, 0]
const mouse = [0, 0];
const target = [0, 0];
const getSize = () => ({
w: containerRef.value!.clientWidth,
h: containerRef.value!.clientHeight
})
});
const onMove = (e: PointerEvent) => {
if (!containerRef.value) return
const r = containerRef.value.getBoundingClientRect()
target[0] = ((e.clientX - r.left) / r.width) * 2 - 1
target[1] = ((r.top + r.height - e.clientY) / r.height) * 2 - 1
}
if (!containerRef.value) return;
const r = containerRef.value.getBoundingClientRect();
target[0] = ((e.clientX - r.left) / r.width) * 2 - 1;
target[1] = ((r.top + r.height - e.clientY) / r.height) * 2 - 1;
};
const drawText = () => {
if (!renderer || !labelMat) return
const texCanvas = document.createElement('canvas')
if (!renderer || !labelMat) return;
const texCanvas = document.createElement('canvas');
const ctx = texCanvas.getContext('2d', {
alpha: true,
colorSpace: 'srgb'
})!
const max = Math.min(renderer.capabilities.maxTextureSize, 4096)
const pixelRatio = (window.devicePixelRatio || 1) * props.supersample
const canvasSize = max * pixelRatio
texCanvas.width = canvasSize
texCanvas.height = canvasSize
texCanvas.style.width = `${max}px`
texCanvas.style.height = `${max}px`
})!;
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.scale(pixelRatio, pixelRatio)
ctx.clearRect(0, 0, max, max)
ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = 'high'
ctx.shadowColor = 'rgba(255,255,255,0.3)'
ctx.shadowBlur = 2
ctx.fillStyle = '#fff'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const max = Math.min(renderer.capabilities.maxTextureSize, 4096);
const pixelRatio = (window.devicePixelRatio || 1) * props.supersample;
const canvasSize = max * pixelRatio;
texCanvas.width = canvasSize;
texCanvas.height = canvasSize;
texCanvas.style.width = `${max}px`;
texCanvas.style.height = `${max}px`;
const refSize = 250
ctx.font = `${props.fontWeight} ${refSize}px ${props.fontFamily}`
const width = ctx.measureText(props.text).width
ctx.font = `${props.fontWeight} ${(refSize * max) / width}px ${props.fontFamily}`
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(pixelRatio, pixelRatio);
ctx.clearRect(0, 0, max, max);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.shadowColor = 'rgba(255,255,255,0.3)';
ctx.shadowBlur = 2;
ctx.fillStyle = '#fff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const cx = max / 2
const cy = max / 2
const refSize = 250;
ctx.font = `${props.fontWeight} ${refSize}px ${props.fontFamily}`;
const width = ctx.measureText(props.text).width;
ctx.font = `${props.fontWeight} ${(refSize * max) / width}px ${props.fontFamily}`;
const cx = max / 2;
const cy = max / 2;
const offs = [
[0, 0],
[0.1, 0],
@@ -218,42 +218,40 @@ const drawText = () => {
[-0.1, -0.1],
[0.1, -0.1],
[-0.1, 0.1]
]
ctx.globalAlpha = 1 / offs.length
offs.forEach(([dx, dy]) => ctx.fillText(props.text, cx + dx, cy + dy))
ctx.globalAlpha = 1
];
ctx.globalAlpha = 1 / offs.length;
offs.forEach(([dx, dy]) => ctx.fillText(props.text, cx + dx, cy + dy));
ctx.globalAlpha = 1;
const tex = new CanvasTexture(texCanvas)
tex.generateMipmaps = true
tex.minFilter = LinearMipmapLinearFilter
tex.magFilter = LinearFilter
labelMat.uniforms.sampler.value = tex
}
const tex = new CanvasTexture(texCanvas);
tex.generateMipmaps = true;
tex.minFilter = LinearMipmapLinearFilter;
tex.magFilter = LinearFilter;
labelMat.uniforms.sampler.value = tex;
};
const initThreeJS = async () => {
if (!containerRef.value) return
if (!containerRef.value) return;
let { w, h } = getSize()
let { w, h } = getSize();
renderer = new WebGLRenderer({ antialias: true })
renderer = new WebGLRenderer({ antialias: true });
renderer.setClearColor(
typeof props.backgroundColor === 'string'
? new Color(props.backgroundColor)
: new Color(props.backgroundColor),
typeof props.backgroundColor === 'string' ? new Color(props.backgroundColor) : new Color(props.backgroundColor),
1
)
renderer.setPixelRatio(window.devicePixelRatio || 1)
renderer.setSize(w, h)
containerRef.value.appendChild(renderer.domElement)
);
renderer.setPixelRatio(window.devicePixelRatio || 1);
renderer.setSize(w, h);
containerRef.value.appendChild(renderer.domElement);
scene = new Scene()
fluidScene = new Scene()
clock = new Clock()
cam = new OrthographicCamera(-w / 2, w / 2, h / 2, -h / 2, 0.1, 10)
cam.position.z = 1
scene = new Scene();
fluidScene = new Scene();
clock = new Clock();
cam = new OrthographicCamera(-w / 2, w / 2, h / 2, -h / 2, 0.1, 10);
cam.position.z = 1;
rt0 = new WebGLRenderTarget(w, h)
rt1 = rt0.clone()
rt0 = new WebGLRenderTarget(w, h);
rt1 = rt0.clone();
quadMat = new ShaderMaterial({
uniforms: {
@@ -268,9 +266,9 @@ const initThreeJS = async () => {
vertexShader: BASE_VERT,
fragmentShader: PERSIST_FRAG,
transparent: true
})
quad = new Mesh(new PlaneGeometry(w, h), quadMat)
fluidScene.add(quad)
});
quad = new Mesh(new PlaneGeometry(w, h), quadMat);
fluidScene.add(quad);
labelMat = new ShaderMaterial({
uniforms: {
@@ -280,133 +278,132 @@ const initThreeJS = async () => {
vertexShader: BASE_VERT,
fragmentShader: TEXT_FRAG,
transparent: true
})
label = new Mesh(new PlaneGeometry(Math.min(w, h), Math.min(w, h)), labelMat)
scene.add(label)
});
label = new Mesh(new PlaneGeometry(Math.min(w, h), Math.min(w, h)), labelMat);
scene.add(label);
await loadFont(props.fontFamily)
drawText()
await loadFont(props.fontFamily);
drawText();
containerRef.value.addEventListener('pointermove', onMove)
containerRef.value.addEventListener('pointermove', onMove);
resizeObserver = new ResizeObserver(() => {
if (!containerRef.value || !renderer || !cam || !quad || !rt0 || !rt1 || !label) return
const size = getSize()
w = size.w
h = size.h
renderer.setSize(w, h)
cam.left = -w / 2
cam.right = w / 2
cam.top = h / 2
cam.bottom = -h / 2
cam.updateProjectionMatrix()
quad.geometry.dispose()
quad.geometry = new PlaneGeometry(w, h)
rt0.setSize(w, h)
rt1.setSize(w, h)
label.geometry.dispose()
label.geometry = new PlaneGeometry(Math.min(w, h), Math.min(w, h))
})
resizeObserver.observe(containerRef.value)
if (!containerRef.value || !renderer || !cam || !quad || !rt0 || !rt1 || !label) return;
const size = getSize();
w = size.w;
h = size.h;
renderer.setSize(w, h);
cam.left = -w / 2;
cam.right = w / 2;
cam.top = h / 2;
cam.bottom = -h / 2;
cam.updateProjectionMatrix();
quad.geometry.dispose();
quad.geometry = new PlaneGeometry(w, h);
rt0.setSize(w, h);
rt1.setSize(w, h);
label.geometry.dispose();
label.geometry = new PlaneGeometry(Math.min(w, h), Math.min(w, h));
});
resizeObserver.observe(containerRef.value);
colorTimer = setInterval(() => {
if (!props.textColor) {
targetColor.value = [Math.random(), Math.random(), Math.random()]
targetColor.value = [Math.random(), Math.random(), Math.random()];
}
}, props.colorCycleInterval)
}, props.colorCycleInterval);
const animate = () => {
if (!renderer || !quadMat || !labelMat || !clock || !scene || !fluidScene || !cam || !rt0 || !rt1) return
if (!renderer || !quadMat || !labelMat || !clock || !scene || !fluidScene || !cam || !rt0 || !rt1) return;
const dt = clock.getDelta()
const dt = clock.getDelta();
if (props.animateColor && !props.textColor) {
for (let i = 0; i < 3; i++)
persistColor.value[i] += (targetColor.value[i] - persistColor.value[i]) * dt
for (let i = 0; i < 3; i++) persistColor.value[i] += (targetColor.value[i] - persistColor.value[i]) * dt;
}
const speed = dt * 5
mouse[0] += (target[0] - mouse[0]) * speed
mouse[1] += (target[1] - mouse[1]) * speed
const speed = dt * 5;
mouse[0] += (target[0] - mouse[0]) * speed;
mouse[1] += (target[1] - mouse[1]) * speed;
quadMat.uniforms.mousePos.value.set(mouse[0], mouse[1])
quadMat.uniforms.sampler.value = rt1.texture
quadMat.uniforms.time.value = clock.getElapsedTime()
labelMat.uniforms.color.value.set(...persistColor.value)
quadMat.uniforms.mousePos.value.set(mouse[0], mouse[1]);
quadMat.uniforms.sampler.value = rt1.texture;
quadMat.uniforms.time.value = clock.getElapsedTime();
labelMat.uniforms.color.value.set(...persistColor.value);
renderer.autoClearColor = false
renderer.setRenderTarget(rt0)
renderer.clearColor()
renderer.render(fluidScene, cam)
renderer.render(scene, cam)
renderer.setRenderTarget(null)
renderer.render(fluidScene, cam)
renderer.render(scene, cam)
;[rt0, rt1] = [rt1, rt0]
}
renderer.autoClearColor = false;
renderer.setRenderTarget(rt0);
renderer.clearColor();
renderer.render(fluidScene, cam);
renderer.render(scene, cam);
renderer.setRenderTarget(null);
renderer.render(fluidScene, cam);
renderer.render(scene, cam);
[rt0, rt1] = [rt1, rt0];
};
renderer.setAnimationLoop(animate)
}
renderer.setAnimationLoop(animate);
};
const cleanup = () => {
if (renderer) {
renderer.setAnimationLoop(null)
renderer.setAnimationLoop(null);
if (containerRef.value && renderer.domElement.parentNode === containerRef.value) {
containerRef.value.removeChild(renderer.domElement)
containerRef.value.removeChild(renderer.domElement);
}
renderer.dispose()
renderer = null
renderer.dispose();
renderer = null;
}
if (colorTimer) {
clearInterval(colorTimer)
colorTimer = null
clearInterval(colorTimer);
colorTimer = null;
}
if (containerRef.value) {
containerRef.value.removeEventListener('pointermove', onMove)
containerRef.value.removeEventListener('pointermove', onMove);
}
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = null
resizeObserver.disconnect();
resizeObserver = null;
}
if (rt0) {
rt0.dispose()
rt0 = null
rt0.dispose();
rt0 = null;
}
if (rt1) {
rt1.dispose()
rt1 = null
rt1.dispose();
rt1 = null;
}
if (quadMat) {
quadMat.dispose()
quadMat = null
quadMat.dispose();
quadMat = null;
}
if (quad) {
quad.geometry.dispose()
quad = null
quad.geometry.dispose();
quad = null;
}
if (labelMat) {
labelMat.dispose()
labelMat = null
labelMat.dispose();
labelMat = null;
}
if (label) {
label.geometry.dispose()
label = null
label.geometry.dispose();
label = null;
}
scene = null
fluidScene = null
clock = null
cam = null
}
scene = null;
fluidScene = null;
clock = null;
cam = null;
};
watch(
() => [
@@ -425,25 +422,25 @@ watch(
props.supersample
],
() => {
cleanup()
cleanup();
if (containerRef.value) {
persistColor.value = hexToRgb(props.textColor || props.startColor).map((c) => c / 255) as [number, number, number]
targetColor.value = [...persistColor.value]
initThreeJS()
persistColor.value = hexToRgb(props.textColor || props.startColor).map(c => c / 255) as [number, number, number];
targetColor.value = [...persistColor.value];
initThreeJS();
}
},
{ deep: true }
)
);
onMounted(() => {
if (containerRef.value) {
initThreeJS()
initThreeJS();
}
})
});
onUnmounted(() => {
cleanup()
})
cleanup();
});
</script>
<template>

View File

@@ -0,0 +1,170 @@
<script setup lang="ts">
import { motion } from 'motion-v';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
interface TrueFocusProps {
sentence?: string;
manualMode?: boolean;
blurAmount?: number;
borderColor?: string;
glowColor?: string;
animationDuration?: number;
pauseBetweenAnimations?: number;
}
const props = withDefaults(defineProps<TrueFocusProps>(), {
sentence: 'True Focus',
manualMode: false,
blurAmount: 5,
borderColor: 'green',
glowColor: 'rgba(0, 255, 0, 0.6)',
animationDuration: 0.5,
pauseBetweenAnimations: 1
});
const words = computed(() => props.sentence.split(' '));
const currentIndex = ref(0);
const lastActiveIndex = ref<number | null>(null);
const containerRef = ref<HTMLDivElement>();
const wordRefs = ref<HTMLSpanElement[]>([]);
const focusRect = ref({ x: 0, y: 0, width: 0, height: 0 });
let interval: number | null = null;
watch(
[() => props.manualMode, () => props.animationDuration, () => props.pauseBetweenAnimations, words],
() => {
if (interval) {
clearInterval(interval);
interval = null;
}
if (!props.manualMode) {
interval = setInterval(
() => {
currentIndex.value = (currentIndex.value + 1) % words.value.length;
},
(props.animationDuration + props.pauseBetweenAnimations) * 1000
);
}
},
{ immediate: true }
);
watch(
[currentIndex, words.value.length],
async () => {
if (currentIndex.value === null || currentIndex.value === -1) return;
if (!wordRefs.value[currentIndex.value] || !containerRef.value) return;
await nextTick();
const parentRect = containerRef.value.getBoundingClientRect();
const activeRect = wordRefs.value[currentIndex.value].getBoundingClientRect();
focusRect.value = {
x: activeRect.left - parentRect.left,
y: activeRect.top - parentRect.top,
width: activeRect.width,
height: activeRect.height
};
},
{ immediate: true }
);
const handleMouseEnter = (index: number) => {
if (props.manualMode) {
lastActiveIndex.value = index;
currentIndex.value = index;
}
};
const handleMouseLeave = () => {
if (props.manualMode) {
currentIndex.value = lastActiveIndex.value || 0;
}
};
const setWordRef = (el: HTMLSpanElement | null, index: number) => {
if (el) {
wordRefs.value[index] = el;
}
};
onMounted(async () => {
await nextTick();
if (wordRefs.value[0] && containerRef.value) {
const parentRect = containerRef.value.getBoundingClientRect();
const activeRect = wordRefs.value[0].getBoundingClientRect();
focusRect.value = {
x: activeRect.left - parentRect.left,
y: activeRect.top - parentRect.top,
width: activeRect.width,
height: activeRect.height
};
}
});
onUnmounted(() => {
if (interval) {
clearInterval(interval);
}
});
</script>
<template>
<div class="relative flex flex-wrap justify-center items-center gap-[1em]" ref="containerRef">
<span
v-for="(word, index) in words"
:key="index"
:ref="el => setWordRef(el as HTMLSpanElement, index)"
class="relative font-black text-5xl transition-[filter,color] duration-300 ease-in-out cursor-pointer"
:style="{
filter: index === currentIndex ? 'blur(0px)' : `blur(${blurAmount}px)`,
'--border-color': borderColor,
'--glow-color': glowColor,
transition: `filter ${animationDuration}s ease`
}"
@mouseenter="handleMouseEnter(index)"
@mouseleave="handleMouseLeave"
>
{{ word }}
</span>
<motion.div
class="top-0 left-0 box-content absolute border-none pointer-events-none"
:animate="{
x: focusRect.x,
y: focusRect.y,
width: focusRect.width,
height: focusRect.height,
opacity: currentIndex >= 0 ? 1 : 0
}"
:transition="{
duration: animationDuration
}"
:style="{
'--border-color': borderColor,
'--glow-color': glowColor
}"
>
<span
class="top-[-10px] left-[-10px] absolute [filter:drop-shadow(0_0_4px_var(--border-color,#fff))] border-[3px] border-[var(--border-color,#fff)] border-r-0 border-b-0 rounded-[3px] w-4 h-4 transition-none"
></span>
<span
class="top-[-10px] right-[-10px] absolute [filter:drop-shadow(0_0_4px_var(--border-color,#fff))] border-[3px] border-[var(--border-color,#fff)] border-b-0 border-l-0 rounded-[3px] w-4 h-4 transition-none"
></span>
<span
class="bottom-[-10px] left-[-10px] absolute [filter:drop-shadow(0_0_4px_var(--border-color,#fff))] border-[3px] border-[var(--border-color,#fff)] border-t-0 border-r-0 rounded-[3px] w-4 h-4 transition-none"
></span>
<span
class="right-[-10px] bottom-[-10px] absolute [filter:drop-shadow(0_0_4px_var(--border-color,#fff))] border-[3px] border-[var(--border-color,#fff)] border-t-0 border-l-0 rounded-[3px] w-4 h-4 transition-none"
></span>
</motion.div>
</div>
</template>