最近领导说要提升一下前端可视化项目的界面效果 ,以后此类项目希望也可以使用。因此我想做一些较为通用的边框类动画。
一、想达到的效果
1、可适应不同大小的元素。
2、可跟随背景图的形状:我希望边框动画的路径可以跟随一些使用了背景图的形状轮廓。
3、速度均匀:我希望它在元素长宽差异较大的情况下看起来也是匀速的。
4、支持背景透明:在一些背景有透明度的元素上使用不会出现有覆盖背景的情况。
二、形状较规则的边框

指的是可带圆角的矩形一类,与跟随背景图形状的情况分开实现。svg实现思路和代码如下:
- 填充边框动画:使用 stroke-dasharray="实线长 间隔",stroke-dashoffset="偏移值"属性组合来实现动画,可支持背景透明、速度均匀
- 达到循环效果需要满足: 以下m,n是整数 边框总长实线长间隔边框总长=(实线长+间隔)∗� 动画移动的距离实线长间隔动画移动的距离=(实线长+间隔)∗�
- 封装成组件:组件中根据元素宽高计算以上两个属性值达到适应不同元素大小的效果(这里使用的vue3)
html
<template>
<div ref="elRef" style="position: absolute;width: 100%;height: 100%; z-index: 10;pointer-events: none;top: 0;left: 0;">
<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg" version="1.1">
<defs>
<!--用来填充边框, 这个渐变你也可以将它放到全局-->
<linearGradient id="line-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="rgb(190,244,248)" />
<stop offset="50%" stop-color="rgb(72,233,244)" />
<stop offset="100%" stop-color="rgb(78,89,249)" />
</linearGradient>
</defs> <!--宽高设置为百分比即可-->
<rect :rx="radius" :ry="radius" x="0" y="0" width="100%" height="100%" :stroke-dasharray="`${stroke.dashLength} ${stroke.dashGap}`" stroke-dashoffset="0" stroke="url(#line-gradient)" stroke-linecap="round" fill="none" :stroke-width="strokeWidth">
<!--移动stroke-dashoffset值产生动画-->
<animate attributeName="stroke-dashoffset" from="0" :to="stroke.offset" attributeType="XML" dur="5s" repeatCount="indefinite" />
</rect>
</svg>
</div>
</template>
<script setup>
import { ref, reactive } from "vue";
const props = defineProps({
// 矩形圆角的大小,四个角统一
radius: {
default: 0,
type: Number,
},
// 边框宽度
strokeWidth: {
default: 4,
type: Number,
},
});
const elRef = ref(null);
const stroke = reactive({
dashLength: 100, // 实线长
dashGap: 200, // 间隔
offset: 0, // 偏移值
});
let resizeObserver = null;
// 容器大小发生变化后,计算实线、间隔、偏移值
function alterSize(w, h) {
const r = props.radius;
// 边框总长。4个角的圆角长度和为1个圆(4个角圆角值相同的情况)
const len = (w + h) * 2 - 8 * r + 2 * 3.14 * r;
// 这里 边框总长,单次动画偏移值 均是(实线长+间隔)的1倍
stroke.dashLength = len * 0.1; stroke.dashGap = len * 0.9; stroke.offset = -len;
}
function observeEl() {
// 元素尺寸变化监听
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.contentBoxSize) {
alterSize(elRef.value?.offsetWidth, elRef.value?.offsetHeight); break;
}
}
});
// 开启监听
resizeObserver.observe(elRef.value);
}
onMounted(observeEl);
// 卸载时移除监听
onUnmounted(() => {
elRef.value && resizeObserver.unobserve(elRef.value);
});
</script>
三、跟随背景图形状的边框
分为背景透明、非透明的情况来解决。
1、背景透明的边框
这类边框我们先遮罩出背景图的形状,然后使用旋转动画填充进去。

实现思路和代码如下:
(1)遮罩出边框形状:用feColorMatrix滤镜将图片像素转为白色,然后使用mask将白色部分显示。
(2)填充边框动画:这里使用一个旋转的锥形渐变全部填充边框(旋转动画看起来并不匀速,但好在完全填充边框后可以弱化用户对速度的感知)
(3)生成光晕 :光晕的形状也要跟随背景图,所以这里再次对图片过滤为白色,然后高斯模糊放到边框下作为光晕(使用mask来实现的话 会把边框形状外的高斯模糊效果遮盖)
(4)封装为组件:方便以后直接使用(使用的vue3)
html
<template>
<div ref="elRef" class="border-animation-box">
<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg" version="1">
<defs>
<!--过滤为白色背景 -->
<filter :id="ids.white">
<feImage x="0" y="0" :width="img.width" preserveAspectRatio="xMinYMin meet" :xlink:href="imgSource"
result="img" />
<!--values中各行分别对应控制 r,g,b,a通道-->
<feColorMatrix type="matrix" in="img" values="255 255 255 0 0
255 255 255 0 0
255 255 255 0 0
1 1 1 1 0" />
</filter>
<!--制作光晕:图片过滤为白色后 加上模糊-->
<filter :id="ids.gentel">
<feImage x="0" y="0" :width="img.width" preserveAspectRatio="xMinYMin meet" :xlink:href="imgSource"
result="img" />
<feColorMatrix type="matrix" in="img" values="255 255 255 0 0
255 255 255 0 0
255 255 255 0 0
0 0 0 0.8 0" result="varColor" />
<feGaussianBlur in="varColor" stdDeviation="3" />
</filter>
<!--黑色部分会被遮蔽,白色部分被显示-->
<mask :id="ids.mask">
<rect x="0" y="0" width="100%" height="100%" fill="#000" />
<rect x="0" y="0" width="100%" height="100%" :filter="`url(#${ids.white})`" />
</mask>
</defs>
<g transform="translate(0,0)">
<!--使用过滤好的边框图片作为 光晕效果-->
<rect width="100%" height="100%" :filter="`url(#${ids.gentel})`" />
<!--svg中没有直接支持锥形渐变,所以用一个div元素 再借助css实现-->
<foreignObject width="100%" height="100%" x="0" y="0" :mask="`url(#${ids.mask})`">
<div class="conic-gradient" />
</foreignObject>
</g>
</svg>
</div>
</template>
<script setup>
defineProps({
// 传入的图片
imgSource: {
type: String,
default: '',
},
});
const img = reactive({
width: 100,
});
const elRef = ref(null);
let resizeObserver = null;
// 滤镜部分每个组件都会不一样,因此对其id动态生成
const ids = {
white: 'filter-white' + String(Math.random()).substring(2, 12),
gentel: 'filter-gentel' + String(Math.random()).substring(2, 12),
mask: 'mask' + String(Math.random()).substring(2, 12),
};
function observeEl() {
// 元素尺寸监听
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.contentBoxSize) {
img.width = elRef.value?.offsetWidth;
break;
}
}
});
// 开启监听
resizeObserver.observe(elRef.value);
}
onMounted(observeEl);
onUnmounted(() => {
elRef.value && resizeObserver.unobserve(elRef.value);
});
</script>
<style scoped>
.border-animation-box {
position: absolute;
width: 100%;
height: 100%;
z-index: 10; /*放在元素内容上层*/
pointer-events: none; /*事件穿透*/
top: 0;
left: 0;
}
.conic-gradient {
position: absolute;
width: 200%;
height: 200%;
top: -50%;
left: -50%;
background: conic-gradient(rgb(118, 75, 235),
rgb(139, 233, 250),
rgb(79, 79, 223),
rgb(169, 233, 245),
rgb(185, 59, 179));
animation: rotate-anim 5s linear;
animation-iteration-count: infinite;
transform-origin: 50% 50%;
}
@keyframes rotate-anim {
0% {
transform: rotate(0);
}
100% {
transform: rotate(360deg);
}
}
</style>
2、背景不完全透明
这种图片比较难以处理,部分透明度较高的背景图处理后会有许多瑕疵 (因为多处透明度的不均匀我们的feColorMatrix很难均衡 不需要完全透明的像素,和需要完全透明的像素) 以下是一个背景透明度较低时达到的效果。

实现思路和代码如下:
(1)提取边框形状:一个图片过滤为白色作为显示部分,一个图片过滤为黑色缩小一圈遮掉中间部分
(2)填充动画:这里使用svg的animateMotion路径动画,圆角处路径也使用贝塞尔描绘出弧度,这样可以很好的达到速度均匀的效果。
(3)封装为组件:判断图片的宽高比,生成路径动画。也方便以后各处使用。
html
<template>
<div ref="elRef" style="position:absolute;width: 100%;height: 100%;z-index:10;pointer-events: none;top: 0;left: 0;">
<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg" version="1">
<defs>
<linearGradient id="line-gradient2" x1="0%" y1="0%" x2="100%" y2="50%">
<stop offset="0%" stop-color="rgb(190,244,248)" stop-opacity="0.1" />
<stop offset="50%" stop-color="rgb(72,233,244)" />
<stop offset="100%" stop-color="rgb(78,89,249)" />
</linearGradient>
<!--过滤为黑色背景-->
<filter :id="ids.black">
<feImage x="0" y="0" width="100%" preserveAspectRatio="xMinYMin meet" :xlink:href="imgSource" result="img" />
<feColorMatrix type="matrix" in="img" values="0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
1 1 1 1 0" />
</filter>
<!--过滤为白色背景-->
<filter :id="ids.white">
<feImage x="0" y="0" width="100%" preserveAspectRatio="xMinYMin meet" :xlink:href="imgSource" result="img" />
<feColorMatrix type="matrix" in="img" values="255 255 255 0 0
255 255 255 0 0
255 255 255 0 0
1 1 1 1 0" />
</filter>
<!--第一个rect的白色图片部分作为显示,第2个rect缩小一圈遮罩掉中间部分-->
<mask :id="ids.mask">
<rect x="0" y="0" width="100%" :height="img.height" :filter="`url(#${ids.white})`" />
<rect x="0" y="0" width="100%" :height="img.height" :filter="`url(#${ids.black})`"
:transform-origin="`${img.width / 2} ${img.height / 2}`" transform="scale(0.97)" />
</mask>
<!--填充边框动画使用,圆形在圆角处过渡更自然-->
<pattern :id="ids.pattern" width="100%" height="100%">
<circle r="40" fill="url(#line-gradient2)">
<!--路径动画,生成路径时再插入进来-->
<animateMotion v-if="d" :path="d" dur="5s" repeatCount="indefinite" />
</circle>
</pattern>
</defs>
<!--填充fill,mask-->
<rect x="0" y="0" width="100%" height="100%" :fill="`url(#${ids.pattern})`" :mask="`url(#${ids.mask})`" />
</svg>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue';
const props = defineProps({
// 距路径动画的圆角大小
radius: {
default: 0,
type: Number,
},
imgSource: {
type: String,
default: '',
},
});
// 随机生成id
const ids = {
white: 'filter-white_' + String(Math.random()).substring(2, 12),
black: 'filter-black_' + String(Math.random()).substring(2, 12),
mask: 'mask_' + String(Math.random()).substring(2, 12),
pattern: 'pattern_' + String(Math.random()).substring(2, 12),
};
const elRef = ref(null);
const img = reactive({
width: 0,
height: 100,
ratio: 1,
});
const d = ref('');
// 根据元素宽、图片高,生成合适的矩形路径
function yieldPath(w, h) {
const r = props.radius;
d.value = `M${r} 0H${w - r}Q${w} 0,${w} ${r}V${h - r}Q${w} ${h},${w - r} ${h}H${r}Q0 ${h},0 ${h - r}V${r}Q0 0,${r} 0`;
}
function loadImg() {
const _img = new Image();
_img.onload = () => {
img.ratio = _img.width / _img.height;
img.height = elRef.value.offsetWidth / img.ratio;
img.width = elRef.value.offsetWidth;
yieldPath(elRef.value?.offsetWidth, img.height);
};
_img.src = props.imgSource;
}
onMounted(loadImg);
</script>
以上代码可能使用中还有些许问题,不过大家可以作为参考使用,希望它能够对大家有所帮助。
使用到的素材图: