7 changed files with 260 additions and 14 deletions
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
@ -0,0 +1,135 @@ |
|||||
|
<script setup lang="ts"> |
||||
|
import { Motion, useAnimationFrame, useMotionValue, useTransform } from 'motion-v'; |
||||
|
import { computed, ref, watch } from 'vue'; |
||||
|
|
||||
|
interface ShinyTextProps { |
||||
|
text: string; |
||||
|
disabled?: boolean; |
||||
|
speed?: number; |
||||
|
className?: string; |
||||
|
color?: string; |
||||
|
shineColor?: string; |
||||
|
spread?: number; |
||||
|
yoyo?: boolean; |
||||
|
pauseOnHover?: boolean; |
||||
|
direction?: 'left' | 'right'; |
||||
|
delay?: number; |
||||
|
} |
||||
|
|
||||
|
const props = withDefaults(defineProps<ShinyTextProps>(), { |
||||
|
disabled: false, |
||||
|
speed: 2, |
||||
|
className: '', |
||||
|
color: '#b5b5b5', |
||||
|
shineColor: '#ffffff', |
||||
|
spread: 120, |
||||
|
yoyo: false, |
||||
|
pauseOnHover: false, |
||||
|
direction: 'left', |
||||
|
delay: 0 |
||||
|
}); |
||||
|
|
||||
|
const isPaused = ref(false); |
||||
|
const progress = useMotionValue(0); |
||||
|
const elapsedRef = ref(0); |
||||
|
const lastTimeRef = ref<number | null>(null); |
||||
|
const directionRef = ref(props.direction === 'left' ? 1 : -1); |
||||
|
|
||||
|
const animationDuration = computed(() => props.speed * 1000); |
||||
|
const delayDuration = computed(() => props.delay * 1000); |
||||
|
|
||||
|
useAnimationFrame(time => { |
||||
|
if (props.disabled || isPaused.value) { |
||||
|
lastTimeRef.value = null; |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (lastTimeRef.value === null) { |
||||
|
lastTimeRef.value = time; |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const deltaTime = time - lastTimeRef.value; |
||||
|
lastTimeRef.value = time; |
||||
|
|
||||
|
elapsedRef.value += deltaTime; |
||||
|
|
||||
|
// Animation goes from 0 to 100 |
||||
|
if (props.yoyo) { |
||||
|
const cycleDuration = animationDuration.value + delayDuration.value; |
||||
|
const fullCycle = cycleDuration * 2; |
||||
|
const cycleTime = elapsedRef.value % fullCycle; |
||||
|
|
||||
|
if (cycleTime < animationDuration.value) { |
||||
|
// Forward animation: 0 -> 100 |
||||
|
const p = (cycleTime / animationDuration.value) * 100; |
||||
|
progress.set(directionRef.value === 1 ? p : 100 - p); |
||||
|
} else if (cycleTime < cycleDuration) { |
||||
|
// Delay at end |
||||
|
progress.set(directionRef.value === 1 ? 100 : 0); |
||||
|
} else if (cycleTime < cycleDuration + animationDuration.value) { |
||||
|
// Reverse animation: 100 -> 0 |
||||
|
const reverseTime = cycleTime - cycleDuration; |
||||
|
const p = 100 - (reverseTime / animationDuration.value) * 100; |
||||
|
progress.set(directionRef.value === 1 ? p : 100 - p); |
||||
|
} else { |
||||
|
// Delay at start |
||||
|
progress.set(directionRef.value === 1 ? 0 : 100); |
||||
|
} |
||||
|
} else { |
||||
|
const cycleDuration = animationDuration.value + delayDuration.value; |
||||
|
const cycleTime = elapsedRef.value % cycleDuration; |
||||
|
|
||||
|
if (cycleTime < animationDuration.value) { |
||||
|
// Animation phase: 0 -> 100 |
||||
|
const p = (cycleTime / animationDuration.value) * 100; |
||||
|
progress.set(directionRef.value === 1 ? p : 100 - p); |
||||
|
} else { |
||||
|
// Delay phase - hold at end (shine off-screen) |
||||
|
progress.set(directionRef.value === 1 ? 100 : 0); |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
watch( |
||||
|
() => props.direction, |
||||
|
() => { |
||||
|
directionRef.value = props.direction === 'left' ? 1 : -1; |
||||
|
elapsedRef.value = 0; |
||||
|
progress.set(0); |
||||
|
}, |
||||
|
{ |
||||
|
immediate: true |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
const backgroundPosition = useTransform(progress, p => `${150 - p * 2}% center`); |
||||
|
|
||||
|
const handleMouseEnter = () => { |
||||
|
if (props.pauseOnHover) isPaused.value = true; |
||||
|
}; |
||||
|
|
||||
|
const handleMouseLeave = () => { |
||||
|
if (props.pauseOnHover) isPaused.value = false; |
||||
|
}; |
||||
|
|
||||
|
const gradientStyle = computed(() => ({ |
||||
|
backgroundImage: `linear-gradient(${props.spread}deg, ${props.color} 0%, ${props.color} 35%, ${props.shineColor} 50%, ${props.color} 65%, ${props.color} 100%)`, |
||||
|
backgroundSize: '200% auto', |
||||
|
WebkitBackgroundClip: 'text', |
||||
|
backgroundClip: 'text', |
||||
|
WebkitTextFillColor: 'transparent' |
||||
|
})); |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<Motion |
||||
|
tag="span" |
||||
|
:class="['inline-block', className]" |
||||
|
:style="{ ...gradientStyle, backgroundPosition }" |
||||
|
@mouseenter="handleMouseEnter" |
||||
|
@mouseleave="handleMouseLeave" |
||||
|
> |
||||
|
{{ text }} |
||||
|
</Motion> |
||||
|
</template> |
||||
@ -0,0 +1,94 @@ |
|||||
|
<template> |
||||
|
<component |
||||
|
:is="as" |
||||
|
:class="['relative inline-block overflow-hidden !bg-transparent !border-none !rounded-[20px]', customClass]" |
||||
|
v-bind="restAttrs" |
||||
|
:style="componentStyle" |
||||
|
> |
||||
|
<div |
||||
|
class="absolute w-[300%] h-[50%] opacity-70 bottom-[-11px] right-[-250%] rounded-full animate-star-movement-bottom z-0" |
||||
|
:style="{ |
||||
|
background: `radial-gradient(circle, ${color}, transparent 10%)`, |
||||
|
animationDuration: speed |
||||
|
}" |
||||
|
></div> |
||||
|
|
||||
|
<div |
||||
|
class="absolute w-[300%] h-[50%] opacity-70 top-[-10px] left-[-250%] rounded-full animate-star-movement-top z-0" |
||||
|
:style="{ |
||||
|
background: `radial-gradient(circle, ${color}, transparent 10%)`, |
||||
|
animationDuration: speed |
||||
|
}" |
||||
|
></div> |
||||
|
|
||||
|
<div |
||||
|
class="relative z-10 border border-[#333] bg-[#0b0b0b] text-white text-[16px] text-center px-[64px] py-[24px] rounded-[20px]" |
||||
|
> |
||||
|
<slot /> |
||||
|
</div> |
||||
|
</component> |
||||
|
</template> |
||||
|
|
||||
|
<script setup lang="ts"> |
||||
|
import { computed, defineProps, useAttrs } from 'vue'; |
||||
|
|
||||
|
interface StarBorderProps { |
||||
|
as?: string; |
||||
|
customClass?: string; |
||||
|
color?: string; |
||||
|
speed?: string; |
||||
|
thickness?: number; |
||||
|
} |
||||
|
|
||||
|
const props = withDefaults(defineProps<StarBorderProps>(), { |
||||
|
as: 'button', |
||||
|
customClass: '', |
||||
|
color: 'white', |
||||
|
speed: '6s', |
||||
|
thickness: 1 |
||||
|
}); |
||||
|
|
||||
|
const restAttrs = useAttrs(); |
||||
|
|
||||
|
const componentStyle = computed(() => { |
||||
|
const base = { |
||||
|
padding: `${props.thickness}px 0` |
||||
|
}; |
||||
|
const userStyle = (restAttrs.style as Record<string, string>) || {}; |
||||
|
return { ...base, ...userStyle }; |
||||
|
}); |
||||
|
</script> |
||||
|
|
||||
|
<style scoped> |
||||
|
@keyframes star-movement-bottom { |
||||
|
0% { |
||||
|
transform: translate(0%, 0%); |
||||
|
opacity: 1; |
||||
|
} |
||||
|
|
||||
|
100% { |
||||
|
transform: translate(-100%, 0%); |
||||
|
opacity: 0; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@keyframes star-movement-top { |
||||
|
0% { |
||||
|
transform: translate(0%, 0%); |
||||
|
opacity: 1; |
||||
|
} |
||||
|
|
||||
|
100% { |
||||
|
transform: translate(100%, 0%); |
||||
|
opacity: 0; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.animate-star-movement-bottom { |
||||
|
animation: star-movement-bottom linear infinite alternate; |
||||
|
} |
||||
|
|
||||
|
.animate-star-movement-top { |
||||
|
animation: star-movement-top linear infinite alternate; |
||||
|
} |
||||
|
</style> |
||||
Loading…
Reference in new issue