Files
BRETT/app/components/effects/CardBorderEffect.vue
T
FliesentischKingandDevin 94cf0aaa23 Add animated pulse and beam variants for the golden upload frame
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 00:34:06 +02:00

213 lines
6.0 KiB
Vue

<script setup lang="ts">
import type { CSSProperties } from 'vue'
import type { CardBorderEffectKey } from '~~/shared/utils/cardBorderEffects'
import { hexToRgbTuple } from '@/lib/color'
// Electric-Variante portiert aus Vue Bits (DavidHDev/vue-bits, ElectricBorder)
const props = withDefaults(defineProps<{
effect: CardBorderEffectKey
color?: string
}>(), {
color: '#6366f1',
})
const canvasRef = useTemplateRef<HTMLCanvasElement>('canvasRef')
const containerRef = useTemplateRef<HTMLDivElement>('containerRef')
function random(x: number): number {
return (Math.sin(x * 12.9898) * 43758.5453) % 1
}
function noise2D(x: number, y: number): number {
const i = Math.floor(x)
const j = Math.floor(y)
const fx = x - i
const fy = y - j
const a = random(i + j * 57)
const b = random(i + 1 + j * 57)
const c = random(i + (j + 1) * 57)
const d = random(i + 1 + (j + 1) * 57)
const ux = fx * fx * (3 - 2 * fx)
const uy = fy * fy * (3 - 2 * fy)
return a * (1 - ux) * (1 - uy) + b * ux * (1 - uy) + c * (1 - ux) * uy + d * ux * uy
}
function octavedNoise(x: number, time: number, seed: number): number {
let y = 0
let amplitude = 0.12
let frequency = 10
for (let i = 0; i < 10; i++) {
y += amplitude * noise2D(frequency * x + seed * 100, time * frequency * 0.3)
frequency *= 1.6
amplitude *= 0.7
}
return y
}
function roundedRectPoint(t: number, w: number, h: number, r: number): { x: number, y: number } {
const sw = w - 2 * r
const sh = h - 2 * r
const arc = (Math.PI * r) / 2
const total = 2 * sw + 2 * sh + 4 * arc
let d = t * total
if (d <= sw) return { x: r + d, y: 0 }
d -= sw
if (d <= arc) {
const a = -Math.PI / 2 + (d / arc) * (Math.PI / 2)
return { x: w - r + r * Math.cos(a), y: r + r * Math.sin(a) }
}
d -= arc
if (d <= sh) return { x: w, y: r + d }
d -= sh
if (d <= arc) {
const a = (d / arc) * (Math.PI / 2)
return { x: w - r + r * Math.cos(a), y: h - r + r * Math.sin(a) }
}
d -= arc
if (d <= sw) return { x: w - r - d, y: h }
d -= sw
if (d <= arc) {
const a = Math.PI / 2 + (d / arc) * (Math.PI / 2)
return { x: r + r * Math.cos(a), y: h - r + r * Math.sin(a) }
}
d -= arc
if (d <= sh) return { x: 0, y: h - r - d }
d -= sh
const a = Math.PI + (d / arc) * (Math.PI / 2)
return { x: r + r * Math.cos(a), y: r + r * Math.sin(a) }
}
let cleanup: (() => void) | null = null
onMounted(() => {
if (props.effect !== 'electric') return
const canvas = canvasRef.value
const container = containerRef.value
if (!canvas || !container) return
const ctx = canvas.getContext('2d')
if (!ctx) return
let width = 0
let height = 0
let radius = 0
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const updateSize = () => {
const rect = container.getBoundingClientRect()
width = rect.width
height = rect.height
canvas.width = width * dpr
canvas.height = height * dpr
canvas.style.width = `${width}px`
canvas.style.height = `${height}px`
radius = parseFloat(getComputedStyle(container).borderRadius) || 0
}
updateSize()
const ro = new ResizeObserver(updateSize)
ro.observe(container)
let raf = 0
let time = 0
let last = 0
const draw = (now: number) => {
time += ((now - last) / 1000) * 1.2
last = now
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, width, height)
ctx.strokeStyle = props.color
ctx.lineWidth = 1.5
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
const inset = 1
const w = width - inset * 2
const h = height - inset * 2
const r = Math.max(0, radius - inset)
const perimeter = 2 * (w + h) + 2 * Math.PI * r
const samples = Math.floor(perimeter / 2)
ctx.beginPath()
for (let i = 0; i <= samples; i++) {
const p = i / samples
const pt = roundedRectPoint(p, w, h, r)
const nx = octavedNoise(p * 8, time, 0) * 14
const ny = octavedNoise(p * 8, time, 1) * 14
if (i === 0) ctx.moveTo(inset + pt.x + nx, inset + pt.y + ny)
else ctx.lineTo(inset + pt.x + nx, inset + pt.y + ny)
}
ctx.closePath()
ctx.stroke()
raf = requestAnimationFrame(draw)
}
raf = requestAnimationFrame(draw)
cleanup = () => {
cancelAnimationFrame(raf)
ro.disconnect()
}
})
onBeforeUnmount(() => {
cleanup?.()
cleanup = null
})
const rgb = computed(() => hexToRgbTuple(props.color).map(v => Math.round(v * 255)).join(' '))
const cssVars = computed<CSSProperties>(() => ({ '--be-color': `rgb(${rgb.value})` }) as CSSProperties)
</script>
<template>
<div ref="containerRef" class="pointer-events-none absolute inset-0 rounded-[inherit]" :style="cssVars">
<canvas v-if="effect === 'electric'" ref="canvasRef" class="absolute inset-0" />
<div v-else-if="effect === 'beam'" class="border-beam absolute inset-0 rounded-[inherit]" />
<div v-else-if="effect === 'pulse'" class="border-pulse absolute inset-0 rounded-[inherit]" />
<div v-else-if="effect === 'rainbow'" class="border-rainbow absolute inset-0 rounded-[inherit]" />
</div>
</template>
<style scoped>
.border-beam {
border: 2px solid transparent;
mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0);
mask-composite: exclude;
}
.border-beam::before {
content: '';
position: absolute;
inset: -100%;
background: conic-gradient(transparent 60%, var(--be-color) 80%, transparent 100%);
animation: be-rotate 3s linear infinite;
}
.border-pulse {
border: 2px solid var(--be-color);
box-shadow: 0 0 16px 2px var(--be-color);
animation: be-pulse 2s ease-in-out infinite;
}
.border-rainbow {
border: 2px solid transparent;
mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0);
mask-composite: exclude;
}
.border-rainbow::before {
content: '';
position: absolute;
inset: -100%;
background: conic-gradient(var(--be-color), #ffffff, var(--be-color));
animation: be-rotate 4s linear infinite;
}
@keyframes be-rotate {
to { transform: rotate(360deg); }
}
@keyframes be-pulse {
0%, 100% { opacity: 0.35; }
50% { opacity: 1; }
}
</style>