Component Boom

This commit is contained in:
David Haz
2025-07-10 15:36:38 +03:00
parent a4982577ad
commit 9b3465b04d
135 changed files with 16697 additions and 60 deletions

View File

@@ -0,0 +1,59 @@
<template>
<div class="preview-color">
<span class="color-label">{{ title }}</span>
<input :value="modelValue" @input="handleColorChange" type="color" :disabled="disabled" class="color-input" />
</div>
</template>
<script setup lang="ts">
defineProps<{
title: string
modelValue: string
disabled?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const handleColorChange = (event: Event) => {
const target = event.target as HTMLInputElement
emit('update:modelValue', target.value)
}
</script>
<style scoped>
.preview-color {
display: flex;
align-items: center;
gap: 0.5rem;
}
.color-label {
font-size: 0.875rem;
white-space: nowrap;
}
.color-input {
width: 50px;
height: 32px;
border: 1px solid #333;
border-radius: 6px;
background: transparent;
cursor: pointer;
}
.color-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.color-input::-webkit-color-swatch-wrapper {
padding: 0;
}
.color-input::-webkit-color-swatch {
border: none;
border-radius: 4px;
}
</style>

View File

@@ -67,7 +67,6 @@ const selectAttributes = computed(() => {
font-size: 14px;
color: #fff;
white-space: nowrap;
min-width: 120px;
}
.custom-select {

View File

@@ -2,7 +2,7 @@ import { ref, onMounted } from 'vue'
import { getStarsCount } from '@/utils/utils'
const CACHE_KEY = 'github_stars_cache'
const CACHE_DURATION = 24 * 60 * 60 * 1000 // 24 hours
const CACHE_DURATION = 24 * 60 * 60 * 1000
export function useStars() {
const stars = ref<number>(0)

View File

@@ -8,24 +8,65 @@ export const CATEGORIES = [
name: 'Text Animations',
subcategories: [
'Split Text',
'Blur Text',
'Circular Text',
'Shiny Text',
'Text Pressure',
'Curved Loop',
'Fuzzy Text',
'Gradient Text',
'Text Trail',
'Falling Text',
'Text Cursor',
'Decrypted Text',
]
},
{
name: 'Animations',
subcategories: [
'Animated Content',
'Fade Content',
'Pixel Transition',
'Glare Hover',
'Magnet Lines',
'Count Up',
'Click Spark',
'Magnet',
'Cubes',
]
},
{
name: 'Components',
subcategories: [
'Masonry',
'Profile Card',
'Dock',
'Gooey Nav',
'Pixel Card',
'Carousel',
'Spotlight Card',
'Flying Posters',
'Card Swap',
'Infinite Scroll',
'Glass Icons',
'Decay Card',
'Flowing Menu',
'Elastic Slider',
],
},
{
name: 'Backgrounds',
subcategories: [
'Aurora',
'Dot Grid',
'Silk',
'Lightning',
'Letter Glitch',
'Particles',
'Waves',
'Squares',
'Iridescence',
'Threads',
],
}
];

View File

@@ -1,17 +1,58 @@
const animations = {
'fade-content': () => import("../demo/Animations/FadeContentDemo.vue"),
'animated-content': () => import("../demo/Animations/AnimatedContentDemo.vue"),
'pixel-transition': () => import("../demo/Animations/PixelTransitionDemo.vue"),
'glare-hover': () => import("../demo/Animations/GlareHoverDemo.vue"),
'magnet-lines': () => import("../demo/Animations/MagnetLinesDemo.vue"),
'click-spark': () => import("../demo/Animations/ClickSparkDemo.vue"),
'magnet': () => import("../demo/Animations/MagnetDemo.vue"),
'cubes': () => import("../demo/Animations/CubesDemo.vue"),
'count-up': () => import("../demo/Animations/CountUpDemo.vue"),
};
const textAnimations = {
'split-text': () => import("../demo/TextAnimations/SplitTextDemo.vue"),
'blur-text': () => import("../demo/TextAnimations/BlurTextDemo.vue"),
'circular-text': () => import("../demo/TextAnimations/CircularTextDemo.vue"),
'shiny-text': () => import("../demo/TextAnimations/ShinyTextDemo.vue"),
'text-pressure': () => import("../demo/TextAnimations/TextPressureDemo.vue"),
'curved-loop': () => import("../demo/TextAnimations/CurvedLoopDemo.vue"),
'fuzzy-text': () => import("../demo/TextAnimations/FuzzyTextDemo.vue"),
'gradient-text': () => import("../demo/TextAnimations/GradientTextDemo.vue"),
'text-trail': () => import("../demo/TextAnimations/TextTrailDemo.vue"),
'falling-text': () => import("../demo/TextAnimations/FallingTextDemo.vue"),
'text-cursor': () => import("../demo/TextAnimations/TextCursorDemo.vue"),
'decrypted-text': () => import("../demo/TextAnimations/DecryptedTextDemo.vue"),
};
const components = {
'masonry': () => import("../demo/Components/MasonryDemo.vue"),
'profile-card': () => import("../demo/Components/ProfileCardDemo.vue"),
'dock': () => import("../demo/Components/DockDemo.vue"),
'gooey-nav': () => import("../demo/Components/GooeyNavDemo.vue"),
'pixel-card': () => import("../demo/Components/PixelCardDemo.vue"),
'carousel': () => import("../demo/Components/CarouselDemo.vue"),
'spotlight-card': () => import("../demo/Components/SpotlightCardDemo.vue"),
'flying-posters': () => import("../demo/Components/FlyingPostersDemo.vue"),
'card-swap': () => import("../demo/Components/CardSwapDemo.vue"),
'infinite-scroll': () => import("../demo/Components/InfiniteScrollDemo.vue"),
'glass-icons': () => import("../demo/Components/GlassIconsDemo.vue"),
'decay-card': () => import("../demo/Components/DecayCardDemo.vue"),
'flowing-menu': () => import("../demo/Components/FlowingMenuDemo.vue"),
'elastic-slider': () => import("../demo/Components/ElasticSliderDemo.vue"),
};
const backgrounds = {
'dot-grid': () => import("../demo/Backgrounds/DotGridDemo.vue"),
'silk': () => import("../demo/Backgrounds/SilkDemo.vue"),
'lightning': () => import("../demo/Backgrounds/LightningDemo.vue"),
'letter-glitch': () => import("../demo/Backgrounds/LetterGlitchDemo.vue"),
'particles': () => import("../demo/Backgrounds/ParticlesDemo.vue"),
'waves': () => import("../demo/Backgrounds/WavesDemo.vue"),
'squares': () => import("../demo/Backgrounds/SquaresDemo.vue"),
'iridescence': () => import("../demo/Backgrounds/IridescenceDemo.vue"),
'threads': () => import("../demo/Backgrounds/ThreadsDemo.vue"),
'aurora': () => import("../demo/Backgrounds/AuroraDemo.vue"),
};
export const componentMap = {

View File

@@ -0,0 +1,35 @@
import code from '@/content/Animations/AnimatedContent/AnimatedContent.vue?raw'
import type { CodeObject } from '@/types/code'
export const animatedContent: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/Animations/AnimatedContent`,
installation: `npm install gsap`,
usage: `<template>
<AnimatedContent
:distance="100"
direction="vertical"
:reverse="false"
:duration="0.8"
ease="power3.out"
:initial-opacity="0"
:animate-opacity="true"
:scale="1"
:threshold="0.1"
:delay="0"
@complete="handleComplete"
>
<div class="your-content">
Content to animate
</div>
</AnimatedContent>
</template>
<script setup lang="ts">
import AnimatedContent from "./AnimatedContent.vue";
const handleComplete = () => {
console.log("Animation completed!");
};
</script>`,
code
}

View File

@@ -0,0 +1,47 @@
import code from '@content/Animations/ClickSpark/ClickSpark.vue?raw'
import type { CodeObject } from '../../../types/code'
export const clickSpark: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/Animations/ClickSpark`,
usage: `<template>
<ClickSpark
spark-color="#ff6b6b"
:spark-size="12"
:spark-radius="25"
:spark-count="12"
:duration="600"
easing="ease-out"
:extra-scale="1.2"
class="interactive-area"
>
<div class="content">
<h3>Click me!</h3>
<p>Click anywhere to create sparks</p>
</div>
</ClickSpark>
</template>
<script setup lang="ts">
import ClickSpark from '@/content/Animations/ClickSpark/ClickSpark.vue'
</script>
<style scoped>
.interactive-area {
width: 100%;
height: 200px;
border: 2px dashed #333;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.content {
text-align: center;
pointer-events: none;
user-select: none;
}
</style>`,
code
}

View File

@@ -0,0 +1,33 @@
import code from '@/content/Animations/CountUp/CountUp.vue?raw'
import type { CodeObject } from '@/types/code'
export const countup: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/Animations/CountUp`,
usage: `<template>
<CountUp
:from="0"
:to="100"
separator=","
direction="up"
:duration="1"
:delay="0"
:start-when="true"
class-name="count-up-text"
@start="handleStart"
@end="handleEnd"
/>
</template>
<script setup lang="ts">
import CountUp from "./CountUp.vue";
const handleStart = () => {
console.log("Count animation started!");
};
const handleEnd = () => {
console.log("Count animation ended!");
};
</script>`,
code
}

View File

@@ -0,0 +1,32 @@
import code from '@content/Animations/Cubes/Cubes.vue?raw'
import type { CodeObject } from '../../../types/code'
export const cubes: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/Animations/Cubes`,
installation: `npm install gsap`,
usage: `// CREDIT
// Component inspired from Can Tastemel's original work for the lambda.ai landing page
// https://cantastemel.com
<template>
<Cubes
:grid-size="10"
:max-angle="45"
:radius="3"
easing="power3.out"
:duration="{ enter: 0.3, leave: 0.6 }"
border-style="1px solid #fff"
face-color="#060010"
:shadow="false"
:auto-animate="true"
:ripple-on-click="true"
ripple-color="#fff"
:ripple-speed="2"
/>
</template>
<script setup lang="ts">
import Cubes from "./Cubes.vue";
</script>`,
code
}

View File

@@ -0,0 +1,29 @@
import code from '@/content/Animations/GlareHover/GlareHover.vue?raw'
import type { CodeObject } from '@/types/code'
export const glareHover: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/Animations/GlareHover`,
usage: `<template>
<GlareHover
width="400px"
height="300px"
background="#060010"
border-color="#271E37"
border-radius="20px"
glare-color="#ffffff"
:glare-opacity="0.3"
:glare-size="300"
:transition-duration="800"
:play-once="false"
>
<div class="text-center text-5xl font-black text-white">
Hover Me
</div>
</GlareHover>
</template>
<script setup lang="ts">
import GlareHover from "./GlareHover.vue";
</script>`,
code
}

View File

@@ -0,0 +1,48 @@
import code from '@content/Animations/Magnet/Magnet.vue?raw'
import type { CodeObject } from '../../../types/code'
export const magnet: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/Animations/Magnet`,
usage: `<template>
<Magnet
:padding="120"
:disabled="false"
:magnet-strength="3"
active-transition="transform 0.2s ease-out"
inactive-transition="transform 0.6s ease-in-out"
wrapper-class-name="custom-wrapper"
inner-class-name="custom-inner"
>
<div class="magnet-element">
<h3>Hover me!</h3>
<p>I'll follow your cursor</p>
</div>
</Magnet>
</template>
<script setup lang="ts">
import Magnet from '@/content/Animations/Magnet/Magnet.vue'
</script>
<style scoped>
.magnet-element {
padding: 2rem;
background: #060010;
border: 1px solid #333;
border-radius: 12px;
color: white;
text-align: center;
}
.magnet-element h3 {
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
}
.magnet-element p {
margin: 0;
opacity: 0.8;
}
</style>`,
code
}

View File

@@ -0,0 +1,22 @@
import code from '@/content/Animations/MagnetLines/MagnetLines.vue?raw'
import type { CodeObject } from '@/types/code'
export const magnetLines: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/Animations/MagnetLines`,
usage: `<template>
<MagnetLines
:rows="10"
:columns="12"
container-size="40vmin"
line-color="#efefef"
line-width="2px"
line-height="30px"
:base-angle="-10"
/>
</template>
<script setup lang="ts">
import MagnetLines from "./MagnetLines.vue";
</script>`,
code
}

View File

@@ -0,0 +1,29 @@
import code from '@/content/Animations/PixelTransition/PixelTransition.vue?raw'
import type { CodeObject } from '@/types/code'
export const pixelTransition: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/Animations/PixelTransition`,
installation: `npm install gsap`,
usage: `<template>
<PixelTransition
:grid-size="8"
pixel-color="#ffffff"
:animation-step-duration="0.4"
class-name="custom-pixel-card"
>
<template #firstContent>
<img src="..." alt="Default" style="width: 100%; height: 100%; object-fit: cover;" />
</template>
<template #secondContent>
<div style="width: 100%; height: 100%; display: grid; place-items: center; background-color: #111;">
<p style="font-weight: 900; font-size: 3rem; color: #fff;">Meow!</p>
</div>
</template>
</PixelTransition>
</template>
<script setup lang=\"ts\">
import PixelTransition from './PixelTransition.vue';
</script>`,
code
}

View File

@@ -0,0 +1,33 @@
import code from '@content/Backgrounds/Aurora/Aurora.vue?raw'
import type { CodeObject } from '../../../types/code'
export const aurora: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/Backgrounds/Aurora`,
installation: `npm install ogl`,
usage: `<template>
<div class="aurora-container">
<Aurora
:color-stops="['#7cff67', '#171D22', '#7cff67']"
:amplitude="1.0"
:blend="0.5"
:speed="1.0"
:intensity="1.0"
class="w-full h-full"
/>
</div>
</template>
<script setup lang="ts">
import Aurora from "./Aurora.vue";
</script>
<style scoped>
.aurora-container {
width: 100%;
height: 500px;
position: relative;
overflow: hidden;
}
</style>`,
code
}

View File

@@ -0,0 +1,22 @@
import code from '@content/Backgrounds/Iridescence/Iridescence.vue?raw'
import type { CodeObject } from '../../../types/code'
export const iridescence: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/Backgrounds/Iridescence`,
installation: `npm install ogl`,
usage: `<template>
<div class="w-full h-[400px]">
<Iridescence
:color="[1, 1, 1]"
:speed="1.0"
:amplitude="0.1"
:mouseReact="true"
/>
</div>
</template>
<script setup lang="ts">
import Iridescence from "./Iridescence.vue";
</script>`,
code
}

View File

@@ -0,0 +1,32 @@
import code from '@content/Backgrounds/LetterGlitch/LetterGlitch.vue?raw'
import type { CodeObject } from '../../../types/code'
export const letterGlitch: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/Backgrounds/LetterGlitch`,
usage: `<template>
<div class="letter-glitch-container">
<LetterGlitch
:glitch-colors="['#2b4539', '#61dca3', '#61b3dc']"
:glitch-speed="50"
:center-vignette="false"
:outer-vignette="false"
:smooth="true"
class="w-full h-full"
/>
</div>
</template>
<script setup lang="ts">
import LetterGlitch from "./LetterGlitch.vue";
</script>
<style scoped>
.letter-glitch-container {
width: 100%;
height: 500px;
position: relative;
overflow: hidden;
}
</style>`,
code
}

View File

@@ -0,0 +1,33 @@
import code from '@content/Backgrounds/Lightning/Lightning.vue?raw'
import type { CodeObject } from '../../../types/code'
export const lightning: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/Backgrounds/Lightning`,
installation: `No additional dependencies required`,
usage: `<template>
<div class="lightning-container">
<Lightning
:hue="230"
:x-offset="0"
:speed="1"
:intensity="1"
:size="1"
class="w-full h-full"
/>
</div>
</template>
<script setup lang="ts">
import Lightning from "./Lightning.vue";
</script>
<style scoped>
.lightning-container {
width: 100%;
height: 500px;
position: relative;
overflow: hidden;
}
</style>`,
code
}

View File

@@ -0,0 +1,39 @@
import code from '@content/Backgrounds/Particles/Particles.vue?raw'
import type { CodeObject } from '../../../types/code'
export const particles: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/Backgrounds/Particles`,
installation: `npm install ogl`,
usage: `<template>
<div class="particles-container">
<Particles
:particle-count="200"
:particle-spread="10"
:speed="0.1"
:particle-colors="['#ffffff']"
:move-particles-on-hover="false"
:particle-hover-factor="1"
:alpha-particles="false"
:particle-base-size="100"
:size-randomness="1"
:camera-distance="20"
:disable-rotation="false"
class="w-full h-full"
/>
</div>
</template>
<script setup lang="ts">
import Particles from "./Particles.vue";
</script>
<style scoped>
.particles-container {
width: 100%;
height: 500px;
position: relative;
overflow: hidden;
}
</style>`,
code
}

View File

@@ -0,0 +1,33 @@
import code from '@content/Backgrounds/Silk/Silk.vue?raw'
import type { CodeObject } from '../../../types/code'
export const silk: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/Backgrounds/Silk`,
installation: `npm install ogl`,
usage: `<template>
<div class="silk-container">
<Silk
:speed="5"
:scale="1"
:color="'#7B7481'"
:noise-intensity="1.5"
:rotation="0"
class="w-full h-full"
/>
</div>
</template>
<script setup lang="ts">
import Silk from "./Silk.vue";
</script>
<style scoped>
.silk-container {
width: 100%;
height: 500px;
position: relative;
overflow: hidden;
}
</style>`,
code
}

View File

@@ -0,0 +1,22 @@
import code from '@content/Backgrounds/Squares/Squares.vue?raw'
import type { CodeObject } from '../../../types/code'
export const squares: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/Backgrounds/Squares`,
usage: `<template>
<div class="w-full h-[400px]">
<Squares
direction="diagonal"
:speed="1"
:squareSize="40"
borderColor="#999"
hoverFillColor="#222"
/>
</div>
</template>
<script setup lang="ts">
import Squares from "./Squares.vue";
</script>`,
code
}

View File

@@ -0,0 +1,22 @@
import code from '@content/Backgrounds/Threads/Threads.vue?raw'
import type { CodeObject } from '../../../types/code'
export const threads: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/Backgrounds/Threads`,
installation: `npm install ogl`,
usage: `<template>
<div class="w-full h-[400px]">
<Threads
:color="[1, 1, 1]"
:amplitude="1"
:distance="0"
:enableMouseInteraction="false"
/>
</div>
</template>
<script setup lang="ts">
import Threads from "./Threads.vue";
</script>`,
code
}

View File

@@ -0,0 +1,38 @@
import code from '@content/Backgrounds/Waves/Waves.vue?raw'
import type { CodeObject } from '../../../types/code'
export const waves: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/Backgrounds/Waves`,
usage: `<template>
<div class="waves-container">
<Waves
line-color="black"
background-color="transparent"
:wave-speed-x="0.0125"
:wave-speed-y="0.005"
:wave-amp-x="32"
:wave-amp-y="16"
:x-gap="10"
:y-gap="32"
:friction="0.925"
:tension="0.005"
:max-cursor-move="100"
class="w-full h-full"
/>
</div>
</template>
<script setup lang="ts">
import Waves from "./Waves.vue";
</script>
<style scoped>
.waves-container {
width: 100%;
height: 500px;
position: relative;
overflow: hidden;
}
</style>`,
code
}

View File

@@ -0,0 +1,54 @@
import code from '@content/Components/CardSwap/CardSwap.vue?raw'
import type { CodeObject } from '../../../types/code'
export const cardSwap: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/Components/CardSwap`,
installation: `npm install gsap`,
usage: `<template>
<CardSwap
:card-distance="60"
:vertical-distance="70"
:delay="5000"
:skew-amount="6"
easing="elastic"
:pause-on-hover="false"
@card-click="handleCardClick"
>
<template #card-0>
<div class="border-b border-white bg-gradient-to-t from-[#222] to-[#0b0b0b]">
<div class="m-2 flex items-center">
<i class="pi pi-circle-fill mr-2"></i>
<span>Smooth</span>
</div>
</div>
</template>
<template #card-1>
<div class="border-b border-white bg-gradient-to-t from-[#222] to-[#0b0b0b]">
<div class="m-2 flex items-center">
<i class="pi pi-code mr-2"></i>
<span>Reliable</span>
</div>
</div>
</template>
<template #card-2>
<div class="border-b border-white bg-gradient-to-t from-[#222] to-[#0b0b0b]">
<div class="m-2 flex items-center">
<i class="pi pi-sliders-h mr-2"></i>
<span>Customizable</span>
</div>
</div>
</template>
</CardSwap>
</template>
<script setup lang="ts">
import CardSwap from "./CardSwap.vue";
const handleCardClick = (index: number) => {
console.log(\`Card \${index} clicked\`);
};
</script>`,
code
}

View File

@@ -0,0 +1,40 @@
import code from '@content/Components/Carousel/Carousel.vue?raw'
import type { CodeObject } from '../../../types/code'
export const carousel: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/Components/Carousel`,
installation: `npm install motion-v`,
usage: `<template>
<Carousel
:items="carouselItems"
:base-width="300"
:autoplay="true"
:autoplay-delay="3000"
:pause-on-hover="true"
:loop="true"
:round="false"
/>
</template>
<script setup lang="ts">
import Carousel from "./Carousel.vue";
import type { CarouselItem } from "./Carousel.vue";
const carouselItems: CarouselItem[] = [
{
title: "Custom Item",
description: "A custom carousel item.",
id: 1,
icon: "circle",
},
{
title: "Another Item",
description: "Another carousel item.",
id: 2,
icon: "layers",
},
// Add more items as needed
];
</script>`,
code
}

View File

@@ -0,0 +1,23 @@
import code from '@content/Components/DecayCard/DecayCard.vue?raw'
import type { CodeObject } from '../../../types/code'
export const decayCard: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/Components/DecayCard`,
installation: `npm install gsap`,
usage: `<template>
<DecayCard
:width="300"
:height="400"
image="https://picsum.photos/300/400?grayscale"
>
<div style="mix-blend-mode: overlay;">
Decay<br />Card
</div>
</DecayCard>
</template>
<script setup lang="ts">
import DecayCard from "./DecayCard.vue";
</script>`,
code
}

View File

@@ -0,0 +1,47 @@
import code from '@content/Components/Dock/Dock.vue?raw'
import type { CodeObject } from '../../../types/code'
export const dock: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/Components/Dock`,
installation: `npm install motion-v`,
usage: `<template>
<Dock
:items="items"
:panel-height="68"
:base-item-size="50"
:magnification="70"
:distance="200"
:dock-height="256"
:spring="{ mass: 0.1, stiffness: 150, damping: 12 }"
/>
</template>
<script setup lang="ts">
import { h } from 'vue'
import Dock from "./Dock.vue";
const items = [
{
icon: () => h('i', { class: 'pi pi-home', style: { fontSize: '18px', color: 'white' } }),
label: 'Home',
onClick: () => console.log('Home clicked!')
},
{
icon: () => h('i', { class: 'pi pi-inbox', style: { fontSize: '18px', color: 'white' } }),
label: 'Archive',
onClick: () => console.log('Archive clicked!')
},
{
icon: () => h('i', { class: 'pi pi-user', style: { fontSize: '18px', color: 'white' } }),
label: 'Profile',
onClick: () => console.log('Profile clicked!')
},
{
icon: () => h('i', { class: 'pi pi-cog', style: { fontSize: '18px', color: 'white' } }),
label: 'Settings',
onClick: () => console.log('Settings clicked!')
},
];
</script>`,
code
}

View File

@@ -0,0 +1,30 @@
import code from '@content/Components/ElasticSlider/ElasticSlider.vue?raw'
import type { CodeObject } from '../../../types/code'
export const elasticSlider: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/Components/ElasticSlider`,
usage: `<template>
<ElasticSlider
:default-value="75"
:starting-value="0"
:max-value="100"
:is-stepped="true"
:step-size="5"
/>
<!-- With custom icons using slots -->
<ElasticSlider>
<template #left-icon>
<i class="pi pi-minus text-xl"></i>
</template>
<template #right-icon>
<i class="pi pi-plus text-xl"></i>
</template>
</ElasticSlider>
</template>
<script setup lang="ts">
import ElasticSlider from "./ElasticSlider.vue";
</script>`,
code
}

View File

@@ -0,0 +1,22 @@
import code from '@content/Components/FlowingMenu/FlowingMenu.vue?raw'
import type { CodeObject } from '../../../types/code'
export const flowingMenu: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/Components/FlowingMenu`,
installation: `npm install gsap`,
usage: `<template>
<FlowingMenu :items="items" />
</template>
<script setup lang="ts">
import FlowingMenu from "./FlowingMenu.vue";
const items = [
{ link: '#', text: 'Mojave', image: 'https://picsum.photos/600/400?random=1' },
{ link: '#', text: 'Sonoma', image: 'https://picsum.photos/600/400?random=2' },
{ link: '#', text: 'Monterey', image: 'https://picsum.photos/600/400?random=3' },
{ link: '#', text: 'Sequoia', image: 'https://picsum.photos/600/400?random=4' }
];
</script>`,
code
}

View File

@@ -0,0 +1,37 @@
import code from '@content/Components/FlyingPosters/FlyingPosters.vue?raw'
import type { CodeObject } from '../../../types/code'
export const flyingPosters: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/Components/FlyingPosters`,
installation: `npm install ogl`,
usage: `<template>
<FlyingPosters
:items="posterImages"
:plane-width="320"
:plane-height="320"
:distortion="3"
:scroll-ease="0.01"
:camera-fov="45"
:camera-z="20"
class="h-full w-full"
/>
</template>
<script setup lang="ts">
import FlyingPosters from "./FlyingPosters.vue";
const posterImages = [
'https://picsum.photos/800/800?grayscale&random=1',
'https://picsum.photos/800/800?grayscale&random=2',
'https://picsum.photos/800/800?grayscale&random=3',
'https://picsum.photos/800/800?grayscale&random=4',
'https://picsum.photos/800/800?grayscale&random=5',
'https://picsum.photos/800/800?grayscale&random=6',
'https://picsum.photos/800/800?grayscale&random=7',
'https://picsum.photos/800/800?grayscale&random=8',
'https://picsum.photos/800/800?grayscale&random=9',
'https://picsum.photos/800/800?grayscale&random=10',
];
</script>`,
code
}

View File

@@ -0,0 +1,23 @@
import code from '@content/Components/GlassIcons/GlassIcons.vue?raw'
import type { CodeObject } from '../../../types/code'
export const glassIcons: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/Components/GlassIcons`,
usage: `<template>
<GlassIcons :items="items" class="my-glass-icons" />
</template>
<script setup lang="ts">
import GlassIcons from "./GlassIcons.vue";
const items = [
{ icon: 'pi pi-file', color: 'blue', label: 'Files' },
{ icon: 'pi pi-book', color: 'purple', label: 'Books' },
{ icon: 'pi pi-heart', color: 'red', label: 'Health' },
{ icon: 'pi pi-cloud', color: 'indigo', label: 'Weather' },
{ icon: 'pi pi-pencil', color: 'orange', label: 'Notes' },
{ icon: 'pi pi-chart-bar', color: 'green', label: 'Stats' }
];
</script>`,
code
}

View File

@@ -0,0 +1,40 @@
import code from '@content/Components/GooeyNav/GooeyNav.vue?raw'
import type { CodeObject } from '../../../types/code'
export const gooeyNav: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/Components/GooeyNav`,
usage: `<template>
<GooeyNav
:items="navItems"
:animation-time="600"
:particle-count="15"
:particle-distances="[90, 10]"
:particle-r="100"
:time-variance="300"
:colors="[1, 2, 3, 1, 2, 3, 1, 4]"
:initial-active-index="0"
/>
</template>
<script setup lang="ts">
import GooeyNav from "./GooeyNav.vue";
const navItems = [
{ label: "Home", href: "/" },
{ label: "About", href: "/about" },
{ label: "Services", href: "/services" },
{ label: "Contact", href: "/contact" },
];
</script>
<style>
/* Add these CSS custom properties to your global styles */
:root {
--color-1: #ff6b6b;
--color-2: #4ecdc4;
--color-3: #45b7d1;
--color-4: #f9ca24;
}
</style>`,
code
}

View File

@@ -0,0 +1,34 @@
import code from '@content/Components/InfiniteScroll/InfiniteScroll.vue?raw'
import type { CodeObject } from '../../../types/code'
export const infiniteScroll: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/Components/InfiniteScroll`,
installation: `npm install gsap`,
usage: `<template>
<InfiniteScroll
:items="items"
width="30rem"
max-height="100%"
:item-min-height="150"
:is-tilted="false"
tilt-direction="left"
:autoplay="false"
:autoplay-speed="0.5"
autoplay-direction="down"
:pause-on-hover="false"
negative-margin="-0.5em"
/>
</template>
<script setup lang="ts">
import InfiniteScroll from "./InfiniteScroll.vue";
const items = [
{ content: "Item 1" },
{ content: "Item 2" },
{ content: "Item 3" },
// Add more items as needed
];
</script>`,
code
}

View File

@@ -0,0 +1,21 @@
import code from '@content/Components/PixelCard/PixelCard.vue?raw'
import type { CodeObject } from '../../../types/code'
export const pixelCard: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/Components/PixelCard`,
usage: `<template>
<PixelCard
variant="default"
:gap="5"
:speed="35"
colors="#f8fafc,#f1f5f9,#cbd5e1"
:no-focus="false"
class-name="custom-class"
></PixelCard>
</template>
<script setup lang="ts">
import PixelCard from "./PixelCard.vue";
</script>`,
code
}

View File

@@ -0,0 +1,31 @@
import code from '@content/Components/ProfileCard/ProfileCard.vue?raw'
import type { CodeObject } from '../../../types/code'
export const profileCard: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/Components/ProfileCard`,
usage: `<template>
<ProfileCard
name="Javi A. Torres"
title="Software Engineer"
handle="javicodes"
status="Online"
contact-text="Contact Me"
avatar-url="/assets/person.png"
icon-url="/assets/iconpattern.png"
grain-url="/assets/grain.webp"
:show-user-info="true"
:show-behind-gradient="true"
:enable-tilt="true"
@contact-click="handleContactClick"
/>
</template>
<script setup lang="ts">
import ProfileCard from "./ProfileCard.vue";
const handleContactClick = () => {
console.log('Contact button clicked!');
};
</script>`,
code
}

View File

@@ -0,0 +1,19 @@
import code from '@content/Components/SpotlightCard/SpotlightCard.vue?raw'
import type { CodeObject } from '../../../types/code'
export const spotlightCard: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/Components/SpotlightCard`,
usage: `<template>
<SpotlightCard
class-name="custom-spotlight-card"
spotlight-color="rgba(255, 255, 255, 0.25)"
>
<-!-- Content inside the card -->
</SpotlightCard>
</template>
<script setup lang="ts">
import SpotlightCard from "./SpotlightCard.vue";
</script>`,
code
}

View File

@@ -0,0 +1,29 @@
import code from '@content/TextAnimations/BlurText/BlurText.vue?raw'
import type { CodeObject } from '../../../types/code'
export const blurText: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/TextAnimations/BlurText`,
installation: `npm install motion-v`,
usage: `<template>
<BlurText
text="Isn't this so cool?!"
:delay="200"
class-name="text-2xl font-semibold text-center"
animate-by="words"
direction="top"
:threshold="0.1"
root-margin="0px"
:step-duration="0.35"
@animation-complete="handleAnimationComplete"
/>
</template>
<script setup lang="ts">
import BlurText from "./BlurText.vue";
const handleAnimationComplete = () => {
console.log('All animations complete!');
};
</script>`,
code
}

View File

@@ -0,0 +1,20 @@
import code from '@content/TextAnimations/CircularText/CircularText.vue?raw'
import type { CodeObject } from '../../../types/code'
export const circularText: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/TextAnimations/CircularText`,
installation: `npm install motion-v`,
usage: `<template>
<CircularText
text="VUE * BITS * IS AWESOME * "
:spin-duration="20"
on-hover="speedUp"
class-name="text-blue-500"
/>
</template>
<script setup lang="ts">
import CircularText from "./CircularText.vue";
</script>`,
code
}

View File

@@ -0,0 +1,20 @@
import code from '@content/TextAnimations/CurvedLoop/CurvedLoop.vue?raw'
import type { CodeObject } from '../../../types/code'
export const curvedLoop: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/TextAnimations/CurvedLoop`,
usage: `<template>
<CurvedLoop
marquee-text="Be ✦ Creative ✦ With ✦ Vue ✦ Bits ✦"
:speed="2"
:curve-amount="400"
direction="left"
:interactive="true"
/>
</template>
<script setup lang="ts">
import CurvedLoop from "./CurvedLoop.vue";
</script>`,
code
}

View File

@@ -0,0 +1,24 @@
import code from '@/content/TextAnimations/DecryptedText/DecryptedText.vue?raw'
import type { CodeObject } from '@/types/code'
export const decryptedText: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/TextAnimations/DecryptedText`,
usage: `<template>
<DecryptedText
text="Hello World!"
:speed="50"
:max-iterations="10"
:sequential="false"
reveal-direction="start"
:use-original-chars-only="false"
animate-on="hover"
class-name="text-green-500"
encrypted-class-name="text-red-500"
/>
</template>
<script setup lang="ts">
import DecryptedText from "./DecryptedText.vue";
</script>`,
code
}

View File

@@ -0,0 +1,22 @@
import code from '@/content/TextAnimations/FallingText/FallingText.vue?raw'
import type { CodeObject } from '@/types/code'
export const fallingText: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/TextAnimations/FallingText`,
installation: `npm install matter-js @types/matter-js`,
usage: `<template>
<FallingText
text="Vue Bits is awesome!"
:highlight-words="['Vue', 'Bits']"
trigger="hover"
:gravity="1"
font-size="2rem"
:mouse-constraint-stiffness="0.2"
/>
</template>
<script setup lang="ts">
import FallingText from "./FallingText.vue";
</script>`,
code
}

View File

@@ -0,0 +1,22 @@
import code from '@content/TextAnimations/FuzzyText/FuzzyText.vue?raw'
import type { CodeObject } from '../../../types/code'
export const fuzzyText: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/TextAnimations/FuzzyText`,
usage: `<template>
<FuzzyText
text="404"
:font-size="140"
font-weight="900"
color="#fff"
:enable-hover="true"
:base-intensity="0.18"
:hover-intensity="0.5"
/>
</template>
<script setup lang="ts">
import FuzzyText from "./FuzzyText.vue";
</script>`,
code
}

View File

@@ -0,0 +1,20 @@
import code from '@content/TextAnimations/GradientText/GradientText.vue?raw'
import type { CodeObject } from '../../../types/code'
export const gradientText: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/TextAnimations/GradientText`,
usage: `<template>
<GradientText
text="Add a splash of color!"
:colors="['#ffaa40', '#9c40ff', '#ffaa40']"
:animation-speed="8"
:show-border="false"
class-name="your-custom-class"
/>
</template>
<script setup lang="ts">
import GradientText from "./GradientText.vue";
</script>`,
code
}

View File

@@ -0,0 +1,19 @@
import code from '@content/TextAnimations/ShinyText/ShinyText.vue?raw'
import type { CodeObject } from '../../../types/code'
export const shinyText: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/TextAnimations/ShinyText`,
usage: `<template>
<ShinyText
text="Just some shiny text!"
:disabled="false"
:speed="3"
class-name="your-custom-class"
/>
</template>
<script setup lang="ts">
import ShinyText from "./ShinyText.vue";
</script>`,
code
}

View File

@@ -0,0 +1,24 @@
import code from '@/content/TextAnimations/TextCursor/TextCursor.vue?raw'
import type { CodeObject } from '@/types/code'
export const textCursor: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/TextAnimations/TextCursor`,
installation: `npm install motion-v`,
usage: `<template>
<TextCursor
text="⚛️"
:delay="0.01"
:spacing="100"
:follow-mouse-direction="true"
:random-float="true"
:exit-duration="0.5"
:removal-interval="30"
:max-points="5"
/>
</template>
<script setup lang="ts">
import TextCursor from "./TextCursor.vue";
</script>`,
code
}

View File

@@ -0,0 +1,25 @@
import code from '@content/TextAnimations/TextPressure/TextPressure.vue?raw'
import type { CodeObject } from '../../../types/code'
export const textPressure: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/TextAnimations/TextPressure`,
usage: `<template>
<TextPressure
text="Hello!"
:flex="true"
:alpha="false"
:stroke="false"
:width="true"
:weight="true"
:italic="true"
text-color="#ffffff"
stroke-color="#27FF64"
:min-font-size="36"
/>
</template>
<script setup lang="ts">
import TextPressure from "./TextPressure.vue";
</script>`,
code
}

View File

@@ -0,0 +1,23 @@
import code from '@/content/TextAnimations/TextTrail/TextTrail.vue?raw'
import type { CodeObject } from '@/types/code'
export const textTrail: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/TextAnimations/TextTrail`,
installation: `npm install three @types/three`,
usage: `<template>
<TextTrail
text="Vibe"
:noise-factor="1"
:noise-scale="0.0005"
font-weight="900"
:alpha-persist-factor="0.95"
:animate-color="false"
text-color="#ffffff"
/>
</template>
<script setup lang="ts">
import TextTrail from "./TextTrail.vue";
</script>`,
code
}

View File

@@ -0,0 +1,142 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { gsap } from 'gsap'
import { ScrollTrigger } from 'gsap/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
}
const props = withDefaults(defineProps<AnimatedContentProps>(), {
distance: 100,
direction: 'vertical',
reverse: false,
duration: 0.8,
ease: 'power3.out',
initialOpacity: 0,
animateOpacity: true,
scale: 1,
threshold: 0.1,
delay: 0,
className: ''
})
const emit = defineEmits<{
complete: []
}>()
const containerRef = ref<HTMLDivElement>()
onMounted(() => {
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
gsap.set(el, {
[axis]: offset,
scale: props.scale,
opacity: props.animateOpacity ? props.initialOpacity : 1,
})
gsap.to(el, {
[axis]: 0,
scale: 1,
opacity: 1,
duration: props.duration,
ease: props.ease,
delay: props.delay,
onComplete: () => emit('complete'),
scrollTrigger: {
trigger: el,
start: `top ${startPct}%`,
toggleActions: 'play none none none',
once: true,
},
})
})
watch(
() => [
props.distance,
props.direction,
props.reverse,
props.duration,
props.ease,
props.initialOpacity,
props.animateOpacity,
props.scale,
props.threshold,
props.delay,
],
() => {
const el = containerRef.value
if (!el) return
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
gsap.set(el, {
[axis]: offset,
scale: props.scale,
opacity: props.animateOpacity ? props.initialOpacity : 1,
})
gsap.to(el, {
[axis]: 0,
scale: 1,
opacity: 1,
duration: props.duration,
ease: props.ease,
delay: props.delay,
onComplete: () => emit('complete'),
scrollTrigger: {
trigger: el,
start: `top ${startPct}%`,
toggleActions: 'play none none none',
once: true,
},
})
},
{ deep: true }
)
onUnmounted(() => {
const el = containerRef.value
if (el) {
ScrollTrigger.getAll().forEach((t) => t.kill())
gsap.killTweensOf(el)
}
})
</script>
<template>
<div
ref="containerRef"
:class="`animated-content ${props.className}`"
>
<slot />
</div>
</template>
<style scoped>
/* GSAP will handle all transforms and opacity */
</style>

View File

@@ -0,0 +1,188 @@
<template>
<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'
interface Spark {
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
}
const props = withDefaults(defineProps<Props>(), {
sparkColor: '#fff',
sparkSize: 10,
sparkRadius: 15,
sparkCount: 8,
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 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
default:
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 now = performance.now()
const newSparks: Spark[] = Array.from({ length: props.sparkCount }, (_, i) => ({
x,
y,
angle: (2 * Math.PI * i) / props.sparkCount,
startTime: now,
}))
sparks.value.push(...newSparks)
}
const draw = (timestamp: number) => {
if (!startTimeRef.value) {
startTimeRef.value = timestamp
}
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
if (elapsed >= props.duration) {
return false
}
const progress = elapsed / props.duration
const eased = easeFunc.value(progress)
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)
ctx.strokeStyle = props.sparkColor
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(x1, y1)
ctx.lineTo(x2, y2)
ctx.stroke()
return true
})
animationId.value = requestAnimationFrame(draw)
}
const resizeCanvas = () => {
const canvas = canvasRef.value
if (!canvas) return
const parent = canvas.parentElement
if (!parent) return
const { width, height } = parent.getBoundingClientRect()
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width
canvas.height = height
}
}
let resizeTimeout: number
const handleResize = () => {
clearTimeout(resizeTimeout)
resizeTimeout = setTimeout(resizeCanvas, 100)
}
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
const canvas = canvasRef.value
if (!canvas) return
const parent = canvas.parentElement
if (!parent) return
resizeObserver = new ResizeObserver(handleResize)
resizeObserver.observe(parent)
resizeCanvas()
animationId.value = requestAnimationFrame(draw)
})
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect()
}
clearTimeout(resizeTimeout)
if (animationId.value) {
cancelAnimationFrame(animationId.value)
}
})
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

@@ -0,0 +1,321 @@
<template>
<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">
<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>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, withDefaults } from 'vue'
import gsap from 'gsap'
interface Gap {
row: number
col: number
}
interface Duration {
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
}
const props = withDefaults(defineProps<Props>(), {
gridSize: 10,
maxAngle: 45,
radius: 3,
easing: 'power3.out',
duration: () => ({ enter: 0.3, leave: 0.6 }),
borderStyle: '1px solid #fff',
faceColor: '#0b0b0b',
shadow: false,
autoAnimate: true,
rippleOnClick: true,
rippleColor: '#fff',
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 colGap = computed(() => {
return typeof props.cellGap === 'number'
? `${props.cellGap}px`
: (props.cellGap as Gap)?.col !== undefined
? `${(props.cellGap as Gap).col}px`
: '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%'
})
const enterDur = computed(() => props.duration.enter)
const leaveDur = computed(() => props.duration.leave)
const cells = computed(() => Array.from({ length: props.gridSize }))
const sceneStyle = computed(() => ({
gridTemplateColumns: props.cubeSize
? `repeat(${props.gridSize}, ${props.cubeSize}px)`
: `repeat(${props.gridSize}, 1fr)`,
gridTemplateRows: props.cubeSize
? `repeat(${props.gridSize}, ${props.cubeSize}px)`
: `repeat(${props.gridSize}, 1fr)`,
columnGap: colGap.value,
rowGap: rowGap.value,
perspective: '99999999px',
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',
...(props.cubeSize
? {
width: `${props.gridSize * props.cubeSize}px`,
height: `${props.gridSize * props.cubeSize}px`,
}
: {}),
}))
const tiltAt = (rowCenter: number, colCenter: number) => {
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)
if (dist <= props.radius) {
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,
})
} else {
gsap.to(cube, {
duration: leaveDur.value,
ease: 'power3.out',
overwrite: true,
rotateX: 0,
rotateY: 0,
})
}
})
}
const onPointerMove = (e: PointerEvent) => {
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
if (rafRef.value) cancelAnimationFrame(rafRef.value)
rafRef.value = requestAnimationFrame(() =>
tiltAt(rowCenter, colCenter)
)
idleTimerRef.value = setTimeout(() => {
userActiveRef.value = false
}, 3000)
}
const resetAll = () => {
if (!sceneRef.value) return
sceneRef.value.querySelectorAll<HTMLDivElement>('.cube').forEach((cube) =>
gsap.to(cube, {
duration: leaveDur.value,
rotateX: 0,
rotateY: 0,
ease: 'power3.out',
})
)
}
const onClick = (e: MouseEvent) => {
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 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 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'))
)
gsap.to(faces, {
backgroundColor: props.rippleColor,
duration: animDuration,
delay,
ease: 'power3.out',
})
gsap.to(faces, {
backgroundColor: props.faceColor,
duration: animDuration,
delay: delay + animDuration + holdTime,
ease: 'power3.out',
})
})
}
const startAutoAnimation = () => {
if (!props.autoAnimate || !sceneRef.value) return
simPosRef.value = {
x: Math.random() * props.gridSize,
y: Math.random() * props.gridSize,
}
simTargetRef.value = {
x: Math.random() * props.gridSize,
y: Math.random() * props.gridSize,
}
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)
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,
}
}
}
simRAFRef.value = requestAnimationFrame(loop)
}
simRAFRef.value = requestAnimationFrame(loop)
}
onMounted(() => {
const el = sceneRef.value
if (!el) return
el.addEventListener('pointermove', onPointerMove)
el.addEventListener('pointerleave', resetAll)
el.addEventListener('click', onClick)
startAutoAnimation()
})
onUnmounted(() => {
const el = sceneRef.value
if (el) {
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)
})
</script>

View File

@@ -0,0 +1,112 @@
<script setup lang="ts">
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>
}
const props = withDefaults(defineProps<GlareHoverProps>(), {
width: '500px',
height: '500px',
background: '#000',
borderRadius: '10px',
borderColor: '#333',
glareColor: '#ffffff',
glareOpacity: 0.5,
glareAngle: -45,
glareSize: 250,
transitionDuration: 650,
playOnce: false,
className: '',
style: () => ({})
})
const overlayRef = ref<HTMLDivElement | null>(null)
const rgba = computed(() => {
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})`
} 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})`
}
return result
})
const overlayStyle = computed(() => ({
position: 'absolute' as const,
inset: '0',
background: `linear-gradient(${props.glareAngle}deg,
hsla(0,0%,0%,0) 60%,
${rgba.value} 70%,
hsla(0,0%,0%,0) 100%)`,
backgroundSize: `${props.glareSize}% ${props.glareSize}%, 100% 100%`,
backgroundRepeat: 'no-repeat',
backgroundPosition: '-100% -100%, 0 0',
pointerEvents: 'none' as const,
}))
const animateIn = () => {
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'
}
const animateOut = () => {
const el = overlayRef.value
if (!el) return
if (props.playOnce) {
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'
}
}
</script>
<template>
<div
:class="`relative grid place-items-center overflow-hidden border cursor-pointer ${props.className}`"
:style="{
width: props.width,
height: props.height,
background: props.background,
borderRadius: props.borderRadius,
borderColor: props.borderColor,
...props.style,
}"
@mouseenter="animateIn"
@mouseleave="animateOut"
>
<div ref="overlayRef" :style="overlayStyle" />
<slot />
</div>
</template>

View File

@@ -0,0 +1,84 @@
<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',
}">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
interface Props {
padding?: number
disabled?: boolean
magnetStrength?: number
activeTransition?: string
inactiveTransition?: string
wrapperClassName?: string
innerClassName?: string
}
const props = withDefaults(defineProps<Props>(), {
padding: 100,
disabled: false,
magnetStrength: 2,
activeTransition: 'transform 0.3s ease-out',
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 transitionStyle = computed(() =>
isActive.value ? props.activeTransition : props.inactiveTransition
)
const handleMouseMove = (e: MouseEvent) => {
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 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 }
} else {
isActive.value = false
position.value = { x: 0, y: 0 }
}
}
onMounted(() => {
window.addEventListener('mousemove', handleMouseMove)
})
onUnmounted(() => {
window.removeEventListener('mousemove', handleMouseMove)
})
watch(() => props.disabled, (newDisabled) => {
if (newDisabled) {
position.value = { x: 0, y: 0 }
isActive.value = false
}
})
</script>

View File

@@ -0,0 +1,101 @@
<script setup lang="ts">
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>
}
const props = withDefaults(defineProps<MagnetLinesProps>(), {
rows: 9,
columns: 9,
containerSize: '80vmin',
lineColor: '#efefef',
lineWidth: '1vmin',
lineHeight: '6vmin',
baseAngle: -10,
className: '',
style: () => ({})
})
const containerRef = ref<HTMLDivElement | null>(null)
const total = computed(() => props.rows * props.columns)
const onPointerMove = (pointer: { x: number; y: number }) => {
const container = containerRef.value
if (!container) return
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
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`)
})
}
const handlePointerMove = (e: PointerEvent) => {
onPointerMove({ x: e.x, y: e.y })
}
onMounted(() => {
const container = containerRef.value
if (!container) return
window.addEventListener('pointermove', handlePointerMove)
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 })
}
})
onUnmounted(() => {
window.removeEventListener('pointermove', handlePointerMove)
})
</script>
<template>
<div
ref="containerRef"
:class="`grid place-items-center ${props.className}`"
:style="{
gridTemplateColumns: `repeat(${props.columns}, 1fr)`,
gridTemplateRows: `repeat(${props.rows}, 1fr)`,
width: props.containerSize,
height: props.containerSize,
...props.style,
}"
>
<span
v-for="i in total"
:key="i"
class="block origin-center"
:style="{
backgroundColor: props.lineColor,
width: props.lineWidth,
height: props.lineHeight,
'--rotate': `${props.baseAngle}deg`,
transform: 'rotate(var(--rotate))',
willChange: 'transform',
}"
/>
</div>
</template>

View File

@@ -0,0 +1,137 @@
<script setup lang="ts">
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
}
const props = withDefaults(defineProps<PixelTransitionProps>(), {
gridSize: 7,
pixelColor: 'currentColor',
animationStepDuration: 0.3,
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 isTouchDevice =
typeof window !== 'undefined' &&
('ontouchstart' in window ||
(navigator && navigator.maxTouchPoints > 0) ||
(window.matchMedia && window.matchMedia('(pointer: coarse)').matches))
function buildPixelGrid() {
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)
}
}
}
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
gsap.to(pixels, {
display: 'block',
duration: 0,
stagger: {
each: staggerDuration,
from: 'random',
},
})
delayedCall = gsap.delayedCall(props.animationStepDuration, () => {
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',
},
})
}
function handleMouseEnter() {
if (isTouchDevice) return
if (!isActive.value) animatePixels(true)
}
function handleMouseLeave() {
if (isTouchDevice) return
if (isActive.value) animatePixels(false)
}
function handleClick() {
if (!isTouchDevice) return
animatePixels(!isActive.value)
}
onMounted(async () => {
await nextTick()
buildPixelGrid()
})
watch(() => [props.gridSize, props.pixelColor], () => {
buildPixelGrid()
})
onUnmounted(() => {
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 :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;">
<slot name="secondContent" />
</div>
<div ref="pixelGridRef" class="absolute inset-0 w-full h-full pointer-events-none z-[3]" />
</div>
</template>
<style scoped>
.pixelated-image-card__pixel {
transition: none;
}
</style>

View File

@@ -0,0 +1,266 @@
<template>
<div ref="containerRef" :class="className" :style="style" class="w-full h-full"></div>
</template>
<script setup lang="ts">
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
}
const props = withDefaults(defineProps<AuroraProps>(), {
colorStops: () => ['#7cff67', '#171D22', '#7cff67'],
amplitude: 1.0,
blend: 0.5,
speed: 1.0,
intensity: 1.0,
className: '',
style: () => ({})
})
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;
uniform float uTime;
uniform float uAmplitude;
uniform vec3 uColorStops[3];
uniform vec2 uResolution;
uniform float uBlend;
uniform float uIntensity;
out vec4 fragColor;
vec3 permute(vec3 x) {
return mod(((x * 34.0) + 1.0) * x, 289.0);
}
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 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
vec4 x12 = x0.xyxy + C.xxzz;
x12.xy -= i1;
i = mod(i, 289.0);
vec3 p = permute(
permute(i.y + vec3(0.0, i1.y, 1.0))
+ i.x + vec3(0.0, i1.x, 1.0)
);
vec3 m = max(
0.5 - vec3(
dot(x0, x0),
dot(x12.xy, x12.xy),
dot(x12.zw, x12.zw)
),
0.0
);
m = m * m;
m = m * m;
vec3 x = 2.0 * fract(p * C.www) - 1.0;
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.0 * dot(m, g);
}
struct ColorStop {
vec3 color;
float position;
};
#define COLOR_RAMP(colors, factor, finalColor) { \
int index = 0; \
for (int i = 0; i < 2; i++) { \
ColorStop currentColor = colors[i]; \
bool isInBetween = currentColor.position <= factor; \
index = int(mix(float(index), float(i), float(isInBetween))); \
} \
ColorStop currentColor = colors[index]; \
ColorStop nextColor = colors[index + 1]; \
float range = nextColor.position - currentColor.position; \
float lerpFactor = (factor - currentColor.position) / range; \
finalColor = mix(currentColor.color, nextColor.color, lerpFactor); \
}
void main() {
vec2 uv = gl_FragCoord.xy / uResolution;
ColorStop colors[3];
colors[0] = ColorStop(uColorStops[0], 0.0);
colors[1] = ColorStop(uColorStops[1], 0.5);
colors[2] = ColorStop(uColorStops[2], 1.0);
vec3 rampColor;
COLOR_RAMP(colors, uv.x, rampColor);
float height = snoise(vec2(uv.x * 2.0 + uTime * 0.1, uTime * 0.25)) * 0.5 * uAmplitude;
height = exp(height);
height = (uv.y * 2.0 - height + 0.2);
float intensity = 0.6 * height;
float midPoint = 0.20;
float auroraAlpha = smoothstep(midPoint - uBlend * 0.5, midPoint + uBlend * 0.5, intensity);
vec3 auroraColor = rampColor;
float finalAlpha = auroraAlpha * smoothstep(0.0, 0.5, intensity) * uIntensity;
fragColor = vec4(auroraColor * finalAlpha, finalAlpha);
}
`
let renderer: Renderer | null = null
let animateId = 0
const initAurora = () => {
const container = containerRef.value
if (!container) return
renderer = new Renderer({
alpha: true,
premultipliedAlpha: 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'
// eslint-disable-next-line prefer-const
let program: Program | undefined
const resize = () => {
if (!container) return
const width = container.offsetWidth
const height = container.offsetHeight
renderer!.setSize(width, height)
if (program) {
program.uniforms.uResolution.value = [width, height]
}
}
window.addEventListener('resize', resize)
const geometry = new Triangle(gl)
if (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]
})
program = new Program(gl, {
vertex: VERT,
fragment: FRAG,
uniforms: {
uTime: { value: 0 },
uAmplitude: { value: props.amplitude },
uColorStops: { value: colorStopsArray },
uResolution: { value: [container.offsetWidth, container.offsetHeight] },
uBlend: { value: props.blend },
uIntensity: { value: props.intensity },
},
})
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'
const update = (t: number) => {
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.uColorStops.value = stops.map((hex: string) => {
const c = new Color(hex)
return [c.r, c.g, c.b]
})
renderer!.render({ scene: mesh })
}
}
animateId = requestAnimationFrame(update)
resize()
return () => {
cancelAnimationFrame(animateId)
window.removeEventListener('resize', resize)
if (container && gl.canvas.parentNode === container) {
container.removeChild(gl.canvas)
}
gl.getExtension('WEBGL_lose_context')?.loseContext()
}
}
const cleanup = () => {
if (animateId) {
cancelAnimationFrame(animateId)
}
if (renderer) {
const gl = renderer.gl
const container = containerRef.value
if (container && gl.canvas.parentNode === container) {
container.removeChild(gl.canvas)
}
gl.getExtension('WEBGL_lose_context')?.loseContext()
}
renderer = null
}
onMounted(() => {
initAurora()
})
onUnmounted(() => {
cleanup()
})
watch(
() => [props.amplitude, props.intensity],
() => {
cleanup()
initAurora()
}
)
</script>

View File

@@ -0,0 +1,204 @@
<template>
<div ref="containerRef" class="w-full h-full" />
</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'
interface Props {
color?: [number, number, number]
speed?: number
amplitude?: number
mouseReact?: boolean
}
const props = withDefaults(defineProps<Props>(), {
color: () => [1, 1, 1] as [number, number, number],
speed: 1.0,
amplitude: 0.1,
mouseReact: true
})
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
const vertexShader = `
attribute vec2 uv;
attribute vec2 position;
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 0, 1);
}
`
const fragmentShader = `
precision highp float;
uniform float uTime;
uniform vec3 uColor;
uniform vec3 uResolution;
uniform vec2 uMouse;
uniform float uAmplitude;
uniform float uSpeed;
varying vec2 vUv;
void main() {
float mr = min(uResolution.x, uResolution.y);
vec2 uv = (vUv.xy * 2.0 - 1.0) * uResolution.xy / mr;
uv += (uMouse - vec2(0.5)) * uAmplitude;
float d = -uTime * 0.5 * uSpeed;
float a = 0.0;
for (float i = 0.0; i < 8.0; ++i) {
a += cos(i - d - a * uv.x);
d += sin(uv.y * i + a);
}
d += uTime * 0.5 * uSpeed;
vec3 col = vec3(cos(uv * vec2(d, a)) * 0.6 + 0.4, cos(a + d) * 0.5 + 0.5);
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
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
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 }
if (program.uniforms.uMouse.value) {
program.uniforms.uMouse.value[0] = x
program.uniforms.uMouse.value[1] = y
}
}
const update = (t: number) => {
if (!program || !renderer || !mesh) return
animationId = requestAnimationFrame(update)
program.uniforms.uTime.value = t * 0.001
renderer.render({ scene: mesh })
}
const initializeScene = () => {
if (!containerRef.value) return
cleanup()
const container = containerRef.value
renderer = new Renderer()
gl = renderer.gl
gl.clearColor(1, 1, 1, 1)
const geometry = new Triangle(gl)
program = new Program(gl, {
vertex: vertexShader,
fragment: fragmentShader,
uniforms: {
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
)
},
uMouse: { value: new Float32Array([mousePos.value.x, mousePos.value.y]) },
uAmplitude: { value: props.amplitude },
uSpeed: { value: props.speed }
}
})
mesh = new Mesh(gl, { geometry, program })
const canvas = gl.canvas as HTMLCanvasElement
canvas.style.width = '100%'
canvas.style.height = '100%'
canvas.style.display = 'block'
container.appendChild(canvas)
window.addEventListener('resize', resize)
if (props.mouseReact) {
container.addEventListener('mousemove', handleMouseMove)
}
resize()
animationId = requestAnimationFrame(update)
}
const cleanup = () => {
if (animationId) {
cancelAnimationFrame(animationId)
animationId = null
}
window.removeEventListener('resize', resize)
if (containerRef.value) {
containerRef.value.removeEventListener('mousemove', handleMouseMove)
const canvas = containerRef.value.querySelector('canvas')
if (canvas) {
containerRef.value.removeChild(canvas)
}
}
if (gl) {
gl.getExtension('WEBGL_lose_context')?.loseContext()
}
renderer = null
gl = null
program = null
mesh = null
}
onMounted(() => {
initializeScene()
})
onUnmounted(() => {
cleanup()
})
watch(
[() => props.color, () => props.speed, () => props.amplitude, () => props.mouseReact],
() => {
initializeScene()
},
{ deep: true }
)
</script>

View File

@@ -0,0 +1,236 @@
<template>
<canvas ref="canvasRef" class="w-full h-full block mix-blend-screen"></canvas>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
interface LightningProps {
hue?: number
xOffset?: number
speed?: number
intensity?: number
size?: number
}
const props = withDefaults(defineProps<LightningProps>(), {
hue: 230,
xOffset: 0,
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 vertexShaderSource = `
attribute vec2 aPosition;
void main() {
gl_Position = vec4(aPosition, 0.0, 1.0);
}
`
const fragmentShaderSource = `
precision mediump float;
uniform vec2 iResolution;
uniform float iTime;
uniform float uHue;
uniform float uXOffset;
uniform float uSpeed;
uniform float uIntensity;
uniform float uSize;
#define OCTAVE_COUNT 10
vec3 hsv2rgb(vec3 c) {
vec3 rgb = clamp(abs(mod(c.x * 6.0 + vec3(0.0,4.0,2.0), 6.0) - 3.0) - 1.0, 0.0, 1.0);
return c.z * mix(vec3(1.0), rgb, c.y);
}
float hash11(float p) {
p = fract(p * .1031);
p *= p + 33.33;
p *= p + p;
return fract(p);
}
float hash12(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * .1031);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
mat2 rotate2d(float theta) {
float c = cos(theta);
float s = sin(theta);
return mat2(c, -s, s, c);
}
float noise(vec2 p) {
vec2 ip = floor(p);
vec2 fp = fract(p);
float a = hash12(ip);
float b = hash12(ip + vec2(1.0, 0.0));
float c = hash12(ip + vec2(0.0, 1.0));
float d = hash12(ip + vec2(1.0, 1.0));
vec2 t = smoothstep(0.0, 1.0, fp);
return mix(mix(a, b, t.x), mix(c, d, t.x), t.y);
}
float fbm(vec2 p) {
float value = 0.0;
float amplitude = 0.5;
for (int i = 0; i < OCTAVE_COUNT; ++i) {
value += amplitude * noise(p);
p *= rotate2d(0.45);
p *= 2.0;
amplitude *= 0.5;
}
return value;
}
void mainImage( out vec4 fragColor, in vec2 fragCoord ) {
vec2 uv = fragCoord / iResolution.xy;
uv = 2.0 * uv - 1.0;
uv.x *= iResolution.x / iResolution.y;
uv.x += uXOffset;
uv += 2.0 * fbm(uv * uSize + 0.8 * iTime * uSpeed) - 1.0;
float dist = abs(uv.x);
vec3 baseColor = hsv2rgb(vec3(uHue / 360.0, 0.7, 0.8));
vec3 col = baseColor * pow(mix(0.0, 0.07, hash11(iTime * uSpeed)) / dist, 1.0) * uIntensity;
col = pow(col, vec3(1.0));
fragColor = vec4(col, 1.0);
}
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.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error('Shader compile error:', gl.getShaderInfoLog(shader))
gl.deleteShader(shader)
return null
}
return shader
}
const initWebGL = () => {
const canvas = canvasRef.value
if (!canvas) return
const resizeCanvas = () => {
const rect = canvas.getBoundingClientRect()
canvas.width = rect.width
canvas.height = rect.height
canvas.style.width = rect.width + 'px'
canvas.style.height = rect.height + 'px'
}
resizeCanvas()
window.addEventListener('resize', resizeCanvas)
gl = canvas.getContext('webgl')
if (!gl) {
console.error('WebGL not supported')
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)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error('Program linking error:', gl.getProgramInfoLog(program))
return
}
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 aPosition = gl.getAttribLocation(program, 'aPosition')
gl.enableVertexAttribArray(aPosition)
gl.vertexAttribPointer(aPosition, 2, gl.FLOAT, false, 0, 0)
startTime = performance.now()
render()
return () => {
window.removeEventListener('resize', resizeCanvas)
}
}
const render = () => {
if (!gl || !program || !canvasRef.value) return
const canvas = canvasRef.value
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'
}
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')
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)
}
onMounted(() => {
initWebGL()
})
onUnmounted(() => {
if (animationId) {
cancelAnimationFrame(animationId)
}
})
watch(
() => [props.hue, props.xOffset, props.speed, props.intensity, props.size],
() => {}
)
</script>

View File

@@ -0,0 +1,314 @@
<template>
<div ref="containerRef" :class="className" class="relative w-full h-full"></div>
</template>
<script setup lang="ts">
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
}
const props = withDefaults(defineProps<ParticlesProps>(), {
particleCount: 200,
particleSpread: 10,
speed: 0.1,
particleColors: () => ['#ffffff'],
moveParticlesOnHover: false,
particleHoverFactor: 1,
alphaParticles: false,
particleBaseSize: 100,
sizeRandomness: 1,
cameraDistance: 20,
disableRotation: false,
className: ''
})
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
const defaultColors = ['#ffffff', '#ffffff', '#ffffff']
const hexToRgb = (hex: string): [number, number, number] => {
hex = hex.replace(/^#/, '')
if (hex.length === 3) {
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 vertex = /* glsl */ `
attribute vec3 position;
attribute vec4 random;
attribute vec3 color;
uniform mat4 modelMatrix;
uniform mat4 viewMatrix;
uniform mat4 projectionMatrix;
uniform float uTime;
uniform float uSpread;
uniform float uBaseSize;
uniform float uSizeRandomness;
varying vec4 vRandom;
varying vec3 vColor;
void main() {
vRandom = random;
vColor = color;
vec3 pos = position * uSpread;
pos.z *= 10.0;
vec4 mPos = modelMatrix * vec4(pos, 1.0);
float t = uTime;
mPos.x += sin(t * random.z + 6.28 * random.w) * mix(0.1, 1.5, random.x);
mPos.y += sin(t * random.y + 6.28 * random.x) * mix(0.1, 1.5, random.w);
mPos.z += sin(t * random.w + 6.28 * random.y) * mix(0.1, 1.5, random.z);
vec4 mvPos = viewMatrix * mPos;
gl_PointSize = (uBaseSize * (1.0 + uSizeRandomness * (random.x - 0.5))) / length(mvPos.xyz);
gl_Position = projectionMatrix * mvPos;
}
`
const fragment = /* glsl */ `
precision highp float;
uniform float uTime;
uniform float uAlphaParticles;
varying vec4 vRandom;
varying vec3 vColor;
void main() {
vec2 uv = gl_PointCoord.xy;
float d = length(uv - vec2(0.5));
if(uAlphaParticles < 0.5) {
if(d > 0.5) {
discard;
}
gl_FragColor = vec4(vColor + 0.2 * sin(uv.yxx + uTime + vRandom.y * 6.28), 1.0);
} else {
float circle = smoothstep(0.5, 0.4, d) * 0.8;
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 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
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'
camera = new Camera(gl, { fov: 15 })
camera.position.set(0, 0, props.cameraDistance)
const resize = () => {
const width = container.clientWidth
const height = container.clientHeight
renderer!.setSize(width, height)
camera!.perspective({ aspect: width / height })
gl.canvas.style.width = '100%'
gl.canvas.style.height = '100%'
gl.canvas.style.display = 'block'
}
window.addEventListener('resize', resize, false)
resize()
if (props.moveParticlesOnHover) {
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
for (let i = 0; i < count; i++) {
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)
}
const geometry = new Geometry(gl, {
position: { size: 3, data: positions },
random: { size: 4, data: randoms },
color: { size: 3, data: colors },
})
program = new Program(gl, {
vertex,
fragment,
uniforms: {
uTime: { value: 0 },
uSpread: { value: props.particleSpread },
uBaseSize: { value: props.particleBaseSize },
uSizeRandomness: { value: props.sizeRandomness },
uAlphaParticles: { value: props.alphaParticles ? 1 : 0 },
},
transparent: true,
depthTest: false,
})
particles = new Mesh(gl, { mode: gl.POINTS, geometry, program })
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 (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
}
if (particles) {
if (props.moveParticlesOnHover) {
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
}
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
}
}
if (renderer && camera && particles) {
renderer.render({ scene: particles, camera })
}
}
animationFrameId = requestAnimationFrame(update)
return () => {
window.removeEventListener('resize', resize)
if (props.moveParticlesOnHover) {
container.removeEventListener('mousemove', handleMouseMove)
}
if (animationFrameId) {
cancelAnimationFrame(animationFrameId)
animationFrameId = null
}
if (container.contains(gl.canvas)) {
container.removeChild(gl.canvas)
}
}
}
const cleanup = () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId)
animationFrameId = null
}
if (renderer) {
const container = containerRef.value
const gl = renderer.gl
if (container && gl.canvas.parentNode === container) {
container.removeChild(gl.canvas)
}
gl.getExtension('WEBGL_lose_context')?.loseContext()
}
renderer = null
camera = null
particles = null
program = null
}
onMounted(() => {
initParticles()
})
onUnmounted(() => {
cleanup()
})
watch(
() => [props.particleCount, props.particleColors],
() => {
cleanup()
initParticles()
},
{ deep: true }
)
watch(
() => [
props.particleSpread,
props.speed,
props.particleBaseSize,
props.sizeRandomness,
props.alphaParticles,
props.moveParticlesOnHover,
props.particleHoverFactor,
props.disableRotation
],
() => {}
)
</script>

View File

@@ -0,0 +1,232 @@
<template>
<div ref="containerRef" :class="className" :style="style" class="w-full h-full"></div>
</template>
<script setup lang="ts">
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
}
const props = withDefaults(defineProps<SilkProps>(), {
speed: 5,
scale: 1,
color: '#7B7481',
noiseIntensity: 1.5,
rotation: 0,
className: '',
style: () => ({})
})
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 vertexShader = `
attribute vec2 uv;
attribute vec3 position;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
varying vec2 vUv;
varying vec3 vPosition;
void main() {
vPosition = position;
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`
const fragmentShader = `
precision highp float;
varying vec2 vUv;
varying vec3 vPosition;
uniform float uTime;
uniform vec3 uColor;
uniform float uSpeed;
uniform float uScale;
uniform float uRotation;
uniform float uNoiseIntensity;
const float e = 2.71828182845904523536;
float noise(vec2 texCoord) {
float G = e;
vec2 r = (G * sin(G * texCoord));
return fract(r.x * r.y * (1.0 + texCoord.x));
}
vec2 rotateUvs(vec2 uv, float angle) {
float c = cos(angle);
float s = sin(angle);
mat2 rot = mat2(c, -s, s, c);
return rot * uv;
}
void main() {
float rnd = noise(gl_FragCoord.xy);
vec2 uv = rotateUvs(vUv * uScale, uRotation);
vec2 tex = uv * uScale;
float tOffset = uSpeed * uTime;
tex.y += 0.03 * sin(8.0 * tex.x - tOffset);
float pattern = 0.6 +
0.4 * sin(5.0 * (tex.x + tex.y +
cos(3.0 * tex.x + 5.0 * tex.y) +
0.02 * tOffset) +
sin(20.0 * (tex.x + tex.y - 0.1 * tOffset)));
vec4 col = vec4(uColor, 1.0) * vec4(pattern) - rnd / 15.0 * uNoiseIntensity;
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
const initSilk = () => {
const container = containerRef.value
if (!container) return
renderer = new Renderer({
alpha: true,
antialias: true,
})
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
const resize = () => {
if (!container || !camera) return
const width = container.offsetWidth
const height = container.offsetHeight
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)
mesh.scale.set(width2, height2, 1)
}
}
window.addEventListener('resize', resize)
const geometry = new Plane(gl, {
width: 1,
height: 1,
})
const colorRGB = hexToNormalizedRGB(props.color)
program = new Program(gl, {
vertex: vertexShader,
fragment: fragmentShader,
uniforms: {
uSpeed: { value: props.speed },
uScale: { value: props.scale },
uNoiseIntensity: { value: props.noiseIntensity },
uColor: { value: colorRGB },
uRotation: { value: props.rotation },
uTime: { value: 0 },
},
})
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'
let lastTime = 0
const update = (t: number) => {
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 })
}
}
animateId = requestAnimationFrame(update)
resize()
return () => {
cancelAnimationFrame(animateId)
window.removeEventListener('resize', resize)
if (container && gl.canvas.parentNode === container) {
container.removeChild(gl.canvas)
}
gl.getExtension('WEBGL_lose_context')?.loseContext()
}
}
const cleanup = () => {
if (animateId) {
cancelAnimationFrame(animateId)
}
if (renderer) {
const gl = renderer.gl
const container = containerRef.value
if (container && gl.canvas.parentNode === container) {
container.removeChild(gl.canvas)
}
gl.getExtension('WEBGL_lose_context')?.loseContext()
}
renderer = null
mesh = null
camera = null
program = null
}
onMounted(() => {
initSilk()
})
onUnmounted(() => {
cleanup()
})
watch(
() => [props.speed, props.scale, props.color, props.noiseIntensity, props.rotation],
() => {}
)
</script>

View File

@@ -0,0 +1,290 @@
<template>
<div ref="containerRef" class="w-full h-full relative" />
</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'
interface Props {
color?: [number, number, number]
amplitude?: number
distance?: number
enableMouseInteraction?: boolean
}
const props = withDefaults(defineProps<Props>(), {
color: () => [1, 1, 1] as [number, number, number],
amplitude: 1,
distance: 0,
enableMouseInteraction: false
})
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]
const vertexShader = `
attribute vec2 position;
attribute vec2 uv;
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 0.0, 1.0);
}
`
const fragmentShader = `
precision highp float;
uniform float iTime;
uniform vec3 iResolution;
uniform vec3 uColor;
uniform float uAmplitude;
uniform float uDistance;
uniform vec2 uMouse;
#define PI 3.1415926538
const int u_line_count = 40;
const float u_line_width = 7.0;
const float u_line_blur = 10.0;
float Perlin2D(vec2 P) {
vec2 Pi = floor(P);
vec4 Pf_Pfmin1 = P.xyxy - vec4(Pi, Pi + 1.0);
vec4 Pt = vec4(Pi.xy, Pi.xy + 1.0);
Pt = Pt - floor(Pt * (1.0 / 71.0)) * 71.0;
Pt += vec2(26.0, 161.0).xyxy;
Pt *= Pt;
Pt = Pt.xzxz * Pt.yyww;
vec4 hash_x = fract(Pt * (1.0 / 951.135664));
vec4 hash_y = fract(Pt * (1.0 / 642.949883));
vec4 grad_x = hash_x - 0.49999;
vec4 grad_y = hash_y - 0.49999;
vec4 grad_results = inversesqrt(grad_x * grad_x + grad_y * grad_y)
* (grad_x * Pf_Pfmin1.xzxz + grad_y * Pf_Pfmin1.yyww);
grad_results *= 1.4142135623730950;
vec2 blend = Pf_Pfmin1.xy * Pf_Pfmin1.xy * Pf_Pfmin1.xy
* (Pf_Pfmin1.xy * (Pf_Pfmin1.xy * 6.0 - 15.0) + 10.0);
vec4 blend2 = vec4(blend, vec2(1.0 - blend));
return dot(grad_results, blend2.zxzx * blend2.wwyy);
}
float pixel(float count, vec2 resolution) {
return (1.0 / max(resolution.x, resolution.y)) * count;
}
float lineFn(vec2 st, float width, float perc, float offset, vec2 mouse, float time, float amplitude, float distance) {
float split_offset = (perc * 0.4);
float split_point = 0.1 + split_offset;
float amplitude_normal = smoothstep(split_point, 0.7, st.x);
float amplitude_strength = 0.5;
float finalAmplitude = amplitude_normal * amplitude_strength
* amplitude * (1.0 + (mouse.y - 0.5) * 0.2);
float time_scaled = time / 10.0 + (mouse.x - 0.5) * 1.0;
float blur = smoothstep(split_point, split_point + 0.05, st.x) * perc;
float xnoise = mix(
Perlin2D(vec2(time_scaled, st.x + perc) * 2.5),
Perlin2D(vec2(time_scaled, st.x + time_scaled) * 3.5) / 1.5,
st.x * 0.3
);
float y = 0.5 + (perc - 0.5) * distance + xnoise / 2.0 * finalAmplitude;
float line_start = smoothstep(
y + (width / 2.0) + (u_line_blur * pixel(1.0, iResolution.xy) * blur),
y,
st.y
);
float line_end = smoothstep(
y,
y - (width / 2.0) - (u_line_blur * pixel(1.0, iResolution.xy) * blur),
st.y
);
return clamp(
(line_start - line_end) * (1.0 - smoothstep(0.0, 1.0, pow(perc, 0.3))),
0.0,
1.0
);
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
float line_strength = 1.0;
for (int i = 0; i < u_line_count; i++) {
float p = float(i) / float(u_line_count);
line_strength *= (1.0 - lineFn(
uv,
u_line_width * pixel(1.0, iResolution.xy) * (1.0 - p),
p,
(PI * 1.0) * p,
uMouse,
iTime,
uAmplitude,
uDistance
));
}
float colorVal = 1.0 - line_strength;
fragColor = vec4(uColor * colorVal, colorVal);
}
void main() {
mainImage(gl_FragColor, gl_FragCoord.xy);
}
`
const resize = () => {
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 handleMouseMove = (e: MouseEvent) => {
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 handleMouseLeave = () => {
targetMouse = [0.5, 0.5]
}
const update = (t: number) => {
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]
} else {
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)
}
const initializeScene = () => {
if (!containerRef.value) return
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 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
)
},
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 })
const canvas = gl.canvas as HTMLCanvasElement
canvas.style.width = '100%'
canvas.style.height = '100%'
canvas.style.display = 'block'
container.appendChild(canvas)
window.addEventListener('resize', resize)
if (props.enableMouseInteraction) {
container.addEventListener('mousemove', handleMouseMove)
container.addEventListener('mouseleave', handleMouseLeave)
}
resize()
animationId = requestAnimationFrame(update)
}
const cleanup = () => {
if (animationId) {
cancelAnimationFrame(animationId)
animationId = null
}
window.removeEventListener('resize', resize)
if (containerRef.value) {
containerRef.value.removeEventListener('mousemove', handleMouseMove)
containerRef.value.removeEventListener('mouseleave', handleMouseLeave)
const canvas = containerRef.value.querySelector('canvas')
if (canvas) {
containerRef.value.removeChild(canvas)
}
}
if (gl) {
gl.getExtension('WEBGL_lose_context')?.loseContext()
}
renderer = null
gl = null
program = null
mesh = null
currentMouse = [0.5, 0.5]
targetMouse = [0.5, 0.5]
}
onMounted(() => {
initializeScene()
})
onUnmounted(() => {
cleanup()
})
watch(
[() => props.color, () => props.amplitude, () => props.distance, () => props.enableMouseInteraction],
() => {
initializeScene()
},
{ deep: true }
)
</script>

View File

@@ -0,0 +1,443 @@
<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',
}" />
<canvas ref="canvasRef" class="block w-full h-full" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, type CSSProperties } from 'vue'
class Grad {
x: number
y: number
z: number
constructor(x: number, y: number, z: number) {
this.x = x
this.y = y
this.z = z
}
dot2(x: number, y: number): number {
return this.x * x + this.y * y
}
}
class Noise {
grad3: Grad[]
p: number[]
perm: number[]
gradP: Grad[]
constructor(seed = 0) {
this.grad3 = [
new Grad(1, 1, 0),
new Grad(-1, 1, 0),
new Grad(1, -1, 0),
new Grad(-1, -1, 0),
new Grad(1, 0, 1),
new Grad(-1, 0, 1),
new Grad(1, 0, -1),
new Grad(-1, 0, -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,
]
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
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]
}
}
fade(t: number): number {
return t * t * t * (t * (t * 6 - 15) + 10)
}
lerp(a: number, b: number, t: number): number {
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
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)
)
}
}
interface Point {
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
}
interface Config {
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
}
const props = withDefaults(defineProps<WavesProps>(), {
lineColor: 'black',
backgroundColor: 'transparent',
waveSpeedX: 0.0125,
waveSpeedY: 0.005,
waveAmpX: 32,
waveAmpY: 16,
xGap: 10,
yGap: 32,
friction: 0.925,
tension: 0.005,
maxCursorMove: 100,
style: () => ({}),
className: ''
})
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[][] = []
const mouse: Mouse = {
x: -10,
y: 0,
lx: 0,
ly: 0,
sx: 0,
sy: 0,
v: 0,
vs: 0,
a: 0,
set: false,
}
let config: Config = {
lineColor: props.lineColor,
waveSpeedX: props.waveSpeedX,
waveSpeedY: props.waveSpeedY,
waveAmpX: props.waveAmpX,
waveAmpY: props.waveAmpY,
friction: props.friction,
tension: props.tension,
maxCursorMove: props.maxCursorMove,
xGap: props.xGap,
yGap: props.yGap,
}
let frameId: number | null = null
const setSize = () => {
const container = containerRef.value
const canvas = canvasRef.value
if (!container || !canvas) return
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
}
const setLines = () => {
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
for (let i = 0; i <= totalLines; i++) {
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 },
})
}
lines.push(pts)
}
}
const movePoints = (time: number) => {
if (!noise) return
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
const dx = p.x - mouse.sx,
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
}
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 drawLines = () => {
const { width, height } = bounding
if (!ctx) return
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)
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 tick = (t: number) => {
const container = containerRef.value
if (!container) return
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`)
movePoints(t)
drawLines()
frameId = requestAnimationFrame(tick)
}
const onResize = () => {
setSize()
setLines()
}
const updateMouse = (x: number, y: number) => {
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
}
}
const onMouseMove = (e: MouseEvent) => {
updateMouse(e.clientX, e.clientY)
}
const onTouchMove = (e: TouchEvent) => {
const touch = e.touches[0]
updateMouse(touch.clientX, touch.clientY)
}
onMounted(() => {
const canvas = canvasRef.value
const container = containerRef.value
if (!canvas || !container) return
ctx = canvas.getContext('2d')
noise = new Noise(Math.random())
setSize()
setLines()
frameId = requestAnimationFrame(tick)
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)
if (frameId !== null) {
cancelAnimationFrame(frameId)
}
})
watch(
() => [
props.lineColor,
props.waveSpeedX,
props.waveSpeedY,
props.waveAmpX,
props.waveAmpY,
props.friction,
props.tension,
props.maxCursorMove,
props.xGap,
props.yGap,
],
() => {
config = {
lineColor: props.lineColor,
waveSpeedX: props.waveSpeedX,
waveSpeedY: props.waveSpeedY,
waveAmpX: props.waveAmpX,
waveAmpY: props.waveAmpY,
friction: props.friction,
tension: props.tension,
maxCursorMove: props.maxCursorMove,
xGap: props.xGap,
yGap: props.yGap,
}
}
)
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,686 @@
<template>
<div ref="containerRef" :class="[
'w-full h-full overflow-hidden relative z-2',
className
]" v-bind="$attrs">
<canvas ref="canvasRef" class="block w-full h-full" />
</div>
</template>
<script lang="ts">
import {
Renderer,
Camera,
Transform,
Plane,
Program,
Mesh,
Texture,
type OGLRenderingContext,
} from "ogl";
type GL = OGLRenderingContext;
type OGLProgram = Program;
type OGLMesh = Mesh;
type OGLTransform = Transform;
type OGLPlane = Plane;
interface ScreenSize {
width: number;
height: number;
}
interface ViewportSize {
width: number;
height: number;
}
interface ScrollState {
position?: number;
ease: number;
current: number;
target: number;
last: number;
}
interface AutoBindOptions {
include?: Array<string | RegExp>;
exclude?: Array<string | RegExp>;
}
interface MediaParams {
gl: GL;
geometry: OGLPlane;
scene: OGLTransform;
screen: ScreenSize;
viewport: ViewportSize;
image: string;
length: number;
index: number;
planeWidth: number;
planeHeight: number;
distortion: number;
}
interface CanvasParams {
container: HTMLElement;
canvas: HTMLCanvasElement;
items: string[];
planeWidth: number;
planeHeight: number;
distortion: number;
scrollEase: number;
cameraFov: number;
cameraZ: number;
}
const vertexShader = `
precision highp float;
attribute vec3 position;
attribute vec2 uv;
attribute vec3 normal;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
uniform mat3 normalMatrix;
uniform float uPosition;
uniform float uTime;
uniform float uSpeed;
uniform vec3 distortionAxis;
uniform vec3 rotationAxis;
uniform float uDistortion;
varying vec2 vUv;
varying vec3 vNormal;
float PI = 3.141592653589793238;
mat4 rotationMatrix(vec3 axis, float angle) {
axis = normalize(axis);
float s = sin(angle);
float c = cos(angle);
float oc = 1.0 - c;
return mat4(
oc * axis.x * axis.x + c, oc * axis.x * axis.y - axis.z * s, oc * axis.z * axis.x + axis.y * s, 0.0,
oc * axis.x * axis.y + axis.z * s,oc * axis.y * axis.y + c, oc * axis.y * axis.z - axis.x * s, 0.0,
oc * axis.z * axis.x - axis.y * s,oc * axis.y * axis.z + axis.x * s, oc * axis.z * axis.z + c, 0.0,
0.0, 0.0, 0.0, 1.0
);
}
vec3 rotate(vec3 v, vec3 axis, float angle) {
mat4 m = rotationMatrix(axis, angle);
return (m * vec4(v, 1.0)).xyz;
}
float qinticInOut(float t) {
return t < 0.5
? 16.0 * pow(t, 5.0)
: -0.5 * abs(pow(2.0 * t - 2.0, 5.0)) + 1.0;
}
void main() {
vUv = uv;
float norm = 0.5;
vec3 newpos = position;
float offset = (dot(distortionAxis, position) + norm / 2.) / norm;
float localprogress = clamp(
(fract(uPosition * 5.0 * 0.01) - 0.01 * uDistortion * offset) / (1. - 0.01 * uDistortion),
0.,
2.
);
localprogress = qinticInOut(localprogress) * PI;
newpos = rotate(newpos, rotationAxis, localprogress);
gl_Position = projectionMatrix * modelViewMatrix * vec4(newpos, 1.0);
}
`;
const fragmentShader = `
precision highp float;
uniform vec2 uImageSize;
uniform vec2 uPlaneSize;
uniform sampler2D tMap;
varying vec2 vUv;
void main() {
vec2 imageSize = uImageSize;
vec2 planeSize = uPlaneSize;
float imageAspect = imageSize.x / imageSize.y;
float planeAspect = planeSize.x / planeSize.y;
vec2 scale = vec2(1.0, 1.0);
if (planeAspect > imageAspect) {
scale.x = imageAspect / planeAspect;
} else {
scale.y = planeAspect / imageAspect;
}
vec2 uv = vUv * scale + (1.0 - scale) * 0.5;
gl_FragColor = texture2D(tMap, uv);
}
`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function AutoBind(self: any, { include, exclude }: AutoBindOptions = {}) {
const getAllProperties = (object: object): Set<[object, string | symbol]> => {
const properties = new Set<[object, string | symbol]>();
let currentObject: object | null = object;
do {
for (const key of Reflect.ownKeys(currentObject)) {
properties.add([currentObject, key]);
}
} while (
(currentObject = Reflect.getPrototypeOf(currentObject)) &&
currentObject !== Object.prototype
);
return properties;
};
const filter = (key: string | symbol) => {
const match = (pattern: string | RegExp) =>
typeof pattern === "string"
? key === pattern
: (pattern as RegExp).test(key.toString());
if (include) return include.some(match);
if (exclude) return !exclude.some(match);
return true;
};
for (const [object, key] of getAllProperties(self.constructor.prototype)) {
if (key === "constructor" || !filter(key)) continue;
const descriptor = Reflect.getOwnPropertyDescriptor(object, key);
if (descriptor && typeof descriptor.value === "function" && typeof key === "string") {
self[key] = self[key].bind(self);
}
}
return self;
}
function lerp(p1: number, p2: number, t: number): number {
return p1 + (p2 - p1) * t;
}
function map(
num: number,
min1: number,
max1: number,
min2: number,
max2: number,
round = false
): number {
const num1 = (num - min1) / (max1 - min1);
const num2 = num1 * (max2 - min2) + min2;
return round ? Math.round(num2) : num2;
}
class Media {
gl: GL;
geometry: OGLPlane;
scene: OGLTransform;
screen: ScreenSize;
viewport: ViewportSize;
image: string;
length: number;
index: number;
planeWidth: number;
planeHeight: number;
distortion: number;
program!: OGLProgram;
plane!: OGLMesh;
extra = 0;
padding = 0;
height = 0;
heightTotal = 0;
y = 0;
constructor({
gl,
geometry,
scene,
screen,
viewport,
image,
length,
index,
planeWidth,
planeHeight,
distortion,
}: MediaParams) {
this.gl = gl;
this.geometry = geometry;
this.scene = scene;
this.screen = screen;
this.viewport = viewport;
this.image = image;
this.length = length;
this.index = index;
this.planeWidth = planeWidth;
this.planeHeight = planeHeight;
this.distortion = distortion;
this.createShader();
this.createMesh();
this.onResize();
}
createShader() {
const texture = new Texture(this.gl, { generateMipmaps: false });
this.program = new Program(this.gl, {
depthTest: false,
depthWrite: false,
fragment: fragmentShader,
vertex: vertexShader,
uniforms: {
tMap: { value: texture },
uPosition: { value: 0 },
uPlaneSize: { value: [0, 0] },
uImageSize: { value: [0, 0] },
uSpeed: { value: 0 },
rotationAxis: { value: [0, 1, 0] },
distortionAxis: { value: [1, 1, 0] },
uDistortion: { value: this.distortion },
uViewportSize: { value: [this.viewport.width, this.viewport.height] },
uTime: { value: 0 },
},
cullFace: false,
});
const img = new Image();
img.crossOrigin = "anonymous";
img.src = this.image;
img.onload = () => {
texture.image = img;
this.program.uniforms.uImageSize.value = [
img.naturalWidth,
img.naturalHeight,
];
};
}
createMesh() {
this.plane = new Mesh(this.gl, {
geometry: this.geometry,
program: this.program,
});
this.plane.setParent(this.scene);
}
setScale() {
this.plane.scale.x =
(this.viewport.width * this.planeWidth) / this.screen.width;
this.plane.scale.y =
(this.viewport.height * this.planeHeight) / this.screen.height;
this.plane.position.x = 0;
this.program.uniforms.uPlaneSize.value = [
this.plane.scale.x,
this.plane.scale.y,
];
}
onResize({
screen,
viewport,
}: { screen?: ScreenSize; viewport?: ViewportSize } = {}) {
if (screen) this.screen = screen;
if (viewport) {
this.viewport = viewport;
this.program.uniforms.uViewportSize.value = [
viewport.width,
viewport.height,
];
}
this.setScale();
this.padding = 5;
this.height = this.plane.scale.y + this.padding;
this.heightTotal = this.height * this.length;
this.y = -this.heightTotal / 2 + (this.index + 0.5) * this.height;
}
update(scroll: ScrollState) {
this.plane.position.y = this.y - scroll.current - this.extra;
const position = map(
this.plane.position.y,
-this.viewport.height,
this.viewport.height,
5,
15
);
this.program.uniforms.uPosition.value = position;
this.program.uniforms.uTime.value += 0.04;
this.program.uniforms.uSpeed.value = scroll.current;
const planeHeight = this.plane.scale.y;
const viewportHeight = this.viewport.height;
const topEdge = this.plane.position.y + planeHeight / 2;
const bottomEdge = this.plane.position.y - planeHeight / 2;
if (topEdge < -viewportHeight / 2) {
this.extra -= this.heightTotal;
} else if (bottomEdge > viewportHeight / 2) {
this.extra += this.heightTotal;
}
}
}
class Canvas {
container: HTMLElement;
canvas: HTMLCanvasElement;
items: string[];
planeWidth: number;
planeHeight: number;
distortion: number;
scroll: ScrollState;
cameraFov: number;
cameraZ: number;
renderer!: Renderer;
gl!: GL;
camera!: Camera;
scene!: OGLTransform;
planeGeometry!: OGLPlane;
medias!: Media[];
screen!: ScreenSize;
viewport!: ViewportSize;
isDown = false;
start = 0;
loaded = 0;
constructor({
container,
canvas,
items,
planeWidth,
planeHeight,
distortion,
scrollEase,
cameraFov,
cameraZ,
}: CanvasParams) {
this.container = container;
this.canvas = canvas;
this.items = items;
this.planeWidth = planeWidth;
this.planeHeight = planeHeight;
this.distortion = distortion;
this.scroll = {
ease: scrollEase,
current: 0,
target: 0,
last: 0,
};
this.cameraFov = cameraFov;
this.cameraZ = cameraZ;
AutoBind(this);
this.createRenderer();
this.createCamera();
this.createScene();
this.onResize();
this.createGeometry();
this.createMedias();
this.initializeScrollPosition();
this.update();
this.addEventListeners();
this.createPreloader();
}
createRenderer() {
this.renderer = new Renderer({
canvas: this.canvas,
alpha: true,
antialias: true,
dpr: Math.min(window.devicePixelRatio, 2),
});
this.gl = this.renderer.gl;
}
createCamera() {
this.camera = new Camera(this.gl);
this.camera.fov = this.cameraFov;
this.camera.position.z = this.cameraZ;
}
createScene() {
this.scene = new Transform();
}
createGeometry() {
this.planeGeometry = new Plane(this.gl, {
heightSegments: 1,
widthSegments: 100,
});
}
createMedias() {
this.medias = this.items.map(
(image, index) =>
new Media({
gl: this.gl,
geometry: this.planeGeometry,
scene: this.scene,
screen: this.screen,
viewport: this.viewport,
image,
length: this.items.length,
index,
planeWidth: this.planeWidth,
planeHeight: this.planeHeight,
distortion: this.distortion,
})
);
}
initializeScrollPosition() {
if (this.medias && this.medias.length > 0) {
const centerIndex = Math.floor(this.medias.length / 2);
const centerMedia = this.medias[centerIndex];
this.scroll.current = centerMedia.y;
this.scroll.target = centerMedia.y;
}
}
createPreloader() {
this.loaded = 0;
this.items.forEach((src) => {
const image = new Image();
image.crossOrigin = "anonymous";
image.src = src;
image.onload = () => {
if (++this.loaded === this.items.length) {
document.documentElement.classList.remove("loading");
document.documentElement.classList.add("loaded");
}
};
});
}
onResize() {
const rect = this.container.getBoundingClientRect();
this.screen = { width: rect.width, height: rect.height };
this.renderer.setSize(this.screen.width, this.screen.height);
this.camera.perspective({
aspect: this.gl.canvas.width / this.gl.canvas.height,
});
const fov = (this.camera.fov * Math.PI) / 180;
const height = 2 * Math.tan(fov / 2) * this.camera.position.z;
const width = height * this.camera.aspect;
this.viewport = { width, height };
this.medias?.forEach((media) =>
media.onResize({ screen: this.screen, viewport: this.viewport })
);
}
onTouchDown(e: MouseEvent | TouchEvent) {
this.isDown = true;
this.scroll.position = this.scroll.current;
this.start = e instanceof TouchEvent ? e.touches[0].clientY : e.clientY;
}
onTouchMove(e: MouseEvent | TouchEvent) {
if (!this.isDown || !this.scroll.position) return;
const y = e instanceof TouchEvent ? e.touches[0].clientY : e.clientY;
const distance = (this.start - y) * 0.1;
this.scroll.target = this.scroll.position + distance;
}
onTouchUp() {
this.isDown = false;
}
onWheel(e: WheelEvent) {
this.scroll.target += e.deltaY * 0.005;
}
update() {
this.scroll.current = lerp(
this.scroll.current,
this.scroll.target,
this.scroll.ease
);
this.medias?.forEach((media) => media.update(this.scroll));
this.renderer.render({ scene: this.scene, camera: this.camera });
this.scroll.last = this.scroll.current;
requestAnimationFrame(this.update);
}
addEventListeners() {
window.addEventListener("resize", this.onResize);
window.addEventListener("wheel", this.onWheel);
window.addEventListener("mousedown", this.onTouchDown);
window.addEventListener("mousemove", this.onTouchMove);
window.addEventListener("mouseup", this.onTouchUp);
window.addEventListener("touchstart", this.onTouchDown as EventListener);
window.addEventListener("touchmove", this.onTouchMove as EventListener);
window.addEventListener("touchend", this.onTouchUp as EventListener);
}
destroy() {
window.removeEventListener("resize", this.onResize);
window.removeEventListener("wheel", this.onWheel);
window.removeEventListener("mousedown", this.onTouchDown);
window.removeEventListener("mousemove", this.onTouchMove);
window.removeEventListener("mouseup", this.onTouchUp);
window.removeEventListener("touchstart", this.onTouchDown as EventListener);
window.removeEventListener("touchmove", this.onTouchMove as EventListener);
window.removeEventListener("touchend", this.onTouchUp as EventListener);
}
}
export interface FlyingPostersProps {
items?: string[];
planeWidth?: number;
planeHeight?: number;
distortion?: number;
scrollEase?: number;
cameraFov?: number;
cameraZ?: number;
className?: string;
}
export { Canvas, Media };
</script>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
const props = withDefaults(defineProps<FlyingPostersProps>(), {
items: () => [],
planeWidth: 320,
planeHeight: 320,
distortion: 3,
scrollEase: 0.01,
cameraFov: 45,
cameraZ: 20,
className: '',
})
const containerRef = ref<HTMLDivElement>()
const canvasRef = ref<HTMLCanvasElement>()
const instanceRef = ref<Canvas | null>(null)
const initCanvas = () => {
if (!containerRef.value || !canvasRef.value) return
instanceRef.value = new Canvas({
container: containerRef.value,
canvas: canvasRef.value,
items: props.items,
planeWidth: props.planeWidth,
planeHeight: props.planeHeight,
distortion: props.distortion,
scrollEase: props.scrollEase,
cameraFov: props.cameraFov,
cameraZ: props.cameraZ,
})
}
const destroyCanvas = () => {
if (instanceRef.value) {
instanceRef.value.destroy()
instanceRef.value = null
}
}
const handleWheel = (e: WheelEvent) => {
e.preventDefault()
if (instanceRef.value) {
instanceRef.value.onWheel(e)
}
}
const handleTouchMove = (e: TouchEvent) => {
e.preventDefault()
}
watch(
() => [
props.items,
props.planeWidth,
props.planeHeight,
props.distortion,
props.scrollEase,
props.cameraFov,
props.cameraZ,
],
() => {
destroyCanvas()
initCanvas()
},
{ deep: true }
)
onMounted(() => {
initCanvas()
if (canvasRef.value) {
const canvasEl = canvasRef.value
canvasEl.addEventListener('wheel', handleWheel, { passive: false })
canvasEl.addEventListener('touchmove', handleTouchMove, { passive: false })
}
})
onUnmounted(() => {
destroyCanvas()
if (canvasRef.value) {
const canvasEl = canvasRef.value
canvasEl.removeEventListener('wheel', handleWheel)
canvasEl.removeEventListener('touchmove', handleTouchMove)
}
})
</script>

View File

@@ -0,0 +1,80 @@
<template>
<div
:class="[
'grid gap-[5em] grid-cols-2 md:grid-cols-3 mx-auto py-[3em] overflow-visible',
className
]"
>
<button
v-for="(item, index) in items"
:key="index"
type="button"
:aria-label="item.label"
:class="[
'relative bg-transparent outline-none w-[4.5em] h-[4.5em] [perspective:24em] [transform-style:preserve-3d] [-webkit-tap-highlight-color:transparent] group',
item.customClass
]"
>
<span
class="absolute top-0 left-0 w-full h-full rounded-[1.25em] block transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.83,0,0.17,1)] origin-[100%_100%] rotate-[15deg] group-hover:[transform:rotate(25deg)_translate3d(-0.5em,-0.5em,0.5em)]"
:style="{
...getBackgroundStyle(item.color),
boxShadow: '0.5em -0.5em 0.75em hsla(223, 10%, 10%, 0.15)'
}"
></span>
<span
class="absolute top-0 left-0 w-full h-full rounded-[1.25em] bg-[hsla(0,0%,100%,0.15)] transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.83,0,0.17,1)] origin-[80%_50%] flex backdrop-blur-[0.75em] [-webkit-backdrop-filter:blur(0.75em)] transform group-hover:[transform:translateZ(2em)]"
:style="{
boxShadow: '0 0 0 0.1em hsla(0, 0%, 100%, 0.3) inset'
}"
>
<span
class="m-auto w-[1.5em] h-[1.5em] flex items-center justify-center"
aria-hidden="true"
>
<i :class="item.icon" class="text-xl"></i>
</span>
</span>
<span class="absolute top-full left-0 right-0 text-center whitespace-nowrap leading-[2] text-base opacity-0 transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.83,0,0.17,1)] translate-y-0 group-hover:opacity-100 group-hover:[transform:translateY(20%)]">
{{ item.label }}
</span>
</button>
</div>
</template>
<script setup lang="ts">
interface GlassIconsItem {
icon: string
color: string
label: string
customClass?: string
}
interface Props {
items: GlassIconsItem[]
className?: string
}
withDefaults(defineProps<Props>(), {
items: () => [],
className: ''
})
const gradientMapping: Record<string, string> = {
blue: 'linear-gradient(hsl(223, 90%, 50%), hsl(208, 90%, 50%))',
purple: 'linear-gradient(hsl(283, 90%, 50%), hsl(268, 90%, 50%))',
red: 'linear-gradient(hsl(3, 90%, 50%), hsl(348, 90%, 50%))',
indigo: 'linear-gradient(hsl(253, 90%, 50%), hsl(238, 90%, 50%))',
orange: 'linear-gradient(hsl(43, 90%, 50%), hsl(28, 90%, 50%))',
green: 'linear-gradient(hsl(123, 90%, 40%), hsl(108, 90%, 40%))'
}
const getBackgroundStyle = (color: string): Record<string, string> => {
if (gradientMapping[color]) {
return { background: gradientMapping[color] }
}
return { background: color }
}
</script>

View File

@@ -0,0 +1,381 @@
<template>
<div>
<div class="relative" ref="containerRef">
<nav
class="flex relative"
:style="{ transform: 'translate3d(0,0,0.01px)' }"
>
<ul
ref="navRef"
class="flex gap-8 list-none p-0 px-4 m-0 relative z-[3]"
:style="{
color: 'white',
textShadow: '0 1px 1px hsl(205deg 30% 10% / 0.2)',
}"
>
<li
v-for="(item, index) in items"
:key="index"
:class="[
'rounded-full relative cursor-pointer transition-[background-color_color_box-shadow] duration-300 ease shadow-[0_0_0.5px_1.5px_transparent] text-white',
{ active: activeIndex === index }
]"
>
<a
:href="item.href || undefined"
@click="(e) => handleClick(e, index)"
@keydown="(e) => handleKeyDown(e, index)"
class="outline-none py-[0.6em] px-[1em] inline-block"
>
{{ item.label }}
</a>
</li>
</ul>
</nav>
<span class="effect filter" ref="filterRef" />
<span class="effect text" ref="textRef" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
interface GooeyNavItem {
label: string
href: string | null
}
interface GooeyNavProps {
items: GooeyNavItem[]
animationTime?: number
particleCount?: number
particleDistances?: [number, number]
particleR?: number
timeVariance?: number
colors?: number[]
initialActiveIndex?: number
}
const props = withDefaults(defineProps<GooeyNavProps>(), {
animationTime: 600,
particleCount: 15,
particleDistances: () => [90, 10],
particleR: 100,
timeVariance: 300,
colors: () => [1, 2, 3, 1, 2, 3, 1, 4],
initialActiveIndex: 0,
})
const containerRef = ref<HTMLDivElement>()
const navRef = ref<HTMLUListElement>()
const filterRef = ref<HTMLSpanElement>()
const textRef = ref<HTMLSpanElement>()
const activeIndex = ref<number>(props.initialActiveIndex)
let resizeObserver: ResizeObserver | null = null
const noise = (n = 1) => n / 2 - Math.random() * n
const getXY = (
distance: number,
pointIndex: number,
totalPoints: number
): [number, number] => {
const angle =
((360 + noise(8)) / totalPoints) * pointIndex * (Math.PI / 180)
return [distance * Math.cos(angle), distance * Math.sin(angle)]
}
const createParticle = (
i: number,
t: number,
d: [number, number],
r: number
) => {
const rotate = noise(r / 10)
return {
start: getXY(d[0], props.particleCount - i, props.particleCount),
end: getXY(d[1] + noise(7), props.particleCount - i, props.particleCount),
time: t,
scale: 1 + noise(0.2),
color: props.colors[Math.floor(Math.random() * props.colors.length)],
rotate: rotate > 0 ? (rotate + r / 20) * 10 : (rotate - r / 20) * 10,
}
}
const makeParticles = (element: HTMLElement) => {
const d: [number, number] = props.particleDistances
const r = props.particleR
const bubbleTime = props.animationTime * 2 + props.timeVariance
element.style.setProperty('--time', `${bubbleTime}ms`)
for (let i = 0; i < props.particleCount; i++) {
const t = props.animationTime * 2 + noise(props.timeVariance * 2)
const p = createParticle(i, t, d, r)
element.classList.remove('active')
setTimeout(() => {
const particle = document.createElement('span')
const point = document.createElement('span')
particle.classList.add('particle')
particle.style.setProperty('--start-x', `${p.start[0]}px`)
particle.style.setProperty('--start-y', `${p.start[1]}px`)
particle.style.setProperty('--end-x', `${p.end[0]}px`)
particle.style.setProperty('--end-y', `${p.end[1]}px`)
particle.style.setProperty('--time', `${p.time}ms`)
particle.style.setProperty('--scale', `${p.scale}`)
particle.style.setProperty('--color', `var(--color-${p.color}, white)`)
particle.style.setProperty('--rotate', `${p.rotate}deg`)
point.classList.add('point')
particle.appendChild(point)
element.appendChild(particle)
requestAnimationFrame(() => {
element.classList.add('active')
})
setTimeout(() => {
try {
element.removeChild(particle)
} catch {}
}, t)
}, 30)
}
}
const updateEffectPosition = (element: HTMLElement) => {
if (!containerRef.value || !filterRef.value || !textRef.value) return
const containerRect = containerRef.value.getBoundingClientRect()
const pos = element.getBoundingClientRect()
const styles = {
left: `${pos.x - containerRect.x}px`,
top: `${pos.y - containerRect.y}px`,
width: `${pos.width}px`,
height: `${pos.height}px`,
}
Object.assign(filterRef.value.style, styles)
Object.assign(textRef.value.style, styles)
textRef.value.innerText = element.innerText
}
const handleClick = (e: Event, index: number) => {
const liEl = (e.currentTarget as HTMLElement).parentElement as HTMLElement
if (activeIndex.value === index) return
activeIndex.value = index
updateEffectPosition(liEl)
if (filterRef.value) {
const particles = filterRef.value.querySelectorAll('.particle')
particles.forEach((p) => filterRef.value!.removeChild(p))
}
if (textRef.value) {
textRef.value.classList.remove('active')
void textRef.value.offsetWidth
textRef.value.classList.add('active')
}
if (filterRef.value) {
makeParticles(filterRef.value)
}
}
const handleKeyDown = (e: KeyboardEvent, index: number) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
const liEl = (e.currentTarget as HTMLElement).parentElement
if (liEl) {
handleClick(
{
currentTarget: liEl,
} as unknown as Event,
index
)
}
}
}
watch(activeIndex, () => {
if (!navRef.value || !containerRef.value) return
const activeLi = navRef.value.querySelectorAll('li')[
activeIndex.value
] as HTMLElement
if (activeLi) {
updateEffectPosition(activeLi)
textRef.value?.classList.add('active')
}
})
onMounted(() => {
if (!navRef.value || !containerRef.value) return
const activeLi = navRef.value.querySelectorAll('li')[
activeIndex.value
] as HTMLElement
if (activeLi) {
updateEffectPosition(activeLi)
textRef.value?.classList.add('active')
}
resizeObserver = new ResizeObserver(() => {
const currentActiveLi = navRef.value?.querySelectorAll('li')[
activeIndex.value
] as HTMLElement
if (currentActiveLi) {
updateEffectPosition(currentActiveLi)
}
})
resizeObserver.observe(containerRef.value)
})
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect()
}
})
</script>
<style>
:root {
--linear-ease: linear(0, 0.068, 0.19 2.7%, 0.804 8.1%, 1.037, 1.199 13.2%, 1.245, 1.27 15.8%, 1.274, 1.272 17.4%, 1.249 19.1%, 0.996 28%, 0.949, 0.928 33.3%, 0.926, 0.933 36.8%, 1.001 45.6%, 1.013, 1.019 50.8%, 1.018 54.4%, 1 63.1%, 0.995 68%, 1.001 85%, 1);
}
.effect {
position: absolute;
opacity: 1;
pointer-events: none;
display: grid;
place-items: center;
z-index: 1;
}
.effect.text {
color: white;
transition: color 0.3s ease;
}
.effect.text.active {
color: black;
}
.effect.filter {
filter: blur(7px) contrast(100) blur(0);
mix-blend-mode: lighten;
}
.effect.filter::before {
content: "";
position: absolute;
inset: -75px;
z-index: -2;
background: black;
}
.effect.filter::after {
content: "";
position: absolute;
inset: 0;
background: white;
transform: scale(0);
opacity: 0;
z-index: -1;
border-radius: 9999px;
}
.effect.active::after {
animation: pill 0.3s ease both;
}
@keyframes pill {
to {
transform: scale(1);
opacity: 1;
}
}
.particle,
.point {
display: block;
opacity: 0;
width: 20px;
height: 20px;
border-radius: 9999px;
transform-origin: center;
}
.particle {
--time: 5s;
position: absolute;
top: calc(50% - 8px);
left: calc(50% - 8px);
animation: particle calc(var(--time)) ease 1 -350ms;
}
.point {
background: var(--color);
opacity: 1;
animation: point calc(var(--time)) ease 1 -350ms;
}
@keyframes particle {
0% {
transform: rotate(0deg) translate(calc(var(--start-x)), calc(var(--start-y)));
opacity: 1;
animation-timing-function: cubic-bezier(0.55, 0, 1, 0.45);
}
70% {
transform: rotate(calc(var(--rotate) * 0.5)) translate(calc(var(--end-x) * 1.2), calc(var(--end-y) * 1.2));
opacity: 1;
animation-timing-function: ease;
}
85% {
transform: rotate(calc(var(--rotate) * 0.66)) translate(calc(var(--end-x)), calc(var(--end-y)));
opacity: 1;
}
100% {
transform: rotate(calc(var(--rotate) * 1.2)) translate(calc(var(--end-x) * 0.5), calc(var(--end-y) * 0.5));
opacity: 1;
}
}
@keyframes point {
0% {
transform: scale(0);
opacity: 0;
animation-timing-function: cubic-bezier(0.55, 0, 1, 0.45);
}
25% {
transform: scale(calc(var(--scale) * 0.25));
}
38% {
opacity: 1;
}
65% {
transform: scale(var(--scale));
opacity: 1;
animation-timing-function: ease;
}
85% {
transform: scale(var(--scale));
opacity: 1;
}
100% {
transform: scale(0);
opacity: 0;
}
}
li.active {
color: black;
text-shadow: none;
}
li.active::after {
opacity: 1;
transform: scale(1);
}
li::after {
content: "";
position: absolute;
inset: 0;
border-radius: 8px;
background: white;
opacity: 0;
transform: scale(0);
transition: all 0.3s ease;
z-index: -1;
}
</style>

View File

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

View File

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

View File

@@ -0,0 +1,786 @@
<template>
<div ref="wrapRef" :class="`pc-card-wrapper ${className}`.trim()" :style="cardStyle">
<section ref="cardRef" class="pc-card">
<div class="pc-inside">
<div class="pc-shine" />
<div class="pc-glare" />
<div class="pc-content pc-avatar-content">
<img class="avatar" :src="avatarUrl" :alt="`${name || 'User'} avatar`" loading="lazy"
@error="handleAvatarError" />
<div v-if="showUserInfo" class="pc-user-info">
<div class="pc-user-details">
<div class="pc-mini-avatar">
<img :src="miniAvatarUrl || avatarUrl" :alt="`${name || 'User'} mini avatar`" loading="lazy"
@error="handleMiniAvatarError" />
</div>
<div class="pc-user-text">
<div class="pc-handle">@{{ handle }}</div>
<div class="pc-status">{{ status }}</div>
</div>
</div>
<button class="pc-contact-btn" @click="handleContactClick" style="pointer-events: auto" type="button"
:aria-label="`Contact ${name || 'user'}`">
{{ contactText }}
</button>
</div>
</div>
<div class="pc-content">
<div class="pc-details">
<h3>{{ name }}</h3>
<p>{{ title }}</p>
</div>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
interface Props {
avatarUrl?: string
iconUrl?: string
grainUrl?: string
behindGradient?: string
innerGradient?: string
showBehindGradient?: boolean
className?: string
enableTilt?: boolean
miniAvatarUrl?: string
name?: string
title?: string
handle?: string
status?: string
contactText?: string
showUserInfo?: boolean
}
const props = withDefaults(defineProps<Props>(), {
avatarUrl: '<Placeholder for avatar URL>',
iconUrl: '<Placeholder for icon URL>',
grainUrl: '<Placeholder for grain URL>',
behindGradient: undefined,
innerGradient: undefined,
showBehindGradient: true,
className: '',
enableTilt: true,
miniAvatarUrl: undefined,
name: 'Javi A. Torres',
title: 'Software Engineer',
handle: 'javicodes',
status: 'Online',
contactText: 'Contact',
showUserInfo: true
})
const emit = defineEmits<{
contactClick: []
}>()
const wrapRef = ref<HTMLDivElement>()
const cardRef = ref<HTMLElement>()
const DEFAULT_BEHIND_GRADIENT = "radial-gradient(farthest-side circle at var(--pointer-x) var(--pointer-y),hsla(266,100%,90%,var(--card-opacity)) 4%,hsla(266,50%,80%,calc(var(--card-opacity)*0.75)) 10%,hsla(266,25%,70%,calc(var(--card-opacity)*0.5)) 50%,hsla(266,0%,60%,0) 100%),radial-gradient(35% 52% at 55% 20%,#00ffaac4 0%,#073aff00 100%),radial-gradient(100% 100% at 50% 50%,#00c1ffff 1%,#073aff00 76%),conic-gradient(from 124deg at 50% 50%,#c137ffff 0%,#07c6ffff 40%,#07c6ffff 60%,#c137ffff 100%)"
const DEFAULT_INNER_GRADIENT = "linear-gradient(145deg,#60496e8c 0%,#71C4FF44 100%)"
const ANIMATION_CONFIG = {
SMOOTH_DURATION: 600,
INITIAL_DURATION: 1500,
INITIAL_X_OFFSET: 70,
INITIAL_Y_OFFSET: 60,
} as const
const clamp = (value: number, min = 0, max = 100): number =>
Math.min(Math.max(value, min), max)
const round = (value: number, precision = 3): number =>
parseFloat(value.toFixed(precision))
const adjust = (
value: number,
fromMin: number,
fromMax: number,
toMin: number,
toMax: number
): number =>
round(toMin + ((toMax - toMin) * (value - fromMin)) / (fromMax - fromMin))
const easeInOutCubic = (x: number): number =>
x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2
let rafId: number | null = null
const updateCardTransform = (
offsetX: number,
offsetY: number,
card: HTMLElement,
wrap: HTMLElement
) => {
const width = card.clientWidth
const height = card.clientHeight
const percentX = clamp((100 / width) * offsetX)
const percentY = clamp((100 / height) * offsetY)
const centerX = percentX - 50
const centerY = percentY - 50
const properties = {
'--pointer-x': `${percentX}%`,
'--pointer-y': `${percentY}%`,
'--background-x': `${adjust(percentX, 0, 100, 35, 65)}%`,
'--background-y': `${adjust(percentY, 0, 100, 35, 65)}%`,
'--pointer-from-center': `${clamp(Math.hypot(percentY - 50, percentX - 50) / 50, 0, 1)}`,
'--pointer-from-top': `${percentY / 100}`,
'--pointer-from-left': `${percentX / 100}`,
'--rotate-x': `${round(-(centerX / 5))}deg`,
'--rotate-y': `${round(centerY / 4)}deg`,
}
Object.entries(properties).forEach(([property, value]) => {
wrap.style.setProperty(property, value)
})
}
const createSmoothAnimation = (
duration: number,
startX: number,
startY: number,
card: HTMLElement,
wrap: HTMLElement
) => {
const startTime = performance.now()
const targetX = wrap.clientWidth / 2
const targetY = wrap.clientHeight / 2
const animationLoop = (currentTime: number) => {
const elapsed = currentTime - startTime
const progress = clamp(elapsed / duration)
const easedProgress = easeInOutCubic(progress)
const currentX = adjust(easedProgress, 0, 1, startX, targetX)
const currentY = adjust(easedProgress, 0, 1, startY, targetY)
updateCardTransform(currentX, currentY, card, wrap)
if (progress < 1) {
rafId = requestAnimationFrame(animationLoop)
}
}
rafId = requestAnimationFrame(animationLoop)
}
const cancelAnimation = () => {
if (rafId) {
cancelAnimationFrame(rafId)
rafId = null
}
}
const handlePointerMove = (event: PointerEvent) => {
const card = cardRef.value
const wrap = wrapRef.value
if (!card || !wrap || !props.enableTilt) return
const rect = card.getBoundingClientRect()
updateCardTransform(
event.clientX - rect.left,
event.clientY - rect.top,
card,
wrap
)
}
const handlePointerEnter = () => {
const card = cardRef.value
const wrap = wrapRef.value
if (!card || !wrap || !props.enableTilt) return
cancelAnimation()
wrap.classList.add('active')
card.classList.add('active')
}
const handlePointerLeave = (event: PointerEvent) => {
const card = cardRef.value
const wrap = wrapRef.value
if (!card || !wrap || !props.enableTilt) return
createSmoothAnimation(
ANIMATION_CONFIG.SMOOTH_DURATION,
event.offsetX,
event.offsetY,
card,
wrap
)
wrap.classList.remove('active')
card.classList.remove('active')
}
const cardStyle = computed(() => ({
'--icon': props.iconUrl ? `url(${props.iconUrl})` : 'none',
'--grain': props.grainUrl ? `url(${props.grainUrl})` : 'none',
'--behind-gradient': props.showBehindGradient
? (props.behindGradient ?? DEFAULT_BEHIND_GRADIENT)
: 'none',
'--inner-gradient': props.innerGradient ?? DEFAULT_INNER_GRADIENT,
}))
const handleContactClick = () => {
emit('contactClick')
}
const handleAvatarError = (event: Event) => {
const target = event.target as HTMLImageElement
target.style.display = 'none'
}
const handleMiniAvatarError = (event: Event) => {
const target = event.target as HTMLImageElement
target.style.opacity = '0.5'
target.src = props.avatarUrl
}
onMounted(() => {
if (!props.enableTilt) return
const card = cardRef.value
const wrap = wrapRef.value
if (!card || !wrap) return
card.addEventListener('pointerenter', handlePointerEnter)
card.addEventListener('pointermove', handlePointerMove)
card.addEventListener('pointerleave', handlePointerLeave)
const initialX = wrap.clientWidth - ANIMATION_CONFIG.INITIAL_X_OFFSET
const initialY = ANIMATION_CONFIG.INITIAL_Y_OFFSET
updateCardTransform(initialX, initialY, card, wrap)
createSmoothAnimation(
ANIMATION_CONFIG.INITIAL_DURATION,
initialX,
initialY,
card,
wrap
)
})
onUnmounted(() => {
const card = cardRef.value
if (card) {
card.removeEventListener('pointerenter', handlePointerEnter)
card.removeEventListener('pointermove', handlePointerMove)
card.removeEventListener('pointerleave', handlePointerLeave)
}
cancelAnimation()
})
</script>
<style scoped>
.pc-card-wrapper {
--pointer-x: 50%;
--pointer-y: 50%;
--pointer-from-center: 0;
--pointer-from-top: 0.5;
--pointer-from-left: 0.5;
--card-opacity: 0;
--rotate-x: 0deg;
--rotate-y: 0deg;
--background-x: 50%;
--background-y: 50%;
--grain: none;
--icon: none;
--behind-gradient: none;
--inner-gradient: none;
--sunpillar-1: hsl(2, 100%, 73%);
--sunpillar-2: hsl(53, 100%, 69%);
--sunpillar-3: hsl(93, 100%, 69%);
--sunpillar-4: hsl(176, 100%, 76%);
--sunpillar-5: hsl(228, 100%, 74%);
--sunpillar-6: hsl(283, 100%, 73%);
--sunpillar-clr-1: var(--sunpillar-1);
--sunpillar-clr-2: var(--sunpillar-2);
--sunpillar-clr-3: var(--sunpillar-3);
--sunpillar-clr-4: var(--sunpillar-4);
--sunpillar-clr-5: var(--sunpillar-5);
--sunpillar-clr-6: var(--sunpillar-6);
--card-radius: 30px;
}
.pc-card-wrapper {
perspective: 500px;
transform: translate3d(0, 0, 0.1px);
position: relative;
touch-action: none;
}
.pc-card-wrapper::before {
content: '';
position: absolute;
inset: -10px;
background: inherit;
background-position: inherit;
border-radius: inherit;
transition: all 0.5s ease;
filter: contrast(2) saturate(2) blur(36px);
transform: scale(0.8) translate3d(0, 0, 0.1px);
background-size: 100% 100%;
background-image: var(--behind-gradient);
}
.pc-card-wrapper:hover,
.pc-card-wrapper.active {
--card-opacity: 1;
}
.pc-card-wrapper:hover::before,
.pc-card-wrapper.active::before {
filter: contrast(1) saturate(2) blur(40px) opacity(1);
transform: scale(0.9) translate3d(0, 0, 0.1px);
}
.pc-card {
height: 80svh;
max-height: 540px;
display: grid;
aspect-ratio: 0.718;
border-radius: var(--card-radius);
position: relative;
background-blend-mode: color-dodge, normal, normal, normal;
animation: glow-bg 12s linear infinite;
box-shadow: rgba(0, 0, 0, 0.8) calc((var(--pointer-from-left) * 10px) - 3px) calc((var(--pointer-from-top) * 20px) - 6px) 20px -5px;
transition: transform 1s ease;
transform: translate3d(0, 0, 0.1px) rotateX(0deg) rotateY(0deg);
background-size: 100% 100%;
background-position: 0 0, 0 0, 50% 50%, 0 0;
background-image: radial-gradient(farthest-side circle at var(--pointer-x) var(--pointer-y), hsla(266, 100%, 90%, var(--card-opacity)) 4%, hsla(266, 50%, 80%, calc(var(--card-opacity) * 0.75)) 10%, hsla(266, 25%, 70%, calc(var(--card-opacity) * 0.5)) 50%, hsla(266, 0%, 60%, 0) 100%), radial-gradient(35% 52% at 55% 20%, #00ffaac4 0%, #073aff00 100%), radial-gradient(100% 100% at 50% 50%, #00c1ffff 1%, #073aff00 76%), conic-gradient(from 124deg at 50% 50%, #c137ffff 0%, #07c6ffff 40%, #07c6ffff 60%, #c137ffff 100%);
overflow: hidden;
}
.pc-card:hover,
.pc-card.active {
transition: none;
transform: translate3d(0, 0, 0.1px) rotateX(var(--rotate-y)) rotateY(var(--rotate-x));
}
.pc-card * {
display: grid;
grid-area: 1/-1;
border-radius: var(--card-radius);
transform: translate3d(0, 0, 0.1px);
pointer-events: none;
}
.pc-inside {
inset: 1px;
position: absolute;
background-image: var(--inner-gradient);
background-color: rgba(0, 0, 0, 0.9);
transform: translate3d(0, 0, 0.01px);
}
.pc-shine {
mask-image: var(--icon);
mask-mode: luminance;
mask-repeat: repeat;
mask-size: 150%;
mask-position: top calc(200% - (var(--background-y) * 5)) left calc(100% - var(--background-x));
-webkit-mask-image: var(--icon);
-webkit-mask-mode: luminance;
-webkit-mask-repeat: repeat;
-webkit-mask-size: 150%;
-webkit-mask-position: top calc(200% - (var(--background-y) * 5)) left calc(100% - var(--background-x));
transition: filter 0.6s ease;
filter: brightness(0.66) contrast(1.33) saturate(0.33) opacity(0.5);
animation: holo-bg 18s linear infinite;
mix-blend-mode: color-dodge;
}
.pc-shine,
.pc-shine::after {
--space: 5%;
--angle: -45deg;
transform: translate3d(0, 0, 1px);
overflow: hidden;
z-index: 3;
background: transparent;
background-size: cover;
background-position: center;
background-image: repeating-linear-gradient(0deg, var(--sunpillar-clr-1) calc(var(--space) * 1), var(--sunpillar-clr-2) calc(var(--space) * 2), var(--sunpillar-clr-3) calc(var(--space) * 3), var(--sunpillar-clr-4) calc(var(--space) * 4), var(--sunpillar-clr-5) calc(var(--space) * 5), var(--sunpillar-clr-6) calc(var(--space) * 6), var(--sunpillar-clr-1) calc(var(--space) * 7)), repeating-linear-gradient(var(--angle), #0e152e 0%, hsl(180, 10%, 60%) 3.8%, hsl(180, 29%, 66%) 4.5%, hsl(180, 10%, 60%) 5.2%, #0e152e 10%, #0e152e 12%), radial-gradient(farthest-corner circle at var(--pointer-x) var(--pointer-y), hsla(0, 0%, 0%, 0.1) 12%, hsla(0, 0%, 0%, 0.15) 20%, hsla(0, 0%, 0%, 0.25) 120%);
background-position: 0 var(--background-y), var(--background-x) var(--background-y), center;
background-blend-mode: color, hard-light;
background-size: 500% 500%, 300% 300%, 200% 200%;
background-repeat: repeat;
}
.pc-shine::before,
.pc-shine::after {
content: '';
background-position: center;
background-size: cover;
grid-area: 1/1;
opacity: 0;
}
.pc-card:hover .pc-shine,
.pc-card.active .pc-shine {
filter: brightness(0.85) contrast(1.5) saturate(0.5);
animation: none;
}
.pc-card:hover .pc-shine::before,
.pc-card.active .pc-shine::before,
.pc-card:hover .pc-shine::after,
.pc-card.active .pc-shine::after {
opacity: 1;
}
.pc-shine::before {
background-image: linear-gradient(45deg, var(--sunpillar-4), var(--sunpillar-5), var(--sunpillar-6), var(--sunpillar-1), var(--sunpillar-2), var(--sunpillar-3)), radial-gradient(circle at var(--pointer-x) var(--pointer-y), hsl(0, 0%, 70%) 0%, hsla(0, 0%, 30%, 0.2) 90%), var(--grain);
background-size: 250% 250%, 100% 100%, 220px 220px;
background-position: var(--pointer-x) var(--pointer-y), center, calc(var(--pointer-x) * 0.01) calc(var(--pointer-y) * 0.01);
background-blend-mode: color-dodge;
filter: brightness(calc(2 - var(--pointer-from-center))) contrast(calc(var(--pointer-from-center) + 2)) saturate(calc(0.5 + var(--pointer-from-center)));
mix-blend-mode: luminosity;
}
.pc-shine::after {
content: '';
background-image: repeating-linear-gradient(0deg, var(--sunpillar-clr-1) calc(5% * 1), var(--sunpillar-clr-2) calc(5% * 2), var(--sunpillar-clr-3) calc(5% * 3), var(--sunpillar-clr-4) calc(5% * 4), var(--sunpillar-clr-5) calc(5% * 5), var(--sunpillar-clr-6) calc(5% * 6), var(--sunpillar-clr-1) calc(5% * 7)), repeating-linear-gradient(-45deg, #0e152e 0%, hsl(180, 10%, 60%) 3.8%, hsl(180, 29%, 66%) 4.5%, hsl(180, 10%, 60%) 5.2%, #0e152e 10%, #0e152e 12%), radial-gradient(farthest-corner circle at var(--pointer-x) var(--pointer-y), hsla(0, 0%, 0%, 0.1) 12%, hsla(0, 0%, 0%, 0.15) 20%, hsla(0, 0%, 0%, 0.25) 120%);
background-position: 0 var(--background-y), calc(var(--background-x) * 0.4) calc(var(--background-y) * 0.5), center;
background-size: 200% 300%, 700% 700%, 100% 100%;
mix-blend-mode: difference;
filter: brightness(0.8) contrast(1.5);
}
.pc-glare {
transform: translate3d(0, 0, 1.1px);
overflow: hidden;
background-image: radial-gradient(farthest-corner circle at var(--pointer-x) var(--pointer-y), hsl(248, 25%, 80%) 12%, hsla(207, 40%, 30%, 0.8) 90%);
mix-blend-mode: overlay;
filter: brightness(0.8) contrast(1.2);
z-index: 4;
}
.pc-avatar-content {
mix-blend-mode: screen;
overflow: hidden;
}
.pc-avatar-content .avatar {
width: 100%;
position: absolute;
left: 50%;
transform: translateX(-50%) scale(1);
bottom: 2px;
opacity: calc(1.75 - var(--pointer-from-center));
}
.pc-avatar-content::before {
content: "";
position: absolute;
inset: 0;
z-index: 1;
backdrop-filter: blur(30px);
mask: linear-gradient(to bottom,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0) 60%,
rgba(0, 0, 0, 1) 90%,
rgba(0, 0, 0, 1) 100%);
pointer-events: none;
}
.pc-user-info {
position: absolute;
bottom: 20px;
left: 20px;
right: 20px;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(30px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 15px;
padding: 12px 14px;
pointer-events: auto;
}
.pc-user-details {
display: flex;
align-items: center;
gap: 12px;
}
.pc-mini-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
flex-shrink: 0;
}
.pc-mini-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
.pc-user-text {
display: flex;
align-items: flex-start;
flex-direction: column;
gap: 6px;
}
.pc-handle {
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
line-height: 1;
}
.pc-status {
font-size: 14px;
color: rgba(255, 255, 255, 0.7);
line-height: 1;
}
.pc-contact-btn {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 8px 16px;
font-size: 14px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
cursor: pointer;
transition: all 0.2s ease;
backdrop-filter: blur(10px);
background: transparent;
}
.pc-contact-btn:hover {
border-color: rgba(255, 255, 255, 0.4);
transform: translateY(-1px);
transition: all 0.2s ease;
}
.pc-content {
max-height: 100%;
overflow: hidden;
text-align: center;
position: relative;
transform: translate3d(calc(var(--pointer-from-left) * -6px + 3px), calc(var(--pointer-from-top) * -6px + 3px), 0.1px) !important;
z-index: 5;
mix-blend-mode: luminosity;
}
.pc-details {
width: 100%;
position: absolute;
top: 3em;
display: flex;
flex-direction: column;
}
.pc-details h3 {
font-weight: 600;
margin: 0;
font-size: min(5svh, 3em);
margin: 0;
background-image: linear-gradient(to bottom, #fff, #6f6fbe);
background-size: 1em 1.5em;
-webkit-text-fill-color: transparent;
background-clip: text;
-webkit-background-clip: text;
}
.pc-details p {
font-weight: 600;
position: relative;
top: -12px;
white-space: nowrap;
font-size: 16px;
margin: 0 auto;
width: min-content;
background-image: linear-gradient(to bottom, #fff, #4a4ac0);
background-size: 1em 1.5em;
-webkit-text-fill-color: transparent;
background-clip: text;
-webkit-background-clip: text;
}
@keyframes glow-bg {
0% {
--bgrotate: 0deg;
}
100% {
--bgrotate: 360deg;
}
}
@keyframes holo-bg {
0% {
background-position: 0 var(--background-y), 0 0, center;
}
100% {
background-position: 0 var(--background-y), 90% 90%, center;
}
}
@media (max-width: 768px) {
.pc-card {
height: 70svh;
max-height: 450px;
}
.pc-details {
top: 2em;
}
.pc-details h3 {
font-size: min(4svh, 2.5em);
}
.pc-details p {
font-size: 14px;
}
.pc-user-info {
bottom: 15px;
left: 15px;
right: 15px;
padding: 10px 12px;
}
.pc-mini-avatar {
width: 28px;
height: 28px;
}
.pc-user-details {
gap: 10px;
}
.pc-handle {
font-size: 13px;
}
.pc-status {
font-size: 10px;
}
.pc-contact-btn {
padding: 6px 12px;
font-size: 11px;
}
}
@media (max-width: 480px) {
.pc-card {
height: 60svh;
max-height: 380px;
}
.pc-details {
top: 1.5em;
}
.pc-details h3 {
font-size: min(3.5svh, 2em);
}
.pc-details p {
font-size: 12px;
top: -8px;
}
.pc-user-info {
bottom: 12px;
left: 12px;
right: 12px;
padding: 8px 10px;
border-radius: 50px;
}
.pc-mini-avatar {
width: 24px;
height: 24px;
}
.pc-user-details {
gap: 8px;
}
.pc-handle {
font-size: 12px;
}
.pc-status {
font-size: 9px;
}
.pc-contact-btn {
padding: 5px 10px;
font-size: 10px;
border-radius: 50px;
}
}
@media (max-width: 320px) {
.pc-card {
height: 55svh;
max-height: 320px;
}
.pc-details h3 {
font-size: min(3svh, 1.5em);
}
.pc-details p {
font-size: 11px;
}
.pc-user-info {
padding: 6px 8px;
border-radius: 50px;
}
.pc-mini-avatar {
width: 20px;
height: 20px;
}
.pc-user-details {
gap: 6px;
}
.pc-handle {
font-size: 11px;
}
.pc-status {
font-size: 8px;
}
.pc-contact-btn {
padding: 4px 8px;
font-size: 9px;
border-radius: 50px;
}
}
</style>

View File

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

View File

@@ -0,0 +1,141 @@
<script setup lang="ts">
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
}
const props = withDefaults(defineProps<BlurTextProps>(), {
text: '',
delay: 200,
className: '',
animateBy: 'words',
direction: 'top',
threshold: 0.1,
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 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 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 }
)
const defaultTo = computed(() => [
{
filter: 'blur(5px)',
opacity: 0.5,
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 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)
)
)
const setupObserver = () => {
if (!rootRef.value) return
observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
inView.value = true
observer?.unobserve(rootRef.value as Element)
}
},
{ threshold: props.threshold, rootMargin: props.rootMargin }
)
observer.observe(rootRef.value)
}
onMounted(() => {
setupObserver()
})
onUnmounted(() => {
observer?.disconnect()
})
watch([() => props.threshold, () => props.rootMargin], () => {
observer?.disconnect()
setupObserver()
})
const getAnimateKeyframes = () => {
return buildKeyframes(fromSnapshot.value, toSnapshots.value)
}
const getTransition = (index: number) => {
return {
duration: totalDuration.value,
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()
}
}
</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="{
display: 'inline-block',
willChange: 'transform, filter, opacity'
}" @animation-complete="handleAnimationComplete(index)">
{{ segment === ' ' ? '\u00A0' : segment }}{{ animateBy === 'words' && index < elements.length - 1 ? '\u00A0' : ''
}} </Motion>
</p>
</template>

View File

@@ -0,0 +1,129 @@
<script setup lang="ts">
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
}
const props = withDefaults(defineProps<CircularTextProps>(), {
text: '',
spinDuration: 20,
onHover: 'speedUp',
className: ''
})
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 getCurrentSpeed = () => {
if (isHovered.value && props.onHover === 'pause') return 0
const baseDuration = props.spinDuration
const baseSpeed = 360 / baseDuration
if (!isHovered.value) return baseSpeed
switch (props.onHover) {
case 'slowDown':
return baseSpeed / 2
case 'speedUp':
return baseSpeed * 4
case 'goBonkers':
return baseSpeed * 20
default:
return baseSpeed
}
}
const getCurrentScale = () => {
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 targetSpeed = getCurrentSpeed()
const speedDiff = targetSpeed - rotationSpeed.value
const smoothingFactor = Math.min(1, deltaTime * 5)
rotationSpeed.value += speedDiff * smoothingFactor
currentRotation.value = (currentRotation.value + rotationSpeed.value * deltaTime) % 360
animationId.value = requestAnimationFrame(animate)
}
const startAnimation = () => {
if (animationId.value) {
cancelAnimationFrame(animationId.value)
}
lastTime.value = Date.now()
rotationSpeed.value = getCurrentSpeed()
animate()
}
watchEffect(() => {
startAnimation()
})
startAnimation()
onUnmounted(() => {
if (animationId.value) {
cancelAnimationFrame(animationId.value)
}
})
const handleHoverStart = () => {
isHovered.value = true
}
const handleHoverEnd = () => {
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)`
}
</script>
<template>
<Motion :animate="{
rotate: currentRotation,
scale: getCurrentScale()
}" :transition="{
rotate: {
duration: 0
},
scale: {
type: 'spring',
damping: 20,
stiffness: 300
}
}"
: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="{
transform: getLetterTransform(i),
WebkitTransform: getLetterTransform(i)
}">
{{ letter }}
</span>
</Motion>
</template>

View File

@@ -0,0 +1,193 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, computed, nextTick } from 'vue'
interface CurvedLoopProps {
marqueeText?: string
speed?: number
className?: string
curveAmount?: number
direction?: 'left' | 'right'
interactive?: boolean
}
const props = withDefaults(defineProps<CurvedLoopProps>(), {
marqueeText: '',
speed: 2,
className: '',
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 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 dragRef = ref(false)
const lastXRef = ref(0)
const dirRef = ref<'left' | 'right'>(props.direction)
const velRef = ref(0)
let animationFrame: number | null = null
const updateSpacing = () => {
if (measureRef.value) {
spacing.value = measureRef.value.getComputedTextLength()
}
}
const updatePathLength = () => {
if (pathRef.value) {
pathLength.value = pathRef.value.getTotalLength()
}
}
const animate = () => {
if (!spacing.value) return
const step = () => {
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 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
}
}
const repeats = computed(() => {
return pathLength.value && spacing.value ? Math.ceil(pathLength.value / spacing.value) + 2 : 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)
}
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())
})
}
const endDrag = () => {
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'
})
onMounted(() => {
nextTick(() => {
updateSpacing()
updatePathLength()
animate()
})
})
onUnmounted(() => {
stopAnimation()
})
watch([text, () => props.className], () => {
nextTick(() => {
updateSpacing()
})
})
watch(() => props.curveAmount, () => {
nextTick(() => {
updatePathLength()
})
})
watch([spacing, () => props.speed], () => {
stopAnimation()
if (spacing.value) {
animate()
}
})
watch(repeats, () => {
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">
<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;">
{{ text }}
</text>
<defs>
<path ref="pathRef" :id="pathId" :d="pathD" fill="none" stroke="transparent" />
</defs>
<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
}">
{{ text }}
</tspan>
</textPath>
</text>
</svg>
</div>
</template>

View File

@@ -0,0 +1,229 @@
<script setup lang="ts">
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'
}
const props = withDefaults(defineProps<DecryptedTextProps>(), {
text: '',
speed: 50,
maxIterations: 10,
sequential: false,
revealDirection: 'start',
useOriginalCharsOnly: false,
characters: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&*()_+',
className: '',
parentClassName: '',
encryptedClassName: '',
animateOn: 'hover'
})
const emit = defineEmits<{
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)
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
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
}
for (let i = 0; i < textLength; i++) {
if (!revealedSet.has(i)) return i
}
return 0
}
default:
return revealedSet.size
}
}
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 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')
}
} 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
}
}
const handleMouseLeave = () => {
if (props.animateOn === 'hover') {
isHovering.value = false
}
}
onMounted(async () => {
if (props.animateOn === 'view') {
await nextTick()
const observerCallback = (entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !hasAnimated.value) {
isHovering.value = true
hasAnimated.value = true
}
})
}
const observerOptions = {
root: null,
rootMargin: '0px',
threshold: 0.1
}
intersectionObserver = new IntersectionObserver(observerCallback, observerOptions)
if (containerRef.value) {
intersectionObserver.observe(containerRef.value)
}
}
})
onUnmounted(() => {
if (interval) {
clearInterval(interval)
}
if (intersectionObserver && containerRef.value) {
intersectionObserver.unobserve(containerRef.value)
}
})
</script>
<template>
<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">
{{ char }}
</span>
</span>
</span>
</template>

View File

@@ -0,0 +1,286 @@
<script setup lang="ts">
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
}
const props = withDefaults(defineProps<FallingTextProps>(), {
text: '',
highlightWords: () => [],
trigger: 'auto',
backgroundColor: 'transparent',
wireframes: false,
gravity: 1,
mouseConstraintStiffness: 0.2,
fontSize: '1rem'
})
const containerRef = ref<HTMLDivElement>()
const textRef = ref<HTMLDivElement>()
const canvasContainerRef = ref<HTMLDivElement>()
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
const createTextHTML = () => {
if (!textRef.value) return
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>`
})
.join(' ')
textRef.value.innerHTML = newHTML
}
const setupTrigger = () => {
if (props.trigger === 'auto') {
setTimeout(() => {
effectStarted.value = true
}, 100)
return
}
if (props.trigger === 'scroll' && containerRef.value) {
intersectionObserver = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
effectStarted.value = true
intersectionObserver?.disconnect()
}
},
{ threshold: 0.1 }
)
intersectionObserver.observe(containerRef.value)
}
}
const handleTrigger = () => {
if (!effectStarted.value && (props.trigger === 'click' || props.trigger === 'hover')) {
effectStarted.value = true
}
}
const startPhysics = async () => {
if (!containerRef.value || !canvasContainerRef.value || !textRef.value) return
await nextTick()
const { Engine, Render, World, Bodies, Runner, Mouse, MouseConstraint } = Matter
const containerRect = containerRef.value.getBoundingClientRect()
const width = containerRect.width
const height = containerRect.height
if (width <= 0 || height <= 0) return
engine = Engine.create()
engine.world.gravity.y = props.gravity
render = Render.create({
element: canvasContainerRef.value,
engine,
options: {
width,
height,
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 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 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)
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'
})
const mouse = Mouse.create(containerRef.value)
mouseConstraint = MouseConstraint.create(engine, {
mouse,
constraint: {
stiffness: props.mouseConstraintStiffness,
render: { visible: false }
}
})
render.mouse = mouse
World.add(engine.world, [
floor,
leftWall,
rightWall,
ceiling,
mouseConstraint,
...wordBodies.map((wb) => wb.body)
])
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 cleanup = () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId)
animationFrameId = null
}
if (render) {
Matter.Render.stop(render)
if (render.canvas && canvasContainerRef.value) {
canvasContainerRef.value.removeChild(render.canvas)
}
render = null
}
if (runner && engine) {
Matter.Runner.stop(runner)
runner = null
}
if (engine) {
Matter.World.clear(engine.world, false)
Matter.Engine.clear(engine)
engine = null
}
if (intersectionObserver) {
intersectionObserver.disconnect()
intersectionObserver = null
}
mouseConstraint = null
wordBodies = []
}
watch(
() => [props.text, props.highlightWords],
() => {
createTextHTML()
},
{ immediate: true, deep: true }
)
watch(
() => props.trigger,
() => {
effectStarted.value = false
cleanup()
setupTrigger()
},
{ immediate: true }
)
watch(
() => effectStarted.value,
(started) => {
if (started) {
startPhysics()
}
}
)
watch(
() => [
props.gravity,
props.wireframes,
props.backgroundColor,
props.mouseConstraintStiffness
],
() => {
if (effectStarted.value) {
cleanup()
startPhysics()
}
},
{ deep: true }
)
onMounted(() => {
createTextHTML()
setupTrigger()
})
onUnmounted(() => {
cleanup()
})
</script>
<template>
<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
}" />
<div class="absolute top-0 left-0 z-0" ref="canvasContainerRef" />
</div>
</template>

View File

@@ -0,0 +1,294 @@
<script setup lang="ts">
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
}
const props = withDefaults(defineProps<FuzzyTextProps>(), {
text: '',
fontSize: 'clamp(2rem, 8vw, 8rem)',
fontWeight: 900,
fontFamily: 'inherit',
color: '#fff',
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 waitForFont = async (fontFamily: string, fontWeight: string | number, fontSize: string): Promise<boolean> => {
if (document.fonts?.check) {
const fontString = `${fontWeight} ${fontSize} ${fontFamily}`
if (document.fonts.check(fontString)) {
return true
}
try {
await document.fonts.load(fontString)
return document.fonts.check(fontString)
} catch (error) {
console.warn('Font loading failed:', error)
return false
}
}
return new Promise((resolve) => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) {
resolve(false)
return
}
ctx.font = `${fontWeight} ${fontSize} ${fontFamily}`
const testWidth = ctx.measureText('M').width
let attempts = 0
const checkFont = () => {
ctx.font = `${fontWeight} ${fontSize} ${fontFamily}`
const newWidth = ctx.measureText('M').width
if (newWidth !== testWidth && newWidth > 0) {
resolve(true)
} else if (attempts < 20) {
attempts++
setTimeout(checkFont, 50)
} else {
resolve(false)
}
}
setTimeout(checkFont, 10)
})
}
const initCanvas = async () => {
if (document.fonts?.ready) {
await document.fonts.ready
}
if (isCancelled) return
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
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
if (typeof props.fontSize === 'number') {
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 fontLoaded = await waitForFont(computedFontFamily, props.fontWeight, fontSizeStr)
if (!fontLoaded) {
console.warn(`Font not loaded: ${computedFontFamily}`)
}
const text = props.text
const offscreen = document.createElement('canvas')
const offCtx = offscreen.getContext('2d')
if (!offCtx) return
const fontString = `${props.fontWeight} ${fontSizeStr} ${computedFontFamily}`
offCtx.font = fontString
const testMetrics = offCtx.measureText('M')
if (testMetrics.width === 0) {
setTimeout(() => {
if (!isCancelled) {
initCanvas()
}
}, 100)
return
}
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 textBoundingWidth = Math.ceil(actualLeft + actualRight)
const tightHeight = Math.ceil(actualAscent + actualDescent)
const extraWidthBuffer = 10
const offscreenWidth = textBoundingWidth + extraWidthBuffer
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 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
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
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
)
}
animationFrameId = window.requestAnimationFrame(run)
}
run()
const isInsideTextArea = (x: number, y: number) =>
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)
}
const handleMouseLeave = () => {
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)
}
const handleTouchEnd = () => {
isHovering = false
}
if (props.enableHover) {
canvas.addEventListener('mousemove', handleMouseMove)
canvas.addEventListener('mouseleave', handleMouseLeave)
canvas.addEventListener('touchmove', handleTouchMove, { passive: false })
canvas.addEventListener('touchend', handleTouchEnd)
}
cleanup = () => {
window.cancelAnimationFrame(animationFrameId)
if (props.enableHover) {
canvas.removeEventListener('mousemove', handleMouseMove)
canvas.removeEventListener('mouseleave', handleMouseLeave)
canvas.removeEventListener('touchmove', handleTouchMove)
canvas.removeEventListener('touchend', handleTouchEnd)
}
}
}
onMounted(() => {
nextTick(() => {
initCanvas()
})
})
onUnmounted(() => {
isCancelled = true
if (animationFrameId) {
window.cancelAnimationFrame(animationFrameId)
}
if (cleanup) {
cleanup()
}
})
watch(
[
() => props.text,
() => props.fontSize,
() => props.fontWeight,
() => props.fontFamily,
() => props.color,
() => props.enableHover,
() => props.baseIntensity,
() => props.hoverIntensity
],
() => {
isCancelled = true
if (animationFrameId) {
window.cancelAnimationFrame(animationFrameId)
}
if (cleanup) {
cleanup()
}
isCancelled = false
nextTick(() => {
initCanvas()
})
}
)
</script>
<template>
<canvas ref="canvasRef" />
</template>

View File

@@ -0,0 +1,84 @@
<script setup lang="ts">
import { computed } from 'vue'
interface GradientTextProps {
text: string
className?: string
colors?: string[]
animationSpeed?: number
showBorder?: boolean
}
const props = withDefaults(defineProps<GradientTextProps>(), {
text: '',
className: '',
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>
<div
:class="`relative mx-auto flex max-w-fit flex-row items-center justify-center rounded-[1.25rem] font-medium backdrop-blur transition-shadow duration-500 overflow-hidden cursor-pointer ${className}`"
>
<div
v-if="showBorder"
class="absolute inset-0 bg-cover z-0 pointer-events-none animate-gradient"
:style="borderStyle"
>
<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%);
"
/>
</div>
<div
class="inline-block relative z-2 text-transparent bg-cover animate-gradient"
:style="textStyle"
>
{{ text }}
</div>
</div>
</template>
<style scoped>
@keyframes gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.animate-gradient {
animation: gradient var(--animation-duration, 8s) linear infinite;
}
</style>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import { computed } from 'vue'
interface ShinyTextProps {
text: string
disabled?: boolean
speed?: number
className?: string
}
const props = withDefaults(defineProps<ShinyTextProps>(), {
text: '',
disabled: false,
speed: 5,
className: ''
})
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%)',
backgroundSize: '200% 100%',
WebkitBackgroundClip: 'text',
animationDuration: animationDuration
}"
>
{{ props.text }}
</div>
</template>
<style scoped>
@keyframes shine {
0% {
background-position: 100%;
}
100% {
background-position: -100%;
}
}
.animate-shine {
animation: shine 5s linear infinite;
}
</style>

View File

@@ -0,0 +1,181 @@
<script setup lang="ts">
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
}
interface TrailItem {
id: number
x: number
y: number
angle: number
randomX?: number
randomY?: number
randomRotate?: number
}
const props = withDefaults(defineProps<TextCursorProps>(), {
text: '⚛️',
delay: 0.01,
spacing: 100,
followMouseDirection: true,
randomFloat: true,
exitDuration: 0.5,
removalInterval: 30,
maxPoints: 5
})
const containerRef = ref<HTMLDivElement>()
const trail = ref<TrailItem[]>([])
const lastMoveTime = ref(Date.now())
const idCounter = ref(0)
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
let newTrail = [...trail.value]
if (newTrail.length === 0) {
newTrail.push({
id: idCounter.value++,
x: mouseX,
y: mouseY,
angle: 0,
...(props.randomFloat && {
randomX: Math.random() * 10 - 5,
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)
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)
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
newTrail.push({
id: idCounter.value++,
x: newX,
y: newY,
angle: computedAngle,
...(props.randomFloat && {
randomX: Math.random() * 10 - 5,
randomY: Math.random() * 10 - 5,
randomRotate: Math.random() * 10 - 5
})
})
}
}
}
if (newTrail.length > props.maxPoints) {
newTrail = newTrail.slice(newTrail.length - props.maxPoints)
}
trail.value = newTrail
lastMoveTime.value = Date.now()
}
const startRemovalInterval = () => {
if (removalIntervalId) {
clearInterval(removalIntervalId)
}
removalIntervalId = setInterval(() => {
if (Date.now() - lastMoveTime.value > 100) {
if (trail.value.length > 0) {
trail.value = trail.value.slice(1)
}
}
}, props.removalInterval)
}
onMounted(() => {
if (containerRef.value) {
containerRef.value.addEventListener('mousemove', handleMouseMove)
startRemovalInterval()
}
})
onUnmounted(() => {
if (containerRef.value) {
containerRef.value.removeEventListener('mousemove', handleMouseMove)
}
if (removalIntervalId) {
clearInterval(removalIntervalId)
}
})
</script>
<template>
<div ref="containerRef" class="w-full h-full relative">
<div class="absolute inset-0 pointer-events-none">
<Motion
v-for="item in trail"
:key="item.id"
:initial="{ opacity: 0, scale: 1, rotate: item.angle }"
: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
}"
:transition="{
duration: props.randomFloat ? 2 : props.exitDuration,
repeat: props.randomFloat ? Infinity : 0,
repeatType: props.randomFloat ? 'mirror' : 'loop'
}"
class="absolute select-none whitespace-nowrap text-3xl"
:style="{ left: `${item.x}px`, top: `${item.y}px` }"
>
{{ props.text }}
</Motion>
</div>
</div>
</template>
<style scoped>
.trail-enter-active,
.trail-leave-active {
transition: all 0.5s ease;
}
.trail-enter-from {
opacity: 0;
transform: scale(0);
}
.trail-leave-to {
opacity: 0;
transform: scale(0);
}
</style>

View File

@@ -0,0 +1,232 @@
<script setup lang="ts">
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
}
const props = withDefaults(defineProps<TextPressureProps>(), {
text: 'Compressa',
fontFamily: 'Compressa VF',
fontUrl: 'https://res.cloudinary.com/dr6lvwubh/raw/upload/v1529908256/CompressaPRO-GX.woff2',
width: true,
weight: true,
italic: true,
alpha: false,
flex: true,
stroke: false,
scale: false,
textColor: '#FFFFFF',
strokeColor: '#FF0000',
strokeWidth: 2,
className: '',
minFontSize: 24
})
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 fontSize = ref(props.minFontSize)
const scaleY = ref(1)
const lineHeight = ref(1)
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 handleMouseMove = (e: MouseEvent) => {
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 setSize = () => {
if (!containerRef.value || !titleRef.value) return
const { width: containerW, height: containerH } = containerRef.value.getBoundingClientRect()
let newFontSize = containerW / (chars.value.length / 2)
newFontSize = Math.max(newFontSize, props.minFontSize)
fontSize.value = newFontSize
scaleY.value = 1
lineHeight.value = 1
nextTick(() => {
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
}
})
}
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
if (titleRef.value) {
const titleRect = titleRef.value.getBoundingClientRect()
const maxDist = titleRect.width / 2
spansRef.value.forEach((span) => {
if (!span) return
const rect = span.getBoundingClientRect()
const charCenter = {
x: rect.x + rect.width / 2,
y: rect.y + rect.height / 2,
}
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 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}`
})
}
rafId = requestAnimationFrame(animate)
}
onMounted(() => {
const styleElement = document.createElement('style')
styleElement.textContent = dynamicStyles.value
document.head.appendChild(styleElement)
styleElement.setAttribute('data-text-pressure', 'true')
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
}
animate()
})
onUnmounted(() => {
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)
if (rafId) {
cancelAnimationFrame(rafId)
}
})
watch([() => props.scale, () => props.text], () => {
setSize()
})
watch([() => props.width, () => props.weight, () => props.italic, () => props.alpha], () => {})
const titleStyle = computed(() => ({
fontFamily: props.fontFamily,
fontSize: fontSize.value + 'px',
lineHeight: lineHeight.value,
transform: `scale(1, ${scaleY.value})`,
transformOrigin: 'center top',
margin: 0,
fontWeight: 100,
color: props.stroke ? undefined : props.textColor,
}))
const dynamicStyles = computed(() => `
@font-face {
font-family: '${props.fontFamily}';
src: url('${props.fontUrl}');
font-style: normal;
}
.stroke span {
position: relative;
color: ${props.textColor};
}
.stroke span::after {
content: attr(data-char);
position: absolute;
left: 0;
top: 0;
color: transparent;
z-index: -1;
-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)
styleElement.setAttribute('data-text-pressure', 'true')
})
onUnmounted(() => {
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"
: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">
{{ char }}
</span>
</h1>
</div>
</template>

View File

@@ -0,0 +1,451 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import {
CanvasTexture,
Clock,
Color,
LinearFilter,
LinearMipmapLinearFilter,
Mesh,
OrthographicCamera,
PlaneGeometry,
Scene,
ShaderMaterial,
Vector2,
Vector3,
WebGLRenderer,
WebGLRenderTarget
} 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
}
const props = withDefaults(defineProps<TextTrailProps>(), {
text: 'Vibe',
fontFamily: 'Figtree',
fontWeight: '900',
noiseFactor: 1,
noiseScale: 0.0005,
rgbPersistFactor: 0.98,
alphaPersistFactor: 0.95,
animateColor: false,
startColor: '#ffffff',
textColor: '#ffffff',
backgroundColor: 0x151515,
colorCycleInterval: 3000,
supersample: 2
})
const containerRef = ref<HTMLDivElement>()
const hexToRgb = (hex: string): [number, number, number] => {
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]
}
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 BASE_VERT = `
varying vec2 v_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.;}
vec4 mod289(vec4 x){return x-floor(x*(1./289.))*289.;}
vec4 permute(vec4 x){return mod289(((x*34.)+1.)*x);}
float snoise3(vec3 v){
const vec2 C=vec2(1./6.,1./3.);
const vec4 D=vec4(0.,.5,1.,2.);
vec3 i=floor(v+dot(v,C.yyy));
vec3 x0=v-i+dot(i,C.xxx);
vec3 g=step(x0.yzx,x0.xyz);
vec3 l=1.-g;
vec3 i1=min(g.xyz,l.zxy);
vec3 i2=max(g.xyz,l.zxy);
vec3 x1=x0-i1+C.xxx;
vec3 x2=x0-i2+C.yyy;
vec3 x3=x0-D.yyy;
i=mod289(i);
vec4 p=permute(permute(permute(i.z+vec4(0.,i1.z,i2.z,1.))+i.y+vec4(0.,i1.y,i2.y,1.))+i.x+vec4(0.,i1.x,i2.x,1.));
float n_=1./7.; vec3 ns=n_*D.wyz-D.xzx;
vec4 j=p-49.*floor(p*ns.z*ns.z);
vec4 x_=floor(j*ns.z);
vec4 y_=floor(j-7.*x_);
vec4 x=x_*ns.x+ns.yyyy;
vec4 y=y_*ns.x+ns.yyyy;
vec4 h=1.-abs(x)-abs(y);
vec4 b0=vec4(x.xy,y.xy);
vec4 b1=vec4(x.zw,y.zw);
vec4 s0=floor(b0)*2.+1.;
vec4 s1=floor(b1)*2.+1.;
vec4 sh=-step(h,vec4(0.));
vec4 a0=b0.xzyw+s0.xzyw*sh.xxyy;
vec4 a1=b1.xzyw+s1.xzyw*sh.zzww;
vec3 p0=vec3(a0.xy,h.x);
vec3 p1=vec3(a0.zw,h.y);
vec3 p2=vec3(a1.xy,h.z);
vec3 p3=vec3(a1.zw,h.w);
vec4 norm=inversesqrt(vec4(dot(p0,p0),dot(p1,p1),dot(p2,p2),dot(p3,p3)));
p0*=norm.x; p1*=norm.y; p2*=norm.z; p3*=norm.w;
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;
uniform float time;
uniform vec2 mousePos;
uniform float noiseFactor,noiseScale,rgbPersistFactor,alphaPersistFactor;
varying vec2 v_uv;
${SIMPLEX}
void main(){
float a=snoise3(vec3(v_uv*noiseFactor,time*.1))*noiseScale;
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;
void main(){
vec4 t=texture2D(sampler,v_uv);
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
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])
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
}
const drawText = () => {
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 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],
[-0.1, 0],
[0, 0.1],
[0, -0.1],
[0.1, 0.1],
[-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
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
let { w, h } = getSize()
renderer = new WebGLRenderer({ antialias: true })
renderer.setClearColor(
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)
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()
quadMat = new ShaderMaterial({
uniforms: {
sampler: { value: null },
time: { value: 0 },
mousePos: { value: new Vector2(-1, 1) },
noiseFactor: { value: props.noiseFactor },
noiseScale: { value: props.noiseScale },
rgbPersistFactor: { value: props.rgbPersistFactor },
alphaPersistFactor: { value: props.alphaPersistFactor }
},
vertexShader: BASE_VERT,
fragmentShader: PERSIST_FRAG,
transparent: true
})
quad = new Mesh(new PlaneGeometry(w, h), quadMat)
fluidScene.add(quad)
labelMat = new ShaderMaterial({
uniforms: {
sampler: { value: null },
color: { value: new Vector3(...persistColor.value) }
},
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)
await loadFont(props.fontFamily)
drawText()
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)
colorTimer = setInterval(() => {
if (!props.textColor) {
targetColor.value = [Math.random(), Math.random(), Math.random()]
}
}, props.colorCycleInterval)
const animate = () => {
if (!renderer || !quadMat || !labelMat || !clock || !scene || !fluidScene || !cam || !rt0 || !rt1) return
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
}
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)
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)
}
const cleanup = () => {
if (renderer) {
renderer.setAnimationLoop(null)
if (containerRef.value && renderer.domElement.parentNode === containerRef.value) {
containerRef.value.removeChild(renderer.domElement)
}
renderer.dispose()
renderer = null
}
if (colorTimer) {
clearInterval(colorTimer)
colorTimer = null
}
if (containerRef.value) {
containerRef.value.removeEventListener('pointermove', onMove)
}
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = null
}
if (rt0) {
rt0.dispose()
rt0 = null
}
if (rt1) {
rt1.dispose()
rt1 = null
}
if (quadMat) {
quadMat.dispose()
quadMat = null
}
if (quad) {
quad.geometry.dispose()
quad = null
}
if (labelMat) {
labelMat.dispose()
labelMat = null
}
if (label) {
label.geometry.dispose()
label = null
}
scene = null
fluidScene = null
clock = null
cam = null
}
watch(
() => [
props.text,
props.fontFamily,
props.fontWeight,
props.noiseFactor,
props.noiseScale,
props.rgbPersistFactor,
props.alphaPersistFactor,
props.animateColor,
props.startColor,
props.textColor,
props.backgroundColor,
props.colorCycleInterval,
props.supersample
],
() => {
cleanup()
if (containerRef.value) {
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()
}
})
onUnmounted(() => {
cleanup()
})
</script>
<template>
<div ref="containerRef" class="w-full h-full" />
</template>

View File

@@ -99,6 +99,13 @@
justify-content: center;
}
.demo-title-extra {
font-size: 1.125rem;
font-weight: 900;
margin: 2rem 0 0 0;
color: #e5e7eb;
}
div:has(> .props-table) {
border: 1px solid #142216;
border-radius: 20px;
@@ -419,7 +426,6 @@ div:has(> .props-table) {
.custom-spotlight-card {
user-select: none;
background-color: #333333 !important;
border: 1px solid #142216 !important;
width: 350px;
height: 300px;

View File

@@ -83,6 +83,7 @@
.sidebar-item:hover {
color: #fff;
cursor: pointer;
}
.active-sidebar-item {
@@ -123,7 +124,7 @@
.sidebar-item .new-tag {
color: #fff;
border: 1px solid #5227ff;
border: 1px solid #27FF64;
background-color: rgba(82, 39, 255, 0.3);
}
@@ -620,7 +621,7 @@
.drawer-navigation .sidebar-item .new-tag {
color: #fff;
border: 1px solid #5227ff;
border: 1px solid #27FF64;
background-color: rgba(82, 39, 255, 0.3);
}

View File

@@ -0,0 +1,197 @@
<template>
<div class="animated-content-demo">
<TabbedLayout>
<template #preview>
<div class="demo-container relative py-6 overflow-hidden">
<RefreshButton @click="forceRerender" />
<div :key="key" class="flex justify-center items-center h-96">
<AnimatedContent :direction="direction" :delay="delay" :distance="distance" :reverse="reverse"
:duration="duration" :ease="ease" :initial-opacity="initialOpacity" :animate-opacity="animateOpacity"
:scale="scale" :threshold="threshold" @complete="() => console.log('✅ Animation Complete!')">
<div class="demo-content">
<h4>Animated Content</h4>
<p>It will animate in when it enters the viewport.</p>
</div>
</AnimatedContent>
</div>
</div>
<Customize>
<PreviewSelect title="Animation Direction" v-model="direction" :options="directionOptions"
@update:model-value="(val) => { direction = val as 'vertical' | 'horizontal'; forceRerender(); }" />
<PreviewSelect title="Easing Function" v-model="ease" :options="easeOptions"
@update:model-value="(val) => { ease = val as string; forceRerender(); }" />
<PreviewSlider title="Distance" v-model="distance" :min="50" :max="300" :step="10"
@update:model-value="forceRerender" />
<PreviewSlider title="Duration" v-model="duration" :min="0.1" :max="3" :step="0.1" value-unit="s"
@update:model-value="forceRerender" />
<PreviewSlider title="Delay" v-model="delay" :min="0" :max="2" :step="0.1" value-unit="s"
@update:model-value="forceRerender" />
<PreviewSlider title="Initial Opacity" v-model="initialOpacity" :min="0" :max="1" :step="0.1"
@update:model-value="forceRerender" />
<PreviewSlider title="Initial Scale" v-model="scale" :min="0.1" :max="2" :step="0.1"
@update:model-value="forceRerender" />
<PreviewSlider title="Threshold" v-model="threshold" :min="0.1" :max="1" :step="0.1"
@update:model-value="forceRerender" />
<PreviewSwitch title="Reverse Direction" v-model="reverse" @update:model-value="forceRerender" />
<PreviewSwitch title="Animate Opacity" v-model="animateOpacity" @update:model-value="forceRerender" />
</Customize>
<PropTable :data="propData" />
<Dependencies :dependency-list="['gsap']" />
</template>
<template #code>
<CodeExample :code-object="animatedContent" />
</template>
<template #cli>
<CliInstallation :command="animatedContent.cli" />
</template>
</TabbedLayout>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import TabbedLayout from '../../components/common/TabbedLayout.vue'
import PropTable from '../../components/common/PropTable.vue'
import Dependencies from '../../components/code/Dependencies.vue'
import CliInstallation from '../../components/code/CliInstallation.vue'
import CodeExample from '../../components/code/CodeExample.vue'
import Customize from '../../components/common/Customize.vue'
import PreviewSlider from '../../components/common/PreviewSlider.vue'
import PreviewSwitch from '../../components/common/PreviewSwitch.vue'
import PreviewSelect from '../../components/common/PreviewSelect.vue'
import RefreshButton from '../../components/common/RefreshButton.vue'
import AnimatedContent from '../../content/Animations/AnimatedContent/AnimatedContent.vue'
import { animatedContent } from '@/constants/code/Animations/animatedContentCode'
import { useForceRerender } from '@/composables/useForceRerender'
const { rerenderKey: key, forceRerender } = useForceRerender()
const direction = ref<'vertical' | 'horizontal'>('vertical')
const distance = ref(100)
const delay = ref(0)
const reverse = ref(false)
const duration = ref(0.8)
const ease = ref('power3.out')
const initialOpacity = ref(0)
const animateOpacity = ref(true)
const scale = ref(1)
const threshold = ref(0.1)
const directionOptions = [
{ label: 'Vertical', value: 'vertical' },
{ label: 'Horizontal', value: 'horizontal' }
]
const easeOptions = [
{ label: 'Power3 Out', value: 'power3.out' },
{ label: 'Bounce Out', value: 'bounce.out' },
{ label: 'Elastic Out', value: 'elastic.out(1, 0.3)' }
]
const propData = [
{
name: 'distance',
type: 'number',
default: '100',
description: 'Distance (in pixels) the component moves during animation.'
},
{
name: 'direction',
type: '"vertical" | "horizontal"',
default: '"vertical"',
description: 'Animation direction. Can be "vertical" or "horizontal".'
},
{
name: 'reverse',
type: 'boolean',
default: 'false',
description: 'Whether the animation moves in the reverse direction.'
},
{
name: 'duration',
type: 'number',
default: '0.8',
description: 'Duration of the animation in seconds.'
},
{
name: 'ease',
type: 'string | function',
default: '"power3.out"',
description: 'GSAP easing function for the animation.'
},
{
name: 'initialOpacity',
type: 'number',
default: '0',
description: 'Initial opacity before animation begins.'
},
{
name: 'animateOpacity',
type: 'boolean',
default: 'true',
description: 'Whether to animate opacity during transition.'
},
{
name: 'scale',
type: 'number',
default: '1',
description: 'Initial scale of the component.'
},
{
name: 'threshold',
type: 'number',
default: '0.1',
description: 'Intersection threshold to trigger animation (0-1).'
},
{
name: 'delay',
type: 'number',
default: '0',
description: 'Delay before animation starts (in seconds).'
},
{
name: 'className',
type: 'string',
default: '""',
description: 'Additional CSS classes for styling.'
}
]
</script>
<style scoped>
.demo-content {
text-align: center;
padding: 2rem;
border: 1px solid #ffffff1c;
border-radius: 12px;
background: rgba(255, 255, 255, 0.02);
max-width: 400px;
}
.demo-content h4 {
color: #fff;
margin-bottom: 1rem;
font-size: 1.5rem;
}
.demo-content p {
color: #a1a1aa;
text-align: center;
max-width: 25ch;
line-height: 1.6;
}
</style>

View File

@@ -0,0 +1,116 @@
<template>
<div class="click-spark-demo">
<TabbedLayout>
<template #preview>
<div class="demo-container">
<ClickSpark :key="rerenderKey" :spark-color="sparkColor" :spark-size="sparkSize" :spark-radius="sparkRadius"
:spark-count="sparkCount" :duration="duration" :easing="easing" :extra-scale="extraScale"
class="click-spark-demo-area">
</ClickSpark>
<div
class="absolute inset-0 flex items-center justify-center pointer-events-none text-[4rem] font-[900] text-[#222] select-none">
Click Around!
</div>
</div>
<Customize>
<PreviewColor title="Spark Color" v-model="sparkColor" @update:model-value="forceRerender" />
<PreviewSlider title="Spark Size" v-model="sparkSize" :min="5" :max="30" :step="1"
@update:model-value="forceRerender" />
<PreviewSlider title="Spark Radius" v-model="sparkRadius" :min="10" :max="50" :step="5"
@update:model-value="forceRerender" />
<PreviewSlider title="Spark Count" v-model="sparkCount" :min="4" :max="20" :step="1"
@update:model-value="forceRerender" />
<PreviewSlider title="Duration (ms)" v-model="duration" :min="200" :max="1000" :step="50"
@update:model-value="forceRerender" />
<PreviewSlider title="Extra Scale" v-model="extraScale" :min="0.5" :max="2" :step="0.1"
@update:model-value="forceRerender" />
</Customize>
<PropTable :data="propData" />
</template>
<template #code>
<CodeExample :code-object="clickSpark" />
</template>
<template #cli>
<CliInstallation :command="clickSpark.cli" />
</template>
</TabbedLayout>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import TabbedLayout from '../../components/common/TabbedLayout.vue'
import PropTable from '../../components/common/PropTable.vue'
import CliInstallation from '../../components/code/CliInstallation.vue'
import CodeExample from '../../components/code/CodeExample.vue'
import Customize from '../../components/common/Customize.vue'
import PreviewColor from '../../components/common/PreviewColor.vue'
import PreviewSlider from '../../components/common/PreviewSlider.vue'
import ClickSpark from '../../content/Animations/ClickSpark/ClickSpark.vue'
import { clickSpark } from '@/constants/code/Animations/clickSparkCode'
import { useForceRerender } from '@/composables/useForceRerender'
const sparkColor = ref('#ffffff')
const sparkSize = ref(10)
const sparkRadius = ref(15)
const sparkCount = ref(8)
const duration = ref(400)
const easing = ref<"linear" | "ease-in" | "ease-out" | "ease-in-out">('ease-out')
const extraScale = ref(1)
const { rerenderKey, forceRerender } = useForceRerender()
const propData = [
{ name: 'sparkColor', type: 'string', default: "'#fff'", description: 'Color of the spark lines.' },
{ name: 'sparkSize', type: 'number', default: '10', description: 'Length of each spark line.' },
{ name: 'sparkRadius', type: 'number', default: '15', description: 'Distance sparks travel from the click center.' },
{ name: 'sparkCount', type: 'number', default: '8', description: 'Number of spark lines per click.' },
{ name: 'duration', type: 'number', default: '400', description: 'Animation duration in milliseconds.' },
{ name: 'easing', type: 'string', default: "'ease-out'", description: 'Easing function: "linear", "ease-in", "ease-out", or "ease-in-out".' },
{ name: 'extraScale', type: 'number', default: '1.0', description: 'Scale multiplier for spark distance and size.' }
]
</script>
<style scoped>
.click-spark-demo-area {
position: absolute;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
cursor: pointer;
}
.demo-text {
text-align: center;
pointer-events: none;
user-select: none;
}
.demo-text h3 {
font-size: 1.2rem;
color: #fff;
margin-bottom: 0.5rem;
}
.demo-text p {
font-size: 0.9rem;
color: #999;
margin: 0;
}
.demo-content {
padding: 2rem;
}
</style>

View File

@@ -0,0 +1,132 @@
<template>
<div class="count-up-demo">
<TabbedLayout>
<template #preview>
<h2 class="demo-title-extra">Default</h2>
<div class="demo-container relative">
<CountUp :key="keyDefault" :from="0" :to="100" separator="," direction="up" :duration="1"
class-name="count-up-text" />
<RefreshButton @click="forceRerenderDefault" />
</div>
<h2 class="demo-title-extra">Start Programatically</h2>
<div class="demo-container flex flex-col justify-center items-center relative min-h-[200px]">
<button class="bg-[#0b0b0b] cursor-pointer rounded-[10px] border border-[#222] text-white px-4 py-2 mb-4"
@click="setStartCounting(true)">
Count to 500!
</button>
<CountUp :key="keyProgramatically" :from="100" :to="500" :start-when="startCounting" :duration="5"
class-name="count-up-text" />
<RefreshButton v-if="startCounting" @click="forceRerenderProgramatically" />
</div>
<PropTable :data="propData" />
</template>
<template #code>
<CodeExample :code-object="countup" />
</template>
<template #cli>
<CliInstallation :command="countup.cli" />
</template>
</TabbedLayout>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import TabbedLayout from '../../components/common/TabbedLayout.vue'
import PropTable from '../../components/common/PropTable.vue'
import CliInstallation from '../../components/code/CliInstallation.vue'
import CodeExample from '../../components/code/CodeExample.vue'
import RefreshButton from '../../components/common/RefreshButton.vue'
import CountUp from '../../content/Animations/CountUp/CountUp.vue'
import { countup } from '@/constants/code/Animations/countUpCode'
import { useForceRerender } from '@/composables/useForceRerender'
const startCounting = ref(false)
const { rerenderKey: keyDefault, forceRerender: forceRerenderDefault } = useForceRerender()
const { rerenderKey: keyProgramatically, forceRerender: forceRerenderProgramatically } = useForceRerender()
const setStartCounting = (value: boolean) => {
startCounting.value = value
if (value) {
forceRerenderProgramatically()
}
}
const propData = [
{
name: 'to',
type: 'number',
default: '—',
description: 'The target number to count up to.'
},
{
name: 'from',
type: 'number',
default: '0',
description: 'The initial number from which the count starts.'
},
{
name: 'direction',
type: 'string',
default: '"up"',
description: 'Direction of the count; can be "up" or "down". When this is set to "down", "from" and "to" become reversed, in order to count down.'
},
{
name: 'delay',
type: 'number',
default: '0',
description: 'Delay in seconds before the counting starts.'
},
{
name: 'duration',
type: 'number',
default: '2',
description: 'Duration of the count animation - based on the damping and stiffness configured inside the component.'
},
{
name: 'className',
type: 'string',
default: '""',
description: 'CSS class to apply to the component for additional styling.'
},
{
name: 'startWhen',
type: 'boolean',
default: 'true',
description: 'A boolean to control whether the animation should start when the component is in view. It basically works like an if statement, if this is true, the count will start.'
},
{
name: 'separator',
type: 'string',
default: '""',
description: 'Character to use as a thousands separator in the displayed number.'
},
{
name: 'onStart',
type: 'function',
default: '—',
description: 'Callback function that is called when the count animation starts.'
},
{
name: 'onEnd',
type: 'function',
default: '—',
description: 'Callback function that is called when the count animation ends.'
}
]
</script>
<style scoped>
.demo-container {
min-height: 200px;
height: 200px;
}
</style>

View File

@@ -0,0 +1,151 @@
<template>
<TabbedLayout>
<template #preview>
<div class="relative demo-container h-[650px] overflow-hidden">
<Cubes :borderStyle="borderStyle" :gridSize="gridSize" :maxAngle="maxAngle" :radius="radius"
:autoAnimate="autoAnimate" :rippleOnClick="rippleOnClick" />
</div>
<Customize>
<PreviewSelect title="Border Preference" :options="borderOptions" v-model="borderStyle" :width="150" />
<PreviewSlider title="Grid Size" :min="6" :max="12" :step="1" v-model="gridSize" :width="150" />
<PreviewSlider title="Max Angle" :min="15" :max="180" :step="5" v-model="maxAngle" valueUnit="°" :width="150" />
<PreviewSlider title="Radius" :min="1" :max="5" :step="1" v-model="radius" :width="150" />
<PreviewSwitch title="Auto Animate" v-model="autoAnimate" />
<PreviewSwitch title="Ripple On Click" v-model="rippleOnClick" />
</Customize>
<PropTable :data="propData" />
<Dependencies :dependencyList="['gsap']" />
</template>
<template #code>
<CodeExample :codeObject="cubes" />
</template>
<template #cli>
<CliInstallation v-bind="cubes" />
</template>
</TabbedLayout>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import TabbedLayout from '../../components/common/TabbedLayout.vue'
import Customize from '../../components/common/Customize.vue'
import PreviewSelect from '../../components/common/PreviewSelect.vue'
import PreviewSlider from '../../components/common/PreviewSlider.vue'
import PreviewSwitch from '../../components/common/PreviewSwitch.vue'
import CodeExample from '../../components/code/CodeExample.vue'
import CliInstallation from '../../components/code/CliInstallation.vue'
import PropTable from '../../components/common/PropTable.vue'
import Dependencies from '../../components/code/Dependencies.vue'
import { cubes } from '../../constants/code/Animations/cubesCode'
import Cubes from '../../content/Animations/Cubes/Cubes.vue'
const borderStyle = ref("2px dashed #A7EF9E")
const gridSize = ref(10)
const maxAngle = ref(45)
const radius = ref(3)
const autoAnimate = ref(true)
const rippleOnClick = ref(true)
const borderOptions = [
{ value: "2px dotted #fff", label: "Dotted White" },
{ value: "2px dashed #A7EF9E", label: "Dashed Green" },
{ value: "3px solid #fff", label: "Solid White" }
]
const propData = [
{
name: "gridSize",
type: "number",
default: "10",
description: "The size of the grid (number of cubes per row/column)"
},
{
name: "cubeSize",
type: "number",
default: "undefined",
description: "Fixed size of each cube in pixels. If not provided, cubes will be responsive"
},
{
name: "maxAngle",
type: "number",
default: "45",
description: "Maximum rotation angle for the tilt effect in degrees"
},
{
name: "radius",
type: "number",
default: "3",
description: "Radius of the tilt effect (how many cubes around the cursor are affected)"
},
{
name: "easing",
type: "string",
default: "'power3.out'",
description: "GSAP easing function for the tilt animation"
},
{
name: "duration",
type: "object",
default: "{ enter: 0.3, leave: 0.6 }",
description: "Animation duration for enter and leave effects"
},
{
name: "cellGap",
type: "number | object",
default: "undefined",
description: "Gap between cubes. Can be a number or object with row/col properties"
},
{
name: "borderStyle",
type: "string",
default: "'1px solid #fff'",
description: "CSS border style for cube faces"
},
{
name: "faceColor",
type: "string",
default: "'#060010'",
description: "Background color for cube faces"
},
{
name: "shadow",
type: "boolean | string",
default: "false",
description: "Shadow effect for cubes. Can be boolean or custom CSS shadow"
},
{
name: "autoAnimate",
type: "boolean",
default: "true",
description: "Whether to automatically animate when user is idle"
},
{
name: "rippleOnClick",
type: "boolean",
default: "true",
description: "Whether to show ripple effect on click"
},
{
name: "rippleColor",
type: "string",
default: "'#fff'",
description: "Color of the ripple effect"
},
{
name: "rippleSpeed",
type: "number",
default: "2",
description: "Speed multiplier for the ripple animation"
}
]
</script>

View File

@@ -0,0 +1,143 @@
<template>
<div class="glare-hover-demo">
<TabbedLayout>
<template #preview>
<div class="demo-container relative h-[600px] overflow-hidden">
<div class="flex justify-center items-center h-full">
<GlareHover background="#111" border-color="#222" border-radius="20px" width="400px" height="300px"
:glare-color="glareColor" :glare-opacity="glareOpacity" :glare-size="glareSize"
:transition-duration="transitionDuration" :play-once="playOnce">
<div class="text-center text-5xl font-black text-[#222] m-0">
Hover Me
</div>
</GlareHover>
</div>
</div>
<Customize>
<PreviewColor title="Glare Color" v-model="glareColor" />
<PreviewSlider title="Glare Opacity" v-model="glareOpacity" :min="0" :max="1" :step="0.1" />
<PreviewSlider title="Glare Size" v-model="glareSize" :min="100" :max="500" :step="25" value-unit="%" />
<PreviewSlider title="Transition Duration" v-model="transitionDuration" :min="200" :max="2000" :step="50"
value-unit="ms" />
<PreviewSwitch title="Play Once" v-model="playOnce" />
</Customize>
<PropTable :data="propData" />
</template>
<template #code>
<CodeExample :code-object="glareHover" />
</template>
<template #cli>
<CliInstallation :command="glareHover.cli" />
</template>
</TabbedLayout>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import TabbedLayout from '../../components/common/TabbedLayout.vue'
import PropTable from '../../components/common/PropTable.vue'
import CliInstallation from '../../components/code/CliInstallation.vue'
import CodeExample from '../../components/code/CodeExample.vue'
import Customize from '../../components/common/Customize.vue'
import PreviewSlider from '../../components/common/PreviewSlider.vue'
import PreviewSwitch from '../../components/common/PreviewSwitch.vue'
import PreviewColor from '../../components/common/PreviewColor.vue'
import GlareHover from '../../content/Animations/GlareHover/GlareHover.vue'
import { glareHover } from '@/constants/code/Animations/glareHoverCode'
const glareColor = ref('#ffffff')
const glareOpacity = ref(0.3)
const glareSize = ref(300)
const transitionDuration = ref(800)
const playOnce = ref(false)
const propData = [
{
name: 'width',
type: 'string',
default: '500px',
description: 'The width of the hover element.'
},
{
name: 'height',
type: 'string',
default: '500px',
description: 'The height of the hover element.'
},
{
name: 'background',
type: 'string',
default: '#000',
description: 'The background color of the element.'
},
{
name: 'borderRadius',
type: 'string',
default: '10px',
description: 'The border radius of the element.'
},
{
name: 'borderColor',
type: 'string',
default: '#333',
description: 'The border color of the element.'
},
{
name: 'glareColor',
type: 'string',
default: '#ffffff',
description: 'The color of the glare effect (hex format).'
},
{
name: 'glareOpacity',
type: 'number',
default: '0.5',
description: 'The opacity of the glare effect (0-1).'
},
{
name: 'glareAngle',
type: 'number',
default: '-45',
description: 'The angle of the glare effect in degrees.'
},
{
name: 'glareSize',
type: 'number',
default: '250',
description: 'The size of the glare effect as a percentage (e.g. 250 = 250%).'
},
{
name: 'transitionDuration',
type: 'number',
default: '650',
description: 'The duration of the transition in milliseconds.'
},
{
name: 'playOnce',
type: 'boolean',
default: 'false',
description: 'If true, the glare only animates on hover and doesn\'t return on mouse leave.'
},
{
name: 'className',
type: 'string',
default: '""',
description: 'Additional CSS class names.'
},
{
name: 'style',
type: 'object',
default: '{}',
description: 'Additional inline styles.'
}
]
</script>

View File

@@ -0,0 +1,155 @@
<template>
<div class="magnet-demo">
<TabbedLayout>
<template #preview>
<h2 class="demo-title-extra">Container</h2>
<div class="demo-container">
<Magnet :key="rerenderKey" :padding="padding" :disabled="disabled" :magnetStrength="magnetStrength">
<div class="magnet-container">
Hover Me!
</div>
</Magnet>
</div>
<h2 class="demo-title-extra">Link</h2>
<div class="demo-container">
<Magnet :key="rerenderKey + 1" :padding="Math.floor(padding / 2)" :disabled="disabled"
:magnetStrength="magnetStrength">
<a href="https://github.com/DavidHDev/vue-bits" target="_blank" rel="noreferrer" class="magnet-link">
Star <span class="accent">Vue Bits</span> on GitHub!
</a>
</Magnet>
</div>
<Customize>
<PreviewSwitch title="Disabled" v-model="disabled" @update:model-value="forceRerender" />
<PreviewSlider title="Padding" v-model="padding" :min="0" :max="300" :step="10" value-unit="px"
@update:model-value="forceRerender" />
<PreviewSlider title="Strength" v-model="magnetStrength" :min="1" :max="10" :step="1"
@update:model-value="forceRerender" />
</Customize>
<PropTable :data="propData" />
</template>
<template #code>
<CodeExample :code-object="magnet" />
</template>
<template #cli>
<CliInstallation :command="magnet.cli" />
</template>
</TabbedLayout>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import TabbedLayout from '../../components/common/TabbedLayout.vue'
import PropTable from '../../components/common/PropTable.vue'
import CliInstallation from '../../components/code/CliInstallation.vue'
import CodeExample from '../../components/code/CodeExample.vue'
import Customize from '../../components/common/Customize.vue'
import PreviewSwitch from '../../components/common/PreviewSwitch.vue'
import PreviewSlider from '../../components/common/PreviewSlider.vue'
import Magnet from '../../content/Animations/Magnet/Magnet.vue'
import { magnet } from '@/constants/code/Animations/magnetCode'
import { useForceRerender } from '@/composables/useForceRerender'
const disabled = ref(false)
const padding = ref(100)
const magnetStrength = ref(2)
const { rerenderKey, forceRerender } = useForceRerender()
const propData = [
{
name: 'padding',
type: 'number',
default: '100',
description: 'Specifies the distance (in pixels) around the element that activates the magnet pull.',
},
{
name: 'disabled',
type: 'boolean',
default: 'false',
description: 'Disables the magnet effect when set to true.',
},
{
name: 'magnetStrength',
type: 'number',
default: '2',
description: 'Controls the strength of the pull; higher values reduce movement, lower values increase it.',
},
{
name: 'activeTransition',
type: 'string',
default: '"transform 0.3s ease-out"',
description: 'CSS transition applied to the element when the magnet is active.',
},
{
name: 'inactiveTransition',
type: 'string',
default: '"transform 0.5s ease-in-out"',
description: 'CSS transition applied when the magnet is inactive (mouse out of range).',
},
{
name: 'wrapperClassName',
type: 'string',
default: '""',
description: 'Optional CSS class name for the outermost wrapper element.',
},
{
name: 'innerClassName',
type: 'string',
default: '""',
description: 'Optional CSS class name for the moving (inner) element.',
}
]
</script>
<style scoped>
.demo-title-extra {
font-size: 1.1rem;
color: #fff;
margin: 2rem 0 1rem 0;
font-weight: 600;
}
.demo-container {
position: relative;
min-height: 300px;
}
.magnet-container {
width: 200px;
height: 100px;
font-size: 1.25rem;
font-weight: bold;
color: #fff;
background: #111;
border: 1px solid #222;
border-radius: 20px;
display: flex;
justify-content: center;
align-items: center;
}
.magnet-link {
font-size: 1.125rem;
color: #fff;
text-decoration: none;
transition: color 0.3s ease;
}
.magnet-link:hover {
color: #f0f0f0;
}
.accent {
color: #27ff56;
}
</style>

View File

@@ -0,0 +1,93 @@
<template>
<div class="magnet-lines-demo">
<TabbedLayout>
<template #preview>
<div class="demo-container overflow-hidden flex justify-center pb-4 items-center">
<MagnetLines
:rows="10"
:columns="12"
container-size="40vmin"
line-width="2px"
line-height="30px"
/>
</div>
<PropTable :data="propData" />
</template>
<template #code>
<CodeExample :code-object="magnetLines" />
</template>
<template #cli>
<CliInstallation :command="magnetLines.cli" />
</template>
</TabbedLayout>
</div>
</template>
<script setup lang="ts">
import TabbedLayout from '../../components/common/TabbedLayout.vue'
import PropTable from '../../components/common/PropTable.vue'
import CliInstallation from '../../components/code/CliInstallation.vue'
import CodeExample from '../../components/code/CodeExample.vue'
import MagnetLines from '../../content/Animations/MagnetLines/MagnetLines.vue'
import { magnetLines } from '@/constants/code/Animations/magnetLinesCode'
const propData = [
{
name: 'rows',
type: 'number',
default: '9',
description: 'Number of grid rows.'
},
{
name: 'columns',
type: 'number',
default: '9',
description: 'Number of grid columns.'
},
{
name: 'containerSize',
type: 'string',
default: '80vmin',
description: 'Specifies the width and height of the entire grid container.'
},
{
name: 'lineColor',
type: 'string',
default: '#efefef',
description: 'Color for each line (the <span> elements).'
},
{
name: 'lineWidth',
type: 'string',
default: '1vmin',
description: "Specifies each line's thickness."
},
{
name: 'lineHeight',
type: 'string',
default: '6vmin',
description: "Specifies each line's length."
},
{
name: 'baseAngle',
type: 'number',
default: '-10',
description: 'Initial rotation angle (in degrees) before pointer movement.'
},
{
name: 'className',
type: 'string',
default: '""',
description: 'Additional class name(s) applied to the container.'
},
{
name: 'style',
type: 'object',
default: '{}',
description: 'Inline styles for the container.'
}
]
</script>

View File

@@ -0,0 +1,122 @@
<template>
<div class="pixel-transition-demo">
<TabbedLayout>
<template #preview>
<div
class="demo-container flex flex-col items-center justify-center min-h-[400px] max-h-[400px] relative overflow-hidden">
<PixelTransition :key="key" :grid-size="gridSize" :pixel-color="pixelColor"
:animation-step-duration="animationStepDuration" class-name="custom-pixel-card">
<template #firstContent>
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cat03.jpg/1200px-Cat03.jpg"
alt="Default" style="width: 100%; height: 100%; object-fit: cover;" />
</template>
<template #secondContent>
<div style="width: 100%; height: 100%; display: grid; place-items: center; background-color: #111;">
<p style="font-weight: 900; font-size: 3rem; color: #fff;">Meow!</p>
</div>
</template>
</PixelTransition>
<div class="mt-2 text-[#a6a6a6]">Psst, hover the card!</div>
</div>
<Customize>
<PreviewSlider title="Grid Size" v-model="gridSize" :min="2" :max="50" :step="1"
@update:model-value="forceRerender" width="200" />
<PreviewSlider title="Animation Duration" v-model="animationStepDuration" :min="0.1" :max="2" :step="0.1"
value-unit="s" @update:model-value="forceRerender" width="200" />
<PreviewColor title="Pixel Color" v-model="pixelColor" @update:model-value="forceRerender" />
</Customize>
<PropTable :data="propData" />
<Dependencies :dependency-list="['gsap']" />
</template>
<template #code>
<CodeExample :code-object="pixelTransition" />
</template>
<template #cli>
<CliInstallation :command="pixelTransition.cli" />
</template>
</TabbedLayout>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import TabbedLayout from '../../components/common/TabbedLayout.vue'
import PropTable from '../../components/common/PropTable.vue'
import Dependencies from '../../components/code/Dependencies.vue'
import CliInstallation from '../../components/code/CliInstallation.vue'
import CodeExample from '../../components/code/CodeExample.vue'
import Customize from '../../components/common/Customize.vue'
import PreviewSlider from '../../components/common/PreviewSlider.vue'
import PixelTransition from '../../content/Animations/PixelTransition/PixelTransition.vue'
import { pixelTransition } from '@/constants/code/Animations/pixelTransitionCode'
import { useForceRerender } from '@/composables/useForceRerender'
import PreviewColor from '../../components/common/PreviewColor.vue'
const { rerenderKey: key, forceRerender } = useForceRerender()
const gridSize = ref(8)
const pixelColor = ref('#ffffff')
const animationStepDuration = ref(0.4)
const propData = [
{
name: 'firstContent',
type: 'VNode | string',
default: '—',
description: 'Content to show by default (e.g., an <img> or text).'
},
{
name: 'secondContent',
type: 'VNode | string',
default: '—',
description: 'Content revealed upon hover or click.'
},
{
name: 'gridSize',
type: 'number',
default: '7',
description: 'Number of rows/columns in the pixel grid.'
},
{
name: 'pixelColor',
type: 'string',
default: 'currentColor',
description: 'Background color used for each pixel block.'
},
{
name: 'animationStepDuration',
type: 'number',
default: '0.3',
description: 'Length of the pixel reveal/hide in seconds.'
},
{
name: 'aspectRatio',
type: 'string',
default: '"100%"',
description: "Sets the 'padding-top' (or aspect-ratio) for the container."
},
{
name: 'className',
type: 'string',
default: '—',
description: 'Optional additional class names for styling.'
},
{
name: 'style',
type: 'object',
default: '{}',
description: 'Optional inline styles for the container.'
}
]
</script>
<style scoped>
.custom-pixel-card {
box-shadow: 0 2px 16px 0 #00000033;
}
</style>

View File

@@ -0,0 +1,90 @@
<template>
<div class="aurora-demo">
<TabbedLayout>
<template #preview>
<div class="demo-container">
<Aurora :color-stops="colorStops" :amplitude="amplitude" :blend="blend" :speed="speed"
:intensity="intensity" class="w-full h-96" />
</div>
<Customize>
<div class="space-y-2">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
Color Stops
</h3>
<div class="flex gap-4">
<PreviewColor v-for="(color, index) in colorStops" :key="index" :title="`Color ${index + 1}`"
:model-value="color" @update:model-value="(value) => updateColorStop(index, value)" />
</div>
</div>
<PreviewSlider title="Amplitude" :model-value="amplitude" @update:model-value="amplitude = $event" :min="0"
:max="2" :step="0.1" />
<PreviewSlider title="Blend" :model-value="blend" @update:model-value="blend = $event" :min="0" :max="1"
:step="0.1" />
<PreviewSlider title="Speed" :model-value="speed" @update:model-value="speed = $event" :min="0" :max="3"
:step="0.1" />
<PreviewSlider title="Intensity" :model-value="intensity" @update:model-value="intensity = $event" :min="0"
:max="2" :step="0.1" />
</Customize>
<PropTable :data="propData" />
<Dependencies :dependency-list="['ogl']" />
</template>
<template #code>
<CodeExample :code-object="aurora" />
</template>
<template #cli>
<CliInstallation :command="aurora.cli" />
</template>
</TabbedLayout>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import TabbedLayout from '../../components/common/TabbedLayout.vue'
import PropTable from '../../components/common/PropTable.vue'
import Dependencies from '../../components/code/Dependencies.vue'
import CliInstallation from '../../components/code/CliInstallation.vue'
import CodeExample from '../../components/code/CodeExample.vue'
import Customize from '../../components/common/Customize.vue'
import Aurora from '@/content/Backgrounds/Aurora/Aurora.vue'
import PreviewColor from '@/components/common/PreviewColor.vue'
import PreviewSlider from '@/components/common/PreviewSlider.vue'
import { aurora } from '@/constants/code/Backgrounds/auroraCode'
const colorStops = ref(['#171D22', '#7cff67', '#171D22'])
const amplitude = ref(1.0)
const blend = ref(0.5)
const speed = ref(1.0)
const intensity = ref(1.0)
const updateColorStop = (index: number, color: string) => {
colorStops.value[index] = color
}
const propData = [
{ name: 'colorStops', type: 'string[]', default: "['#171D22', '#7cff67', '#171D22']", description: 'Array of color stops for the aurora gradient.' },
{ name: 'amplitude', type: 'number', default: '1.0', description: 'Controls the height variation of the aurora effect.' },
{ name: 'blend', type: 'number', default: '0.5', description: 'Controls the blending/smoothness of the aurora effect.' },
{ name: 'speed', type: 'number', default: '1.0', description: 'Controls the animation speed of the aurora effect.' },
{ name: 'intensity', type: 'number', default: '1.0', description: 'Controls the overall intensity/opacity of the aurora effect.' },
{ name: 'time', type: 'number', default: 'undefined', description: 'Optional time override for the animation.' },
{ name: 'className', type: 'string', default: '""', description: 'Additional CSS class names for styling.' },
{ name: 'style', type: 'CSSProperties', default: '{}', description: 'Inline styles for the component.' }
]
</script>
<style scoped>
.demo-container {
overflow: hidden;
padding: 0;
}
</style>

View File

@@ -3,8 +3,6 @@
<TabbedLayout>
<template #preview>
<div class="demo-container" style="height: 500px; overflow: hidden;">
<RefreshButton @refresh="forceRerender" />
<DotGrid :key="rerenderKey" :dot-size="dotSize" :gap="gap" :base-color="baseColor" :active-color="activeColor"
:proximity="proximity" :speed-trigger="speedTrigger" :shock-radius="shockRadius"
:shock-strength="shockStrength" :max-speed="maxSpeed" :resistance="resistance"
@@ -13,14 +11,8 @@
<Customize>
<div class="color-controls">
<div class="color-input">
<label>Base Color</label>
<input type="color" v-model="baseColor" @change="forceRerender" />
</div>
<div class="color-input">
<label>Active Color</label>
<input type="color" v-model="activeColor" @change="forceRerender" />
</div>
<PreviewColor title="Base Color" v-model="baseColor" @update:model-value="forceRerender" />
<PreviewColor title="Active Color" v-model="activeColor" @update:model-value="forceRerender" />
</div>
<PreviewSlider title="Dot Size" v-model="dotSize" :min="2" :max="50" :step="1"
@@ -68,13 +60,13 @@
<script setup lang="ts">
import { ref } from 'vue'
import TabbedLayout from '../../components/common/TabbedLayout.vue'
import RefreshButton from '../../components/common/RefreshButton.vue'
import PropTable from '../../components/common/PropTable.vue'
import Dependencies from '../../components/code/Dependencies.vue'
import CliInstallation from '../../components/code/CliInstallation.vue'
import CodeExample from '../../components/code/CodeExample.vue'
import Customize from '../../components/common/Customize.vue'
import PreviewSlider from '../../components/common/PreviewSlider.vue'
import PreviewColor from '../../components/common/PreviewColor.vue'
import DotGrid from '../../content/Backgrounds/DotGrid/DotGrid.vue'
import { dotGrid } from '@/constants/code/Backgrounds/dotGridCode'
import { useForceRerender } from '@/composables/useForceRerender'
@@ -120,34 +112,4 @@ const propData = [
gap: 1rem;
margin-bottom: 1rem;
}
.color-input {
display: flex;
align-items: center;
gap: 0.5rem;
}
.color-input label {
font-size: 0.875rem;
color: #a1a1aa;
white-space: nowrap;
}
.color-input input[type="color"] {
width: 50px;
height: 32px;
border: 1px solid #333;
border-radius: 6px;
background: transparent;
cursor: pointer;
}
.color-input input[type="color"]::-webkit-color-swatch-wrapper {
padding: 0;
}
.color-input input[type="color"]::-webkit-color-swatch {
border: none;
border-radius: 4px;
}
</style>

View File

@@ -0,0 +1,91 @@
<template>
<TabbedLayout>
<template #preview>
<div class="relative demo-container h-[500px] p-0 overflow-hidden">
<Iridescence :key="key" :speed="speed" :color="colors" :mouseReact="mouseInteraction" :amplitude="amplitude" />
</div>
<Customize>
<PreviewSlider :min="0" :max="1" :step="0.1" v-model="colors[0]" title="Red" />
<PreviewSlider :min="0" :max="1" :step="0.1" v-model="colors[1]" title="Green" />
<PreviewSlider :min="0" :max="1" :step="0.1" v-model="colors[2]" title="Blue" />
<PreviewSlider :min="0" :max="2" :step="0.1" v-model="speed" title="Speed" @update:modelValue="forceRerender" />
<PreviewSlider :min="0" :max="0.5" :step="0.01" v-model="amplitude" title="Amplitude"
@update:modelValue="forceRerender" />
<PreviewSwitch v-model="mouseInteraction" title="Enable Mouse Interaction" @update:modelValue="forceRerender" />
</Customize>
<PropTable :data="propData" />
<Dependencies :dependencyList="['ogl']" />
</template>
<template #code>
<CodeExample :codeObject="iridescence" />
</template>
<template #cli>
<CliInstallation v-bind="iridescence" />
</template>
</TabbedLayout>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import TabbedLayout from '../../components/common/TabbedLayout.vue'
import Customize from '../../components/common/Customize.vue'
import PreviewSlider from '../../components/common/PreviewSlider.vue'
import PreviewSwitch from '../../components/common/PreviewSwitch.vue'
import PropTable from '../../components/common/PropTable.vue'
import Dependencies from '../../components/code/Dependencies.vue'
import CodeExample from '../../components/code/CodeExample.vue'
import CliInstallation from '../../components/code/CliInstallation.vue'
import Iridescence from '../../content/Backgrounds/Iridescence/Iridescence.vue'
import { iridescence } from '../../constants/code/Backgrounds/iridescenceCode'
import { useForceRerender } from '../../composables/useForceRerender'
const colors = ref<[number, number, number]>([1, 1, 1])
const speed = ref(1)
const amplitude = ref(0.1)
const mouseInteraction = ref(true)
const { rerenderKey: key, forceRerender } = useForceRerender()
const propData = [
{
name: "color",
type: "Array<number>",
default: "[1, 1, 1]",
description: "Base color as an array of RGB values (each between 0 and 1)."
},
{
name: "speed",
type: "number",
default: "1.0",
description: "Speed multiplier for the animation."
},
{
name: "amplitude",
type: "number",
default: "0.1",
description: "Amplitude for the mouse-driven effect."
},
{
name: "mouseReact",
type: "boolean",
default: "true",
description: "Enable or disable mouse interaction with the shader."
}
]
</script>
<style scoped>
.demo-container {
overflow: hidden;
padding: 0;
}
</style>

View File

@@ -0,0 +1,128 @@
<template>
<div class="letter-glitch-demo">
<TabbedLayout>
<template #preview>
<div class="demo-container">
<LetterGlitch
:key="rerenderKey"
:glitch-colors="colors"
:glitch-speed="speed"
:center-vignette="showCenterVignette"
:outer-vignette="showOuterVignette"
:smooth="smooth"
class="w-full h-96"
/>
</div>
<Customize>
<button
@click="randomizeColors"
class="px-3 py-2 text-xs bg-[#111] hover:bg-[#222] text-white rounded-lg border border-[#333] transition-colors"
>
Randomize Colors
</button>
<PreviewSlider
title="Glitch Speed"
:model-value="speed"
@update:model-value="speed = $event"
:min="0"
:max="100"
:step="5"
/>
<PreviewSwitch
title="Smooth Animation"
:model-value="smooth"
@update:model-value="updateSmooth"
/>
<PreviewSwitch
title="Show Center Vignette"
:model-value="showCenterVignette"
@update:model-value="updateCenterVignette"
/>
<PreviewSwitch
title="Show Outer Vignette"
:model-value="showOuterVignette"
@update:model-value="updateOuterVignette"
/>
</Customize>
<PropTable :data="propData" />
<Dependencies :dependency-list="[]" />
</template>
<template #code>
<CodeExample :code-object="letterGlitch" />
</template>
<template #cli>
<CliInstallation :command="letterGlitch.cli" />
</template>
</TabbedLayout>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import TabbedLayout from '../../components/common/TabbedLayout.vue'
import PropTable from '../../components/common/PropTable.vue'
import Dependencies from '../../components/code/Dependencies.vue'
import CliInstallation from '../../components/code/CliInstallation.vue'
import CodeExample from '../../components/code/CodeExample.vue'
import Customize from '../../components/common/Customize.vue'
import LetterGlitch from '@/content/Backgrounds/LetterGlitch/LetterGlitch.vue'
import PreviewSlider from '@/components/common/PreviewSlider.vue'
import PreviewSwitch from '@/components/common/PreviewSwitch.vue'
import { letterGlitch } from '@/constants/code/Backgrounds/letterGlitchCode'
import { useForceRerender } from '@/composables/useForceRerender'
const smooth = ref(true)
const speed = ref(10)
const colors = ref(['#2b4539', '#61dca3', '#61b3dc'])
const showCenterVignette = ref(true)
const showOuterVignette = ref(false)
const { rerenderKey, forceRerender } = useForceRerender()
const randomHex = () => {
return '#' + Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0')
}
const randomizeColors = () => {
colors.value = [randomHex(), randomHex(), randomHex()]
forceRerender()
}
const updateSmooth = (value: boolean) => {
smooth.value = value
forceRerender()
}
const updateCenterVignette = (value: boolean) => {
showCenterVignette.value = value
forceRerender()
}
const updateOuterVignette = (value: boolean) => {
showOuterVignette.value = value
forceRerender()
}
const propData = [
{ name: 'glitchColors', type: 'string[]', default: "['#2b4539', '#61dca3', '#61b3dc']", description: 'Controls the colors of the letters rendered in the canvas.' },
{ name: 'glitchSpeed', type: 'number', default: '50', description: 'Controls the speed at which letters scramble in the animation.' },
{ name: 'centerVignette', type: 'boolean', default: 'false', description: 'When true, renders a radial gradient in the center of the container' },
{ name: 'outerVignette', type: 'boolean', default: 'true', description: 'When true, renders an inner radial gradient around the edges of the container.' },
{ name: 'smooth', type: 'boolean', default: 'true', description: 'When true, smoothens the animation of the letters for a more subtle feel.' }
]
</script>
<style scoped>
.demo-container {
overflow: hidden;
padding: 0;
}
</style>

View File

@@ -0,0 +1,74 @@
<template>
<div class="lightning-demo">
<TabbedLayout>
<template #preview>
<div class="demo-container">
<Lightning :hue="hue" :x-offset="xOffset" :speed="speed" :intensity="intensity"
:size="size" class="w-full h-96" />
</div>
<Customize>
<PreviewSlider title="Hue" :model-value="hue" @update:model-value="hue = $event" :min="0" :max="360"
:step="1" />
<PreviewSlider title="X Offset" :model-value="xOffset" @update:model-value="xOffset = $event" :min="-2"
:max="2" :step="0.1" />
<PreviewSlider title="Speed" :model-value="speed" @update:model-value="speed = $event" :min="0.5" :max="2"
:step="0.1" />
<PreviewSlider title="Intensity" :model-value="intensity" @update:model-value="intensity = $event" :min="0.1"
:max="2" :step="0.1" />
<PreviewSlider title="Size" :model-value="size" @update:model-value="size = $event" :min="0.1" :max="3"
:step="0.1" />
</Customize>
<PropTable :data="propData" />
<Dependencies :dependency-list="[]" />
</template>
<template #code>
<CodeExample :code-object="lightning" />
</template>
<template #cli>
<CliInstallation :command="lightning.cli" />
</template>
</TabbedLayout>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import TabbedLayout from '../../components/common/TabbedLayout.vue'
import PropTable from '../../components/common/PropTable.vue'
import Dependencies from '../../components/code/Dependencies.vue'
import CliInstallation from '../../components/code/CliInstallation.vue'
import CodeExample from '../../components/code/CodeExample.vue'
import Customize from '../../components/common/Customize.vue'
import Lightning from '@/content/Backgrounds/Lightning/Lightning.vue'
import PreviewSlider from '@/components/common/PreviewSlider.vue'
import { lightning } from '@/constants/code/Backgrounds/lightningCode'
const hue = ref(160)
const xOffset = ref(0)
const speed = ref(1)
const intensity = ref(1)
const size = ref(1)
const propData = [
{ name: 'hue', type: 'number', default: '230', description: 'Hue of the lightning in degrees (0 to 360).' },
{ name: 'xOffset', type: 'number', default: '0', description: 'Horizontal offset of the lightning in normalized units.' },
{ name: 'speed', type: 'number', default: '1', description: 'Animation speed multiplier for the lightning.' },
{ name: 'intensity', type: 'number', default: '1', description: 'Brightness multiplier for the lightning.' },
{ name: 'size', type: 'number', default: '1', description: 'Scale factor for the bolt size.' }
]
</script>
<style scoped>
.demo-container {
overflow: hidden;
padding: 0;
}
</style>

View File

@@ -0,0 +1,105 @@
<template>
<div class="particles-demo">
<TabbedLayout>
<template #preview>
<div class="demo-container">
<Particles :key="rerenderKey" :particle-colors="[color]" :particle-count="particleCount"
:particle-spread="particleSpread" :speed="speed" :particle-base-size="baseSize"
:move-particles-on-hover="moveParticlesOnHover" :alpha-particles="alphaParticles"
:disable-rotation="disableRotation" class="w-full h-96" />
</div>
<Customize>
<div class="flex gap-4 items-center">
<PreviewColor title="Color" :model-value="color" @update:model-value="updateColor" />
</div>
<PreviewSlider title="Count" :model-value="particleCount" @update:model-value="particleCount = $event"
:min="100" :max="1000" :step="100" />
<PreviewSlider title="Spread" :model-value="particleSpread" @update:model-value="particleSpread = $event"
:min="10" :max="100" :step="10" />
<PreviewSlider title="Speed" :model-value="speed" @update:model-value="speed = $event" :min="0" :max="2"
:step="0.1" />
<PreviewSlider title="Base Size" :model-value="baseSize" @update:model-value="baseSize = $event" :min="100"
:max="1000" :step="100" />
<PreviewSwitch title="Mouse Interaction" :model-value="moveParticlesOnHover"
@update:model-value="moveParticlesOnHover = $event" />
<PreviewSwitch title="Particle Transparency" :model-value="alphaParticles"
@update:model-value="alphaParticles = $event" />
<PreviewSwitch title="Disable Rotation" :model-value="disableRotation"
@update:model-value="disableRotation = $event" />
</Customize>
<PropTable :data="propData" />
<Dependencies :dependency-list="['ogl']" />
</template>
<template #code>
<CodeExample :code-object="particles" />
</template>
<template #cli>
<CliInstallation :command="particles.cli" />
</template>
</TabbedLayout>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import TabbedLayout from '../../components/common/TabbedLayout.vue'
import PropTable from '../../components/common/PropTable.vue'
import Dependencies from '../../components/code/Dependencies.vue'
import CliInstallation from '../../components/code/CliInstallation.vue'
import CodeExample from '../../components/code/CodeExample.vue'
import Customize from '../../components/common/Customize.vue'
import Particles from '@/content/Backgrounds/Particles/Particles.vue'
import PreviewSlider from '@/components/common/PreviewSlider.vue'
import PreviewSwitch from '@/components/common/PreviewSwitch.vue'
import PreviewColor from '@/components/common/PreviewColor.vue'
import { particles } from '@/constants/code/Backgrounds/particlesCode'
import { useForceRerender } from '@/composables/useForceRerender'
const color = ref('#ffffff')
const particleCount = ref(200)
const particleSpread = ref(10)
const speed = ref(0.1)
const baseSize = ref(100)
const moveParticlesOnHover = ref(true)
const alphaParticles = ref(false)
const disableRotation = ref(false)
const { rerenderKey, forceRerender } = useForceRerender()
const updateColor = (value: string) => {
color.value = value
forceRerender()
}
const propData = [
{ name: 'particleCount', type: 'number', default: '200', description: 'The number of particles to generate.' },
{ name: 'particleSpread', type: 'number', default: '10', description: 'Controls how far particles are spread from the center.' },
{ name: 'speed', type: 'number', default: '0.1', description: 'Speed factor controlling the animation pace.' },
{ name: 'particleColors', type: 'string[]', default: "['#ffffff']", description: 'An array of hex color strings used to color the particles.' },
{ name: 'moveParticlesOnHover', type: 'boolean', default: 'false', description: 'Determines if particles should move in response to mouse hover.' },
{ name: 'particleHoverFactor', type: 'number', default: '1', description: 'Multiplier for the particle movement when hovering.' },
{ name: 'alphaParticles', type: 'boolean', default: 'false', description: 'If true, particles are rendered with varying transparency; otherwise, as solid circles.' },
{ name: 'particleBaseSize', type: 'number', default: '100', description: 'The base size of the particles.' },
{ name: 'sizeRandomness', type: 'number', default: '1', description: 'Controls the variation in particle sizes (0 means all particles have the same size).' },
{ name: 'cameraDistance', type: 'number', default: '20', description: 'Distance from the camera to the particle system.' },
{ name: 'disableRotation', type: 'boolean', default: 'false', description: 'If true, stops the particle system from rotating.' }
]
</script>
<style scoped>
.demo-container {
overflow: hidden;
padding: 0;
}
</style>

Some files were not shown because too many files have changed in this diff Show More