improvement(wrapped-ui): 引入 Bits 动效组件并新增 GSAP 依赖

- 新增卡片交换、网格流动、文本拆分三类动效组件,统一沉淀到 wrapped 共享层
- 前端新增 gsap 依赖并同步 lock 文件,确保动画能力可复现
- 为年度总结封面动效与后续复用打基础
This commit is contained in:
2977094657
2026-02-07 14:20:30 +08:00
parent 30134354a0
commit 676ad84db9
5 changed files with 924 additions and 8 deletions

View File

@@ -0,0 +1,291 @@
<template>
<div
ref="containerRef"
class="bits-card-swap absolute bottom-0 right-0 translate-x-[24%] translate-y-[-2%] origin-bottom-right overflow-visible [perspective:900px]"
:style="containerStyle"
>
<div
v-for="(_, index) in visibleCardCount"
:key="index"
ref="cardRefs"
class="bits-card-swap-item absolute top-1/2 left-1/2 rounded-xl [transform-style:preserve-3d] [will-change:transform] [backface-visibility:hidden]"
:style="cardStyle"
@click="onCardClick(index)"
>
<slot :name="`card-${index}`" :index="index" />
</div>
</div>
</template>
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import gsap from 'gsap'
const props = defineProps({
cardCount: { type: Number, default: 3 },
width: { type: [Number, String], default: 250 },
height: { type: [Number, String], default: 300 },
cardDistance: { type: Number, default: 24 },
verticalDistance: { type: Number, default: 34 },
delay: { type: Number, default: 4200 },
pauseOnHover: { type: Boolean, default: false },
onCardClick: { type: Function, default: null },
skewAmount: { type: Number, default: 4 },
easing: { type: String, default: 'elastic' }
})
const emit = defineEmits(['cardClick'])
const containerRef = ref(null)
const cardRefs = ref([])
const order = ref([0, 1, 2])
const timelineRef = ref(null)
let intervalRef = null
const toPx = (value) => (typeof value === 'number' ? `${value}px` : value)
const containerStyle = computed(() => ({
width: toPx(props.width),
height: toPx(props.height)
}))
const cardStyle = computed(() => ({
width: toPx(props.width),
height: toPx(props.height)
}))
const visibleCardCount = computed(() => {
const count = Number(props.cardCount)
if (!Number.isFinite(count)) return 1
const normalized = Math.floor(count)
return Math.max(1, normalized)
})
const config = computed(() => {
if (props.easing === 'elastic') {
return {
ease: 'elastic.out(0.6,0.9)',
dropDuration: 1.8,
moveDuration: 1.8,
returnDuration: 1.8,
overlap: 0.85,
returnDelay: 0.08
}
}
return {
ease: 'power1.inOut',
dropDuration: 0.8,
moveDuration: 0.8,
returnDuration: 0.8,
overlap: 0.45,
returnDelay: 0.2
}
})
const makeSlot = (index, total) => ({
x: index * props.cardDistance,
y: -index * props.verticalDistance,
z: -index * props.cardDistance * 1.5,
zIndex: total - index
})
const placeNow = (element, slot) => {
gsap.set(element, {
x: slot.x,
y: slot.y,
z: slot.z,
xPercent: -50,
yPercent: -50,
skewY: props.skewAmount,
transformOrigin: 'center center',
zIndex: slot.zIndex,
force3D: true
})
}
const initializeCards = () => {
const list = cardRefs.value || []
if (!list.length) return
const total = visibleCardCount.value
list.forEach((element, index) => {
if (!element) return
placeNow(element, makeSlot(index, total))
})
}
const updateCardPositions = () => {
const list = cardRefs.value || []
if (!list.length) return
const total = visibleCardCount.value
list.forEach((element, index) => {
if (!element) return
const slot = makeSlot(index, total)
gsap.set(element, {
x: slot.x,
y: slot.y,
z: slot.z,
skewY: props.skewAmount
})
})
}
const runSwap = () => {
const total = visibleCardCount.value
if (order.value.length !== total) {
order.value = Array.from({ length: total }, (_, idx) => idx)
}
const activeOrder = order.value.slice(0, total)
if (activeOrder.length < 2) return
const [front, ...rest] = activeOrder
const frontElement = cardRefs.value[front]
if (!frontElement) return
const tl = gsap.timeline()
timelineRef.value = tl
tl.to(frontElement, {
y: '+=480',
duration: config.value.dropDuration,
ease: config.value.ease
})
tl.addLabel('promote', `-=${config.value.dropDuration * config.value.overlap}`)
rest.forEach((index, slotIndex) => {
const element = cardRefs.value[index]
if (!element) return
const slot = makeSlot(slotIndex, activeOrder.length)
tl.set(element, { zIndex: slot.zIndex }, 'promote')
tl.to(
element,
{
x: slot.x,
y: slot.y,
z: slot.z,
duration: config.value.moveDuration,
ease: config.value.ease
},
`promote+=${slotIndex * 0.15}`
)
})
const backSlot = makeSlot(activeOrder.length - 1, activeOrder.length)
tl.addLabel('return', `promote+=${config.value.moveDuration * config.value.returnDelay}`)
tl.call(() => {
gsap.set(frontElement, { zIndex: backSlot.zIndex })
}, undefined, 'return')
tl.set(frontElement, { x: backSlot.x, z: backSlot.z }, 'return')
tl.to(
frontElement,
{
y: backSlot.y,
duration: config.value.returnDuration,
ease: config.value.ease
},
'return'
)
tl.call(() => {
order.value = [...rest, front]
})
}
const stopAnimation = () => {
if (timelineRef.value) {
timelineRef.value.kill()
timelineRef.value = null
}
if (intervalRef) {
clearInterval(intervalRef)
intervalRef = null
}
}
const startAnimation = () => {
stopAnimation()
if (visibleCardCount.value < 2) {
initializeCards()
return
}
runSwap()
intervalRef = window.setInterval(runSwap, props.delay)
}
const resumeAnimation = () => {
timelineRef.value?.play()
if (!intervalRef) intervalRef = window.setInterval(runSwap, props.delay)
}
const onMouseEnter = () => {
stopAnimation()
}
const onMouseLeave = () => {
resumeAnimation()
}
const setupHoverListeners = () => {
if (!props.pauseOnHover || !containerRef.value) return
containerRef.value.addEventListener('mouseenter', onMouseEnter)
containerRef.value.addEventListener('mouseleave', onMouseLeave)
}
const removeHoverListeners = () => {
if (!containerRef.value) return
containerRef.value.removeEventListener('mouseenter', onMouseEnter)
containerRef.value.removeEventListener('mouseleave', onMouseLeave)
}
const onCardClick = (index) => {
emit('cardClick', index)
if (typeof props.onCardClick === 'function') props.onCardClick(index)
}
watch(
() => [props.cardDistance, props.verticalDistance, props.skewAmount],
() => {
updateCardPositions()
}
)
watch(
() => props.delay,
() => {
if (intervalRef) {
clearInterval(intervalRef)
intervalRef = window.setInterval(runSwap, props.delay)
}
}
)
watch(
() => props.pauseOnHover,
() => {
removeHoverListeners()
setupHoverListeners()
}
)
onMounted(async () => {
await nextTick()
initializeCards()
startAnimation()
setupHoverListeners()
})
watch(
() => visibleCardCount.value,
async () => {
order.value = Array.from({ length: visibleCardCount.value }, (_, idx) => idx)
await nextTick()
initializeCards()
startAnimation()
}
)
onUnmounted(() => {
stopAnimation()
removeHoverListeners()
})
</script>

View File

@@ -0,0 +1,184 @@
<template>
<div class="bits-grid-motion w-full h-full overflow-hidden">
<section class="relative flex h-full w-full items-center justify-center overflow-hidden" :style="sectionStyle">
<div class="bits-grid-motion-grid">
<div
v-for="rowIndex in safeRowCount"
:key="`row-${rowIndex}`"
class="bits-grid-motion-row"
:style="rowInlineStyle"
>
<div
v-for="columnIndex in renderColumnCount"
:key="`cell-${rowIndex}-${columnIndex}`"
class="bits-grid-motion-cell"
v-show="loopedItems[resolveIndex(rowIndex, columnIndex)]"
>
<slot
name="item"
:item="loopedItems[resolveIndex(rowIndex, columnIndex)]"
:index="resolveIndex(rowIndex, columnIndex)"
>
<div class="bits-grid-motion-fallback">
{{ String(loopedItems[resolveIndex(rowIndex, columnIndex)] ?? '') }}
</div>
</slot>
</div>
</div>
</div>
<div class="bits-grid-motion-mask" />
</section>
</div>
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import gsap from 'gsap'
const props = defineProps({
items: { type: Array, default: () => [] },
gradientColor: { type: String, default: 'rgba(7, 193, 96, 0.2)' },
rowCount: { type: Number, default: 8 },
columnCount: { type: Number, default: 10 },
scrollSpeed: { type: Number, default: 38 },
baseOffsetX: { type: Number, default: 0 },
itemWidth: { type: Number, default: 300 },
rowGap: { type: Number, default: 12 }
})
let removeTicker = null
let lastTickAt = 0
let loopDistance = 0
const marqueeX = ref(0)
const safeRowCount = computed(() => Math.max(1, Number(props.rowCount) || 1))
const safeColumnCount = computed(() => Math.max(1, Number(props.columnCount) || 1))
const safeItemWidth = computed(() => Math.max(1, Number(props.itemWidth) || 1))
const safeRowGap = computed(() => Math.max(0, Number(props.rowGap) || 0))
const safeScrollSpeed = computed(() => Math.max(0, Number(props.scrollSpeed) || 0))
const renderColumnCount = computed(() => safeColumnCount.value * 2)
const totalSlots = computed(() => safeRowCount.value * renderColumnCount.value)
const repeatedItems = computed(() => {
const source = Array.isArray(props.items) ? props.items.filter(Boolean) : []
if (!source.length) return []
const output = []
for (let idx = 0; idx < safeRowCount.value * safeColumnCount.value; idx += 1) {
output.push(source[idx % source.length])
}
return output
})
const loopedItems = computed(() => {
const base = repeatedItems.value
if (!base.length) return []
const output = []
for (let idx = 0; idx < totalSlots.value; idx += 1) {
output.push(base[idx % base.length])
}
return output
})
const rowInlineStyle = computed(() => ({
willChange: 'transform',
transform: `translate3d(${props.baseOffsetX + marqueeX.value}px, 0, 0)`
}))
const sectionStyle = computed(() => ({
background: `radial-gradient(circle at center, ${props.gradientColor} 0%, transparent 72%)`
}))
const resolveIndex = (rowIndex, columnIndex) => (
(Number(rowIndex) - 1) * renderColumnCount.value + (Number(columnIndex) - 1)
)
const updateMotion = () => {
if (typeof window === 'undefined') return
const now = typeof performance !== 'undefined' ? performance.now() : Date.now()
const dt = lastTickAt > 0 ? Math.min((now - lastTickAt) / 1000, 0.08) : 0
lastTickAt = now
if (loopDistance <= 0 || safeScrollSpeed.value <= 0 || dt <= 0) return
marqueeX.value -= safeScrollSpeed.value * dt
if (marqueeX.value <= -loopDistance) {
marqueeX.value += loopDistance
}
}
onMounted(() => {
if (typeof window === 'undefined') return
loopDistance = safeColumnCount.value * (safeItemWidth.value + safeRowGap.value)
marqueeX.value = 0
lastTickAt = 0
// Kick one frame immediately to avoid initial static delay.
marqueeX.value = -Math.min(loopDistance * 0.02, 8)
gsap.ticker.lagSmoothing(1000, 33)
removeTicker = gsap.ticker.add(updateMotion)
})
onUnmounted(() => {
loopDistance = 0
lastTickAt = 0
if (typeof removeTicker === 'function') removeTicker()
})
</script>
<style scoped>
.bits-grid-motion-grid {
position: relative;
z-index: 2;
display: grid;
grid-template-columns: 1fr;
gap: 12px;
width: 180%;
height: 165%;
transform: rotate(-15deg);
transform-origin: center;
}
.bits-grid-motion-row {
display: flex;
gap: 12px;
}
.bits-grid-motion-cell {
position: relative;
height: 210px;
min-width: 300px;
flex-shrink: 0;
}
.bits-grid-motion-fallback {
height: 100%;
width: 100%;
border-radius: 12px;
border: 1px solid rgba(7, 193, 96, 0.2);
background: #ffffff;
color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
text-align: center;
font-size: 14px;
}
.bits-grid-motion-mask {
position: absolute;
inset: 0;
pointer-events: none;
background:
linear-gradient(180deg, rgba(243, 255, 248, 0.78) 0%, rgba(243, 255, 248, 0.12) 20%, rgba(243, 255, 248, 0) 38%),
linear-gradient(90deg, rgba(243, 255, 248, 0.86) 0%, rgba(243, 255, 248, 0.12) 24%, rgba(243, 255, 248, 0) 44%),
linear-gradient(270deg, rgba(243, 255, 248, 0.9) 0%, rgba(243, 255, 248, 0.14) 30%, rgba(243, 255, 248, 0) 48%),
linear-gradient(0deg, rgba(243, 255, 248, 0.88) 0%, rgba(243, 255, 248, 0.16) 36%, rgba(243, 255, 248, 0) 58%);
z-index: 3;
}
</style>

View File

@@ -0,0 +1,160 @@
<template>
<p
ref="textRef"
class="bits-split-text inline-block overflow-hidden whitespace-normal"
:class="className"
:style="{ textAlign, wordWrap: 'break-word' }"
>
{{ text }}
</p>
</template>
<script setup>
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { gsap } from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { SplitText as GSAPSplitText } from 'gsap/SplitText'
const props = defineProps({
text: { type: String, required: true },
className: { type: String, default: '' },
delay: { type: Number, default: 100 },
duration: { type: Number, default: 0.6 },
ease: { type: [String, Function], default: 'power3.out' },
splitType: { type: String, default: 'chars' },
from: { type: Object, default: () => ({ opacity: 0, y: 32 }) },
to: { type: Object, default: () => ({ opacity: 1, y: 0 }) },
threshold: { type: Number, default: 0.1 },
rootMargin: { type: String, default: '-100px' },
textAlign: { type: String, default: 'center' },
onLetterAnimationComplete: { type: Function, default: null }
})
const emit = defineEmits(['animationComplete'])
gsap.registerPlugin(ScrollTrigger, GSAPSplitText)
const textRef = ref(null)
let timeline = null
let scrollTrigger = null
let splitter = null
const cleanup = () => {
if (timeline) {
timeline.kill()
timeline = null
}
if (scrollTrigger) {
scrollTrigger.kill()
scrollTrigger = null
}
if (splitter) {
splitter.revert()
splitter = null
}
}
const createAnimation = async () => {
if (!import.meta.client || !textRef.value || !props.text) return
await nextTick()
const element = textRef.value
const absoluteLines = props.splitType === 'lines'
if (absoluteLines) element.style.position = 'relative'
try {
splitter = new GSAPSplitText(element, {
type: props.splitType,
absolute: absoluteLines,
linesClass: 'split-line'
})
} catch {
return
}
let targets = splitter.chars
if (props.splitType === 'words') targets = splitter.words
if (props.splitType === 'lines') targets = splitter.lines
if (!targets?.length) {
cleanup()
return
}
targets.forEach((target) => {
target.style.willChange = 'transform, opacity'
})
const startPercent = (1 - props.threshold) * 100
const marginMatch = /^(-?\d+(?:\.\d+)?)(px|em|rem|%)?$/.exec(props.rootMargin)
const marginValue = marginMatch ? Number.parseFloat(marginMatch[1]) : 0
const marginUnit = marginMatch ? marginMatch[2] || 'px' : 'px'
const sign = marginValue < 0
? `-=${Math.abs(marginValue)}${marginUnit}`
: `+=${marginValue}${marginUnit}`
timeline = gsap.timeline({
scrollTrigger: {
trigger: element,
start: `top ${startPercent}%${sign}`,
toggleActions: 'play none none none',
once: true,
onToggle: (self) => {
scrollTrigger = self
}
},
onComplete: () => {
gsap.set(targets, {
...props.to,
clearProps: 'willChange',
immediateRender: true
})
if (typeof props.onLetterAnimationComplete === 'function') {
props.onLetterAnimationComplete()
}
emit('animationComplete')
}
})
timeline.set(targets, {
...props.from,
immediateRender: false,
force3D: true
})
timeline.to(targets, {
...props.to,
duration: props.duration,
ease: props.ease,
stagger: props.delay / 1000,
force3D: true
})
}
watch(
() => [
props.text,
props.delay,
props.duration,
props.ease,
props.splitType,
props.from,
props.to,
props.threshold,
props.rootMargin,
props.textAlign,
props.onLetterAnimationComplete
],
async () => {
cleanup()
await createAnimation()
}
)
onMounted(async () => {
await createAnimation()
})
onBeforeUnmount(() => {
cleanup()
})
</script>