mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 06:29:30 -07:00
Component Boom
This commit is contained in:
10
README.md
10
README.md
@@ -28,13 +28,17 @@ Go to [vue-bits.dev](https://vue-bits.dev/) to view the documentation.
|
||||
|
||||
## 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
|
||||
|
||||
- 40+ total components (text animations, animations, components, backgrounds), growing every week
|
||||
- All components have with minimal dependencies, and are highly customizable through props
|
||||
- Designed to integrate seamlessly with any modern Vue project
|
||||
- All components have minimal dependencies, and are highly customizable through props
|
||||
- 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>)
|
||||
|
||||
|
||||
1083
package-lock.json
generated
1083
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -15,12 +15,18 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"gsap": "^3.13.0",
|
||||
"matter-js": "^0.20.0",
|
||||
"motion-v": "^1.5.0",
|
||||
"ogl": "^1.0.11",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.3.6",
|
||||
"three": "^0.178.0",
|
||||
"vue": "^3.5.17",
|
||||
"vue-router": "^4.5.1",
|
||||
"vue-sonner": "^2.0.1"
|
||||
@@ -29,6 +35,7 @@
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tsconfig/node22": "^22.0.2",
|
||||
"@types/node": "^22.15.32",
|
||||
"@types/three": "^0.178.0",
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"@vitejs/plugin-vue-jsx": "^5.0.0",
|
||||
"@vue/eslint-config-typescript": "^14.5.1",
|
||||
|
||||
BIN
public/assets/iconpattern.png
Normal file
BIN
public/assets/iconpattern.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
BIN
public/assets/person.png
Normal file
BIN
public/assets/person.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.2 MiB |
@@ -30,8 +30,8 @@ Object.values(paths).forEach((dir) => {
|
||||
|
||||
const files = [
|
||||
path.join(paths.content, `${componentName}.vue`),
|
||||
path.join(paths.demo, `${componentName}Demo.jsx`),
|
||||
path.join(paths.constants, `${componentNameLower}Code.js`),
|
||||
path.join(paths.demo, `${componentName}Demo.vue`),
|
||||
path.join(paths.constants, `${componentNameLower}Code.ts`),
|
||||
];
|
||||
|
||||
files.forEach((file) => {
|
||||
|
||||
59
src/components/common/PreviewColor.vue
Normal file
59
src/components/common/PreviewColor.vue
Normal 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>
|
||||
@@ -67,7 +67,6 @@ const selectAttributes = computed(() => {
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.custom-select {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ref, onMounted } from 'vue'
|
||||
import { getStarsCount } from '@/utils/utils'
|
||||
|
||||
const CACHE_KEY = 'github_stars_cache'
|
||||
const CACHE_DURATION = 24 * 60 * 60 * 1000 // 24 hours
|
||||
const CACHE_DURATION = 24 * 60 * 60 * 1000
|
||||
|
||||
export function useStars() {
|
||||
const stars = ref<number>(0)
|
||||
|
||||
@@ -8,24 +8,65 @@ export const CATEGORIES = [
|
||||
name: 'Text Animations',
|
||||
subcategories: [
|
||||
'Split Text',
|
||||
'Blur Text',
|
||||
'Circular Text',
|
||||
'Shiny Text',
|
||||
'Text Pressure',
|
||||
'Curved Loop',
|
||||
'Fuzzy Text',
|
||||
'Gradient Text',
|
||||
'Text Trail',
|
||||
'Falling Text',
|
||||
'Text Cursor',
|
||||
'Decrypted Text',
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Animations',
|
||||
subcategories: [
|
||||
'Animated Content',
|
||||
'Fade Content',
|
||||
'Pixel Transition',
|
||||
'Glare Hover',
|
||||
'Magnet Lines',
|
||||
'Count Up',
|
||||
'Click Spark',
|
||||
'Magnet',
|
||||
'Cubes',
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Components',
|
||||
subcategories: [
|
||||
'Masonry',
|
||||
'Profile Card',
|
||||
'Dock',
|
||||
'Gooey Nav',
|
||||
'Pixel Card',
|
||||
'Carousel',
|
||||
'Spotlight Card',
|
||||
'Flying Posters',
|
||||
'Card Swap',
|
||||
'Infinite Scroll',
|
||||
'Glass Icons',
|
||||
'Decay Card',
|
||||
'Flowing Menu',
|
||||
'Elastic Slider',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Backgrounds',
|
||||
subcategories: [
|
||||
'Aurora',
|
||||
'Dot Grid',
|
||||
'Silk',
|
||||
'Lightning',
|
||||
'Letter Glitch',
|
||||
'Particles',
|
||||
'Waves',
|
||||
'Squares',
|
||||
'Iridescence',
|
||||
'Threads',
|
||||
],
|
||||
}
|
||||
];
|
||||
@@ -1,17 +1,58 @@
|
||||
const animations = {
|
||||
'fade-content': () => import("../demo/Animations/FadeContentDemo.vue"),
|
||||
'animated-content': () => import("../demo/Animations/AnimatedContentDemo.vue"),
|
||||
'pixel-transition': () => import("../demo/Animations/PixelTransitionDemo.vue"),
|
||||
'glare-hover': () => import("../demo/Animations/GlareHoverDemo.vue"),
|
||||
'magnet-lines': () => import("../demo/Animations/MagnetLinesDemo.vue"),
|
||||
'click-spark': () => import("../demo/Animations/ClickSparkDemo.vue"),
|
||||
'magnet': () => import("../demo/Animations/MagnetDemo.vue"),
|
||||
'cubes': () => import("../demo/Animations/CubesDemo.vue"),
|
||||
'count-up': () => import("../demo/Animations/CountUpDemo.vue"),
|
||||
};
|
||||
|
||||
const textAnimations = {
|
||||
'split-text': () => import("../demo/TextAnimations/SplitTextDemo.vue"),
|
||||
'blur-text': () => import("../demo/TextAnimations/BlurTextDemo.vue"),
|
||||
'circular-text': () => import("../demo/TextAnimations/CircularTextDemo.vue"),
|
||||
'shiny-text': () => import("../demo/TextAnimations/ShinyTextDemo.vue"),
|
||||
'text-pressure': () => import("../demo/TextAnimations/TextPressureDemo.vue"),
|
||||
'curved-loop': () => import("../demo/TextAnimations/CurvedLoopDemo.vue"),
|
||||
'fuzzy-text': () => import("../demo/TextAnimations/FuzzyTextDemo.vue"),
|
||||
'gradient-text': () => import("../demo/TextAnimations/GradientTextDemo.vue"),
|
||||
'text-trail': () => import("../demo/TextAnimations/TextTrailDemo.vue"),
|
||||
'falling-text': () => import("../demo/TextAnimations/FallingTextDemo.vue"),
|
||||
'text-cursor': () => import("../demo/TextAnimations/TextCursorDemo.vue"),
|
||||
'decrypted-text': () => import("../demo/TextAnimations/DecryptedTextDemo.vue"),
|
||||
};
|
||||
|
||||
const components = {
|
||||
'masonry': () => import("../demo/Components/MasonryDemo.vue"),
|
||||
'profile-card': () => import("../demo/Components/ProfileCardDemo.vue"),
|
||||
'dock': () => import("../demo/Components/DockDemo.vue"),
|
||||
'gooey-nav': () => import("../demo/Components/GooeyNavDemo.vue"),
|
||||
'pixel-card': () => import("../demo/Components/PixelCardDemo.vue"),
|
||||
'carousel': () => import("../demo/Components/CarouselDemo.vue"),
|
||||
'spotlight-card': () => import("../demo/Components/SpotlightCardDemo.vue"),
|
||||
'flying-posters': () => import("../demo/Components/FlyingPostersDemo.vue"),
|
||||
'card-swap': () => import("../demo/Components/CardSwapDemo.vue"),
|
||||
'infinite-scroll': () => import("../demo/Components/InfiniteScrollDemo.vue"),
|
||||
'glass-icons': () => import("../demo/Components/GlassIconsDemo.vue"),
|
||||
'decay-card': () => import("../demo/Components/DecayCardDemo.vue"),
|
||||
'flowing-menu': () => import("../demo/Components/FlowingMenuDemo.vue"),
|
||||
'elastic-slider': () => import("../demo/Components/ElasticSliderDemo.vue"),
|
||||
};
|
||||
|
||||
const backgrounds = {
|
||||
'dot-grid': () => import("../demo/Backgrounds/DotGridDemo.vue"),
|
||||
'silk': () => import("../demo/Backgrounds/SilkDemo.vue"),
|
||||
'lightning': () => import("../demo/Backgrounds/LightningDemo.vue"),
|
||||
'letter-glitch': () => import("../demo/Backgrounds/LetterGlitchDemo.vue"),
|
||||
'particles': () => import("../demo/Backgrounds/ParticlesDemo.vue"),
|
||||
'waves': () => import("../demo/Backgrounds/WavesDemo.vue"),
|
||||
'squares': () => import("../demo/Backgrounds/SquaresDemo.vue"),
|
||||
'iridescence': () => import("../demo/Backgrounds/IridescenceDemo.vue"),
|
||||
'threads': () => import("../demo/Backgrounds/ThreadsDemo.vue"),
|
||||
'aurora': () => import("../demo/Backgrounds/AuroraDemo.vue"),
|
||||
};
|
||||
|
||||
export const componentMap = {
|
||||
|
||||
35
src/constants/code/Animations/animatedContentCode.ts
Normal file
35
src/constants/code/Animations/animatedContentCode.ts
Normal 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
|
||||
}
|
||||
47
src/constants/code/Animations/clickSparkCode.ts
Normal file
47
src/constants/code/Animations/clickSparkCode.ts
Normal 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
|
||||
}
|
||||
33
src/constants/code/Animations/countUpCode.ts
Normal file
33
src/constants/code/Animations/countUpCode.ts
Normal 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
|
||||
}
|
||||
32
src/constants/code/Animations/cubesCode.ts
Normal file
32
src/constants/code/Animations/cubesCode.ts
Normal 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
|
||||
}
|
||||
29
src/constants/code/Animations/glareHoverCode.ts
Normal file
29
src/constants/code/Animations/glareHoverCode.ts
Normal 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
|
||||
}
|
||||
48
src/constants/code/Animations/magnetCode.ts
Normal file
48
src/constants/code/Animations/magnetCode.ts
Normal 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
|
||||
}
|
||||
22
src/constants/code/Animations/magnetLinesCode.ts
Normal file
22
src/constants/code/Animations/magnetLinesCode.ts
Normal 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
|
||||
}
|
||||
29
src/constants/code/Animations/pixelTransitionCode.ts
Normal file
29
src/constants/code/Animations/pixelTransitionCode.ts
Normal 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
|
||||
}
|
||||
33
src/constants/code/Backgrounds/auroraCode.ts
Normal file
33
src/constants/code/Backgrounds/auroraCode.ts
Normal 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
|
||||
}
|
||||
22
src/constants/code/Backgrounds/iridescenceCode.ts
Normal file
22
src/constants/code/Backgrounds/iridescenceCode.ts
Normal 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
|
||||
}
|
||||
32
src/constants/code/Backgrounds/letterGlitchCode.ts
Normal file
32
src/constants/code/Backgrounds/letterGlitchCode.ts
Normal 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
|
||||
}
|
||||
33
src/constants/code/Backgrounds/lightningCode.ts
Normal file
33
src/constants/code/Backgrounds/lightningCode.ts
Normal 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
|
||||
}
|
||||
39
src/constants/code/Backgrounds/particlesCode.ts
Normal file
39
src/constants/code/Backgrounds/particlesCode.ts
Normal 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
|
||||
}
|
||||
33
src/constants/code/Backgrounds/silkCode.ts
Normal file
33
src/constants/code/Backgrounds/silkCode.ts
Normal 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
|
||||
}
|
||||
22
src/constants/code/Backgrounds/squaresCode.ts
Normal file
22
src/constants/code/Backgrounds/squaresCode.ts
Normal 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
|
||||
}
|
||||
22
src/constants/code/Backgrounds/threadsCode.ts
Normal file
22
src/constants/code/Backgrounds/threadsCode.ts
Normal 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
|
||||
}
|
||||
38
src/constants/code/Backgrounds/wavesCode.ts
Normal file
38
src/constants/code/Backgrounds/wavesCode.ts
Normal 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
|
||||
}
|
||||
54
src/constants/code/Components/cardSwapCode.ts
Normal file
54
src/constants/code/Components/cardSwapCode.ts
Normal 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
|
||||
}
|
||||
40
src/constants/code/Components/carouselCode.ts
Normal file
40
src/constants/code/Components/carouselCode.ts
Normal 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
|
||||
}
|
||||
23
src/constants/code/Components/decayCardCode.ts
Normal file
23
src/constants/code/Components/decayCardCode.ts
Normal 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
|
||||
}
|
||||
47
src/constants/code/Components/dockCode.ts
Normal file
47
src/constants/code/Components/dockCode.ts
Normal 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
|
||||
}
|
||||
30
src/constants/code/Components/elasticSliderCode.ts
Normal file
30
src/constants/code/Components/elasticSliderCode.ts
Normal 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
|
||||
}
|
||||
22
src/constants/code/Components/flowingMenuCode.ts
Normal file
22
src/constants/code/Components/flowingMenuCode.ts
Normal 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
|
||||
}
|
||||
37
src/constants/code/Components/flyingPostersCode.ts
Normal file
37
src/constants/code/Components/flyingPostersCode.ts
Normal 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
|
||||
}
|
||||
23
src/constants/code/Components/glassIconsCode.ts
Normal file
23
src/constants/code/Components/glassIconsCode.ts
Normal 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
|
||||
}
|
||||
40
src/constants/code/Components/gooeyNavCode.ts
Normal file
40
src/constants/code/Components/gooeyNavCode.ts
Normal 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
|
||||
}
|
||||
34
src/constants/code/Components/infiniteScrollCode.ts
Normal file
34
src/constants/code/Components/infiniteScrollCode.ts
Normal 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
|
||||
}
|
||||
21
src/constants/code/Components/pixelCardCode.ts
Normal file
21
src/constants/code/Components/pixelCardCode.ts
Normal 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
|
||||
}
|
||||
31
src/constants/code/Components/profileCardCode.ts
Normal file
31
src/constants/code/Components/profileCardCode.ts
Normal 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
|
||||
}
|
||||
19
src/constants/code/Components/spotlightCardCode.ts
Normal file
19
src/constants/code/Components/spotlightCardCode.ts
Normal 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
|
||||
}
|
||||
29
src/constants/code/TextAnimations/blurTextCode.ts
Normal file
29
src/constants/code/TextAnimations/blurTextCode.ts
Normal 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
|
||||
}
|
||||
20
src/constants/code/TextAnimations/circularTextCode.ts
Normal file
20
src/constants/code/TextAnimations/circularTextCode.ts
Normal 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
|
||||
}
|
||||
20
src/constants/code/TextAnimations/curvedLoopCode.ts
Normal file
20
src/constants/code/TextAnimations/curvedLoopCode.ts
Normal 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
|
||||
}
|
||||
24
src/constants/code/TextAnimations/decryptedTextCode.ts
Normal file
24
src/constants/code/TextAnimations/decryptedTextCode.ts
Normal 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
|
||||
}
|
||||
22
src/constants/code/TextAnimations/fallingTextCode.ts
Normal file
22
src/constants/code/TextAnimations/fallingTextCode.ts
Normal 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
|
||||
}
|
||||
22
src/constants/code/TextAnimations/fuzzyTextCode.ts
Normal file
22
src/constants/code/TextAnimations/fuzzyTextCode.ts
Normal 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
|
||||
}
|
||||
20
src/constants/code/TextAnimations/gradientTextCode.ts
Normal file
20
src/constants/code/TextAnimations/gradientTextCode.ts
Normal 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
|
||||
}
|
||||
19
src/constants/code/TextAnimations/shinyTextCode.ts
Normal file
19
src/constants/code/TextAnimations/shinyTextCode.ts
Normal 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
|
||||
}
|
||||
24
src/constants/code/TextAnimations/textCursorCode.ts
Normal file
24
src/constants/code/TextAnimations/textCursorCode.ts
Normal 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
|
||||
}
|
||||
25
src/constants/code/TextAnimations/textPressureCode.ts
Normal file
25
src/constants/code/TextAnimations/textPressureCode.ts
Normal 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
|
||||
}
|
||||
23
src/constants/code/TextAnimations/textTrailCode.ts
Normal file
23
src/constants/code/TextAnimations/textTrailCode.ts
Normal 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
|
||||
}
|
||||
142
src/content/Animations/AnimatedContent/AnimatedContent.vue
Normal file
142
src/content/Animations/AnimatedContent/AnimatedContent.vue
Normal 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>
|
||||
188
src/content/Animations/ClickSpark/ClickSpark.vue
Normal file
188
src/content/Animations/ClickSpark/ClickSpark.vue
Normal 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>
|
||||
321
src/content/Animations/Cubes/Cubes.vue
Normal file
321
src/content/Animations/Cubes/Cubes.vue
Normal 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>
|
||||
112
src/content/Animations/GlareHover/GlareHover.vue
Normal file
112
src/content/Animations/GlareHover/GlareHover.vue
Normal 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>
|
||||
84
src/content/Animations/Magnet/Magnet.vue
Normal file
84
src/content/Animations/Magnet/Magnet.vue
Normal 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>
|
||||
101
src/content/Animations/MagnetLines/MagnetLines.vue
Normal file
101
src/content/Animations/MagnetLines/MagnetLines.vue
Normal 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>
|
||||
137
src/content/Animations/PixelTransition/PixelTransition.vue
Normal file
137
src/content/Animations/PixelTransition/PixelTransition.vue
Normal 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>
|
||||
266
src/content/Backgrounds/Aurora/Aurora.vue
Normal file
266
src/content/Backgrounds/Aurora/Aurora.vue
Normal 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>
|
||||
204
src/content/Backgrounds/Iridescence/Iridescence.vue
Normal file
204
src/content/Backgrounds/Iridescence/Iridescence.vue
Normal 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>
|
||||
236
src/content/Backgrounds/Lightning/Lightning.vue
Normal file
236
src/content/Backgrounds/Lightning/Lightning.vue
Normal 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>
|
||||
314
src/content/Backgrounds/Particles/Particles.vue
Normal file
314
src/content/Backgrounds/Particles/Particles.vue
Normal 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>
|
||||
232
src/content/Backgrounds/Silk/Silk.vue
Normal file
232
src/content/Backgrounds/Silk/Silk.vue
Normal 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>
|
||||
290
src/content/Backgrounds/Threads/Threads.vue
Normal file
290
src/content/Backgrounds/Threads/Threads.vue
Normal 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>
|
||||
443
src/content/Backgrounds/Waves/Waves.vue
Normal file
443
src/content/Backgrounds/Waves/Waves.vue
Normal 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>
|
||||
290
src/content/Components/CardSwap/CardSwap.vue
Normal file
290
src/content/Components/CardSwap/CardSwap.vue
Normal file
@@ -0,0 +1,290 @@
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="card-swap-container absolute bottom-0 right-0 transform translate-x-[5%] translate-y-[20%] origin-bottom-right perspective-[900px] overflow-visible max-[768px]:translate-x-[25%] max-[768px]:translate-y-[25%] max-[768px]:scale-[0.75] max-[480px]:translate-x-[25%] max-[480px]:translate-y-[25%] max-[480px]:scale-[0.55]"
|
||||
:style="{ width: typeof width === 'number' ? `${width}px` : width, height: typeof height === 'number' ? `${height}px` : height }"
|
||||
>
|
||||
<div
|
||||
v-for="(_, index) in 3"
|
||||
:key="index"
|
||||
ref="cardRefs"
|
||||
class="card-swap-card absolute top-1/2 left-1/2 rounded-xl border border-white bg-black [transform-style:preserve-3d] [will-change:transform] [backface-visibility:hidden]"
|
||||
:style="{ width: typeof width === 'number' ? `${width}px` : width, height: typeof height === 'number' ? `${height}px` : height }"
|
||||
@click="handleCardClick(index)"
|
||||
>
|
||||
<slot :name="`card-${index}`" :index="index" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import gsap from 'gsap'
|
||||
|
||||
export interface CardSwapProps {
|
||||
width?: number | string
|
||||
height?: number | string
|
||||
cardDistance?: number
|
||||
verticalDistance?: number
|
||||
delay?: number
|
||||
pauseOnHover?: boolean
|
||||
onCardClick?: (idx: number) => void
|
||||
skewAmount?: number
|
||||
easing?: 'linear' | 'elastic'
|
||||
}
|
||||
|
||||
interface Slot {
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
zIndex: number
|
||||
}
|
||||
|
||||
const makeSlot = (
|
||||
i: number,
|
||||
distX: number,
|
||||
distY: number,
|
||||
total: number
|
||||
): Slot => ({
|
||||
x: i * distX,
|
||||
y: -i * distY,
|
||||
z: -i * distX * 1.5,
|
||||
zIndex: total - i,
|
||||
})
|
||||
|
||||
const placeNow = (el: HTMLElement, slot: Slot, skew: number) => {
|
||||
gsap.set(el, {
|
||||
x: slot.x,
|
||||
y: slot.y,
|
||||
z: slot.z,
|
||||
xPercent: -50,
|
||||
yPercent: -50,
|
||||
skewY: skew,
|
||||
transformOrigin: 'center center',
|
||||
zIndex: slot.zIndex,
|
||||
force3D: true,
|
||||
})
|
||||
}
|
||||
|
||||
export { makeSlot, placeNow }
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<CardSwapProps>(), {
|
||||
width: 500,
|
||||
height: 400,
|
||||
cardDistance: 60,
|
||||
verticalDistance: 70,
|
||||
delay: 5000,
|
||||
pauseOnHover: false,
|
||||
skewAmount: 6,
|
||||
easing: 'elastic',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'card-click': [index: number]
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const cardRefs = ref<HTMLElement[]>([])
|
||||
const order = ref<number[]>([0, 1, 2])
|
||||
const tlRef = ref<gsap.core.Timeline | null>(null)
|
||||
const intervalRef = ref<number>()
|
||||
|
||||
const handleCardClick = (index: number) => {
|
||||
emit('card-click', index)
|
||||
props.onCardClick?.(index)
|
||||
}
|
||||
|
||||
const config = computed(() => {
|
||||
return props.easing === 'elastic'
|
||||
? {
|
||||
ease: 'elastic.out(0.6,0.9)',
|
||||
durDrop: 2,
|
||||
durMove: 2,
|
||||
durReturn: 2,
|
||||
promoteOverlap: 0.9,
|
||||
returnDelay: 0.05,
|
||||
}
|
||||
: {
|
||||
ease: 'power1.inOut',
|
||||
durDrop: 0.8,
|
||||
durMove: 0.8,
|
||||
durReturn: 0.8,
|
||||
promoteOverlap: 0.45,
|
||||
returnDelay: 0.2,
|
||||
}
|
||||
})
|
||||
|
||||
const initializeCards = () => {
|
||||
if (!cardRefs.value.length) return
|
||||
|
||||
const total = cardRefs.value.length
|
||||
|
||||
cardRefs.value.forEach((el, i) => {
|
||||
if (el) {
|
||||
placeNow(el, makeSlot(i, props.cardDistance, props.verticalDistance, total), props.skewAmount)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const updateCardPositions = () => {
|
||||
if (!cardRefs.value.length) return
|
||||
|
||||
const total = cardRefs.value.length
|
||||
|
||||
cardRefs.value.forEach((el, i) => {
|
||||
if (el) {
|
||||
const slot = makeSlot(i, props.cardDistance, props.verticalDistance, total)
|
||||
gsap.set(el, {
|
||||
x: slot.x,
|
||||
y: slot.y,
|
||||
z: slot.z,
|
||||
skewY: props.skewAmount,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const swap = () => {
|
||||
if (order.value.length < 2) return
|
||||
|
||||
const [front, ...rest] = order.value
|
||||
const elFront = cardRefs.value[front]
|
||||
if (!elFront) return
|
||||
|
||||
const tl = gsap.timeline()
|
||||
tlRef.value = tl
|
||||
|
||||
tl.to(elFront, {
|
||||
y: '+=500',
|
||||
duration: config.value.durDrop,
|
||||
ease: config.value.ease,
|
||||
})
|
||||
|
||||
tl.addLabel('promote', `-=${config.value.durDrop * config.value.promoteOverlap}`)
|
||||
rest.forEach((idx, i) => {
|
||||
const el = cardRefs.value[idx]
|
||||
if (!el) return
|
||||
|
||||
const slot = makeSlot(i, props.cardDistance, props.verticalDistance, cardRefs.value.length)
|
||||
tl.set(el, { zIndex: slot.zIndex }, 'promote')
|
||||
tl.to(
|
||||
el,
|
||||
{
|
||||
x: slot.x,
|
||||
y: slot.y,
|
||||
z: slot.z,
|
||||
duration: config.value.durMove,
|
||||
ease: config.value.ease,
|
||||
},
|
||||
`promote+=${i * 0.15}`
|
||||
)
|
||||
})
|
||||
|
||||
const backSlot = makeSlot(
|
||||
cardRefs.value.length - 1,
|
||||
props.cardDistance,
|
||||
props.verticalDistance,
|
||||
cardRefs.value.length
|
||||
)
|
||||
tl.addLabel('return', `promote+=${config.value.durMove * config.value.returnDelay}`)
|
||||
tl.call(
|
||||
() => {
|
||||
gsap.set(elFront, { zIndex: backSlot.zIndex })
|
||||
},
|
||||
undefined,
|
||||
'return'
|
||||
)
|
||||
tl.set(elFront, { x: backSlot.x, z: backSlot.z }, 'return')
|
||||
tl.to(
|
||||
elFront,
|
||||
{
|
||||
y: backSlot.y,
|
||||
duration: config.value.durReturn,
|
||||
ease: config.value.ease,
|
||||
},
|
||||
'return'
|
||||
)
|
||||
|
||||
tl.call(() => {
|
||||
order.value = [...rest, front]
|
||||
})
|
||||
}
|
||||
|
||||
const startAnimation = () => {
|
||||
stopAnimation()
|
||||
swap()
|
||||
intervalRef.value = window.setInterval(swap, props.delay)
|
||||
}
|
||||
|
||||
const stopAnimation = () => {
|
||||
tlRef.value?.kill()
|
||||
if (intervalRef.value) {
|
||||
clearInterval(intervalRef.value)
|
||||
}
|
||||
}
|
||||
|
||||
const resumeAnimation = () => {
|
||||
tlRef.value?.play()
|
||||
intervalRef.value = window.setInterval(swap, props.delay)
|
||||
}
|
||||
|
||||
const setupHoverListeners = () => {
|
||||
if (props.pauseOnHover && containerRef.value) {
|
||||
containerRef.value.addEventListener('mouseenter', stopAnimation)
|
||||
containerRef.value.addEventListener('mouseleave', resumeAnimation)
|
||||
}
|
||||
}
|
||||
|
||||
const removeHoverListeners = () => {
|
||||
if (containerRef.value) {
|
||||
containerRef.value.removeEventListener('mouseenter', stopAnimation)
|
||||
containerRef.value.removeEventListener('mouseleave', resumeAnimation)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.cardDistance, props.verticalDistance, props.skewAmount],
|
||||
() => {
|
||||
updateCardPositions()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.delay,
|
||||
() => {
|
||||
if (intervalRef.value) {
|
||||
clearInterval(intervalRef.value)
|
||||
intervalRef.value = window.setInterval(swap, props.delay)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.pauseOnHover,
|
||||
() => {
|
||||
removeHoverListeners()
|
||||
setupHoverListeners()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.easing,
|
||||
() => {}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initializeCards()
|
||||
startAnimation()
|
||||
setupHoverListeners()
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAnimation()
|
||||
removeHoverListeners()
|
||||
})
|
||||
</script>
|
||||
277
src/content/Components/Carousel/Carousel.vue
Normal file
277
src/content/Components/Carousel/Carousel.vue
Normal file
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<div ref="containerRef" :class="[
|
||||
'relative overflow-hidden p-4',
|
||||
round
|
||||
? 'rounded-full border border-[#333]'
|
||||
: 'rounded-[24px] border border-[#333]'
|
||||
]" :style="{
|
||||
width: `${baseWidth}px`,
|
||||
...(round && { height: `${baseWidth}px` }),
|
||||
}">
|
||||
<Motion tag="div" class="flex" drag="x" :dragConstraints="dragConstraints" :style="{
|
||||
width: itemWidth + 'px',
|
||||
gap: `${GAP}px`,
|
||||
perspective: 1000,
|
||||
perspectiveOrigin: `${currentIndex * trackItemOffset + itemWidth / 2}px 50%`,
|
||||
x: motionX,
|
||||
}" @dragEnd="handleDragEnd" :animate="{ x: -(currentIndex * trackItemOffset) }" :transition="effectiveTransition"
|
||||
@animationComplete="handleAnimationComplete">
|
||||
<Motion v-for="(item, index) in carouselItems" :key="index" tag="div" :class="[
|
||||
'relative shrink-0 flex flex-col overflow-hidden cursor-grab active:cursor-grabbing',
|
||||
round
|
||||
? 'items-center justify-center text-center bg-[#111] border border-[#333] rounded-full'
|
||||
: 'items-start justify-between bg-[#111] border border-[#333] rounded-[12px]'
|
||||
]" :style="{
|
||||
width: itemWidth + 'px',
|
||||
height: round ? itemWidth + 'px' : '100%',
|
||||
rotateY: getRotateY(index),
|
||||
...(round && { borderRadius: '50%' }),
|
||||
}" :transition="effectiveTransition">
|
||||
<div :class="round ? 'p-0 m-0' : 'mb-4 p-5'">
|
||||
<span class="flex h-[28px] w-[28px] items-center justify-center rounded-full bg-[#060010]">
|
||||
<i :class="item.icon" class="text-white text-base"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div class="mb-1 font-black text-lg text-white">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<p class="text-sm text-white">{{ item.description }}</p>
|
||||
</div>
|
||||
</Motion>
|
||||
</Motion>
|
||||
<div :class="[
|
||||
'flex w-full justify-center',
|
||||
round ? 'absolute z-20 bottom-12 left-1/2 -translate-x-1/2' : ''
|
||||
]">
|
||||
<div class="mt-4 flex w-[150px] justify-between px-8">
|
||||
<Motion v-for="(_, index) in items" :key="index" tag="div" :class="[
|
||||
'h-2 w-2 rounded-full cursor-pointer transition-colors duration-150',
|
||||
currentIndex % items.length === index
|
||||
? round
|
||||
? 'bg-white'
|
||||
: 'bg-[#333333]'
|
||||
: round
|
||||
? 'bg-[#555]'
|
||||
: 'bg-[rgba(51,51,51,0.4)]'
|
||||
]" :animate="{
|
||||
scale: currentIndex % items.length === index ? 1.2 : 1,
|
||||
}" @click="() => setCurrentIndex(index)" :transition="{ duration: 0.15 }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export interface CarouselItem {
|
||||
title: string
|
||||
description: string
|
||||
id: number
|
||||
icon: string
|
||||
}
|
||||
|
||||
export interface CarouselProps {
|
||||
items?: CarouselItem[]
|
||||
baseWidth?: number
|
||||
autoplay?: boolean
|
||||
autoplayDelay?: number
|
||||
pauseOnHover?: boolean
|
||||
loop?: boolean
|
||||
round?: boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_ITEMS: CarouselItem[] = [
|
||||
{
|
||||
title: "Text Animations",
|
||||
description: "Cool text animations for your projects.",
|
||||
id: 1,
|
||||
icon: "pi pi-file",
|
||||
},
|
||||
{
|
||||
title: "Animations",
|
||||
description: "Smooth animations for your projects.",
|
||||
id: 2,
|
||||
icon: "pi pi-circle",
|
||||
},
|
||||
{
|
||||
title: "Components",
|
||||
description: "Reusable components for your projects.",
|
||||
id: 3,
|
||||
icon: "pi pi-objects-column",
|
||||
},
|
||||
{
|
||||
title: "Backgrounds",
|
||||
description: "Beautiful backgrounds and patterns for your projects.",
|
||||
id: 4,
|
||||
icon: "pi pi-table",
|
||||
},
|
||||
{
|
||||
title: "Common UI",
|
||||
description: "Common UI components are coming soon!",
|
||||
id: 5,
|
||||
icon: "pi pi-code",
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { Motion, useMotionValue, useTransform } from 'motion-v'
|
||||
|
||||
const DRAG_BUFFER = 0
|
||||
const VELOCITY_THRESHOLD = 500
|
||||
const GAP = 16
|
||||
const SPRING_OPTIONS = { type: "spring" as const, stiffness: 300, damping: 30 }
|
||||
|
||||
const props = withDefaults(defineProps<CarouselProps>(), {
|
||||
items: () => DEFAULT_ITEMS,
|
||||
baseWidth: 300,
|
||||
autoplay: false,
|
||||
autoplayDelay: 3000,
|
||||
pauseOnHover: false,
|
||||
loop: false,
|
||||
round: false,
|
||||
})
|
||||
|
||||
const containerPadding = 16
|
||||
const itemWidth = computed(() => props.baseWidth - containerPadding * 2)
|
||||
const trackItemOffset = computed(() => itemWidth.value + GAP)
|
||||
|
||||
const carouselItems = computed(() => props.loop ? [...props.items, props.items[0]] : props.items)
|
||||
const currentIndex = ref<number>(0)
|
||||
const motionX = useMotionValue(0)
|
||||
const isHovered = ref<boolean>(false)
|
||||
const isResetting = ref<boolean>(false)
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
let autoplayTimer: number | null = null
|
||||
|
||||
const dragConstraints = computed(() => {
|
||||
return props.loop
|
||||
? {}
|
||||
: {
|
||||
left: -trackItemOffset.value * (carouselItems.value.length - 1),
|
||||
right: 0,
|
||||
}
|
||||
})
|
||||
|
||||
const effectiveTransition = computed(() =>
|
||||
isResetting.value ? { duration: 0 } : SPRING_OPTIONS
|
||||
)
|
||||
|
||||
const maxItems = Math.max(props.items.length + 1, 10)
|
||||
const rotateYTransforms = Array.from({ length: maxItems }, (_, index) => {
|
||||
const range = computed(() => [
|
||||
-(index + 1) * trackItemOffset.value,
|
||||
-index * trackItemOffset.value,
|
||||
-(index - 1) * trackItemOffset.value,
|
||||
])
|
||||
const outputRange = [90, 0, -90]
|
||||
return useTransform(motionX, range, outputRange, { clamp: false })
|
||||
})
|
||||
|
||||
const getRotateY = (index: number) => {
|
||||
return rotateYTransforms[index] || rotateYTransforms[0]
|
||||
}
|
||||
|
||||
const setCurrentIndex = (index: number) => {
|
||||
currentIndex.value = index
|
||||
}
|
||||
|
||||
const handleAnimationComplete = () => {
|
||||
if (props.loop && currentIndex.value === carouselItems.value.length - 1) {
|
||||
isResetting.value = true
|
||||
motionX.set(0)
|
||||
currentIndex.value = 0
|
||||
setTimeout(() => {
|
||||
isResetting.value = false
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
|
||||
interface DragInfo {
|
||||
offset: { x: number; y: number }
|
||||
velocity: { x: number; y: number }
|
||||
}
|
||||
|
||||
const handleDragEnd = (event: Event, info: DragInfo) => {
|
||||
const offset = info.offset.x
|
||||
const velocity = info.velocity.x
|
||||
|
||||
if (offset < -DRAG_BUFFER || velocity < -VELOCITY_THRESHOLD) {
|
||||
if (props.loop && currentIndex.value === props.items.length - 1) {
|
||||
currentIndex.value = currentIndex.value + 1
|
||||
} else {
|
||||
currentIndex.value = Math.min(currentIndex.value + 1, carouselItems.value.length - 1)
|
||||
}
|
||||
} else if (offset > DRAG_BUFFER || velocity > VELOCITY_THRESHOLD) {
|
||||
if (props.loop && currentIndex.value === 0) {
|
||||
currentIndex.value = props.items.length - 1
|
||||
} else {
|
||||
currentIndex.value = Math.max(currentIndex.value - 1, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const startAutoplay = () => {
|
||||
if (props.autoplay && (!props.pauseOnHover || !isHovered.value)) {
|
||||
autoplayTimer = window.setInterval(() => {
|
||||
currentIndex.value = (() => {
|
||||
const prev = currentIndex.value
|
||||
if (prev === props.items.length - 1 && props.loop) {
|
||||
return prev + 1
|
||||
}
|
||||
if (prev === carouselItems.value.length - 1) {
|
||||
return props.loop ? 0 : prev
|
||||
}
|
||||
return prev + 1
|
||||
})()
|
||||
}, props.autoplayDelay)
|
||||
}
|
||||
}
|
||||
|
||||
const stopAutoplay = () => {
|
||||
if (autoplayTimer) {
|
||||
clearInterval(autoplayTimer)
|
||||
autoplayTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
isHovered.value = true
|
||||
if (props.pauseOnHover) {
|
||||
stopAutoplay()
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
isHovered.value = false
|
||||
if (props.pauseOnHover) {
|
||||
startAutoplay()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => props.autoplay, () => props.autoplayDelay, isHovered, () => props.loop, () => props.items.length, () => carouselItems.value.length, () => props.pauseOnHover],
|
||||
() => {
|
||||
stopAutoplay()
|
||||
startAutoplay()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (props.pauseOnHover && containerRef.value) {
|
||||
containerRef.value.addEventListener('mouseenter', handleMouseEnter)
|
||||
containerRef.value.addEventListener('mouseleave', handleMouseLeave)
|
||||
}
|
||||
startAutoplay()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (containerRef.value) {
|
||||
containerRef.value.removeEventListener('mouseenter', handleMouseEnter)
|
||||
containerRef.value.removeEventListener('mouseleave', handleMouseLeave)
|
||||
}
|
||||
stopAutoplay()
|
||||
})
|
||||
</script>
|
||||
161
src/content/Components/DecayCard/DecayCard.vue
Normal file
161
src/content/Components/DecayCard/DecayCard.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div ref="svgRef" class="relative" :style="{ width: `${width}px`, height: `${height}px` }">
|
||||
<svg viewBox="-60 -75 720 900" preserveAspectRatio="xMidYMid slice"
|
||||
class="relative w-full h-full block [will-change:transform]">
|
||||
<filter id="imgFilter">
|
||||
<feTurbulence type="turbulence" baseFrequency="0.015" numOctaves="5" seed="4" stitchTiles="stitch" x="0%" y="0%"
|
||||
width="100%" height="100%" result="turbulence1" />
|
||||
<feDisplacementMap ref="displacementMapRef" in="SourceGraphic" in2="turbulence1" scale="0" xChannelSelector="R"
|
||||
yChannelSelector="B" x="0%" y="0%" width="100%" height="100%" result="displacementMap3" />
|
||||
</filter>
|
||||
<g>
|
||||
<image :href="image" x="0" y="0" width="600" height="750" filter="url(#imgFilter)"
|
||||
preserveAspectRatio="xMidYMid slice" />
|
||||
</g>
|
||||
</svg>
|
||||
<div
|
||||
class="absolute bottom-[1.2em] left-[1em] tracking-[-0.5px] font-black text-[2rem] leading-[1.5em] first-line:text-[4rem]">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { gsap } from 'gsap'
|
||||
|
||||
interface Props {
|
||||
width?: number
|
||||
height?: number
|
||||
image?: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
width: 300,
|
||||
height: 400,
|
||||
image: 'https://picsum.photos/300/400?grayscale'
|
||||
})
|
||||
|
||||
const svgRef = ref<HTMLDivElement | null>(null)
|
||||
const displacementMapRef = ref<SVGFEDisplacementMapElement | null>(null)
|
||||
|
||||
let cursor = {
|
||||
x: typeof window !== 'undefined' ? window.innerWidth / 2 : 0,
|
||||
y: typeof window !== 'undefined' ? window.innerHeight / 2 : 0
|
||||
}
|
||||
|
||||
let cachedCursor = { ...cursor }
|
||||
|
||||
let winsize = {
|
||||
width: typeof window !== 'undefined' ? window.innerWidth : 0,
|
||||
height: typeof window !== 'undefined' ? window.innerHeight : 0
|
||||
}
|
||||
|
||||
let animationFrameId: number | null = null
|
||||
|
||||
const lerp = (a: number, b: number, n: number): number =>
|
||||
(1 - n) * a + n * b
|
||||
|
||||
const map = (
|
||||
x: number,
|
||||
a: number,
|
||||
b: number,
|
||||
c: number,
|
||||
d: number
|
||||
): number => ((x - a) * (d - c)) / (b - a) + c
|
||||
|
||||
const distance = (x1: number, x2: number, y1: number, y2: number): number =>
|
||||
Math.hypot(x1 - x2, y1 - y2)
|
||||
|
||||
const handleResize = (): void => {
|
||||
winsize = {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseMove = (ev: MouseEvent): void => {
|
||||
cursor = { x: ev.clientX, y: ev.clientY }
|
||||
}
|
||||
|
||||
const imgValues = {
|
||||
imgTransforms: { x: 0, y: 0, rz: 0 },
|
||||
displacementScale: 0
|
||||
}
|
||||
|
||||
const render = () => {
|
||||
let targetX = lerp(
|
||||
imgValues.imgTransforms.x,
|
||||
map(cursor.x, 0, winsize.width, -120, 120),
|
||||
0.1
|
||||
)
|
||||
let targetY = lerp(
|
||||
imgValues.imgTransforms.y,
|
||||
map(cursor.y, 0, winsize.height, -120, 120),
|
||||
0.1
|
||||
)
|
||||
const targetRz = lerp(
|
||||
imgValues.imgTransforms.rz,
|
||||
map(cursor.x, 0, winsize.width, -10, 10),
|
||||
0.1
|
||||
)
|
||||
|
||||
const bound = 50
|
||||
if (targetX > bound) targetX = bound + (targetX - bound) * 0.2
|
||||
if (targetX < -bound) targetX = -bound + (targetX + bound) * 0.2
|
||||
if (targetY > bound) targetY = bound + (targetY - bound) * 0.2
|
||||
if (targetY < -bound) targetY = -bound + (targetY + bound) * 0.2
|
||||
|
||||
imgValues.imgTransforms.x = targetX
|
||||
imgValues.imgTransforms.y = targetY
|
||||
imgValues.imgTransforms.rz = targetRz
|
||||
|
||||
if (svgRef.value) {
|
||||
gsap.set(svgRef.value, {
|
||||
x: imgValues.imgTransforms.x,
|
||||
y: imgValues.imgTransforms.y,
|
||||
rotateZ: imgValues.imgTransforms.rz
|
||||
})
|
||||
}
|
||||
|
||||
const cursorTravelledDistance = distance(
|
||||
cachedCursor.x,
|
||||
cursor.x,
|
||||
cachedCursor.y,
|
||||
cursor.y
|
||||
)
|
||||
imgValues.displacementScale = lerp(
|
||||
imgValues.displacementScale,
|
||||
map(cursorTravelledDistance, 0, 200, 0, 400),
|
||||
0.06
|
||||
)
|
||||
|
||||
if (displacementMapRef.value) {
|
||||
gsap.set(displacementMapRef.value, {
|
||||
attr: { scale: imgValues.displacementScale }
|
||||
})
|
||||
}
|
||||
|
||||
cachedCursor = { ...cursor }
|
||||
|
||||
animationFrameId = requestAnimationFrame(render)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('resize', handleResize)
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
render()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
window.removeEventListener('mousemove', handleMouseMove)
|
||||
}
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
261
src/content/Components/Dock/Dock.vue
Normal file
261
src/content/Components/Dock/Dock.vue
Normal file
@@ -0,0 +1,261 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, defineComponent, h } from 'vue'
|
||||
import { useMotionValue, useSpring, useTransform, type SpringOptions } from 'motion-v'
|
||||
|
||||
export type DockItemData = {
|
||||
icon: unknown
|
||||
label: unknown
|
||||
onClick: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export type DockProps = {
|
||||
items: DockItemData[]
|
||||
className?: string
|
||||
distance?: number
|
||||
panelHeight?: number
|
||||
baseItemSize?: number
|
||||
dockHeight?: number
|
||||
magnification?: number
|
||||
spring?: SpringOptions
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<DockProps>(), {
|
||||
className: '',
|
||||
distance: 200,
|
||||
panelHeight: 64,
|
||||
baseItemSize: 50,
|
||||
dockHeight: 256,
|
||||
magnification: 70,
|
||||
spring: () => ({ mass: 0.1, stiffness: 150, damping: 12 })
|
||||
})
|
||||
|
||||
const mouseX = useMotionValue(Infinity)
|
||||
const isHovered = useMotionValue(0)
|
||||
const currentHeight = ref(props.panelHeight)
|
||||
|
||||
const maxHeight = computed(() =>
|
||||
Math.max(props.dockHeight, props.magnification + props.magnification / 2 + 4)
|
||||
)
|
||||
|
||||
const heightRow = useTransform(isHovered, [0, 1], [props.panelHeight, maxHeight.value])
|
||||
const height = useSpring(heightRow, props.spring)
|
||||
|
||||
let unsubscribeHeight: (() => void) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
unsubscribeHeight = height.on('change', (latest: number) => {
|
||||
currentHeight.value = latest
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unsubscribeHeight) {
|
||||
unsubscribeHeight()
|
||||
}
|
||||
})
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
isHovered.set(1)
|
||||
mouseX.set(event.pageX)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
isHovered.set(0)
|
||||
mouseX.set(Infinity)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :style="{ height: currentHeight + 'px', scrollbarWidth: 'none' }" class="mx-2 flex max-w-full items-center">
|
||||
<div @mousemove="handleMouseMove" @mouseleave="handleMouseLeave"
|
||||
:class="`${props.className} absolute bottom-2 left-1/2 transform -translate-x-1/2 flex items-end w-fit gap-4 rounded-2xl border-neutral-700 border-2 pb-2 px-4`"
|
||||
:style="{ height: props.panelHeight + 'px' }" role="toolbar" aria-="Application dock">
|
||||
<DockItem v-for="(item, index) in props.items" :key="index" :onClick="item.onClick" :className="item.className"
|
||||
:mouseX="mouseX" :spring="props.spring" :distance="props.distance" :magnification="props.magnification"
|
||||
:baseItemSize="props.baseItemSize" :item="item" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
const DockItem = defineComponent({
|
||||
name: 'DockItem',
|
||||
props: {
|
||||
className: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
onClick: {
|
||||
type: Function,
|
||||
default: () => { }
|
||||
},
|
||||
mouseX: {
|
||||
type: Object as () => ReturnType<typeof useMotionValue<number>>,
|
||||
required: true
|
||||
},
|
||||
spring: {
|
||||
type: Object as () => SpringOptions,
|
||||
required: true
|
||||
},
|
||||
distance: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
baseItemSize: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
magnification: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
item: {
|
||||
type: Object as () => DockItemData,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const itemRef = ref<HTMLDivElement>()
|
||||
const isHovered = useMotionValue(0)
|
||||
const currentSize = ref(props.baseItemSize)
|
||||
|
||||
const mouseDistance = useTransform(props.mouseX, (val: number) => {
|
||||
const rect = itemRef.value?.getBoundingClientRect() ?? {
|
||||
x: 0,
|
||||
width: props.baseItemSize,
|
||||
}
|
||||
return val - rect.x - props.baseItemSize / 2
|
||||
})
|
||||
|
||||
const targetSize = useTransform(
|
||||
mouseDistance,
|
||||
[-props.distance, 0, props.distance],
|
||||
[props.baseItemSize, props.magnification, props.baseItemSize]
|
||||
)
|
||||
const size = useSpring(targetSize, props.spring)
|
||||
|
||||
let unsubscribeSize: (() => void) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
unsubscribeSize = size.on('change', (latest: number) => {
|
||||
currentSize.value = latest
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unsubscribeSize) {
|
||||
unsubscribeSize()
|
||||
}
|
||||
})
|
||||
|
||||
const handleHoverStart = () => isHovered.set(1)
|
||||
const handleHoverEnd = () => isHovered.set(0)
|
||||
const handleFocus = () => isHovered.set(1)
|
||||
const handleBlur = () => isHovered.set(0)
|
||||
|
||||
return {
|
||||
itemRef,
|
||||
size,
|
||||
currentSize,
|
||||
isHovered,
|
||||
handleHoverStart,
|
||||
handleHoverEnd,
|
||||
handleFocus,
|
||||
handleBlur
|
||||
}
|
||||
},
|
||||
render() {
|
||||
const icon = typeof this.item.icon === 'function' ? this.item.icon() : this.item.icon
|
||||
const label = typeof this.item.label === 'function' ? this.item.label() : this.item.label
|
||||
|
||||
return h('div', {
|
||||
ref: 'itemRef',
|
||||
style: {
|
||||
width: this.currentSize + 'px',
|
||||
height: this.currentSize + 'px',
|
||||
},
|
||||
onMouseenter: this.handleHoverStart,
|
||||
onMouseleave: this.handleHoverEnd,
|
||||
onFocus: this.handleFocus,
|
||||
onBlur: this.handleBlur,
|
||||
onClick: this.onClick,
|
||||
class: `relative cursor-pointer inline-flex items-center justify-center rounded-full bg-[#111] border-neutral-700 border-2 shadow-md ${this.className}`,
|
||||
tabindex: 0,
|
||||
role: 'button',
|
||||
'aria-haspopup': 'true'
|
||||
}, [
|
||||
h(DockIcon, {}, () => [icon]),
|
||||
h(DockLabel, { isHovered: this.isHovered }, () => [typeof label === 'string' ? label : label])
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
const DockLabel = defineComponent({
|
||||
name: 'DockLabel',
|
||||
props: {
|
||||
className: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
isHovered: {
|
||||
type: Object as () => ReturnType<typeof useMotionValue<number>>,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const isVisible = ref(false)
|
||||
|
||||
let unsubscribe: (() => void) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
unsubscribe = props.isHovered.on('change', (latest: number) => {
|
||||
isVisible.value = latest === 1
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unsubscribe) {
|
||||
unsubscribe()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
isVisible
|
||||
}
|
||||
},
|
||||
render() {
|
||||
return h('div', {
|
||||
class: `${this.className} absolute -top-8 left-1/2 w-fit whitespace-pre rounded-md border border-neutral-700 bg-[#111] px-2 py-0.5 text-xs text-white transition-all duration-200`,
|
||||
role: 'tooltip',
|
||||
style: {
|
||||
transform: 'translateX(-50%)',
|
||||
opacity: this.isVisible ? 1 : 0,
|
||||
visibility: this.isVisible ? 'visible' : 'hidden'
|
||||
}
|
||||
}, this.$slots.default?.())
|
||||
}
|
||||
})
|
||||
|
||||
const DockIcon = defineComponent({
|
||||
name: 'DockIcon',
|
||||
props: {
|
||||
className: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
render() {
|
||||
return h('div', {
|
||||
class: `flex items-center justify-center ${this.className}`
|
||||
}, this.$slots.default?.())
|
||||
}
|
||||
})
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Dock',
|
||||
components: {
|
||||
DockItem
|
||||
}
|
||||
})
|
||||
</script>
|
||||
328
src/content/Components/ElasticSlider/ElasticSlider.vue
Normal file
328
src/content/Components/ElasticSlider/ElasticSlider.vue
Normal file
@@ -0,0 +1,328 @@
|
||||
<template>
|
||||
<div :class="`flex flex-col items-center justify-center gap-4 w-48 ${className}`">
|
||||
<div class="flex w-full touch-none select-none items-center justify-center gap-4" :style="{
|
||||
scale: scale,
|
||||
opacity: sliderOpacity
|
||||
}" @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave" @touchstart="handleTouchStart"
|
||||
@touchend="handleTouchEnd">
|
||||
<div ref="leftIconRef" :style="{
|
||||
transform: `translateX(${leftIconTranslateX}px) scale(${leftIconScale})`,
|
||||
}" class="transition-transform duration-200 ease-out">
|
||||
<slot name="left-icon">
|
||||
<component :is="leftIcon" v-if="leftIcon && typeof leftIcon === 'object'" />
|
||||
<span v-else-if="leftIcon">{{ leftIcon }}</span>
|
||||
<span v-else>-</span>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div ref="sliderRef"
|
||||
class="relative flex w-full max-w-xs flex-grow cursor-grab touch-none select-none items-center py-4"
|
||||
@pointermove="handlePointerMove" @pointerdown="handlePointerDown" @pointerup="handlePointerUp">
|
||||
<div :style="{
|
||||
transform: `scaleX(${sliderScaleX}) scaleY(${sliderScaleY})`,
|
||||
transformOrigin: transformOrigin,
|
||||
height: `${sliderHeight}px`,
|
||||
marginTop: `${sliderMarginTop}px`,
|
||||
marginBottom: `${sliderMarginBottom}px`,
|
||||
}" class="flex flex-grow">
|
||||
<div class="relative h-full flex-grow overflow-hidden rounded-full bg-gray-400">
|
||||
<div class="absolute h-full bg-[#27FF64] rounded-full" :style="{ width: `${rangePercentage}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="rightIconRef" :style="{
|
||||
transform: `translateX(${rightIconTranslateX}px) scale(${rightIconScale})`,
|
||||
}" class="transition-transform duration-200 ease-out">
|
||||
<slot name="right-icon">
|
||||
<component :is="rightIcon" v-if="rightIcon && typeof rightIcon === 'object'" />
|
||||
<span v-else-if="rightIcon">{{ rightIcon }}</span>
|
||||
<span v-else>+</span>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="absolute text-gray-400 transform -translate-y-6 font-medium tracking-wide">
|
||||
{{ Math.round(value) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, type Component } from 'vue'
|
||||
|
||||
const MAX_OVERFLOW = 50
|
||||
|
||||
interface Props {
|
||||
defaultValue?: number
|
||||
startingValue?: number
|
||||
maxValue?: number
|
||||
className?: string
|
||||
isStepped?: boolean
|
||||
stepSize?: number
|
||||
leftIcon?: Component | string
|
||||
rightIcon?: Component | string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
defaultValue: 50,
|
||||
startingValue: 0,
|
||||
maxValue: 100,
|
||||
className: '',
|
||||
isStepped: false,
|
||||
stepSize: 1,
|
||||
leftIcon: '-',
|
||||
rightIcon: '+'
|
||||
})
|
||||
|
||||
const sliderRef = ref<HTMLDivElement>()
|
||||
const leftIconRef = ref<HTMLDivElement>()
|
||||
const rightIconRef = ref<HTMLDivElement>()
|
||||
|
||||
const value = ref(props.defaultValue)
|
||||
const region = ref<'left' | 'middle' | 'right'>('middle')
|
||||
const clientX = ref(0)
|
||||
const overflow = ref(0)
|
||||
const scale = ref(1)
|
||||
const leftIconScale = ref(1)
|
||||
const rightIconScale = ref(1)
|
||||
|
||||
let scaleAnimation: number | null = null
|
||||
let overflowAnimation: number | null = null
|
||||
|
||||
watch(() => props.defaultValue, (newValue) => {
|
||||
value.value = newValue
|
||||
})
|
||||
|
||||
watch(clientX, (latest) => {
|
||||
if (sliderRef.value) {
|
||||
const { left, right } = sliderRef.value.getBoundingClientRect()
|
||||
let newValue: number
|
||||
if (latest < left) {
|
||||
region.value = 'left'
|
||||
newValue = left - latest
|
||||
} else if (latest > right) {
|
||||
region.value = 'right'
|
||||
newValue = latest - right
|
||||
} else {
|
||||
region.value = 'middle'
|
||||
newValue = 0
|
||||
}
|
||||
overflow.value = decay(newValue, MAX_OVERFLOW)
|
||||
}
|
||||
})
|
||||
|
||||
const rangePercentage = computed(() => {
|
||||
const totalRange = props.maxValue - props.startingValue
|
||||
if (totalRange === 0) return 0
|
||||
return ((value.value - props.startingValue) / totalRange) * 100
|
||||
})
|
||||
|
||||
const sliderScaleX = computed(() => {
|
||||
if (!sliderRef.value) return 1
|
||||
const { width } = sliderRef.value.getBoundingClientRect()
|
||||
return 1 + overflow.value / width
|
||||
})
|
||||
|
||||
const sliderScaleY = computed(() => {
|
||||
const t = overflow.value / MAX_OVERFLOW
|
||||
return 1 + t * (0.8 - 1)
|
||||
})
|
||||
|
||||
const transformOrigin = computed(() => {
|
||||
if (!sliderRef.value) return 'center'
|
||||
const { left, width } = sliderRef.value.getBoundingClientRect()
|
||||
return clientX.value < left + width / 2 ? 'right' : 'left'
|
||||
})
|
||||
|
||||
const sliderHeight = computed(() => {
|
||||
const t = (scale.value - 1) / (1.2 - 1)
|
||||
return 6 + t * (12 - 6)
|
||||
})
|
||||
|
||||
const sliderMarginTop = computed(() => {
|
||||
const t = (scale.value - 1) / (1.2 - 1)
|
||||
return 0 + t * (-3 - 0)
|
||||
})
|
||||
|
||||
const sliderMarginBottom = computed(() => {
|
||||
const t = (scale.value - 1) / (1.2 - 1)
|
||||
return 0 + t * (-3 - 0)
|
||||
})
|
||||
|
||||
const sliderOpacity = computed(() => {
|
||||
const t = (scale.value - 1) / (1.2 - 1)
|
||||
return 0.7 + t * (1 - 0.7)
|
||||
})
|
||||
|
||||
const leftIconTranslateX = computed(() => {
|
||||
return region.value === 'left' ? -overflow.value / scale.value : 0
|
||||
})
|
||||
|
||||
const rightIconTranslateX = computed(() => {
|
||||
return region.value === 'right' ? overflow.value / scale.value : 0
|
||||
})
|
||||
|
||||
const decay = (inputValue: number, max: number): number => {
|
||||
if (max === 0) return 0
|
||||
const entry = inputValue / max
|
||||
const sigmoid = 2 * (1 / (1 + Math.exp(-entry)) - 0.5)
|
||||
return sigmoid * max
|
||||
}
|
||||
|
||||
const animate = (target: { value: number }, to: number, options: { type?: string; bounce?: number; duration?: number } = {}) => {
|
||||
const { type = 'tween', bounce = 0, duration = 0.3 } = options
|
||||
|
||||
if (type === 'spring') {
|
||||
return animateSpring(target, to, bounce, duration)
|
||||
} else {
|
||||
return animateValue(target, to, duration)
|
||||
}
|
||||
}
|
||||
|
||||
const animateValue = (target: { value: number }, to: number, duration = 300) => {
|
||||
const start = target.value
|
||||
const diff = to - start
|
||||
const startTime = performance.now()
|
||||
|
||||
const animateFrame = (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
|
||||
const easeOut = 1 - Math.pow(1 - progress, 3)
|
||||
target.value = start + diff * easeOut
|
||||
|
||||
if (progress < 1) {
|
||||
return requestAnimationFrame(animateFrame)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return requestAnimationFrame(animateFrame)
|
||||
}
|
||||
|
||||
const animateSpring = (target: { value: number }, to: number, bounce = 0.5, duration = 600) => {
|
||||
const start = target.value
|
||||
const startTime = performance.now()
|
||||
|
||||
const mass = 1
|
||||
const stiffness = 170
|
||||
const damping = 26 * (1 - bounce)
|
||||
|
||||
const dampingRatio = damping / (2 * Math.sqrt(mass * stiffness))
|
||||
const angularFreq = Math.sqrt(stiffness / mass)
|
||||
const dampedFreq = angularFreq * Math.sqrt(1 - dampingRatio * dampingRatio)
|
||||
|
||||
const animateFrame = (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime
|
||||
const t = elapsed / 1000
|
||||
|
||||
let displacement: number
|
||||
|
||||
if (dampingRatio < 1) {
|
||||
const envelope = Math.exp(-dampingRatio * angularFreq * t)
|
||||
const cos = Math.cos(dampedFreq * t)
|
||||
const sin = Math.sin(dampedFreq * t)
|
||||
|
||||
displacement = envelope * (cos + (dampingRatio * angularFreq / dampedFreq) * sin)
|
||||
} else {
|
||||
displacement = Math.exp(-angularFreq * t)
|
||||
}
|
||||
|
||||
const currentValue = to + (start - to) * displacement
|
||||
target.value = currentValue
|
||||
|
||||
const velocity = Math.abs(currentValue - to)
|
||||
const isSettled = velocity < 0.01 && elapsed > 100
|
||||
|
||||
if (!isSettled && elapsed < duration * 3) {
|
||||
return requestAnimationFrame(animateFrame)
|
||||
} else {
|
||||
target.value = to
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return requestAnimationFrame(animateFrame)
|
||||
}
|
||||
|
||||
const animateIconScale = (target: { value: number }, isActive: boolean) => {
|
||||
if (isActive) {
|
||||
animate(target, 1.4, { duration: 125 })
|
||||
setTimeout(() => {
|
||||
animate(target, 1, { duration: 125 })
|
||||
}, 125)
|
||||
} else {
|
||||
animate(target, 1, { duration: 250 })
|
||||
}
|
||||
}
|
||||
|
||||
watch(region, (newRegion, oldRegion) => {
|
||||
if (newRegion === 'left' && oldRegion !== 'left') {
|
||||
animateIconScale(leftIconScale, true)
|
||||
} else if (newRegion === 'right' && oldRegion !== 'right') {
|
||||
animateIconScale(rightIconScale, true)
|
||||
}
|
||||
})
|
||||
|
||||
const handlePointerMove = (e: PointerEvent) => {
|
||||
if (e.buttons > 0 && sliderRef.value) {
|
||||
const { left, width } = sliderRef.value.getBoundingClientRect()
|
||||
|
||||
let newValue = props.startingValue + ((e.clientX - left) / width) * (props.maxValue - props.startingValue)
|
||||
|
||||
if (props.isStepped) {
|
||||
newValue = Math.round(newValue / props.stepSize) * props.stepSize
|
||||
}
|
||||
|
||||
newValue = Math.min(Math.max(newValue, props.startingValue), props.maxValue)
|
||||
value.value = newValue
|
||||
|
||||
clientX.value = e.clientX
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerDown = (e: PointerEvent) => {
|
||||
handlePointerMove(e)
|
||||
; (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
}
|
||||
|
||||
const handlePointerUp = () => {
|
||||
if (overflowAnimation) {
|
||||
cancelAnimationFrame(overflowAnimation)
|
||||
}
|
||||
overflowAnimation = animate(overflow, 0, { type: 'spring', bounce: 0.4, duration: 500 })
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (scaleAnimation) {
|
||||
cancelAnimationFrame(scaleAnimation)
|
||||
}
|
||||
scaleAnimation = animate(scale, 1.2, { duration: 200 })
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (scaleAnimation) {
|
||||
cancelAnimationFrame(scaleAnimation)
|
||||
}
|
||||
scaleAnimation = animate(scale, 1, { duration: 200 })
|
||||
}
|
||||
|
||||
const handleTouchStart = () => {
|
||||
if (scaleAnimation) {
|
||||
cancelAnimationFrame(scaleAnimation)
|
||||
}
|
||||
scaleAnimation = animate(scale, 1.2, { duration: 200 })
|
||||
}
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (scaleAnimation) {
|
||||
cancelAnimationFrame(scaleAnimation)
|
||||
}
|
||||
scaleAnimation = animate(scale, 1, { duration: 200 })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
value.value = props.defaultValue
|
||||
})
|
||||
</script>
|
||||
131
src/content/Components/FlowingMenu/FlowingMenu.vue
Normal file
131
src/content/Components/FlowingMenu/FlowingMenu.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-hidden">
|
||||
<nav class="flex flex-col h-full m-0 p-0">
|
||||
<div v-for="(item, idx) in items" :key="idx"
|
||||
class="flex-1 relative overflow-hidden text-center shadow-[0_-1px_0_0_#fff]"
|
||||
:ref="(el) => setItemRef(el as HTMLDivElement, idx)">
|
||||
<a class="flex items-center justify-center h-full relative cursor-pointer uppercase no-underline font-semibold text-white text-[4vh] hover:text-[#060010] focus:text-white focus-visible:text-[#060010]"
|
||||
:href="item.link" @mouseenter="(ev) => handleMouseEnter(ev, idx)"
|
||||
@mouseleave="(ev) => handleMouseLeave(ev, idx)">
|
||||
{{ item.text }}
|
||||
</a>
|
||||
<div class="absolute top-0 left-0 w-full h-full overflow-hidden pointer-events-none bg-white translate-y-[101%]"
|
||||
:ref="(el) => marqueeRefs[idx] = el as HTMLDivElement">
|
||||
<div class="h-full w-[200%] flex" :ref="(el) => marqueeInnerRefs[idx] = el as HTMLDivElement">
|
||||
<div class="flex items-center relative h-full w-[200%] will-change-transform animate-marquee">
|
||||
<template v-for="i in 4" :key="`${idx}-${i}`">
|
||||
<span class="text-[#060010] uppercase font-normal text-[4vh] leading-[1.2] p-[1vh_1vw_0]">
|
||||
{{ item.text }}
|
||||
</span>
|
||||
<div class="w-[200px] h-[7vh] my-[2em] mx-[2vw] p-[1em_0] rounded-[50px] bg-cover bg-center"
|
||||
:style="{ backgroundImage: `url(${item.image})` }" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { gsap } from 'gsap'
|
||||
|
||||
interface MenuItemProps {
|
||||
link: string
|
||||
text: string
|
||||
image: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items?: MenuItemProps[]
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
items: () => []
|
||||
})
|
||||
|
||||
const itemRefs = ref<(HTMLDivElement | null)[]>([])
|
||||
const marqueeRefs = ref<(HTMLDivElement | null)[]>([])
|
||||
const marqueeInnerRefs = ref<(HTMLDivElement | null)[]>([])
|
||||
|
||||
const animationDefaults = { duration: 0.6, ease: 'expo' }
|
||||
|
||||
const setItemRef = (el: HTMLDivElement | null, idx: number) => {
|
||||
if (el) {
|
||||
itemRefs.value[idx] = el
|
||||
}
|
||||
}
|
||||
|
||||
const findClosestEdge = (
|
||||
mouseX: number,
|
||||
mouseY: number,
|
||||
width: number,
|
||||
height: number
|
||||
): 'top' | 'bottom' => {
|
||||
const topEdgeDist = Math.pow(mouseX - width / 2, 2) + Math.pow(mouseY, 2)
|
||||
const bottomEdgeDist =
|
||||
Math.pow(mouseX - width / 2, 2) + Math.pow(mouseY - height, 2)
|
||||
return topEdgeDist < bottomEdgeDist ? 'top' : 'bottom'
|
||||
}
|
||||
|
||||
const handleMouseEnter = (ev: MouseEvent, idx: number) => {
|
||||
const itemRef = itemRefs.value[idx]
|
||||
const marqueeRef = marqueeRefs.value[idx]
|
||||
const marqueeInnerRef = marqueeInnerRefs.value[idx]
|
||||
|
||||
if (!itemRef || !marqueeRef || !marqueeInnerRef) return
|
||||
|
||||
const rect = itemRef.getBoundingClientRect()
|
||||
const edge = findClosestEdge(
|
||||
ev.clientX - rect.left,
|
||||
ev.clientY - rect.top,
|
||||
rect.width,
|
||||
rect.height
|
||||
)
|
||||
|
||||
const tl = gsap.timeline({ defaults: animationDefaults })
|
||||
tl.set(marqueeRef, { y: edge === 'top' ? '-101%' : '101%' })
|
||||
.set(marqueeInnerRef, { y: edge === 'top' ? '101%' : '-101%' })
|
||||
.to([marqueeRef, marqueeInnerRef], { y: '0%' })
|
||||
}
|
||||
|
||||
const handleMouseLeave = (ev: MouseEvent, idx: number) => {
|
||||
const itemRef = itemRefs.value[idx]
|
||||
const marqueeRef = marqueeRefs.value[idx]
|
||||
const marqueeInnerRef = marqueeInnerRefs.value[idx]
|
||||
|
||||
if (!itemRef || !marqueeRef || !marqueeInnerRef) return
|
||||
|
||||
const rect = itemRef.getBoundingClientRect()
|
||||
const edge = findClosestEdge(
|
||||
ev.clientX - rect.left,
|
||||
ev.clientY - rect.top,
|
||||
rect.width,
|
||||
rect.height
|
||||
)
|
||||
|
||||
const tl = gsap.timeline({ defaults: animationDefaults })
|
||||
tl.to(marqueeRef, { y: edge === 'top' ? '-101%' : '101%' }).to(
|
||||
marqueeInnerRef,
|
||||
{ y: edge === 'top' ? '101%' : '-101%' }
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes marquee {
|
||||
from {
|
||||
transform: translateX(0%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-marquee {
|
||||
animation: marquee 15s linear infinite;
|
||||
}
|
||||
</style>
|
||||
686
src/content/Components/FlyingPosters/FlyingPosters.vue
Normal file
686
src/content/Components/FlyingPosters/FlyingPosters.vue
Normal file
@@ -0,0 +1,686 @@
|
||||
<template>
|
||||
<div ref="containerRef" :class="[
|
||||
'w-full h-full overflow-hidden relative z-2',
|
||||
className
|
||||
]" v-bind="$attrs">
|
||||
<canvas ref="canvasRef" class="block w-full h-full" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
Renderer,
|
||||
Camera,
|
||||
Transform,
|
||||
Plane,
|
||||
Program,
|
||||
Mesh,
|
||||
Texture,
|
||||
type OGLRenderingContext,
|
||||
} from "ogl";
|
||||
|
||||
type GL = OGLRenderingContext;
|
||||
type OGLProgram = Program;
|
||||
type OGLMesh = Mesh;
|
||||
type OGLTransform = Transform;
|
||||
type OGLPlane = Plane;
|
||||
|
||||
interface ScreenSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface ViewportSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface ScrollState {
|
||||
position?: number;
|
||||
ease: number;
|
||||
current: number;
|
||||
target: number;
|
||||
last: number;
|
||||
}
|
||||
|
||||
interface AutoBindOptions {
|
||||
include?: Array<string | RegExp>;
|
||||
exclude?: Array<string | RegExp>;
|
||||
}
|
||||
|
||||
interface MediaParams {
|
||||
gl: GL;
|
||||
geometry: OGLPlane;
|
||||
scene: OGLTransform;
|
||||
screen: ScreenSize;
|
||||
viewport: ViewportSize;
|
||||
image: string;
|
||||
length: number;
|
||||
index: number;
|
||||
planeWidth: number;
|
||||
planeHeight: number;
|
||||
distortion: number;
|
||||
}
|
||||
|
||||
interface CanvasParams {
|
||||
container: HTMLElement;
|
||||
canvas: HTMLCanvasElement;
|
||||
items: string[];
|
||||
planeWidth: number;
|
||||
planeHeight: number;
|
||||
distortion: number;
|
||||
scrollEase: number;
|
||||
cameraFov: number;
|
||||
cameraZ: number;
|
||||
}
|
||||
|
||||
const vertexShader = `
|
||||
precision highp float;
|
||||
|
||||
attribute vec3 position;
|
||||
attribute vec2 uv;
|
||||
attribute vec3 normal;
|
||||
|
||||
uniform mat4 modelViewMatrix;
|
||||
uniform mat4 projectionMatrix;
|
||||
uniform mat3 normalMatrix;
|
||||
|
||||
uniform float uPosition;
|
||||
uniform float uTime;
|
||||
uniform float uSpeed;
|
||||
uniform vec3 distortionAxis;
|
||||
uniform vec3 rotationAxis;
|
||||
uniform float uDistortion;
|
||||
|
||||
varying vec2 vUv;
|
||||
varying vec3 vNormal;
|
||||
|
||||
float PI = 3.141592653589793238;
|
||||
mat4 rotationMatrix(vec3 axis, float angle) {
|
||||
axis = normalize(axis);
|
||||
float s = sin(angle);
|
||||
float c = cos(angle);
|
||||
float oc = 1.0 - c;
|
||||
|
||||
return mat4(
|
||||
oc * axis.x * axis.x + c, oc * axis.x * axis.y - axis.z * s, oc * axis.z * axis.x + axis.y * s, 0.0,
|
||||
oc * axis.x * axis.y + axis.z * s,oc * axis.y * axis.y + c, oc * axis.y * axis.z - axis.x * s, 0.0,
|
||||
oc * axis.z * axis.x - axis.y * s,oc * axis.y * axis.z + axis.x * s, oc * axis.z * axis.z + c, 0.0,
|
||||
0.0, 0.0, 0.0, 1.0
|
||||
);
|
||||
}
|
||||
|
||||
vec3 rotate(vec3 v, vec3 axis, float angle) {
|
||||
mat4 m = rotationMatrix(axis, angle);
|
||||
return (m * vec4(v, 1.0)).xyz;
|
||||
}
|
||||
|
||||
float qinticInOut(float t) {
|
||||
return t < 0.5
|
||||
? 16.0 * pow(t, 5.0)
|
||||
: -0.5 * abs(pow(2.0 * t - 2.0, 5.0)) + 1.0;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vUv = uv;
|
||||
|
||||
float norm = 0.5;
|
||||
vec3 newpos = position;
|
||||
float offset = (dot(distortionAxis, position) + norm / 2.) / norm;
|
||||
float localprogress = clamp(
|
||||
(fract(uPosition * 5.0 * 0.01) - 0.01 * uDistortion * offset) / (1. - 0.01 * uDistortion),
|
||||
0.,
|
||||
2.
|
||||
);
|
||||
localprogress = qinticInOut(localprogress) * PI;
|
||||
newpos = rotate(newpos, rotationAxis, localprogress);
|
||||
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(newpos, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = `
|
||||
precision highp float;
|
||||
|
||||
uniform vec2 uImageSize;
|
||||
uniform vec2 uPlaneSize;
|
||||
uniform sampler2D tMap;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vec2 imageSize = uImageSize;
|
||||
vec2 planeSize = uPlaneSize;
|
||||
|
||||
float imageAspect = imageSize.x / imageSize.y;
|
||||
float planeAspect = planeSize.x / planeSize.y;
|
||||
vec2 scale = vec2(1.0, 1.0);
|
||||
|
||||
if (planeAspect > imageAspect) {
|
||||
scale.x = imageAspect / planeAspect;
|
||||
} else {
|
||||
scale.y = planeAspect / imageAspect;
|
||||
}
|
||||
|
||||
vec2 uv = vUv * scale + (1.0 - scale) * 0.5;
|
||||
|
||||
gl_FragColor = texture2D(tMap, uv);
|
||||
}
|
||||
`;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function AutoBind(self: any, { include, exclude }: AutoBindOptions = {}) {
|
||||
const getAllProperties = (object: object): Set<[object, string | symbol]> => {
|
||||
const properties = new Set<[object, string | symbol]>();
|
||||
let currentObject: object | null = object;
|
||||
do {
|
||||
for (const key of Reflect.ownKeys(currentObject)) {
|
||||
properties.add([currentObject, key]);
|
||||
}
|
||||
} while (
|
||||
(currentObject = Reflect.getPrototypeOf(currentObject)) &&
|
||||
currentObject !== Object.prototype
|
||||
);
|
||||
return properties;
|
||||
};
|
||||
|
||||
const filter = (key: string | symbol) => {
|
||||
const match = (pattern: string | RegExp) =>
|
||||
typeof pattern === "string"
|
||||
? key === pattern
|
||||
: (pattern as RegExp).test(key.toString());
|
||||
|
||||
if (include) return include.some(match);
|
||||
if (exclude) return !exclude.some(match);
|
||||
return true;
|
||||
};
|
||||
|
||||
for (const [object, key] of getAllProperties(self.constructor.prototype)) {
|
||||
if (key === "constructor" || !filter(key)) continue;
|
||||
const descriptor = Reflect.getOwnPropertyDescriptor(object, key);
|
||||
if (descriptor && typeof descriptor.value === "function" && typeof key === "string") {
|
||||
self[key] = self[key].bind(self);
|
||||
}
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
function lerp(p1: number, p2: number, t: number): number {
|
||||
return p1 + (p2 - p1) * t;
|
||||
}
|
||||
|
||||
function map(
|
||||
num: number,
|
||||
min1: number,
|
||||
max1: number,
|
||||
min2: number,
|
||||
max2: number,
|
||||
round = false
|
||||
): number {
|
||||
const num1 = (num - min1) / (max1 - min1);
|
||||
const num2 = num1 * (max2 - min2) + min2;
|
||||
return round ? Math.round(num2) : num2;
|
||||
}
|
||||
|
||||
class Media {
|
||||
gl: GL;
|
||||
geometry: OGLPlane;
|
||||
scene: OGLTransform;
|
||||
screen: ScreenSize;
|
||||
viewport: ViewportSize;
|
||||
image: string;
|
||||
length: number;
|
||||
index: number;
|
||||
planeWidth: number;
|
||||
planeHeight: number;
|
||||
distortion: number;
|
||||
|
||||
program!: OGLProgram;
|
||||
plane!: OGLMesh;
|
||||
extra = 0;
|
||||
padding = 0;
|
||||
height = 0;
|
||||
heightTotal = 0;
|
||||
y = 0;
|
||||
|
||||
constructor({
|
||||
gl,
|
||||
geometry,
|
||||
scene,
|
||||
screen,
|
||||
viewport,
|
||||
image,
|
||||
length,
|
||||
index,
|
||||
planeWidth,
|
||||
planeHeight,
|
||||
distortion,
|
||||
}: MediaParams) {
|
||||
this.gl = gl;
|
||||
this.geometry = geometry;
|
||||
this.scene = scene;
|
||||
this.screen = screen;
|
||||
this.viewport = viewport;
|
||||
this.image = image;
|
||||
this.length = length;
|
||||
this.index = index;
|
||||
this.planeWidth = planeWidth;
|
||||
this.planeHeight = planeHeight;
|
||||
this.distortion = distortion;
|
||||
|
||||
this.createShader();
|
||||
this.createMesh();
|
||||
this.onResize();
|
||||
}
|
||||
|
||||
createShader() {
|
||||
const texture = new Texture(this.gl, { generateMipmaps: false });
|
||||
this.program = new Program(this.gl, {
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
fragment: fragmentShader,
|
||||
vertex: vertexShader,
|
||||
uniforms: {
|
||||
tMap: { value: texture },
|
||||
uPosition: { value: 0 },
|
||||
uPlaneSize: { value: [0, 0] },
|
||||
uImageSize: { value: [0, 0] },
|
||||
uSpeed: { value: 0 },
|
||||
rotationAxis: { value: [0, 1, 0] },
|
||||
distortionAxis: { value: [1, 1, 0] },
|
||||
uDistortion: { value: this.distortion },
|
||||
uViewportSize: { value: [this.viewport.width, this.viewport.height] },
|
||||
uTime: { value: 0 },
|
||||
},
|
||||
cullFace: false,
|
||||
});
|
||||
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.src = this.image;
|
||||
img.onload = () => {
|
||||
texture.image = img;
|
||||
this.program.uniforms.uImageSize.value = [
|
||||
img.naturalWidth,
|
||||
img.naturalHeight,
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
createMesh() {
|
||||
this.plane = new Mesh(this.gl, {
|
||||
geometry: this.geometry,
|
||||
program: this.program,
|
||||
});
|
||||
this.plane.setParent(this.scene);
|
||||
}
|
||||
|
||||
setScale() {
|
||||
this.plane.scale.x =
|
||||
(this.viewport.width * this.planeWidth) / this.screen.width;
|
||||
this.plane.scale.y =
|
||||
(this.viewport.height * this.planeHeight) / this.screen.height;
|
||||
this.plane.position.x = 0;
|
||||
this.program.uniforms.uPlaneSize.value = [
|
||||
this.plane.scale.x,
|
||||
this.plane.scale.y,
|
||||
];
|
||||
}
|
||||
|
||||
onResize({
|
||||
screen,
|
||||
viewport,
|
||||
}: { screen?: ScreenSize; viewport?: ViewportSize } = {}) {
|
||||
if (screen) this.screen = screen;
|
||||
if (viewport) {
|
||||
this.viewport = viewport;
|
||||
this.program.uniforms.uViewportSize.value = [
|
||||
viewport.width,
|
||||
viewport.height,
|
||||
];
|
||||
}
|
||||
this.setScale();
|
||||
|
||||
this.padding = 5;
|
||||
this.height = this.plane.scale.y + this.padding;
|
||||
this.heightTotal = this.height * this.length;
|
||||
this.y = -this.heightTotal / 2 + (this.index + 0.5) * this.height;
|
||||
}
|
||||
|
||||
update(scroll: ScrollState) {
|
||||
this.plane.position.y = this.y - scroll.current - this.extra;
|
||||
const position = map(
|
||||
this.plane.position.y,
|
||||
-this.viewport.height,
|
||||
this.viewport.height,
|
||||
5,
|
||||
15
|
||||
);
|
||||
|
||||
this.program.uniforms.uPosition.value = position;
|
||||
this.program.uniforms.uTime.value += 0.04;
|
||||
this.program.uniforms.uSpeed.value = scroll.current;
|
||||
|
||||
const planeHeight = this.plane.scale.y;
|
||||
const viewportHeight = this.viewport.height;
|
||||
const topEdge = this.plane.position.y + planeHeight / 2;
|
||||
const bottomEdge = this.plane.position.y - planeHeight / 2;
|
||||
|
||||
if (topEdge < -viewportHeight / 2) {
|
||||
this.extra -= this.heightTotal;
|
||||
} else if (bottomEdge > viewportHeight / 2) {
|
||||
this.extra += this.heightTotal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Canvas {
|
||||
container: HTMLElement;
|
||||
canvas: HTMLCanvasElement;
|
||||
items: string[];
|
||||
planeWidth: number;
|
||||
planeHeight: number;
|
||||
distortion: number;
|
||||
scroll: ScrollState;
|
||||
cameraFov: number;
|
||||
cameraZ: number;
|
||||
|
||||
renderer!: Renderer;
|
||||
gl!: GL;
|
||||
camera!: Camera;
|
||||
scene!: OGLTransform;
|
||||
planeGeometry!: OGLPlane;
|
||||
medias!: Media[];
|
||||
screen!: ScreenSize;
|
||||
viewport!: ViewportSize;
|
||||
isDown = false;
|
||||
start = 0;
|
||||
loaded = 0;
|
||||
|
||||
constructor({
|
||||
container,
|
||||
canvas,
|
||||
items,
|
||||
planeWidth,
|
||||
planeHeight,
|
||||
distortion,
|
||||
scrollEase,
|
||||
cameraFov,
|
||||
cameraZ,
|
||||
}: CanvasParams) {
|
||||
this.container = container;
|
||||
this.canvas = canvas;
|
||||
this.items = items;
|
||||
this.planeWidth = planeWidth;
|
||||
this.planeHeight = planeHeight;
|
||||
this.distortion = distortion;
|
||||
this.scroll = {
|
||||
ease: scrollEase,
|
||||
current: 0,
|
||||
target: 0,
|
||||
last: 0,
|
||||
};
|
||||
this.cameraFov = cameraFov;
|
||||
this.cameraZ = cameraZ;
|
||||
|
||||
AutoBind(this);
|
||||
this.createRenderer();
|
||||
this.createCamera();
|
||||
this.createScene();
|
||||
this.onResize();
|
||||
this.createGeometry();
|
||||
this.createMedias();
|
||||
this.initializeScrollPosition();
|
||||
this.update();
|
||||
this.addEventListeners();
|
||||
this.createPreloader();
|
||||
}
|
||||
|
||||
createRenderer() {
|
||||
this.renderer = new Renderer({
|
||||
canvas: this.canvas,
|
||||
alpha: true,
|
||||
antialias: true,
|
||||
dpr: Math.min(window.devicePixelRatio, 2),
|
||||
});
|
||||
this.gl = this.renderer.gl;
|
||||
}
|
||||
|
||||
createCamera() {
|
||||
this.camera = new Camera(this.gl);
|
||||
this.camera.fov = this.cameraFov;
|
||||
this.camera.position.z = this.cameraZ;
|
||||
}
|
||||
|
||||
createScene() {
|
||||
this.scene = new Transform();
|
||||
}
|
||||
|
||||
createGeometry() {
|
||||
this.planeGeometry = new Plane(this.gl, {
|
||||
heightSegments: 1,
|
||||
widthSegments: 100,
|
||||
});
|
||||
}
|
||||
|
||||
createMedias() {
|
||||
this.medias = this.items.map(
|
||||
(image, index) =>
|
||||
new Media({
|
||||
gl: this.gl,
|
||||
geometry: this.planeGeometry,
|
||||
scene: this.scene,
|
||||
screen: this.screen,
|
||||
viewport: this.viewport,
|
||||
image,
|
||||
length: this.items.length,
|
||||
index,
|
||||
planeWidth: this.planeWidth,
|
||||
planeHeight: this.planeHeight,
|
||||
distortion: this.distortion,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
initializeScrollPosition() {
|
||||
if (this.medias && this.medias.length > 0) {
|
||||
const centerIndex = Math.floor(this.medias.length / 2);
|
||||
const centerMedia = this.medias[centerIndex];
|
||||
this.scroll.current = centerMedia.y;
|
||||
this.scroll.target = centerMedia.y;
|
||||
}
|
||||
}
|
||||
|
||||
createPreloader() {
|
||||
this.loaded = 0;
|
||||
this.items.forEach((src) => {
|
||||
const image = new Image();
|
||||
image.crossOrigin = "anonymous";
|
||||
image.src = src;
|
||||
image.onload = () => {
|
||||
if (++this.loaded === this.items.length) {
|
||||
document.documentElement.classList.remove("loading");
|
||||
document.documentElement.classList.add("loaded");
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
onResize() {
|
||||
const rect = this.container.getBoundingClientRect();
|
||||
this.screen = { width: rect.width, height: rect.height };
|
||||
this.renderer.setSize(this.screen.width, this.screen.height);
|
||||
|
||||
this.camera.perspective({
|
||||
aspect: this.gl.canvas.width / this.gl.canvas.height,
|
||||
});
|
||||
|
||||
const fov = (this.camera.fov * Math.PI) / 180;
|
||||
const height = 2 * Math.tan(fov / 2) * this.camera.position.z;
|
||||
const width = height * this.camera.aspect;
|
||||
this.viewport = { width, height };
|
||||
|
||||
this.medias?.forEach((media) =>
|
||||
media.onResize({ screen: this.screen, viewport: this.viewport })
|
||||
);
|
||||
}
|
||||
|
||||
onTouchDown(e: MouseEvent | TouchEvent) {
|
||||
this.isDown = true;
|
||||
this.scroll.position = this.scroll.current;
|
||||
this.start = e instanceof TouchEvent ? e.touches[0].clientY : e.clientY;
|
||||
}
|
||||
|
||||
onTouchMove(e: MouseEvent | TouchEvent) {
|
||||
if (!this.isDown || !this.scroll.position) return;
|
||||
const y = e instanceof TouchEvent ? e.touches[0].clientY : e.clientY;
|
||||
const distance = (this.start - y) * 0.1;
|
||||
this.scroll.target = this.scroll.position + distance;
|
||||
}
|
||||
|
||||
onTouchUp() {
|
||||
this.isDown = false;
|
||||
}
|
||||
|
||||
onWheel(e: WheelEvent) {
|
||||
this.scroll.target += e.deltaY * 0.005;
|
||||
}
|
||||
|
||||
update() {
|
||||
this.scroll.current = lerp(
|
||||
this.scroll.current,
|
||||
this.scroll.target,
|
||||
this.scroll.ease
|
||||
);
|
||||
this.medias?.forEach((media) => media.update(this.scroll));
|
||||
this.renderer.render({ scene: this.scene, camera: this.camera });
|
||||
this.scroll.last = this.scroll.current;
|
||||
requestAnimationFrame(this.update);
|
||||
}
|
||||
|
||||
addEventListeners() {
|
||||
window.addEventListener("resize", this.onResize);
|
||||
window.addEventListener("wheel", this.onWheel);
|
||||
window.addEventListener("mousedown", this.onTouchDown);
|
||||
window.addEventListener("mousemove", this.onTouchMove);
|
||||
window.addEventListener("mouseup", this.onTouchUp);
|
||||
window.addEventListener("touchstart", this.onTouchDown as EventListener);
|
||||
window.addEventListener("touchmove", this.onTouchMove as EventListener);
|
||||
window.addEventListener("touchend", this.onTouchUp as EventListener);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
window.removeEventListener("resize", this.onResize);
|
||||
window.removeEventListener("wheel", this.onWheel);
|
||||
window.removeEventListener("mousedown", this.onTouchDown);
|
||||
window.removeEventListener("mousemove", this.onTouchMove);
|
||||
window.removeEventListener("mouseup", this.onTouchUp);
|
||||
window.removeEventListener("touchstart", this.onTouchDown as EventListener);
|
||||
window.removeEventListener("touchmove", this.onTouchMove as EventListener);
|
||||
window.removeEventListener("touchend", this.onTouchUp as EventListener);
|
||||
}
|
||||
}
|
||||
|
||||
export interface FlyingPostersProps {
|
||||
items?: string[];
|
||||
planeWidth?: number;
|
||||
planeHeight?: number;
|
||||
distortion?: number;
|
||||
scrollEase?: number;
|
||||
cameraFov?: number;
|
||||
cameraZ?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export { Canvas, Media };
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<FlyingPostersProps>(), {
|
||||
items: () => [],
|
||||
planeWidth: 320,
|
||||
planeHeight: 320,
|
||||
distortion: 3,
|
||||
scrollEase: 0.01,
|
||||
cameraFov: 45,
|
||||
cameraZ: 20,
|
||||
className: '',
|
||||
})
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const canvasRef = ref<HTMLCanvasElement>()
|
||||
const instanceRef = ref<Canvas | null>(null)
|
||||
|
||||
const initCanvas = () => {
|
||||
if (!containerRef.value || !canvasRef.value) return
|
||||
|
||||
instanceRef.value = new Canvas({
|
||||
container: containerRef.value,
|
||||
canvas: canvasRef.value,
|
||||
items: props.items,
|
||||
planeWidth: props.planeWidth,
|
||||
planeHeight: props.planeHeight,
|
||||
distortion: props.distortion,
|
||||
scrollEase: props.scrollEase,
|
||||
cameraFov: props.cameraFov,
|
||||
cameraZ: props.cameraZ,
|
||||
})
|
||||
}
|
||||
|
||||
const destroyCanvas = () => {
|
||||
if (instanceRef.value) {
|
||||
instanceRef.value.destroy()
|
||||
instanceRef.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
e.preventDefault()
|
||||
if (instanceRef.value) {
|
||||
instanceRef.value.onWheel(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [
|
||||
props.items,
|
||||
props.planeWidth,
|
||||
props.planeHeight,
|
||||
props.distortion,
|
||||
props.scrollEase,
|
||||
props.cameraFov,
|
||||
props.cameraZ,
|
||||
],
|
||||
() => {
|
||||
destroyCanvas()
|
||||
initCanvas()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
initCanvas()
|
||||
|
||||
if (canvasRef.value) {
|
||||
const canvasEl = canvasRef.value
|
||||
canvasEl.addEventListener('wheel', handleWheel, { passive: false })
|
||||
canvasEl.addEventListener('touchmove', handleTouchMove, { passive: false })
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
destroyCanvas()
|
||||
|
||||
if (canvasRef.value) {
|
||||
const canvasEl = canvasRef.value
|
||||
canvasEl.removeEventListener('wheel', handleWheel)
|
||||
canvasEl.removeEventListener('touchmove', handleTouchMove)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
80
src/content/Components/GlassIcons/GlassIcons.vue
Normal file
80
src/content/Components/GlassIcons/GlassIcons.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'grid gap-[5em] grid-cols-2 md:grid-cols-3 mx-auto py-[3em] overflow-visible',
|
||||
className
|
||||
]"
|
||||
>
|
||||
<button
|
||||
v-for="(item, index) in items"
|
||||
:key="index"
|
||||
type="button"
|
||||
:aria-label="item.label"
|
||||
:class="[
|
||||
'relative bg-transparent outline-none w-[4.5em] h-[4.5em] [perspective:24em] [transform-style:preserve-3d] [-webkit-tap-highlight-color:transparent] group',
|
||||
item.customClass
|
||||
]"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0 left-0 w-full h-full rounded-[1.25em] block transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.83,0,0.17,1)] origin-[100%_100%] rotate-[15deg] group-hover:[transform:rotate(25deg)_translate3d(-0.5em,-0.5em,0.5em)]"
|
||||
:style="{
|
||||
...getBackgroundStyle(item.color),
|
||||
boxShadow: '0.5em -0.5em 0.75em hsla(223, 10%, 10%, 0.15)'
|
||||
}"
|
||||
></span>
|
||||
|
||||
<span
|
||||
class="absolute top-0 left-0 w-full h-full rounded-[1.25em] bg-[hsla(0,0%,100%,0.15)] transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.83,0,0.17,1)] origin-[80%_50%] flex backdrop-blur-[0.75em] [-webkit-backdrop-filter:blur(0.75em)] transform group-hover:[transform:translateZ(2em)]"
|
||||
:style="{
|
||||
boxShadow: '0 0 0 0.1em hsla(0, 0%, 100%, 0.3) inset'
|
||||
}"
|
||||
>
|
||||
<span
|
||||
class="m-auto w-[1.5em] h-[1.5em] flex items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<i :class="item.icon" class="text-xl"></i>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span class="absolute top-full left-0 right-0 text-center whitespace-nowrap leading-[2] text-base opacity-0 transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.83,0,0.17,1)] translate-y-0 group-hover:opacity-100 group-hover:[transform:translateY(20%)]">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface GlassIconsItem {
|
||||
icon: string
|
||||
color: string
|
||||
label: string
|
||||
customClass?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: GlassIconsItem[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
items: () => [],
|
||||
className: ''
|
||||
})
|
||||
|
||||
const gradientMapping: Record<string, string> = {
|
||||
blue: 'linear-gradient(hsl(223, 90%, 50%), hsl(208, 90%, 50%))',
|
||||
purple: 'linear-gradient(hsl(283, 90%, 50%), hsl(268, 90%, 50%))',
|
||||
red: 'linear-gradient(hsl(3, 90%, 50%), hsl(348, 90%, 50%))',
|
||||
indigo: 'linear-gradient(hsl(253, 90%, 50%), hsl(238, 90%, 50%))',
|
||||
orange: 'linear-gradient(hsl(43, 90%, 50%), hsl(28, 90%, 50%))',
|
||||
green: 'linear-gradient(hsl(123, 90%, 40%), hsl(108, 90%, 40%))'
|
||||
}
|
||||
|
||||
const getBackgroundStyle = (color: string): Record<string, string> => {
|
||||
if (gradientMapping[color]) {
|
||||
return { background: gradientMapping[color] }
|
||||
}
|
||||
return { background: color }
|
||||
}
|
||||
</script>
|
||||
381
src/content/Components/GooeyNav/GooeyNav.vue
Normal file
381
src/content/Components/GooeyNav/GooeyNav.vue
Normal file
@@ -0,0 +1,381 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="relative" ref="containerRef">
|
||||
<nav
|
||||
class="flex relative"
|
||||
:style="{ transform: 'translate3d(0,0,0.01px)' }"
|
||||
>
|
||||
<ul
|
||||
ref="navRef"
|
||||
class="flex gap-8 list-none p-0 px-4 m-0 relative z-[3]"
|
||||
:style="{
|
||||
color: 'white',
|
||||
textShadow: '0 1px 1px hsl(205deg 30% 10% / 0.2)',
|
||||
}"
|
||||
>
|
||||
<li
|
||||
v-for="(item, index) in items"
|
||||
:key="index"
|
||||
:class="[
|
||||
'rounded-full relative cursor-pointer transition-[background-color_color_box-shadow] duration-300 ease shadow-[0_0_0.5px_1.5px_transparent] text-white',
|
||||
{ active: activeIndex === index }
|
||||
]"
|
||||
>
|
||||
<a
|
||||
:href="item.href || undefined"
|
||||
@click="(e) => handleClick(e, index)"
|
||||
@keydown="(e) => handleKeyDown(e, index)"
|
||||
class="outline-none py-[0.6em] px-[1em] inline-block"
|
||||
>
|
||||
{{ item.label }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<span class="effect filter" ref="filterRef" />
|
||||
<span class="effect text" ref="textRef" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
interface GooeyNavItem {
|
||||
label: string
|
||||
href: string | null
|
||||
}
|
||||
|
||||
interface GooeyNavProps {
|
||||
items: GooeyNavItem[]
|
||||
animationTime?: number
|
||||
particleCount?: number
|
||||
particleDistances?: [number, number]
|
||||
particleR?: number
|
||||
timeVariance?: number
|
||||
colors?: number[]
|
||||
initialActiveIndex?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<GooeyNavProps>(), {
|
||||
animationTime: 600,
|
||||
particleCount: 15,
|
||||
particleDistances: () => [90, 10],
|
||||
particleR: 100,
|
||||
timeVariance: 300,
|
||||
colors: () => [1, 2, 3, 1, 2, 3, 1, 4],
|
||||
initialActiveIndex: 0,
|
||||
})
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const navRef = ref<HTMLUListElement>()
|
||||
const filterRef = ref<HTMLSpanElement>()
|
||||
const textRef = ref<HTMLSpanElement>()
|
||||
const activeIndex = ref<number>(props.initialActiveIndex)
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
const noise = (n = 1) => n / 2 - Math.random() * n
|
||||
|
||||
const getXY = (
|
||||
distance: number,
|
||||
pointIndex: number,
|
||||
totalPoints: number
|
||||
): [number, number] => {
|
||||
const angle =
|
||||
((360 + noise(8)) / totalPoints) * pointIndex * (Math.PI / 180)
|
||||
return [distance * Math.cos(angle), distance * Math.sin(angle)]
|
||||
}
|
||||
|
||||
const createParticle = (
|
||||
i: number,
|
||||
t: number,
|
||||
d: [number, number],
|
||||
r: number
|
||||
) => {
|
||||
const rotate = noise(r / 10)
|
||||
return {
|
||||
start: getXY(d[0], props.particleCount - i, props.particleCount),
|
||||
end: getXY(d[1] + noise(7), props.particleCount - i, props.particleCount),
|
||||
time: t,
|
||||
scale: 1 + noise(0.2),
|
||||
color: props.colors[Math.floor(Math.random() * props.colors.length)],
|
||||
rotate: rotate > 0 ? (rotate + r / 20) * 10 : (rotate - r / 20) * 10,
|
||||
}
|
||||
}
|
||||
|
||||
const makeParticles = (element: HTMLElement) => {
|
||||
const d: [number, number] = props.particleDistances
|
||||
const r = props.particleR
|
||||
const bubbleTime = props.animationTime * 2 + props.timeVariance
|
||||
element.style.setProperty('--time', `${bubbleTime}ms`)
|
||||
for (let i = 0; i < props.particleCount; i++) {
|
||||
const t = props.animationTime * 2 + noise(props.timeVariance * 2)
|
||||
const p = createParticle(i, t, d, r)
|
||||
element.classList.remove('active')
|
||||
setTimeout(() => {
|
||||
const particle = document.createElement('span')
|
||||
const point = document.createElement('span')
|
||||
particle.classList.add('particle')
|
||||
particle.style.setProperty('--start-x', `${p.start[0]}px`)
|
||||
particle.style.setProperty('--start-y', `${p.start[1]}px`)
|
||||
particle.style.setProperty('--end-x', `${p.end[0]}px`)
|
||||
particle.style.setProperty('--end-y', `${p.end[1]}px`)
|
||||
particle.style.setProperty('--time', `${p.time}ms`)
|
||||
particle.style.setProperty('--scale', `${p.scale}`)
|
||||
particle.style.setProperty('--color', `var(--color-${p.color}, white)`)
|
||||
particle.style.setProperty('--rotate', `${p.rotate}deg`)
|
||||
point.classList.add('point')
|
||||
particle.appendChild(point)
|
||||
element.appendChild(particle)
|
||||
requestAnimationFrame(() => {
|
||||
element.classList.add('active')
|
||||
})
|
||||
setTimeout(() => {
|
||||
try {
|
||||
element.removeChild(particle)
|
||||
} catch {}
|
||||
}, t)
|
||||
}, 30)
|
||||
}
|
||||
}
|
||||
|
||||
const updateEffectPosition = (element: HTMLElement) => {
|
||||
if (!containerRef.value || !filterRef.value || !textRef.value) return
|
||||
const containerRect = containerRef.value.getBoundingClientRect()
|
||||
const pos = element.getBoundingClientRect()
|
||||
const styles = {
|
||||
left: `${pos.x - containerRect.x}px`,
|
||||
top: `${pos.y - containerRect.y}px`,
|
||||
width: `${pos.width}px`,
|
||||
height: `${pos.height}px`,
|
||||
}
|
||||
Object.assign(filterRef.value.style, styles)
|
||||
Object.assign(textRef.value.style, styles)
|
||||
textRef.value.innerText = element.innerText
|
||||
}
|
||||
|
||||
const handleClick = (e: Event, index: number) => {
|
||||
const liEl = (e.currentTarget as HTMLElement).parentElement as HTMLElement
|
||||
if (activeIndex.value === index) return
|
||||
activeIndex.value = index
|
||||
updateEffectPosition(liEl)
|
||||
if (filterRef.value) {
|
||||
const particles = filterRef.value.querySelectorAll('.particle')
|
||||
particles.forEach((p) => filterRef.value!.removeChild(p))
|
||||
}
|
||||
if (textRef.value) {
|
||||
textRef.value.classList.remove('active')
|
||||
void textRef.value.offsetWidth
|
||||
textRef.value.classList.add('active')
|
||||
}
|
||||
if (filterRef.value) {
|
||||
makeParticles(filterRef.value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent, index: number) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
const liEl = (e.currentTarget as HTMLElement).parentElement
|
||||
if (liEl) {
|
||||
handleClick(
|
||||
{
|
||||
currentTarget: liEl,
|
||||
} as unknown as Event,
|
||||
index
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(activeIndex, () => {
|
||||
if (!navRef.value || !containerRef.value) return
|
||||
const activeLi = navRef.value.querySelectorAll('li')[
|
||||
activeIndex.value
|
||||
] as HTMLElement
|
||||
if (activeLi) {
|
||||
updateEffectPosition(activeLi)
|
||||
textRef.value?.classList.add('active')
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (!navRef.value || !containerRef.value) return
|
||||
const activeLi = navRef.value.querySelectorAll('li')[
|
||||
activeIndex.value
|
||||
] as HTMLElement
|
||||
if (activeLi) {
|
||||
updateEffectPosition(activeLi)
|
||||
textRef.value?.classList.add('active')
|
||||
}
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
const currentActiveLi = navRef.value?.querySelectorAll('li')[
|
||||
activeIndex.value
|
||||
] as HTMLElement
|
||||
if (currentActiveLi) {
|
||||
updateEffectPosition(currentActiveLi)
|
||||
}
|
||||
})
|
||||
resizeObserver.observe(containerRef.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--linear-ease: linear(0, 0.068, 0.19 2.7%, 0.804 8.1%, 1.037, 1.199 13.2%, 1.245, 1.27 15.8%, 1.274, 1.272 17.4%, 1.249 19.1%, 0.996 28%, 0.949, 0.928 33.3%, 0.926, 0.933 36.8%, 1.001 45.6%, 1.013, 1.019 50.8%, 1.018 54.4%, 1 63.1%, 0.995 68%, 1.001 85%, 1);
|
||||
}
|
||||
|
||||
.effect {
|
||||
position: absolute;
|
||||
opacity: 1;
|
||||
pointer-events: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.effect.text {
|
||||
color: white;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.effect.text.active {
|
||||
color: black;
|
||||
}
|
||||
|
||||
.effect.filter {
|
||||
filter: blur(7px) contrast(100) blur(0);
|
||||
mix-blend-mode: lighten;
|
||||
}
|
||||
|
||||
.effect.filter::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -75px;
|
||||
z-index: -2;
|
||||
background: black;
|
||||
}
|
||||
|
||||
.effect.filter::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: white;
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
z-index: -1;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.effect.active::after {
|
||||
animation: pill 0.3s ease both;
|
||||
}
|
||||
|
||||
@keyframes pill {
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.particle,
|
||||
.point {
|
||||
display: block;
|
||||
opacity: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 9999px;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.particle {
|
||||
--time: 5s;
|
||||
position: absolute;
|
||||
top: calc(50% - 8px);
|
||||
left: calc(50% - 8px);
|
||||
animation: particle calc(var(--time)) ease 1 -350ms;
|
||||
}
|
||||
|
||||
.point {
|
||||
background: var(--color);
|
||||
opacity: 1;
|
||||
animation: point calc(var(--time)) ease 1 -350ms;
|
||||
}
|
||||
|
||||
@keyframes particle {
|
||||
0% {
|
||||
transform: rotate(0deg) translate(calc(var(--start-x)), calc(var(--start-y)));
|
||||
opacity: 1;
|
||||
animation-timing-function: cubic-bezier(0.55, 0, 1, 0.45);
|
||||
}
|
||||
70% {
|
||||
transform: rotate(calc(var(--rotate) * 0.5)) translate(calc(var(--end-x) * 1.2), calc(var(--end-y) * 1.2));
|
||||
opacity: 1;
|
||||
animation-timing-function: ease;
|
||||
}
|
||||
85% {
|
||||
transform: rotate(calc(var(--rotate) * 0.66)) translate(calc(var(--end-x)), calc(var(--end-y)));
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: rotate(calc(var(--rotate) * 1.2)) translate(calc(var(--end-x) * 0.5), calc(var(--end-y) * 0.5));
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes point {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
animation-timing-function: cubic-bezier(0.55, 0, 1, 0.45);
|
||||
}
|
||||
25% {
|
||||
transform: scale(calc(var(--scale) * 0.25));
|
||||
}
|
||||
38% {
|
||||
opacity: 1;
|
||||
}
|
||||
65% {
|
||||
transform: scale(var(--scale));
|
||||
opacity: 1;
|
||||
animation-timing-function: ease;
|
||||
}
|
||||
85% {
|
||||
transform: scale(var(--scale));
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
li.active {
|
||||
color: black;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
li.active::after {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
li::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
opacity: 0;
|
||||
transform: scale(0);
|
||||
transition: all 0.3s ease;
|
||||
z-index: -1;
|
||||
}
|
||||
</style>
|
||||
265
src/content/Components/InfiniteScroll/InfiniteScroll.vue
Normal file
265
src/content/Components/InfiniteScroll/InfiniteScroll.vue
Normal file
@@ -0,0 +1,265 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="infinite-scroll-wrapper relative flex items-center justify-center w-full overflow-hidden"
|
||||
ref="wrapperRef" :style="{
|
||||
maxHeight: maxHeight,
|
||||
overscrollBehavior: 'none'
|
||||
}">
|
||||
<div class="infinite-scroll-container flex flex-col px-4 cursor-grab" ref="containerRef" :style="{
|
||||
transform: getTiltTransform(),
|
||||
width: width,
|
||||
overscrollBehavior: 'contain',
|
||||
transformOrigin: 'center center',
|
||||
transformStyle: 'preserve-3d'
|
||||
}">
|
||||
<div v-for="(item, index) in items" :key="index"
|
||||
class="infinite-scroll-item rounded-2xl flex items-center justify-center p-4 text-xl font-semibold text-center border-2 border-white select-none box-border relative"
|
||||
:style="{
|
||||
height: itemMinHeight + 'px',
|
||||
marginTop: negativeMargin
|
||||
}">
|
||||
<component :is="item.content" v-if="typeof item.content === 'object'" />
|
||||
<template v-else>{{ item.content }}</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { gsap } from 'gsap'
|
||||
import { Observer } from 'gsap/Observer'
|
||||
|
||||
gsap.registerPlugin(Observer)
|
||||
|
||||
interface InfiniteScrollItem {
|
||||
content: string | object
|
||||
}
|
||||
|
||||
interface Props {
|
||||
width?: string
|
||||
maxHeight?: string
|
||||
negativeMargin?: string
|
||||
items?: InfiniteScrollItem[]
|
||||
itemMinHeight?: number
|
||||
isTilted?: boolean
|
||||
tiltDirection?: 'left' | 'right'
|
||||
autoplay?: boolean
|
||||
autoplaySpeed?: number
|
||||
autoplayDirection?: 'down' | 'up'
|
||||
pauseOnHover?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
width: '30rem',
|
||||
maxHeight: '100%',
|
||||
negativeMargin: '-0.5em',
|
||||
items: () => [],
|
||||
itemMinHeight: 150,
|
||||
isTilted: false,
|
||||
tiltDirection: 'left',
|
||||
autoplay: false,
|
||||
autoplaySpeed: 0.5,
|
||||
autoplayDirection: 'down',
|
||||
pauseOnHover: false
|
||||
})
|
||||
|
||||
const wrapperRef = ref<HTMLDivElement | null>(null)
|
||||
const containerRef = ref<HTMLDivElement | null>(null)
|
||||
let observer: Observer | null = null
|
||||
let rafId: number | null = null
|
||||
let velocity = 0
|
||||
let stopTicker: (() => void) | null = null
|
||||
let startTicker: (() => void) | null = null
|
||||
|
||||
const getTiltTransform = (): string => {
|
||||
if (!props.isTilted) return 'none'
|
||||
return props.tiltDirection === 'left'
|
||||
? 'rotateX(20deg) rotateZ(-20deg) skewX(20deg)'
|
||||
: 'rotateX(20deg) rotateZ(20deg) skewX(-20deg)'
|
||||
}
|
||||
|
||||
const initializeScroll = () => {
|
||||
const container = containerRef.value
|
||||
if (!container) return
|
||||
if (props.items.length === 0) return
|
||||
|
||||
const divItems = gsap.utils.toArray<HTMLDivElement>(container.children)
|
||||
if (!divItems.length) return
|
||||
|
||||
const firstItem = divItems[0]
|
||||
const itemStyle = getComputedStyle(firstItem)
|
||||
const itemHeight = firstItem.offsetHeight
|
||||
const itemMarginTop = parseFloat(itemStyle.marginTop) || 0
|
||||
const totalItemHeight = itemHeight + itemMarginTop
|
||||
const totalHeight = itemHeight * props.items.length + itemMarginTop * (props.items.length - 1)
|
||||
|
||||
const wrapFn = gsap.utils.wrap(-totalHeight, totalHeight)
|
||||
|
||||
divItems.forEach((child, i) => {
|
||||
const y = i * totalItemHeight
|
||||
gsap.set(child, { y })
|
||||
})
|
||||
|
||||
observer = Observer.create({
|
||||
target: container,
|
||||
type: 'wheel,touch,pointer',
|
||||
preventDefault: true,
|
||||
onPress: ({ target }) => {
|
||||
; (target as HTMLElement).style.cursor = 'grabbing'
|
||||
},
|
||||
onRelease: ({ target }) => {
|
||||
; (target as HTMLElement).style.cursor = 'grab'
|
||||
if (Math.abs(velocity) > 0.1) {
|
||||
const momentum = velocity * 0.8
|
||||
divItems.forEach((child) => {
|
||||
gsap.to(child, {
|
||||
duration: 1.5,
|
||||
ease: 'power2.out',
|
||||
y: `+=${momentum}`,
|
||||
modifiers: {
|
||||
y: gsap.utils.unitize(wrapFn)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
velocity = 0
|
||||
},
|
||||
onChange: ({ deltaY, isDragging, event }) => {
|
||||
const d = event.type === 'wheel' ? -deltaY : deltaY
|
||||
const distance = isDragging ? d * 5 : d * 1.5
|
||||
|
||||
velocity = distance * 0.5
|
||||
|
||||
divItems.forEach((child) => {
|
||||
gsap.to(child, {
|
||||
duration: isDragging ? 0.3 : 1.2,
|
||||
ease: isDragging ? 'power1.out' : 'power3.out',
|
||||
y: `+=${distance}`,
|
||||
modifiers: {
|
||||
y: gsap.utils.unitize(wrapFn)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (props.autoplay) {
|
||||
const directionFactor = props.autoplayDirection === 'down' ? 1 : -1
|
||||
const speedPerFrame = props.autoplaySpeed * directionFactor
|
||||
|
||||
const tick = () => {
|
||||
divItems.forEach((child) => {
|
||||
gsap.set(child, {
|
||||
y: `+=${speedPerFrame}`,
|
||||
modifiers: {
|
||||
y: gsap.utils.unitize(wrapFn)
|
||||
}
|
||||
})
|
||||
})
|
||||
rafId = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(tick)
|
||||
|
||||
if (props.pauseOnHover) {
|
||||
stopTicker = () => rafId && cancelAnimationFrame(rafId)
|
||||
startTicker = () => {
|
||||
rafId = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
container.addEventListener('mouseenter', stopTicker)
|
||||
container.addEventListener('mouseleave', startTicker)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
if (observer) {
|
||||
observer.kill()
|
||||
observer = null
|
||||
}
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
|
||||
velocity = 0
|
||||
|
||||
const container = containerRef.value
|
||||
if (container && props.pauseOnHover && stopTicker && startTicker) {
|
||||
container.removeEventListener('mouseenter', stopTicker)
|
||||
container.removeEventListener('mouseleave', startTicker)
|
||||
}
|
||||
|
||||
stopTicker = null
|
||||
startTicker = null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initializeScroll()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
watch(
|
||||
[
|
||||
() => props.items,
|
||||
() => props.autoplay,
|
||||
() => props.autoplaySpeed,
|
||||
() => props.autoplayDirection,
|
||||
() => props.pauseOnHover,
|
||||
() => props.isTilted,
|
||||
() => props.tiltDirection,
|
||||
() => props.negativeMargin
|
||||
],
|
||||
() => {
|
||||
cleanup()
|
||||
setTimeout(() => {
|
||||
initializeScroll()
|
||||
}, 0)
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.infinite-scroll-wrapper::before,
|
||||
.infinite-scroll-wrapper::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background: linear-gradient(var(--dir, to bottom), #060010, transparent);
|
||||
height: 25%;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.infinite-scroll-wrapper::before {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.infinite-scroll-wrapper::after {
|
||||
--dir: to top;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.infinite-scroll-container {
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
-moz-backface-visibility: hidden;
|
||||
-ms-backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.infinite-scroll-item {
|
||||
--accent-color: #ffffff;
|
||||
border-color: var(--accent-color);
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
-moz-backface-visibility: hidden;
|
||||
-ms-backface-visibility: hidden;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
</style>
|
||||
315
src/content/Components/PixelCard/PixelCard.vue
Normal file
315
src/content/Components/PixelCard/PixelCard.vue
Normal file
@@ -0,0 +1,315 @@
|
||||
<template>
|
||||
<div ref="containerRef" :class="[
|
||||
'h-[400px] w-[300px] relative overflow-hidden grid place-items-center aspect-[4/5] border border-[#27272a] rounded-[25px] isolate transition-colors duration-200 ease-[cubic-bezier(0.5,1,0.89,1)] select-none',
|
||||
className
|
||||
]" @mouseenter="onMouseEnter" @mouseleave="onMouseLeave" @focus="finalNoFocus ? undefined : onFocus"
|
||||
@blur="finalNoFocus ? undefined : onBlur" :tabindex="finalNoFocus ? -1 : 0">
|
||||
<canvas class="w-full h-full block" ref="canvasRef" />
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
|
||||
class Pixel {
|
||||
width: number
|
||||
height: number
|
||||
ctx: CanvasRenderingContext2D
|
||||
x: number
|
||||
y: number
|
||||
color: string
|
||||
speed: number
|
||||
size: number
|
||||
sizeStep: number
|
||||
minSize: number
|
||||
maxSizeInteger: number
|
||||
maxSize: number
|
||||
delay: number
|
||||
counter: number
|
||||
counterStep: number
|
||||
isIdle: boolean
|
||||
isReverse: boolean
|
||||
isShimmer: boolean
|
||||
|
||||
constructor(
|
||||
canvas: HTMLCanvasElement,
|
||||
context: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
color: string,
|
||||
speed: number,
|
||||
delay: number
|
||||
) {
|
||||
this.width = canvas.width
|
||||
this.height = canvas.height
|
||||
this.ctx = context
|
||||
this.x = x
|
||||
this.y = y
|
||||
this.color = color
|
||||
this.speed = this.getRandomValue(0.1, 0.9) * speed
|
||||
this.size = 0
|
||||
this.sizeStep = Math.random() * 0.4
|
||||
this.minSize = 0.5
|
||||
this.maxSizeInteger = 2
|
||||
this.maxSize = this.getRandomValue(this.minSize, this.maxSizeInteger)
|
||||
this.delay = delay
|
||||
this.counter = 0
|
||||
this.counterStep = Math.random() * 4 + (this.width + this.height) * 0.01
|
||||
this.isIdle = false
|
||||
this.isReverse = false
|
||||
this.isShimmer = false
|
||||
}
|
||||
|
||||
getRandomValue(min: number, max: number) {
|
||||
return Math.random() * (max - min) + min
|
||||
}
|
||||
|
||||
draw() {
|
||||
const centerOffset = this.maxSizeInteger * 0.5 - this.size * 0.5
|
||||
this.ctx.fillStyle = this.color
|
||||
this.ctx.fillRect(
|
||||
this.x + centerOffset,
|
||||
this.y + centerOffset,
|
||||
this.size,
|
||||
this.size
|
||||
)
|
||||
}
|
||||
|
||||
appear() {
|
||||
this.isIdle = false
|
||||
if (this.counter <= this.delay) {
|
||||
this.counter += this.counterStep
|
||||
return
|
||||
}
|
||||
if (this.size >= this.maxSize) {
|
||||
this.isShimmer = true
|
||||
}
|
||||
if (this.isShimmer) {
|
||||
this.shimmer()
|
||||
} else {
|
||||
this.size += this.sizeStep
|
||||
}
|
||||
this.draw()
|
||||
}
|
||||
|
||||
disappear() {
|
||||
this.isShimmer = false
|
||||
this.counter = 0
|
||||
if (this.size <= 0) {
|
||||
this.isIdle = true
|
||||
return
|
||||
} else {
|
||||
this.size -= 0.1
|
||||
}
|
||||
this.draw()
|
||||
}
|
||||
|
||||
shimmer() {
|
||||
if (this.size >= this.maxSize) {
|
||||
this.isReverse = true
|
||||
} else if (this.size <= this.minSize) {
|
||||
this.isReverse = false
|
||||
}
|
||||
if (this.isReverse) {
|
||||
this.size -= this.speed
|
||||
} else {
|
||||
this.size += this.speed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getEffectiveSpeed(value: number, reducedMotion: boolean) {
|
||||
const min = 0
|
||||
const max = 100
|
||||
const throttle = 0.001
|
||||
|
||||
if (value <= min || reducedMotion) {
|
||||
return min
|
||||
} else if (value >= max) {
|
||||
return max * throttle
|
||||
} else {
|
||||
return value * throttle
|
||||
}
|
||||
}
|
||||
|
||||
const VARIANTS = {
|
||||
default: {
|
||||
activeColor: null,
|
||||
gap: 5,
|
||||
speed: 35,
|
||||
colors: '#f8fafc,#f1f5f9,#cbd5e1',
|
||||
noFocus: false,
|
||||
},
|
||||
blue: {
|
||||
activeColor: '#e0f2fe',
|
||||
gap: 10,
|
||||
speed: 25,
|
||||
colors: '#e0f2fe,#7dd3fc,#0ea5e9',
|
||||
noFocus: false,
|
||||
},
|
||||
yellow: {
|
||||
activeColor: '#fef08a',
|
||||
gap: 3,
|
||||
speed: 20,
|
||||
colors: '#fef08a,#fde047,#eab308',
|
||||
noFocus: false,
|
||||
},
|
||||
pink: {
|
||||
activeColor: '#fecdd3',
|
||||
gap: 6,
|
||||
speed: 80,
|
||||
colors: '#fecdd3,#fda4af,#e11d48',
|
||||
noFocus: true,
|
||||
},
|
||||
}
|
||||
|
||||
interface PixelCardProps {
|
||||
variant?: 'default' | 'blue' | 'yellow' | 'pink'
|
||||
gap?: number
|
||||
speed?: number
|
||||
colors?: string
|
||||
noFocus?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface VariantConfig {
|
||||
activeColor: string | null
|
||||
gap: number
|
||||
speed: number
|
||||
colors: string
|
||||
noFocus: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<PixelCardProps>(), {
|
||||
variant: 'default',
|
||||
className: '',
|
||||
})
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const canvasRef = ref<HTMLCanvasElement>()
|
||||
const pixelsRef = ref<Pixel[]>([])
|
||||
const animationRef = ref<number | null>(null)
|
||||
const timePreviousRef = ref(performance.now())
|
||||
const reducedMotion = ref(
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
)
|
||||
|
||||
const variantCfg = computed((): VariantConfig => VARIANTS[props.variant] || VARIANTS.default)
|
||||
const finalGap = computed(() => props.gap ?? variantCfg.value.gap)
|
||||
const finalSpeed = computed(() => props.speed ?? variantCfg.value.speed)
|
||||
const finalColors = computed(() => props.colors ?? variantCfg.value.colors)
|
||||
const finalNoFocus = computed(() => props.noFocus ?? variantCfg.value.noFocus)
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
const initPixels = () => {
|
||||
if (!containerRef.value || !canvasRef.value) return
|
||||
|
||||
const rect = containerRef.value.getBoundingClientRect()
|
||||
const width = Math.floor(rect.width)
|
||||
const height = Math.floor(rect.height)
|
||||
const ctx = canvasRef.value.getContext('2d')
|
||||
|
||||
canvasRef.value.width = width
|
||||
canvasRef.value.height = height
|
||||
canvasRef.value.style.width = `${width}px`
|
||||
canvasRef.value.style.height = `${height}px`
|
||||
|
||||
const colorsArray = finalColors.value.split(',')
|
||||
const pxs = []
|
||||
for (let x = 0; x < width; x += parseInt(finalGap.value.toString(), 10)) {
|
||||
for (let y = 0; y < height; y += parseInt(finalGap.value.toString(), 10)) {
|
||||
const color =
|
||||
colorsArray[Math.floor(Math.random() * colorsArray.length)]
|
||||
|
||||
const dx = x - width / 2
|
||||
const dy = y - height / 2
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
const delay = reducedMotion.value ? 0 : distance
|
||||
if (!ctx) return
|
||||
pxs.push(
|
||||
new Pixel(
|
||||
canvasRef.value,
|
||||
ctx,
|
||||
x,
|
||||
y,
|
||||
color,
|
||||
getEffectiveSpeed(finalSpeed.value, reducedMotion.value),
|
||||
delay
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
pixelsRef.value = pxs
|
||||
}
|
||||
|
||||
const doAnimate = (fnName: keyof Pixel) => {
|
||||
animationRef.value = requestAnimationFrame(() => doAnimate(fnName))
|
||||
const timeNow = performance.now()
|
||||
const timePassed = timeNow - timePreviousRef.value
|
||||
const timeInterval = 1000 / 60
|
||||
|
||||
if (timePassed < timeInterval) return
|
||||
timePreviousRef.value = timeNow - (timePassed % timeInterval)
|
||||
|
||||
const ctx = canvasRef.value?.getContext('2d')
|
||||
if (!ctx || !canvasRef.value) return
|
||||
|
||||
ctx.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height)
|
||||
|
||||
let allIdle = true
|
||||
for (let i = 0; i < pixelsRef.value.length; i++) {
|
||||
const pixel = pixelsRef.value[i]
|
||||
// @ts-expect-error - Dynamic method call on Pixel class
|
||||
pixel[fnName]()
|
||||
if (!pixel.isIdle) {
|
||||
allIdle = false
|
||||
}
|
||||
}
|
||||
if (allIdle && animationRef.value) {
|
||||
cancelAnimationFrame(animationRef.value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAnimation = (name: keyof Pixel) => {
|
||||
if (animationRef.value !== null) {
|
||||
cancelAnimationFrame(animationRef.value)
|
||||
}
|
||||
animationRef.value = requestAnimationFrame(() => doAnimate(name))
|
||||
}
|
||||
|
||||
const onMouseEnter = () => handleAnimation('appear')
|
||||
const onMouseLeave = () => handleAnimation('disappear')
|
||||
const onFocus = (e: FocusEvent) => {
|
||||
if ((e.currentTarget as HTMLElement).contains(e.relatedTarget as Node)) return
|
||||
handleAnimation('appear')
|
||||
}
|
||||
const onBlur = (e: FocusEvent) => {
|
||||
if ((e.currentTarget as HTMLElement).contains(e.relatedTarget as Node)) return
|
||||
handleAnimation('disappear')
|
||||
}
|
||||
|
||||
watch([finalGap, finalSpeed, finalColors, finalNoFocus], () => {
|
||||
initPixels()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initPixels()
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
initPixels()
|
||||
})
|
||||
if (containerRef.value) {
|
||||
resizeObserver.observe(containerRef.value)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
if (animationRef.value !== null) {
|
||||
cancelAnimationFrame(animationRef.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
786
src/content/Components/ProfileCard/ProfileCard.vue
Normal file
786
src/content/Components/ProfileCard/ProfileCard.vue
Normal file
@@ -0,0 +1,786 @@
|
||||
<template>
|
||||
<div ref="wrapRef" :class="`pc-card-wrapper ${className}`.trim()" :style="cardStyle">
|
||||
<section ref="cardRef" class="pc-card">
|
||||
<div class="pc-inside">
|
||||
<div class="pc-shine" />
|
||||
<div class="pc-glare" />
|
||||
<div class="pc-content pc-avatar-content">
|
||||
<img class="avatar" :src="avatarUrl" :alt="`${name || 'User'} avatar`" loading="lazy"
|
||||
@error="handleAvatarError" />
|
||||
<div v-if="showUserInfo" class="pc-user-info">
|
||||
<div class="pc-user-details">
|
||||
<div class="pc-mini-avatar">
|
||||
<img :src="miniAvatarUrl || avatarUrl" :alt="`${name || 'User'} mini avatar`" loading="lazy"
|
||||
@error="handleMiniAvatarError" />
|
||||
</div>
|
||||
<div class="pc-user-text">
|
||||
<div class="pc-handle">@{{ handle }}</div>
|
||||
<div class="pc-status">{{ status }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="pc-contact-btn" @click="handleContactClick" style="pointer-events: auto" type="button"
|
||||
:aria-label="`Contact ${name || 'user'}`">
|
||||
{{ contactText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pc-content">
|
||||
<div class="pc-details">
|
||||
<h3>{{ name }}</h3>
|
||||
<p>{{ title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
interface Props {
|
||||
avatarUrl?: string
|
||||
iconUrl?: string
|
||||
grainUrl?: string
|
||||
behindGradient?: string
|
||||
innerGradient?: string
|
||||
showBehindGradient?: boolean
|
||||
className?: string
|
||||
enableTilt?: boolean
|
||||
miniAvatarUrl?: string
|
||||
name?: string
|
||||
title?: string
|
||||
handle?: string
|
||||
status?: string
|
||||
contactText?: string
|
||||
showUserInfo?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
avatarUrl: '<Placeholder for avatar URL>',
|
||||
iconUrl: '<Placeholder for icon URL>',
|
||||
grainUrl: '<Placeholder for grain URL>',
|
||||
behindGradient: undefined,
|
||||
innerGradient: undefined,
|
||||
showBehindGradient: true,
|
||||
className: '',
|
||||
enableTilt: true,
|
||||
miniAvatarUrl: undefined,
|
||||
name: 'Javi A. Torres',
|
||||
title: 'Software Engineer',
|
||||
handle: 'javicodes',
|
||||
status: 'Online',
|
||||
contactText: 'Contact',
|
||||
showUserInfo: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
contactClick: []
|
||||
}>()
|
||||
|
||||
const wrapRef = ref<HTMLDivElement>()
|
||||
const cardRef = ref<HTMLElement>()
|
||||
|
||||
const DEFAULT_BEHIND_GRADIENT = "radial-gradient(farthest-side circle at var(--pointer-x) var(--pointer-y),hsla(266,100%,90%,var(--card-opacity)) 4%,hsla(266,50%,80%,calc(var(--card-opacity)*0.75)) 10%,hsla(266,25%,70%,calc(var(--card-opacity)*0.5)) 50%,hsla(266,0%,60%,0) 100%),radial-gradient(35% 52% at 55% 20%,#00ffaac4 0%,#073aff00 100%),radial-gradient(100% 100% at 50% 50%,#00c1ffff 1%,#073aff00 76%),conic-gradient(from 124deg at 50% 50%,#c137ffff 0%,#07c6ffff 40%,#07c6ffff 60%,#c137ffff 100%)"
|
||||
|
||||
const DEFAULT_INNER_GRADIENT = "linear-gradient(145deg,#60496e8c 0%,#71C4FF44 100%)"
|
||||
|
||||
const ANIMATION_CONFIG = {
|
||||
SMOOTH_DURATION: 600,
|
||||
INITIAL_DURATION: 1500,
|
||||
INITIAL_X_OFFSET: 70,
|
||||
INITIAL_Y_OFFSET: 60,
|
||||
} as const
|
||||
|
||||
const clamp = (value: number, min = 0, max = 100): number =>
|
||||
Math.min(Math.max(value, min), max)
|
||||
|
||||
const round = (value: number, precision = 3): number =>
|
||||
parseFloat(value.toFixed(precision))
|
||||
|
||||
const adjust = (
|
||||
value: number,
|
||||
fromMin: number,
|
||||
fromMax: number,
|
||||
toMin: number,
|
||||
toMax: number
|
||||
): number =>
|
||||
round(toMin + ((toMax - toMin) * (value - fromMin)) / (fromMax - fromMin))
|
||||
|
||||
const easeInOutCubic = (x: number): number =>
|
||||
x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2
|
||||
|
||||
let rafId: number | null = null
|
||||
|
||||
const updateCardTransform = (
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
card: HTMLElement,
|
||||
wrap: HTMLElement
|
||||
) => {
|
||||
const width = card.clientWidth
|
||||
const height = card.clientHeight
|
||||
|
||||
const percentX = clamp((100 / width) * offsetX)
|
||||
const percentY = clamp((100 / height) * offsetY)
|
||||
|
||||
const centerX = percentX - 50
|
||||
const centerY = percentY - 50
|
||||
|
||||
const properties = {
|
||||
'--pointer-x': `${percentX}%`,
|
||||
'--pointer-y': `${percentY}%`,
|
||||
'--background-x': `${adjust(percentX, 0, 100, 35, 65)}%`,
|
||||
'--background-y': `${adjust(percentY, 0, 100, 35, 65)}%`,
|
||||
'--pointer-from-center': `${clamp(Math.hypot(percentY - 50, percentX - 50) / 50, 0, 1)}`,
|
||||
'--pointer-from-top': `${percentY / 100}`,
|
||||
'--pointer-from-left': `${percentX / 100}`,
|
||||
'--rotate-x': `${round(-(centerX / 5))}deg`,
|
||||
'--rotate-y': `${round(centerY / 4)}deg`,
|
||||
}
|
||||
|
||||
Object.entries(properties).forEach(([property, value]) => {
|
||||
wrap.style.setProperty(property, value)
|
||||
})
|
||||
}
|
||||
|
||||
const createSmoothAnimation = (
|
||||
duration: number,
|
||||
startX: number,
|
||||
startY: number,
|
||||
card: HTMLElement,
|
||||
wrap: HTMLElement
|
||||
) => {
|
||||
const startTime = performance.now()
|
||||
const targetX = wrap.clientWidth / 2
|
||||
const targetY = wrap.clientHeight / 2
|
||||
|
||||
const animationLoop = (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime
|
||||
const progress = clamp(elapsed / duration)
|
||||
const easedProgress = easeInOutCubic(progress)
|
||||
|
||||
const currentX = adjust(easedProgress, 0, 1, startX, targetX)
|
||||
const currentY = adjust(easedProgress, 0, 1, startY, targetY)
|
||||
|
||||
updateCardTransform(currentX, currentY, card, wrap)
|
||||
|
||||
if (progress < 1) {
|
||||
rafId = requestAnimationFrame(animationLoop)
|
||||
}
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(animationLoop)
|
||||
}
|
||||
|
||||
const cancelAnimation = () => {
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const card = cardRef.value
|
||||
const wrap = wrapRef.value
|
||||
|
||||
if (!card || !wrap || !props.enableTilt) return
|
||||
|
||||
const rect = card.getBoundingClientRect()
|
||||
updateCardTransform(
|
||||
event.clientX - rect.left,
|
||||
event.clientY - rect.top,
|
||||
card,
|
||||
wrap
|
||||
)
|
||||
}
|
||||
|
||||
const handlePointerEnter = () => {
|
||||
const card = cardRef.value
|
||||
const wrap = wrapRef.value
|
||||
|
||||
if (!card || !wrap || !props.enableTilt) return
|
||||
|
||||
cancelAnimation()
|
||||
wrap.classList.add('active')
|
||||
card.classList.add('active')
|
||||
}
|
||||
|
||||
const handlePointerLeave = (event: PointerEvent) => {
|
||||
const card = cardRef.value
|
||||
const wrap = wrapRef.value
|
||||
|
||||
if (!card || !wrap || !props.enableTilt) return
|
||||
|
||||
createSmoothAnimation(
|
||||
ANIMATION_CONFIG.SMOOTH_DURATION,
|
||||
event.offsetX,
|
||||
event.offsetY,
|
||||
card,
|
||||
wrap
|
||||
)
|
||||
wrap.classList.remove('active')
|
||||
card.classList.remove('active')
|
||||
}
|
||||
|
||||
const cardStyle = computed(() => ({
|
||||
'--icon': props.iconUrl ? `url(${props.iconUrl})` : 'none',
|
||||
'--grain': props.grainUrl ? `url(${props.grainUrl})` : 'none',
|
||||
'--behind-gradient': props.showBehindGradient
|
||||
? (props.behindGradient ?? DEFAULT_BEHIND_GRADIENT)
|
||||
: 'none',
|
||||
'--inner-gradient': props.innerGradient ?? DEFAULT_INNER_GRADIENT,
|
||||
}))
|
||||
|
||||
const handleContactClick = () => {
|
||||
emit('contactClick')
|
||||
}
|
||||
|
||||
const handleAvatarError = (event: Event) => {
|
||||
const target = event.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
}
|
||||
|
||||
const handleMiniAvatarError = (event: Event) => {
|
||||
const target = event.target as HTMLImageElement
|
||||
target.style.opacity = '0.5'
|
||||
target.src = props.avatarUrl
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.enableTilt) return
|
||||
|
||||
const card = cardRef.value
|
||||
const wrap = wrapRef.value
|
||||
|
||||
if (!card || !wrap) return
|
||||
|
||||
card.addEventListener('pointerenter', handlePointerEnter)
|
||||
card.addEventListener('pointermove', handlePointerMove)
|
||||
card.addEventListener('pointerleave', handlePointerLeave)
|
||||
|
||||
const initialX = wrap.clientWidth - ANIMATION_CONFIG.INITIAL_X_OFFSET
|
||||
const initialY = ANIMATION_CONFIG.INITIAL_Y_OFFSET
|
||||
|
||||
updateCardTransform(initialX, initialY, card, wrap)
|
||||
createSmoothAnimation(
|
||||
ANIMATION_CONFIG.INITIAL_DURATION,
|
||||
initialX,
|
||||
initialY,
|
||||
card,
|
||||
wrap
|
||||
)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
const card = cardRef.value
|
||||
|
||||
if (card) {
|
||||
card.removeEventListener('pointerenter', handlePointerEnter)
|
||||
card.removeEventListener('pointermove', handlePointerMove)
|
||||
card.removeEventListener('pointerleave', handlePointerLeave)
|
||||
}
|
||||
|
||||
cancelAnimation()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pc-card-wrapper {
|
||||
--pointer-x: 50%;
|
||||
--pointer-y: 50%;
|
||||
--pointer-from-center: 0;
|
||||
--pointer-from-top: 0.5;
|
||||
--pointer-from-left: 0.5;
|
||||
--card-opacity: 0;
|
||||
--rotate-x: 0deg;
|
||||
--rotate-y: 0deg;
|
||||
--background-x: 50%;
|
||||
--background-y: 50%;
|
||||
--grain: none;
|
||||
--icon: none;
|
||||
--behind-gradient: none;
|
||||
--inner-gradient: none;
|
||||
--sunpillar-1: hsl(2, 100%, 73%);
|
||||
--sunpillar-2: hsl(53, 100%, 69%);
|
||||
--sunpillar-3: hsl(93, 100%, 69%);
|
||||
--sunpillar-4: hsl(176, 100%, 76%);
|
||||
--sunpillar-5: hsl(228, 100%, 74%);
|
||||
--sunpillar-6: hsl(283, 100%, 73%);
|
||||
--sunpillar-clr-1: var(--sunpillar-1);
|
||||
--sunpillar-clr-2: var(--sunpillar-2);
|
||||
--sunpillar-clr-3: var(--sunpillar-3);
|
||||
--sunpillar-clr-4: var(--sunpillar-4);
|
||||
--sunpillar-clr-5: var(--sunpillar-5);
|
||||
--sunpillar-clr-6: var(--sunpillar-6);
|
||||
--card-radius: 30px;
|
||||
}
|
||||
|
||||
.pc-card-wrapper {
|
||||
perspective: 500px;
|
||||
transform: translate3d(0, 0, 0.1px);
|
||||
position: relative;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.pc-card-wrapper::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -10px;
|
||||
background: inherit;
|
||||
background-position: inherit;
|
||||
border-radius: inherit;
|
||||
transition: all 0.5s ease;
|
||||
filter: contrast(2) saturate(2) blur(36px);
|
||||
transform: scale(0.8) translate3d(0, 0, 0.1px);
|
||||
background-size: 100% 100%;
|
||||
background-image: var(--behind-gradient);
|
||||
}
|
||||
|
||||
.pc-card-wrapper:hover,
|
||||
.pc-card-wrapper.active {
|
||||
--card-opacity: 1;
|
||||
}
|
||||
|
||||
.pc-card-wrapper:hover::before,
|
||||
.pc-card-wrapper.active::before {
|
||||
filter: contrast(1) saturate(2) blur(40px) opacity(1);
|
||||
transform: scale(0.9) translate3d(0, 0, 0.1px);
|
||||
}
|
||||
|
||||
.pc-card {
|
||||
height: 80svh;
|
||||
max-height: 540px;
|
||||
display: grid;
|
||||
aspect-ratio: 0.718;
|
||||
border-radius: var(--card-radius);
|
||||
position: relative;
|
||||
background-blend-mode: color-dodge, normal, normal, normal;
|
||||
animation: glow-bg 12s linear infinite;
|
||||
box-shadow: rgba(0, 0, 0, 0.8) calc((var(--pointer-from-left) * 10px) - 3px) calc((var(--pointer-from-top) * 20px) - 6px) 20px -5px;
|
||||
transition: transform 1s ease;
|
||||
transform: translate3d(0, 0, 0.1px) rotateX(0deg) rotateY(0deg);
|
||||
background-size: 100% 100%;
|
||||
background-position: 0 0, 0 0, 50% 50%, 0 0;
|
||||
background-image: radial-gradient(farthest-side circle at var(--pointer-x) var(--pointer-y), hsla(266, 100%, 90%, var(--card-opacity)) 4%, hsla(266, 50%, 80%, calc(var(--card-opacity) * 0.75)) 10%, hsla(266, 25%, 70%, calc(var(--card-opacity) * 0.5)) 50%, hsla(266, 0%, 60%, 0) 100%), radial-gradient(35% 52% at 55% 20%, #00ffaac4 0%, #073aff00 100%), radial-gradient(100% 100% at 50% 50%, #00c1ffff 1%, #073aff00 76%), conic-gradient(from 124deg at 50% 50%, #c137ffff 0%, #07c6ffff 40%, #07c6ffff 60%, #c137ffff 100%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pc-card:hover,
|
||||
.pc-card.active {
|
||||
transition: none;
|
||||
transform: translate3d(0, 0, 0.1px) rotateX(var(--rotate-y)) rotateY(var(--rotate-x));
|
||||
}
|
||||
|
||||
.pc-card * {
|
||||
display: grid;
|
||||
grid-area: 1/-1;
|
||||
border-radius: var(--card-radius);
|
||||
transform: translate3d(0, 0, 0.1px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pc-inside {
|
||||
inset: 1px;
|
||||
position: absolute;
|
||||
background-image: var(--inner-gradient);
|
||||
background-color: rgba(0, 0, 0, 0.9);
|
||||
transform: translate3d(0, 0, 0.01px);
|
||||
}
|
||||
|
||||
.pc-shine {
|
||||
mask-image: var(--icon);
|
||||
mask-mode: luminance;
|
||||
mask-repeat: repeat;
|
||||
mask-size: 150%;
|
||||
mask-position: top calc(200% - (var(--background-y) * 5)) left calc(100% - var(--background-x));
|
||||
-webkit-mask-image: var(--icon);
|
||||
-webkit-mask-mode: luminance;
|
||||
-webkit-mask-repeat: repeat;
|
||||
-webkit-mask-size: 150%;
|
||||
-webkit-mask-position: top calc(200% - (var(--background-y) * 5)) left calc(100% - var(--background-x));
|
||||
transition: filter 0.6s ease;
|
||||
filter: brightness(0.66) contrast(1.33) saturate(0.33) opacity(0.5);
|
||||
animation: holo-bg 18s linear infinite;
|
||||
mix-blend-mode: color-dodge;
|
||||
}
|
||||
|
||||
.pc-shine,
|
||||
.pc-shine::after {
|
||||
--space: 5%;
|
||||
--angle: -45deg;
|
||||
transform: translate3d(0, 0, 1px);
|
||||
overflow: hidden;
|
||||
z-index: 3;
|
||||
background: transparent;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-image: repeating-linear-gradient(0deg, var(--sunpillar-clr-1) calc(var(--space) * 1), var(--sunpillar-clr-2) calc(var(--space) * 2), var(--sunpillar-clr-3) calc(var(--space) * 3), var(--sunpillar-clr-4) calc(var(--space) * 4), var(--sunpillar-clr-5) calc(var(--space) * 5), var(--sunpillar-clr-6) calc(var(--space) * 6), var(--sunpillar-clr-1) calc(var(--space) * 7)), repeating-linear-gradient(var(--angle), #0e152e 0%, hsl(180, 10%, 60%) 3.8%, hsl(180, 29%, 66%) 4.5%, hsl(180, 10%, 60%) 5.2%, #0e152e 10%, #0e152e 12%), radial-gradient(farthest-corner circle at var(--pointer-x) var(--pointer-y), hsla(0, 0%, 0%, 0.1) 12%, hsla(0, 0%, 0%, 0.15) 20%, hsla(0, 0%, 0%, 0.25) 120%);
|
||||
background-position: 0 var(--background-y), var(--background-x) var(--background-y), center;
|
||||
background-blend-mode: color, hard-light;
|
||||
background-size: 500% 500%, 300% 300%, 200% 200%;
|
||||
background-repeat: repeat;
|
||||
}
|
||||
|
||||
.pc-shine::before,
|
||||
.pc-shine::after {
|
||||
content: '';
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
grid-area: 1/1;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.pc-card:hover .pc-shine,
|
||||
.pc-card.active .pc-shine {
|
||||
filter: brightness(0.85) contrast(1.5) saturate(0.5);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.pc-card:hover .pc-shine::before,
|
||||
.pc-card.active .pc-shine::before,
|
||||
.pc-card:hover .pc-shine::after,
|
||||
.pc-card.active .pc-shine::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.pc-shine::before {
|
||||
background-image: linear-gradient(45deg, var(--sunpillar-4), var(--sunpillar-5), var(--sunpillar-6), var(--sunpillar-1), var(--sunpillar-2), var(--sunpillar-3)), radial-gradient(circle at var(--pointer-x) var(--pointer-y), hsl(0, 0%, 70%) 0%, hsla(0, 0%, 30%, 0.2) 90%), var(--grain);
|
||||
background-size: 250% 250%, 100% 100%, 220px 220px;
|
||||
background-position: var(--pointer-x) var(--pointer-y), center, calc(var(--pointer-x) * 0.01) calc(var(--pointer-y) * 0.01);
|
||||
background-blend-mode: color-dodge;
|
||||
filter: brightness(calc(2 - var(--pointer-from-center))) contrast(calc(var(--pointer-from-center) + 2)) saturate(calc(0.5 + var(--pointer-from-center)));
|
||||
mix-blend-mode: luminosity;
|
||||
}
|
||||
|
||||
.pc-shine::after {
|
||||
content: '';
|
||||
background-image: repeating-linear-gradient(0deg, var(--sunpillar-clr-1) calc(5% * 1), var(--sunpillar-clr-2) calc(5% * 2), var(--sunpillar-clr-3) calc(5% * 3), var(--sunpillar-clr-4) calc(5% * 4), var(--sunpillar-clr-5) calc(5% * 5), var(--sunpillar-clr-6) calc(5% * 6), var(--sunpillar-clr-1) calc(5% * 7)), repeating-linear-gradient(-45deg, #0e152e 0%, hsl(180, 10%, 60%) 3.8%, hsl(180, 29%, 66%) 4.5%, hsl(180, 10%, 60%) 5.2%, #0e152e 10%, #0e152e 12%), radial-gradient(farthest-corner circle at var(--pointer-x) var(--pointer-y), hsla(0, 0%, 0%, 0.1) 12%, hsla(0, 0%, 0%, 0.15) 20%, hsla(0, 0%, 0%, 0.25) 120%);
|
||||
background-position: 0 var(--background-y), calc(var(--background-x) * 0.4) calc(var(--background-y) * 0.5), center;
|
||||
background-size: 200% 300%, 700% 700%, 100% 100%;
|
||||
mix-blend-mode: difference;
|
||||
filter: brightness(0.8) contrast(1.5);
|
||||
}
|
||||
|
||||
.pc-glare {
|
||||
transform: translate3d(0, 0, 1.1px);
|
||||
overflow: hidden;
|
||||
background-image: radial-gradient(farthest-corner circle at var(--pointer-x) var(--pointer-y), hsl(248, 25%, 80%) 12%, hsla(207, 40%, 30%, 0.8) 90%);
|
||||
mix-blend-mode: overlay;
|
||||
filter: brightness(0.8) contrast(1.2);
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.pc-avatar-content {
|
||||
mix-blend-mode: screen;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pc-avatar-content .avatar {
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) scale(1);
|
||||
bottom: 2px;
|
||||
opacity: calc(1.75 - var(--pointer-from-center));
|
||||
}
|
||||
|
||||
.pc-avatar-content::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
backdrop-filter: blur(30px);
|
||||
mask: linear-gradient(to bottom,
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
rgba(0, 0, 0, 0) 60%,
|
||||
rgba(0, 0, 0, 1) 90%,
|
||||
rgba(0, 0, 0, 1) 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pc-user-info {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(30px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 15px;
|
||||
padding: 12px 14px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.pc-user-details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pc-mini-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pc-mini-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.pc-user-text {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pc-handle {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.pc-status {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.pc-contact-btn {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.pc-contact-btn:hover {
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
transform: translateY(-1px);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.pc-content {
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
transform: translate3d(calc(var(--pointer-from-left) * -6px + 3px), calc(var(--pointer-from-top) * -6px + 3px), 0.1px) !important;
|
||||
z-index: 5;
|
||||
mix-blend-mode: luminosity;
|
||||
}
|
||||
|
||||
.pc-details {
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
top: 3em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pc-details h3 {
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
font-size: min(5svh, 3em);
|
||||
margin: 0;
|
||||
background-image: linear-gradient(to bottom, #fff, #6f6fbe);
|
||||
background-size: 1em 1.5em;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
}
|
||||
|
||||
.pc-details p {
|
||||
font-weight: 600;
|
||||
position: relative;
|
||||
top: -12px;
|
||||
white-space: nowrap;
|
||||
font-size: 16px;
|
||||
margin: 0 auto;
|
||||
width: min-content;
|
||||
background-image: linear-gradient(to bottom, #fff, #4a4ac0);
|
||||
background-size: 1em 1.5em;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
}
|
||||
|
||||
@keyframes glow-bg {
|
||||
0% {
|
||||
--bgrotate: 0deg;
|
||||
}
|
||||
|
||||
100% {
|
||||
--bgrotate: 360deg;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes holo-bg {
|
||||
0% {
|
||||
background-position: 0 var(--background-y), 0 0, center;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 0 var(--background-y), 90% 90%, center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pc-card {
|
||||
height: 70svh;
|
||||
max-height: 450px;
|
||||
}
|
||||
|
||||
.pc-details {
|
||||
top: 2em;
|
||||
}
|
||||
|
||||
.pc-details h3 {
|
||||
font-size: min(4svh, 2.5em);
|
||||
}
|
||||
|
||||
.pc-details p {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pc-user-info {
|
||||
bottom: 15px;
|
||||
left: 15px;
|
||||
right: 15px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.pc-mini-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.pc-user-details {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pc-handle {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pc-status {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.pc-contact-btn {
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.pc-card {
|
||||
height: 60svh;
|
||||
max-height: 380px;
|
||||
}
|
||||
|
||||
.pc-details {
|
||||
top: 1.5em;
|
||||
}
|
||||
|
||||
.pc-details h3 {
|
||||
font-size: min(3.5svh, 2em);
|
||||
}
|
||||
|
||||
.pc-details p {
|
||||
font-size: 12px;
|
||||
top: -8px;
|
||||
}
|
||||
|
||||
.pc-user-info {
|
||||
bottom: 12px;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 50px;
|
||||
}
|
||||
|
||||
.pc-mini-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.pc-user-details {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pc-handle {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pc-status {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.pc-contact-btn {
|
||||
padding: 5px 10px;
|
||||
font-size: 10px;
|
||||
border-radius: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 320px) {
|
||||
.pc-card {
|
||||
height: 55svh;
|
||||
max-height: 320px;
|
||||
}
|
||||
|
||||
.pc-details h3 {
|
||||
font-size: min(3svh, 1.5em);
|
||||
}
|
||||
|
||||
.pc-details p {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.pc-user-info {
|
||||
padding: 6px 8px;
|
||||
border-radius: 50px;
|
||||
}
|
||||
|
||||
.pc-mini-avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.pc-user-details {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pc-handle {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.pc-status {
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.pc-contact-btn {
|
||||
padding: 4px 8px;
|
||||
font-size: 9px;
|
||||
border-radius: 50px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
59
src/content/Components/SpotlightCard/SpotlightCard.vue
Normal file
59
src/content/Components/SpotlightCard/SpotlightCard.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div ref="divRef" @mousemove="handleMouseMove" @focus="handleFocus" @blur="handleBlur" @mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave" :class="[
|
||||
'relative rounded-3xl border overflow-hidden p-8',
|
||||
className
|
||||
]">
|
||||
<div class="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-500 ease-in-out" :style="{
|
||||
opacity,
|
||||
background: `radial-gradient(circle at ${position.x}px ${position.y}px, ${spotlightColor}, transparent 80%)`,
|
||||
}" />
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface Position {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
interface SpotlightCardProps {
|
||||
className?: string
|
||||
spotlightColor?: string
|
||||
}
|
||||
|
||||
const { className = '', spotlightColor = 'rgba(255, 255, 255, 0.25)' } = defineProps<SpotlightCardProps>()
|
||||
|
||||
const divRef = ref<HTMLDivElement>()
|
||||
const isFocused = ref<boolean>(false)
|
||||
const position = ref<Position>({ x: 0, y: 0 })
|
||||
const opacity = ref<number>(0)
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!divRef.value || isFocused.value) return
|
||||
|
||||
const rect = divRef.value.getBoundingClientRect()
|
||||
position.value = { x: e.clientX - rect.left, y: e.clientY - rect.top }
|
||||
}
|
||||
|
||||
const handleFocus = () => {
|
||||
isFocused.value = true
|
||||
opacity.value = 0.6
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
isFocused.value = false
|
||||
opacity.value = 0
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
opacity.value = 0.6
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
opacity.value = 0
|
||||
}
|
||||
</script>
|
||||
141
src/content/TextAnimations/BlurText/BlurText.vue
Normal file
141
src/content/TextAnimations/BlurText/BlurText.vue
Normal 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>
|
||||
129
src/content/TextAnimations/CircularText/CircularText.vue
Normal file
129
src/content/TextAnimations/CircularText/CircularText.vue
Normal 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>
|
||||
193
src/content/TextAnimations/CurvedLoop/CurvedLoop.vue
Normal file
193
src/content/TextAnimations/CurvedLoop/CurvedLoop.vue
Normal 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>
|
||||
229
src/content/TextAnimations/DecryptedText/DecryptedText.vue
Normal file
229
src/content/TextAnimations/DecryptedText/DecryptedText.vue
Normal 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>
|
||||
286
src/content/TextAnimations/FallingText/FallingText.vue
Normal file
286
src/content/TextAnimations/FallingText/FallingText.vue
Normal 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>
|
||||
294
src/content/TextAnimations/FuzzyText/FuzzyText.vue
Normal file
294
src/content/TextAnimations/FuzzyText/FuzzyText.vue
Normal 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>
|
||||
84
src/content/TextAnimations/GradientText/GradientText.vue
Normal file
84
src/content/TextAnimations/GradientText/GradientText.vue
Normal 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>
|
||||
48
src/content/TextAnimations/ShinyText/ShinyText.vue
Normal file
48
src/content/TextAnimations/ShinyText/ShinyText.vue
Normal 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>
|
||||
181
src/content/TextAnimations/TextCursor/TextCursor.vue
Normal file
181
src/content/TextAnimations/TextCursor/TextCursor.vue
Normal 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>
|
||||
232
src/content/TextAnimations/TextPressure/TextPressure.vue
Normal file
232
src/content/TextAnimations/TextPressure/TextPressure.vue
Normal 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>
|
||||
451
src/content/TextAnimations/TextTrail/TextTrail.vue
Normal file
451
src/content/TextAnimations/TextTrail/TextTrail.vue
Normal 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>
|
||||
@@ -99,6 +99,13 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.demo-title-extra {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 900;
|
||||
margin: 2rem 0 0 0;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
div:has(> .props-table) {
|
||||
border: 1px solid #142216;
|
||||
border-radius: 20px;
|
||||
@@ -419,7 +426,6 @@ div:has(> .props-table) {
|
||||
|
||||
.custom-spotlight-card {
|
||||
user-select: none;
|
||||
background-color: #333333 !important;
|
||||
border: 1px solid #142216 !important;
|
||||
width: 350px;
|
||||
height: 300px;
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
|
||||
.sidebar-item:hover {
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.active-sidebar-item {
|
||||
@@ -123,7 +124,7 @@
|
||||
|
||||
.sidebar-item .new-tag {
|
||||
color: #fff;
|
||||
border: 1px solid #5227ff;
|
||||
border: 1px solid #27FF64;
|
||||
background-color: rgba(82, 39, 255, 0.3);
|
||||
}
|
||||
|
||||
@@ -620,7 +621,7 @@
|
||||
|
||||
.drawer-navigation .sidebar-item .new-tag {
|
||||
color: #fff;
|
||||
border: 1px solid #5227ff;
|
||||
border: 1px solid #27FF64;
|
||||
background-color: rgba(82, 39, 255, 0.3);
|
||||
}
|
||||
|
||||
|
||||
197
src/demo/Animations/AnimatedContentDemo.vue
Normal file
197
src/demo/Animations/AnimatedContentDemo.vue
Normal 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>
|
||||
116
src/demo/Animations/ClickSparkDemo.vue
Normal file
116
src/demo/Animations/ClickSparkDemo.vue
Normal 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>
|
||||
132
src/demo/Animations/CountUpDemo.vue
Normal file
132
src/demo/Animations/CountUpDemo.vue
Normal 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>
|
||||
151
src/demo/Animations/CubesDemo.vue
Normal file
151
src/demo/Animations/CubesDemo.vue
Normal 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>
|
||||
143
src/demo/Animations/GlareHoverDemo.vue
Normal file
143
src/demo/Animations/GlareHoverDemo.vue
Normal 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>
|
||||
155
src/demo/Animations/MagnetDemo.vue
Normal file
155
src/demo/Animations/MagnetDemo.vue
Normal 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>
|
||||
93
src/demo/Animations/MagnetLinesDemo.vue
Normal file
93
src/demo/Animations/MagnetLinesDemo.vue
Normal 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>
|
||||
122
src/demo/Animations/PixelTransitionDemo.vue
Normal file
122
src/demo/Animations/PixelTransitionDemo.vue
Normal 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
Reference in New Issue
Block a user