mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 06:29:30 -07:00
Merge pull request #78 from Utkarsh-Singhal-26/feat/electric-border
Added <ElectricBorder /> Animation
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
28
src/constants/code/Animations/electricBorderCode.ts
Normal file
28
src/constants/code/Animations/electricBorderCode.ts
Normal 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>`
|
||||
});
|
||||
221
src/content/Animations/ElectricBorder/ElectricBorder.vue
Normal file
221
src/content/Animations/ElectricBorder/ElectricBorder.vue
Normal 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>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
168
src/demo/Animations/ElectricBorderDemo.vue
Normal file
168
src/demo/Animations/ElectricBorderDemo.vue
Normal 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>
|
||||
Reference in New Issue
Block a user