Appearance
图片路径跟随
🖼️ 图片路径跟随
进来滑滑~
点我查看组件代码
js
<template>
<div class="content" ref="containerRef">
<div v-for="(url, i) in items" :key="i" class="content__img" :style="{width: `${size}px`}">
<div
class="content__img-inner"
:style="{ backgroundImage: `url(${url})` }"></div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { gsap } from 'gsap'
import iconAntd from "../static/icons/icon-antd.svg";
import iconPet from "../static/icons/icon-pet.svg";
import iconVue from "../static/icons/icon-vue.svg";
import iconVite from "../static/icons/icon-vite.svg";
import iconSass from "../static/icons/icon-sass.svg";
import iconReact from "../static/icons/icon-react.svg";
import iconGit from "../static/icons/icon-git.svg";
import iconGsap from "../static/icons/icon-gsap.svg";
import iconAngellist from "../static/icons/icon-angellist.svg";
import iconHot from "../static/icons/icon-hot.svg";
import iconMysql from "../static/icons/icon-mysql.svg";
import iconP5js from "../static/icons/icon-p5js.svg";
import iconPs from "../static/icons/icon-ps.svg";
import iconTomcat from "../static/icons/icon-tomcat.svg";
import iconVscode from "../static/icons/icon-vscode.svg";
import iconPython from "../static/icons/icon-python.svg";
const props = defineProps({
items: {
type: Array,
default: () => [iconAntd,
iconPet,
iconVue,
iconVite,
iconSass,
iconReact,
iconGit,
iconGsap,
iconAngellist,
iconHot,
iconMysql,
iconP5js,
iconPs,
iconTomcat,
iconVscode,
iconPython
]
},
size: {
type: Number,
default: 80
},
variant: {
type: Number,
default: 8
}
})
const containerRef = ref(null)
// 基础工具函数
const utils = {
lerp: (a, b, n) => (1 - n) * a + n * b,
getLocalPointerPos: (e, rect) => {
const clientX = e.touches?.[0]?.clientX ?? e.clientX
const clientY = e.touches?.[0]?.clientY ?? e.clientY
return {
x: clientX - rect.left,
y: clientY - rect.top
}
},
getMouseDistance: (p1, p2) => Math.hypot(p1.x - p2.x, p1.y - p2.y)
}
// 图片项类
class ImageItem {
constructor(DOM_el) {
this.DOM = {
el: DOM_el,
inner: DOM_el.querySelector('.content__img-inner')
}
this.defaultStyle = { scale: 1, x: 0, y: 0, opacity: 0 }
this.rect = null
this.getRect()
this.initEvents()
}
getRect() {
this.rect = this.DOM.el.getBoundingClientRect()
}
initEvents() {
const resize = () => {
gsap.set(this.DOM.el, this.defaultStyle)
this.getRect()
}
window.addEventListener('resize', resize)
this._cleanup = () => window.removeEventListener('resize', resize)
}
cleanup() {
this._cleanup?.()
}
}
// 动画轨迹基类
class ImageTrailBase {
constructor(container, options = {}) {
this.container = container
this.images = [...container.querySelectorAll('.content__img')].map(img => new ImageItem(img))
this.imagesTotal = this.images.length
this.threshold = options.threshold ?? 80
this.state = {
imgPosition: 0,
zIndexVal: 1,
activeImagesCount: 0,
isIdle: true,
mousePos: { x: 0, y: 0 },
lastMousePos: { x: 0, y: 0 },
cacheMousePos: { x: 0, y: 0 }
}
this.initEvents()
}
initEvents() {
const handlePointerMove = e => {
const rect = this.container.getBoundingClientRect()
this.state.mousePos = utils.getLocalPointerPos(e, rect)
}
const initRender = e => {
const rect = this.container.getBoundingClientRect()
this.state.mousePos = utils.getLocalPointerPos(e, rect)
this.state.cacheMousePos = { ...this.state.mousePos }
this.render()
this.container.removeEventListener('mousemove', initRender)
this.container.removeEventListener('touchmove', initRender)
}
this.container.addEventListener('mousemove', handlePointerMove)
this.container.addEventListener('touchmove', handlePointerMove)
this.container.addEventListener('mousemove', initRender)
this.container.addEventListener('touchmove', initRender)
this._cleanup = () => {
this.container.removeEventListener('mousemove', handlePointerMove)
this.container.removeEventListener('touchmove', handlePointerMove)
}
}
render() {
const { mousePos, lastMousePos, cacheMousePos } = this.state
const distance = utils.getMouseDistance(mousePos, lastMousePos)
cacheMousePos.x = utils.lerp(cacheMousePos.x, mousePos.x, 0.1)
cacheMousePos.y = utils.lerp(cacheMousePos.y, mousePos.y, 0.1)
if (distance > this.threshold) {
this.showNextImage()
this.state.lastMousePos = { ...mousePos }
}
if (this.state.isIdle && this.state.zIndexVal !== 1) {
this.state.zIndexVal = 1
}
requestAnimationFrame(() => this.render())
}
getNextImagePosition() {
this.state.imgPosition = this.state.imgPosition < this.imagesTotal - 1
? this.state.imgPosition + 1
: 0
return this.images[this.state.imgPosition]
}
createAnimation(img, animationConfig) {
gsap.killTweensOf(img.DOM.el)
return gsap.timeline({
onStart: () => this.onImageActivated(),
onComplete: () => this.onImageDeactivated(),
...animationConfig
})
}
onImageActivated() {
this.state.activeImagesCount++
this.state.isIdle = false
}
onImageDeactivated() {
this.state.activeImagesCount--
if (this.state.activeImagesCount === 0) {
this.state.isIdle = true
}
}
cleanup() {
this._cleanup?.()
this.images.forEach(img => img.cleanup())
}
}
// 具体变体实现
const variants = {
1: class extends ImageTrailBase {
showNextImage() {
const img = this.getNextImagePosition()
this.state.zIndexVal++
this.createAnimation(img)
.fromTo(img.DOM.el, {
opacity: 1,
scale: 1,
zIndex: this.state.zIndexVal,
x: this.state.cacheMousePos.x - img.rect.width / 2,
y: this.state.cacheMousePos.y - img.rect.height / 2
}, {
duration: 0.4,
ease: 'power1',
x: this.state.mousePos.x - img.rect.width / 2,
y: this.state.mousePos.y - img.rect.height / 2
}, 0)
.to(img.DOM.el, {
duration: 0.4,
ease: 'power3',
opacity: 0,
scale: 0.2
}, 0.4)
}
},
2: class extends ImageTrailBase {
showNextImage() {
const img = this.getNextImagePosition()
this.state.zIndexVal++
this.createAnimation(img)
.fromTo(img.DOM.el, {
opacity: 1,
scale: 0,
zIndex: this.state.zIndexVal,
x: this.state.cacheMousePos.x - img.rect.width / 2,
y: this.state.cacheMousePos.y - img.rect.height / 2
}, {
duration: 0.4,
ease: 'power1',
scale: 1,
x: this.state.mousePos.x - img.rect.width / 2,
y: this.state.mousePos.y - img.rect.height / 2
}, 0)
.fromTo(img.DOM.inner, {
scale: 2.8,
filter: 'brightness(250%)'
}, {
duration: 0.4,
ease: 'power1',
scale: 1,
filter: 'brightness(100%)'
}, 0)
.to(img.DOM.el, {
duration: 0.4,
ease: 'power2',
opacity: 0,
scale: 0.2
}, 0.45)
}
},
}
onMounted(() => {
if (!containerRef.value) return
const VariantClass = variants[props.variant] || variants[1]
const instance = new VariantClass(containerRef.value)
// 组件卸载时清理
return () => instance.cleanup()
})
</script>
<style>
.content {
width: 100%;
height: 100%;
position: relative;
z-index: 1;
background: transparent;
overflow: visible;
}
.content__img {
width: 80px;
height: auto;
aspect-ratio: 1.1;
position: absolute;
top: 0;
left: 0;
opacity: 0;
}
.content__img-inner {
background-position: 50% 50%;
width: calc(100% + 20px);
height: calc(100% + 20px);
background-size: 100% 100%;
position: absolute;
top: calc(-1 * 20px / 2);
left: calc(-1 * 20px / 2);
}
</style>