Merge pull request #78 from Utkarsh-Singhal-26/feat/electric-border

Added <ElectricBorder /> Animation
This commit is contained in:
David
2025-08-23 10:48:40 +03:00
committed by GitHub
6 changed files with 528 additions and 1 deletions

View File

@@ -1,5 +1,5 @@
// Highlighted sidebar items
export const NEW = ['Prism', 'Plasma' 'Target Cursor', 'Ripple Grid', 'Magic Bento', 'Galaxy', 'Glass Surface', 'Sticker Peel', 'Scroll Stack', 'Faulty Terminal', 'Pill Nav', 'Card Nav', 'Logo Loop'];
export const NEW = ['Prism', 'Plasma', 'Electric Border', 'Target Cursor', 'Ripple Grid', 'Magic Bento', 'Galaxy', 'Glass Surface', 'Sticker Peel', 'Scroll Stack', 'Faulty Terminal', 'Pill Nav', 'Card Nav', 'Logo Loop'];
export const UPDATED = [];
// Used for main sidebar navigation
@@ -41,6 +41,7 @@ export const CATEGORIES = [
'Logo Loop',
'Pixel Transition',
'Target Cursor',
'Electric Border',
'Sticker Peel',
'Ribbons',
'Glare Hover',

View File

@@ -21,6 +21,7 @@ const animations = {
'target-cursor': () => import('../demo/Animations/TargetCursorDemo.vue'),
'crosshair': () => import('../demo/Animations/CrosshairDemo.vue'),
'sticker-peel': () => import('../demo/Animations/StickerPeelDemo.vue'),
'electric-border': () => import('../demo/Animations/ElectricBorderDemo.vue'),
};
const textAnimations = {

View File

@@ -0,0 +1,28 @@
import code from '@/content/Animations/ElectricBorder/ElectricBorder.vue?raw';
import { createCodeObject } from '@/types/code';
export const electricBorder = createCodeObject(code, 'Animations/ElectricBorder', {
usage: `// CREDIT
// Component inspired by @BalintFerenczy on X
// https://codepen.io/BalintFerenczy/pen/KwdoyEN
<template>
<ElectricBorder
:color="'#7df9ff'"
:speed="1"
:chaos="0.5"
:thickness="2"
:style="{ borderRadius: '16px' }"
>
<div>
<p :style="{ margin: '6px 0 0', opacity: 0.8 }">
A glowing, animated border wrapper.
</p>
</div>
</ElectricBorder>
</template>
<script setup lang="ts">
import ElectricBorder from "./ElectricBorder.vue";
</script>`
});

View File

@@ -0,0 +1,221 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, useTemplateRef, watch, type CSSProperties } from 'vue';
type ElectricBorderProps = {
color?: string;
speed?: number;
chaos?: number;
thickness?: number;
className?: string;
style?: CSSProperties;
};
const props = withDefaults(defineProps<ElectricBorderProps>(), {
color: '#28FF85',
speed: 1,
chaos: 1,
thickness: 2
});
function hexToRgba(hex: string, alpha = 1): string {
if (!hex) return `rgba(0,0,0,${alpha})`;
let h = hex.replace('#', '');
if (h.length === 3) {
h = h
.split('')
.map(c => c + c)
.join('');
}
const int = parseInt(h, 16);
const r = (int >> 16) & 255;
const g = (int >> 8) & 255;
const b = int & 255;
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
const rawId = `id-${crypto.randomUUID().replace(/[:]/g, '')}`;
const filterId = `turbulent-displace-${rawId}`;
const svgRef = useTemplateRef('svgRef');
const rootRef = useTemplateRef('rootRef');
const strokeRef = useTemplateRef('strokeRef');
const updateAnim = () => {
const svg = svgRef.value;
const host = rootRef.value;
if (!svg || !host) return;
if (strokeRef.value) {
strokeRef.value.style.filter = `url(#${filterId})`;
}
const width = Math.max(1, Math.round(host.clientWidth || host.getBoundingClientRect().width || 0));
const height = Math.max(1, Math.round(host.clientHeight || host.getBoundingClientRect().height || 0));
const dyAnims = Array.from(svg.querySelectorAll('feOffset > animate[attributeName="dy"]')) as SVGAnimateElement[];
if (dyAnims.length >= 2) {
dyAnims[0].setAttribute('values', `${height}; 0`);
dyAnims[1].setAttribute('values', `0; -${height}`);
}
const dxAnims = Array.from(svg.querySelectorAll('feOffset > animate[attributeName="dx"]')) as SVGAnimateElement[];
if (dxAnims.length >= 2) {
dxAnims[0].setAttribute('values', `${width}; 0`);
dxAnims[1].setAttribute('values', `0; -${width}`);
}
const baseDur = 6;
const dur = Math.max(0.001, baseDur / (props.speed || 1));
[...dyAnims, ...dxAnims].forEach(a => a.setAttribute('dur', `${dur}s`));
const disp = svg.querySelector('feDisplacementMap');
if (disp) disp.setAttribute('scale', String(30 * (props.chaos || 1)));
const filterEl = svg.querySelector<SVGFilterElement>(`#${CSS.escape(filterId)}`);
if (filterEl) {
filterEl.setAttribute('x', '-200%');
filterEl.setAttribute('y', '-200%');
filterEl.setAttribute('width', '500%');
filterEl.setAttribute('height', '500%');
}
requestAnimationFrame(() => {
[...dyAnims, ...dxAnims].forEach((a: SVGAnimateElement) => {
if (typeof a.beginElement === 'function') {
try {
a.beginElement();
} catch {}
}
});
});
};
watch(
() => [props.speed, props.chaos],
() => {
updateAnim();
},
{ deep: true }
);
let ro: ResizeObserver | null = null;
onMounted(() => {
if (!rootRef.value) return;
ro = new ResizeObserver(() => updateAnim());
ro.observe(rootRef.value);
updateAnim();
});
onBeforeUnmount(() => {
if (ro) ro.disconnect();
});
const inheritRadius = computed<CSSProperties>(() => {
const radius = props.style?.borderRadius;
if (radius === undefined) {
return { borderRadius: 'inherit' };
}
if (typeof radius === 'number') {
return { borderRadius: `${radius}px` };
}
return { borderRadius: radius };
});
const strokeStyle = computed<CSSProperties>(() => ({
...inheritRadius.value,
borderWidth: `${props.thickness}px`,
borderStyle: 'solid',
borderColor: props.color
}));
const glow1Style = computed<CSSProperties>(() => ({
...inheritRadius.value,
borderWidth: `${props.thickness}px`,
borderStyle: 'solid',
borderColor: hexToRgba(props.color, 0.6),
filter: `blur(${0.5 + props.thickness * 0.25}px)`,
opacity: 0.5
}));
const glow2Style = computed<CSSProperties>(() => ({
...inheritRadius.value,
borderWidth: `${props.thickness}px`,
borderStyle: 'solid',
borderColor: props.color,
filter: `blur(${2 + props.thickness * 0.5}px)`,
opacity: 0.5
}));
const bgGlowStyle = computed<CSSProperties>(() => ({
...inheritRadius.value,
transform: 'scale(1.08)',
filter: 'blur(32px)',
opacity: 0.3,
zIndex: -1,
background: `linear-gradient(-30deg, ${hexToRgba(props.color, 0.8)}, transparent, ${props.color})`
}));
</script>
<template>
<div ref="rootRef" :class="['relative isolate', className]" :style="style">
<svg
ref="svgRef"
class="fixed opacity-0 w-0 h-0 pointer-events-none"
style="position: absolute; top: -9999px; left: -9999px"
aria-hidden="true"
focusable="false"
>
<defs>
<filter :id="filterId" color-interpolation-filters="sRGB" x="-200%" y="-200%" width="500%" height="500%">
<feTurbulence type="turbulence" baseFrequency="0.015" numOctaves="8" result="noise1" seed="1" />
<feOffset in="noise1" dx="0" dy="0" result="offsetNoise1">
<animate attributeName="dy" values="500; 0" dur="6s" repeatCount="indefinite" calcMode="linear" />
</feOffset>
<feTurbulence type="turbulence" baseFrequency="0.015" numOctaves="8" result="noise2" seed="3" />
<feOffset in="noise2" dx="0" dy="0" result="offsetNoise2">
<animate attributeName="dy" values="0; -500" dur="6s" repeatCount="indefinite" calcMode="linear" />
</feOffset>
<feTurbulence type="turbulence" baseFrequency="0.02" numOctaves="6" result="noise3" seed="5" />
<feOffset in="noise3" dx="0" dy="0" result="offsetNoise3">
<animate attributeName="dx" values="500; 0" dur="6s" repeatCount="indefinite" calcMode="linear" />
</feOffset>
<feTurbulence type="turbulence" baseFrequency="0.02" numOctaves="6" result="noise4" seed="7" />
<feOffset in="noise4" dx="0" dy="0" result="offsetNoise4">
<animate attributeName="dx" values="0; -500" dur="6s" repeatCount="indefinite" calcMode="linear" />
</feOffset>
<feComposite in="offsetNoise1" in2="offsetNoise2" operator="add" result="verticalNoise" />
<feComposite in="offsetNoise3" in2="offsetNoise4" operator="add" result="horizontalNoise" />
<feBlend in="verticalNoise" in2="horizontalNoise" mode="screen" result="combinedNoise" />
<feDisplacementMap
in="SourceGraphic"
in2="combinedNoise"
scale="30"
xChannelSelector="R"
yChannelSelector="G"
result="displaced"
/>
</filter>
</defs>
</svg>
<div class="absolute inset-0 pointer-events-none" :style="inheritRadius">
<div ref="strokeRef" class="box-border absolute inset-0" :style="strokeStyle" />
<div class="box-border absolute inset-0" :style="glow1Style" />
<div class="box-border absolute inset-0" :style="glow2Style" />
<div class="absolute inset-0" :style="bgGlowStyle" />
</div>
<div class="relative" :style="inheritRadius">
<slot />
</div>
</div>
</template>

View File

@@ -678,3 +678,111 @@ div:has(> .props-table) {
font-size: 3rem;
}
}
.eb-demo-card {
position: relative;
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
padding: 24px;
border-radius: 16px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02));
color: #e9f8ff;
}
.eb-demo-badge {
align-self: flex-start;
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 4px 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.15);
color: #fff;
}
.eb-demo-title {
margin: 0;
font-size: 24px;
font-weight: 700;
}
.eb-demo-desc {
margin: 0;
opacity: 0.7;
font-size: 14px;
}
.eb-demo-row {
display: flex;
gap: 6px;
margin-top: auto;
}
.eb-demo-chip {
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.eb-demo-cta {
margin-top: 6px;
align-self: flex-start;
font-size: 14px;
font-weight: 600;
width: 100%;
color: #2e2e2e;
background: #fff;
border: none;
border-radius: 10px;
padding: 8px 10px;
cursor: pointer;
transition:
transform 120ms ease,
filter 120ms ease,
box-shadow 120ms ease;
box-shadow: 0 4px 16px rgba(255, 255, 255, 0.15);
}
.eb-demo-cta:hover {
transform: translateY(-1px);
filter: brightness(0.98);
box-shadow: 0 6px 24px rgba(125, 249, 255, 0.25);
}
.eb-demo-button-wrap {
width: 150px;
height: 50px;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
}
.eb-demo-button {
font-size: 16px;
line-height: 0;
letter-spacing: -0.5px;
color: #b0f29e;
border: none;
border-radius: 999px;
padding: 8px 12px;
cursor: pointer;
}
.eb-button-container {
transition: 0.3s ease;
}
.eb-button-container:hover {
transform: scale(1.1);
background-color: rgba(177, 158, 239, 0.1);
box-shadow: 0 8px 16px rgba(177, 158, 239, 0.1);
transition: 0.3s ease;
filter: saturate(1.5);
}

View File

@@ -0,0 +1,168 @@
<template>
<TabbedLayout>
<template #preview>
<div class="flex-col h-[500px] overflow-hidden demo-container">
<ElectricBorder
v-if="example === 'card'"
:color="cardProps.color"
:speed="cardProps.speed"
:chaos="cardProps.chaos"
:thickness="cardProps.thickness"
:style="{
borderRadius: cardProps.radius
}"
>
<div :style="{ width: '300px', height: '360px' }" class="eb-demo-card">
<div class="eb-demo-badge">Featured</div>
<h3 class="eb-demo-title">Electric Card</h3>
<p class="eb-demo-desc">An electric border for shocking your users, the right way.</p>
<div class="eb-demo-row">
<span class="eb-demo-chip">Live</span>
<span class="eb-demo-chip">v1.0</span>
</div>
<button class="eb-demo-cta">Get Started</button>
</div>
</ElectricBorder>
<ElectricBorder
v-if="example === 'button'"
:color="buttonProps.color"
:speed="buttonProps.speed"
:chaos="buttonProps.chaos"
:thickness="buttonProps.thickness"
:style="{
borderRadius: buttonProps.radius
}"
class-name="eb-button-container"
>
<div class="eb-demo-button-wrap">
<button class="eb-demo-button">Learn More</button>
</div>
</ElectricBorder>
<ElectricBorder
v-if="example === 'circle'"
:color="circleProps.color"
:speed="circleProps.speed"
:chaos="circleProps.chaos"
:thickness="circleProps.thickness"
:style="{
borderRadius: circleProps.radius
}"
>
<div style="width: 200px; height: 200px; border-radius: 50%" />
</ElectricBorder>
</div>
<Customize>
<PreviewSelect title="Example" v-model="example" :options="exampleOptions" />
<PreviewColor title="Color" v-model="activeProps.color" />
<PreviewSlider title="Speed" :min="0.1" :max="3" :step="0.1" v-model="activeProps.speed" />
<PreviewSlider title="Chaos" :min="0.1" :max="1" :step="0.1" v-model="activeProps.chaos" />
<PreviewSlider title="Thickness" :min="1" :max="5" :step="1" v-model="activeProps.thickness" value-unit="px" />
</Customize>
<PropTable :data="propData" />
</template>
<template #code>
<CodeExample :code-object="electricBorder" />
</template>
<template #cli>
<CliInstallation :command="electricBorder.cli" />
</template>
</TabbedLayout>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import CliInstallation from '../../components/code/CliInstallation.vue';
import CodeExample from '../../components/code/CodeExample.vue';
import Customize from '../../components/common/Customize.vue';
import PreviewColor from '../../components/common/PreviewColor.vue';
import PreviewSelect from '../../components/common/PreviewSelect.vue';
import PreviewSlider from '../../components/common/PreviewSlider.vue';
import PropTable from '../../components/common/PropTable.vue';
import TabbedLayout from '../../components/common/TabbedLayout.vue';
import { electricBorder } from '../../constants/code/Animations/electricBorderCode';
import ElectricBorder from '../../content/Animations/ElectricBorder/ElectricBorder.vue';
const example = ref('card');
const exampleOptions = [
{ label: 'Card', value: 'card' },
{ label: 'Button', value: 'button' },
{ label: 'Circle', value: 'circle' }
];
const cardProps = reactive({
color: '#85FF80',
speed: 1,
chaos: 0.5,
thickness: 2,
radius: 16
});
const buttonProps = reactive({
color: '#9EF1CC',
speed: 1,
chaos: 0.5,
thickness: 2,
radius: 999
});
const circleProps = reactive({ color: '#85FF80', speed: 1, chaos: 0.5, thickness: 2, radius: '50%' });
const activeProps = computed(() => {
if (example.value === 'card') return cardProps;
if (example.value === 'button') return buttonProps;
return circleProps;
});
const propData = [
{
name: 'color',
type: 'string',
default: '"#28FF85"',
description: 'Stroke/glow color. Any CSS color (hex, rgb, hsl).'
},
{
name: 'speed',
type: 'number',
default: '1',
description: 'Animation speed multiplier (higher = faster).'
},
{
name: 'chaos',
type: 'number',
default: '1',
description: 'Distortion intensity from the SVG displacement (0 disables warp).'
},
{
name: 'thickness',
type: 'number',
default: '2',
description: 'Border width in pixels.'
},
{
name: 'className',
type: 'string',
default: '—',
description: 'Optional className applied to the root wrapper.'
},
{
name: 'style',
type: 'React.CSSProperties',
default: '—',
description: 'Inline styles for the wrapper. Set borderRadius here to round corners.'
},
{
name: 'children',
type: 'ReactNode',
default: '—',
description: 'Content rendered inside the bordered container.'
}
];
</script>
<style scoped>
.demo-container {
padding: 0;
}
</style>