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

@@ -28,13 +28,17 @@ Go to [vue-bits.dev](https://vue-bits.dev/) to view the documentation.
## About ## About
Vue Bits is a large collection of animated Vue components made to spice up your web creations. We've got animations, components, backgrounds, and awesome stuff that you won't be able to find anywhere else - all free for you to use! These components are all enhanced with customization options as props, to make it easy for you to get exactly what you need. This is the official Vue port of [React Bits](https://reactbits.dev)!
Vue Bits is a large collection of animated Vue UI components made to spice up your web creations. We've got animations, components, backgrounds, and awesome stuff that you won't be able to find anywhere else - all free for you to use!
These components are all enhanced with customization options as props, to make it easy for you to get exactly what you need.
## Key Features ## Key Features
- 40+ total components (text animations, animations, components, backgrounds), growing every week - 40+ total components (text animations, animations, components, backgrounds), growing every week
- All components have with minimal dependencies, and are highly customizable through props - All components have minimal dependencies, and are highly customizable through props
- Designed to integrate seamlessly with any modern Vue project - Designed to integrate seamlessly with any modern Vue/Nuxt project
## CLI (<a href="https://jsrepo.dev"><img src="https://jsrepo.dev/badges/jsrepo.svg" width="50" alt="jsrepo"></a>) ## CLI (<a href="https://jsrepo.dev"><img src="https://jsrepo.dev/badges/jsrepo.svg" width="50" alt="jsrepo"></a>)

1083
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,12 +15,18 @@
}, },
"dependencies": { "dependencies": {
"@primeuix/themes": "^1.2.1", "@primeuix/themes": "^1.2.1",
"@tresjs/cientos": "^4.3.1",
"@tresjs/core": "^4.3.6",
"@types/matter-js": "^0.19.8",
"@vueuse/motion": "^3.0.3",
"@wdns/vue-code-block": "^2.3.5", "@wdns/vue-code-block": "^2.3.5",
"gsap": "^3.13.0", "gsap": "^3.13.0",
"matter-js": "^0.20.0",
"motion-v": "^1.5.0", "motion-v": "^1.5.0",
"ogl": "^1.0.11", "ogl": "^1.0.11",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primevue": "^4.3.6", "primevue": "^4.3.6",
"three": "^0.178.0",
"vue": "^3.5.17", "vue": "^3.5.17",
"vue-router": "^4.5.1", "vue-router": "^4.5.1",
"vue-sonner": "^2.0.1" "vue-sonner": "^2.0.1"
@@ -29,6 +35,7 @@
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"@tsconfig/node22": "^22.0.2", "@tsconfig/node22": "^22.0.2",
"@types/node": "^22.15.32", "@types/node": "^22.15.32",
"@types/three": "^0.178.0",
"@vitejs/plugin-vue": "^6.0.0", "@vitejs/plugin-vue": "^6.0.0",
"@vitejs/plugin-vue-jsx": "^5.0.0", "@vitejs/plugin-vue-jsx": "^5.0.0",
"@vue/eslint-config-typescript": "^14.5.1", "@vue/eslint-config-typescript": "^14.5.1",

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

BIN
public/assets/person.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 MiB

View File

@@ -30,8 +30,8 @@ Object.values(paths).forEach((dir) => {
const files = [ const files = [
path.join(paths.content, `${componentName}.vue`), path.join(paths.content, `${componentName}.vue`),
path.join(paths.demo, `${componentName}Demo.jsx`), path.join(paths.demo, `${componentName}Demo.vue`),
path.join(paths.constants, `${componentNameLower}Code.js`), path.join(paths.constants, `${componentNameLower}Code.ts`),
]; ];
files.forEach((file) => { files.forEach((file) => {

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; font-size: 14px;
color: #fff; color: #fff;
white-space: nowrap; white-space: nowrap;
min-width: 120px;
} }
.custom-select { .custom-select {

View File

@@ -2,7 +2,7 @@ import { ref, onMounted } from 'vue'
import { getStarsCount } from '@/utils/utils' import { getStarsCount } from '@/utils/utils'
const CACHE_KEY = 'github_stars_cache' 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() { export function useStars() {
const stars = ref<number>(0) const stars = ref<number>(0)

View File

@@ -8,24 +8,65 @@ export const CATEGORIES = [
name: 'Text Animations', name: 'Text Animations',
subcategories: [ subcategories: [
'Split Text', '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', name: 'Animations',
subcategories: [ subcategories: [
'Animated Content',
'Fade Content', 'Fade Content',
'Pixel Transition',
'Glare Hover',
'Magnet Lines',
'Count Up',
'Click Spark',
'Magnet',
'Cubes',
] ]
}, },
{ {
name: 'Components', name: 'Components',
subcategories: [ subcategories: [
'Masonry', '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', name: 'Backgrounds',
subcategories: [ subcategories: [
'Aurora',
'Dot Grid', 'Dot Grid',
'Silk',
'Lightning',
'Letter Glitch',
'Particles',
'Waves',
'Squares',
'Iridescence',
'Threads',
], ],
} }
]; ];

View File

@@ -1,17 +1,58 @@
const animations = { const animations = {
'fade-content': () => import("../demo/Animations/FadeContentDemo.vue"), '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 = { const textAnimations = {
'split-text': () => import("../demo/TextAnimations/SplitTextDemo.vue"), '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 = { const components = {
'masonry': () => import("../demo/Components/MasonryDemo.vue"), '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 = { const backgrounds = {
'dot-grid': () => import("../demo/Backgrounds/DotGridDemo.vue"), '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 = { 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; justify-content: center;
} }
.demo-title-extra {
font-size: 1.125rem;
font-weight: 900;
margin: 2rem 0 0 0;
color: #e5e7eb;
}
div:has(> .props-table) { div:has(> .props-table) {
border: 1px solid #142216; border: 1px solid #142216;
border-radius: 20px; border-radius: 20px;
@@ -419,7 +426,6 @@ div:has(> .props-table) {
.custom-spotlight-card { .custom-spotlight-card {
user-select: none; user-select: none;
background-color: #333333 !important;
border: 1px solid #142216 !important; border: 1px solid #142216 !important;
width: 350px; width: 350px;
height: 300px; height: 300px;

View File

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

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