Skip to content

Commit cb94cf2

Browse files
committed
feat: add animated beam component
1 parent 812319e commit cb94cf2

File tree

7 files changed

+433
-0
lines changed

7 files changed

+433
-0
lines changed

docs/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ import Demos from './src/components/Demos.vue'
5555

5656
<demo src="./src/example/animatedBeamDemo/Demo.vue" />
5757

58+
<demo src="./src/example/beam/Demo.vue" />
59+
5860
<demo src="./src/example/animatedGradientText/Demo.vue" />
5961

6062
<demo src="./src/example/skewedInfiniteScroll/Demo.vue" />

docs/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"clsx": "^2.1.1",
1919
"fs-extra": "^11.2.0",
2020
"markdown-it": "^14.1.0",
21+
"nanoid": "^5.0.7",
2122
"tailwind-merge": "^2.5.3"
2223
},
2324
"devDependencies": {
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
<script setup lang="ts">
2+
import { nanoid } from 'nanoid'
3+
import { nextTick, onMounted, ref, watch } from 'vue'
4+
5+
const props = withDefaults(defineProps<{
6+
containerRef: any
7+
fromRef: any
8+
toRef: any
9+
className?: string
10+
curvature?: number
11+
reverse?: boolean
12+
pathColor?: string
13+
pathWidth?: number
14+
pathOpacity?: number
15+
gradientStartColor?: string
16+
gradientStopColor?: string
17+
delay?: number
18+
duration?: number
19+
startXOffset?: number
20+
startYOffset?: number
21+
endXOffset?: number
22+
endYOffset?: number
23+
}>(), {
24+
curvature: 0,
25+
reverse: false,
26+
duration: Math.random() * 3 + 4,
27+
delay: 3,
28+
pathColor: 'gray',
29+
pathWidth: 2,
30+
pathOpacity: 0.2,
31+
gradientStartColor: '#ffaa40',
32+
gradientStopColor: '#9c40ff',
33+
startXOffset: 0,
34+
startYOffset: 0,
35+
endXOffset: 0,
36+
endYOffset: 0,
37+
})
38+
39+
const id = nanoid()
40+
41+
const initial = {
42+
x1: '10%',
43+
x2: '0%',
44+
y1: '0%',
45+
y2: '0%',
46+
}
47+
const svgDimensions = ref({ width: 0, height: 0 })
48+
const pathD = ref('')
49+
function updatePath() {
50+
if (
51+
props.containerRef
52+
&& props.fromRef?.circleRef
53+
&& props.toRef?.circleRef
54+
) {
55+
const containerRect = props.containerRef?.getBoundingClientRect()
56+
const rectA = props.fromRef?.circleRef?.getBoundingClientRect()
57+
const rectB = props.toRef?.circleRef?.getBoundingClientRect()
58+
59+
const svgWidth = containerRect?.width
60+
const svgHeight = containerRect?.height
61+
svgDimensions.value.width = svgWidth
62+
svgDimensions.value.height = svgHeight
63+
64+
const startX = rectA.left - containerRect.left + rectA.width / 2 + props.startXOffset
65+
const startY = rectA.top - containerRect.top + rectA.height / 2 + props.startYOffset
66+
const endX = rectB.left - containerRect.left + rectB.width / 2 + props.endXOffset
67+
const endY = rectB.top - containerRect.top + rectB.height / 2 + props.endYOffset
68+
69+
const controlY = startY - props.curvature
70+
const d = `M ${startX},${startY} Q ${(startX + endX) / 2
71+
},${controlY} ${endX},${endY}`
72+
pathD.value = d
73+
}
74+
}
75+
76+
watch(() => props, (_) => {
77+
updatePath()
78+
}, { immediate: true, deep: true })
79+
80+
onMounted(async () => {
81+
await nextTick()
82+
})
83+
</script>
84+
85+
<template>
86+
<svg
87+
:width="svgDimensions?.width" :height="svgDimensions?.height" xmlns="http://www.w3.org/2000/svg" class="pointer-events-none absolute left-0 top-0 transform-gpu stroke-2" :class="[
88+
props.className,
89+
]" :viewBox="`0 0 ${svgDimensions?.width} ${svgDimensions?.height}`"
90+
>
91+
<path
92+
:d="pathD" :stroke="pathColor" fill="none" :stroke-width="pathWidth" :stroke-opacity="pathOpacity"
93+
stroke-linecap="round"
94+
/>
95+
<path :d="pathD" :stroke="`url(#${id}`" fill="none" stroke-linecap="round" />
96+
<defs>
97+
<linearGradient
98+
:id="id" v-motion :initial="{
99+
opacity: 0,
100+
x: props.reverse ? ['100%', '0%'] : ['0%', '100%'],
101+
}" :enter="{
102+
opacity: 1,
103+
x: props.reverse ? ['100%', '0%'] : ['0%', '100%'],
104+
transition: {
105+
duration: 1600,
106+
type: 'keyframes',
107+
easings: [0.16, 1, 0.3, 1],
108+
repeat: Infinity,
109+
},
110+
}" gradientUnits="userSpaceOnUse" :x1="initial.x1" :x2="initial.x2" :y1="initial.y1"
111+
:y2="initial.y2"
112+
>
113+
<stop :stop-color="props.gradientStartColor" stop-opacity="0" />
114+
<stop :stop-color="props.gradientStartColor" />
115+
<stop offset="32.5%" :stop-color="props.gradientStopColor" />
116+
<stop offset="100%" :stop-color="props.gradientStopColor" stop-opacity="0" />
117+
</linearGradient>
118+
</defs>
119+
</svg>
120+
</template>
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<script setup lang='ts'>
2+
import { ref } from 'vue'
3+
4+
const circleRef = ref()
5+
6+
defineExpose({
7+
circleRef,
8+
})
9+
</script>
10+
11+
<template>
12+
<div
13+
ref="circleRef"
14+
class="z-10 flex h-12 w-12 items-center justify-center rounded-full border-2 bg-white p-3 shadow-[0_0_20px_-12px_rgba(0,0,0,0.8)]"
15+
>
16+
<slot />
17+
</div>
18+
</template>
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
<script setup lang="ts">
2+
import { nanoid } from 'nanoid'
3+
import { nextTick, onMounted, ref, watch } from 'vue'
4+
5+
const props = withDefaults(defineProps<{
6+
containerRef: any
7+
fromRef: any
8+
toRef: any
9+
className?: string
10+
curvature?: number
11+
reverse?: boolean
12+
pathColor?: string
13+
pathWidth?: number
14+
pathOpacity?: number
15+
gradientStartColor?: string
16+
gradientStopColor?: string
17+
delay?: number
18+
duration?: number
19+
startXOffset?: number
20+
startYOffset?: number
21+
endXOffset?: number
22+
endYOffset?: number
23+
}>(), {
24+
curvature: 0,
25+
reverse: false,
26+
duration: Math.random() * 3 + 4,
27+
delay: 3,
28+
pathColor: 'gray',
29+
pathWidth: 2,
30+
pathOpacity: 0.2,
31+
gradientStartColor: '#ffaa40',
32+
gradientStopColor: '#9c40ff',
33+
startXOffset: 0,
34+
startYOffset: 0,
35+
endXOffset: 0,
36+
endYOffset: 0,
37+
})
38+
39+
const id = nanoid()
40+
41+
const initial = {
42+
x1: '10%',
43+
x2: '0%',
44+
y1: '0%',
45+
y2: '0%',
46+
}
47+
const svgDimensions = ref({ width: 0, height: 0 })
48+
const pathD = ref('')
49+
function updatePath() {
50+
if (
51+
props.containerRef
52+
&& props.fromRef?.circleRef
53+
&& props.toRef?.circleRef
54+
) {
55+
const containerRect = props.containerRef?.getBoundingClientRect()
56+
const rectA = props.fromRef?.circleRef?.getBoundingClientRect()
57+
const rectB = props.toRef?.circleRef?.getBoundingClientRect()
58+
59+
const svgWidth = containerRect?.width
60+
const svgHeight = containerRect?.height
61+
svgDimensions.value.width = svgWidth
62+
svgDimensions.value.height = svgHeight
63+
64+
const startX = rectA.left - containerRect.left + rectA.width / 2 + props.startXOffset
65+
const startY = rectA.top - containerRect.top + rectA.height / 2 + props.startYOffset
66+
const endX = rectB.left - containerRect.left + rectB.width / 2 + props.endXOffset
67+
const endY = rectB.top - containerRect.top + rectB.height / 2 + props.endYOffset
68+
69+
const controlY = startY - props.curvature
70+
const d = `M ${startX},${startY} Q ${(startX + endX) / 2
71+
},${controlY} ${endX},${endY}`
72+
pathD.value = d
73+
}
74+
}
75+
76+
watch(() => props, (_) => {
77+
updatePath()
78+
}, { immediate: true, deep: true })
79+
80+
onMounted(async () => {
81+
await nextTick()
82+
})
83+
</script>
84+
85+
<template>
86+
<svg
87+
:width="svgDimensions?.width" :height="svgDimensions?.height" xmlns="http://www.w3.org/2000/svg" class="pointer-events-none absolute left-0 top-0 transform-gpu stroke-2" :class="[
88+
props.className,
89+
]" :viewBox="`0 0 ${svgDimensions?.width} ${svgDimensions?.height}`"
90+
>
91+
<path
92+
:d="pathD" :stroke="pathColor" fill="none" :stroke-width="pathWidth" :stroke-opacity="pathOpacity"
93+
stroke-linecap="round"
94+
/>
95+
<path :d="pathD" :stroke="`url(#${id}`" fill="none" stroke-linecap="round" />
96+
<defs>
97+
<linearGradient
98+
:id="id" v-motion :initial="{
99+
opacity: 0,
100+
x: props.reverse ? ['100%', '0%'] : ['0%', '100%'],
101+
}" :enter="{
102+
opacity: 1,
103+
x: props.reverse ? ['100%', '0%'] : ['0%', '100%'],
104+
transition: {
105+
duration: 1600,
106+
type: 'keyframes',
107+
easings: [0.16, 1, 0.3, 1],
108+
repeat: Infinity,
109+
},
110+
}" gradientUnits="userSpaceOnUse" :x1="initial.x1" :x2="initial.x2" :y1="initial.y1"
111+
:y2="initial.y2"
112+
>
113+
<stop :stop-color="props.gradientStartColor" stop-opacity="0" />
114+
<stop :stop-color="props.gradientStartColor" />
115+
<stop offset="32.5%" :stop-color="props.gradientStopColor" />
116+
<stop offset="100%" :stop-color="props.gradientStopColor" stop-opacity="0" />
117+
</linearGradient>
118+
</defs>
119+
</svg>
120+
</template>

docs/src/example/beam/Circle.vue

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<script setup lang='ts'>
2+
import { ref } from 'vue'
3+
4+
const circleRef = ref()
5+
6+
defineExpose({
7+
circleRef,
8+
})
9+
</script>
10+
11+
<template>
12+
<div
13+
ref="circleRef"
14+
class="z-10 flex size-12 items-center justify-center rounded-full border-2 bg-white p-3 shadow-[0_0_20px_-12px_rgba(0,0,0,0.8)]"
15+
>
16+
<slot />
17+
</div>
18+
</template>

0 commit comments

Comments
 (0)