feat: added <BorderGlow /> component

This commit is contained in:
Utkarsh-Singhal-26
2026-03-22 13:34:14 +05:30
parent 7baf50277c
commit 256828c2ec
3 changed files with 477 additions and 0 deletions
@@ -0,0 +1,27 @@
import code from '@content/Components/BorderGlow/BorderGlow.vue?raw';
import { createCodeObject } from '../../../types/code';
export const borderGlow = createCodeObject(code, 'Components/BorderGlow', {
usage: `<template>
<BorderGlow
:edgeSensitivity="30"
glowColor="40 80 80"
backgroundColor="#060010"
:borderRadius="28"
:glowRadius="40"
:glowIntensity="1"
:coneSpread="25"
:animated="false"
:colors="['#c084fc', '#f472b6', '#38bdf8']"
>
<div class="p-[2em]">
<h2>Your Content Here</h2>
<p>Hover near the edges to see the glow.</p>
</div>
</BorderGlow>
</template>
<script setup>
import BorderGlow from './BorderGlow.vue'
</script>`
});
@@ -0,0 +1,312 @@
<script setup lang="ts">
import { computed, ref, useTemplateRef, watch } from 'vue';
interface BorderGlowProps {
className?: string;
edgeSensitivity?: number;
glowColor?: string;
backgroundColor?: string;
borderRadius?: number;
glowRadius?: number;
glowIntensity?: number;
coneSpread?: number;
animated?: boolean;
colors?: string[];
fillOpacity?: number;
}
function parseHSL(hslStr: string): { h: number; s: number; l: number } {
const match = hslStr.match(/([\d.]+)\s*([\d.]+)%?\s*([\d.]+)%?/);
if (!match) return { h: 40, s: 80, l: 80 };
return { h: parseFloat(match[1]), s: parseFloat(match[2]), l: parseFloat(match[3]) };
}
function buildBoxShadow(glowColor: string, intensity: number): string {
const { h, s, l } = parseHSL(glowColor);
const base = `${h}deg ${s}% ${l}%`;
const layers: [number, number, number, number, number, boolean][] = [
[0, 0, 0, 1, 100, true],
[0, 0, 1, 0, 60, true],
[0, 0, 3, 0, 50, true],
[0, 0, 6, 0, 40, true],
[0, 0, 15, 0, 30, true],
[0, 0, 25, 2, 20, true],
[0, 0, 50, 2, 10, true],
[0, 0, 1, 0, 60, false],
[0, 0, 3, 0, 50, false],
[0, 0, 6, 0, 40, false],
[0, 0, 15, 0, 30, false],
[0, 0, 25, 2, 20, false],
[0, 0, 50, 2, 10, false]
];
return layers
.map(([x, y, blur, spread, alpha, inset]) => {
const a = Math.min(alpha * intensity, 100);
return `${inset ? 'inset ' : ''}${x}px ${y}px ${blur}px ${spread}px hsl(${base} / ${a}%)`;
})
.join(', ');
}
function easeOutCubic(x: number) {
return 1 - Math.pow(1 - x, 3);
}
function easeInCubic(x: number) {
return x * x * x;
}
interface AnimateOpts {
start?: number;
end?: number;
duration?: number;
delay?: number;
ease?: (t: number) => number;
onUpdate: (v: number) => void;
onEnd?: () => void;
}
function animateValue({
start = 0,
end = 100,
duration = 1000,
delay = 0,
ease = easeOutCubic,
onUpdate,
onEnd
}: AnimateOpts) {
const t0 = performance.now() + delay;
function tick() {
const elapsed = performance.now() - t0;
const t = Math.min(elapsed / duration, 1);
onUpdate(start + (end - start) * ease(t));
if (t < 1) requestAnimationFrame(tick);
else if (onEnd) onEnd();
}
setTimeout(() => requestAnimationFrame(tick), delay);
}
const GRADIENT_POSITIONS = ['80% 55%', '69% 34%', '8% 6%', '41% 38%', '86% 85%', '82% 18%', '51% 4%'];
const COLOR_MAP = [0, 1, 2, 0, 1, 2, 1];
function buildMeshGradients(colors: string[]): string[] {
const gradients: string[] = [];
for (let i = 0; i < 7; i++) {
const c = colors[Math.min(COLOR_MAP[i], colors.length - 1)];
gradients.push(`radial-gradient(at ${GRADIENT_POSITIONS[i]}, ${c} 0px, transparent 50%)`);
}
gradients.push(`linear-gradient(${colors[0]} 0 100%)`);
return gradients;
}
const props = withDefaults(defineProps<BorderGlowProps>(), {
className: '',
edgeSensitivity: 30,
glowColor: '40 80 80',
backgroundColor: '#060010',
borderRadius: 28,
glowRadius: 40,
glowIntensity: 1.0,
coneSpread: 25,
animated: false,
colors: () => ['#c084fc', '#f472b6', '#38bdf8'],
fillOpacity: 0.5
});
const cardRef = useTemplateRef<HTMLDivElement>('cardRef');
const isHovered = ref(false);
const cursorAngle = ref(45);
const edgeProximity = ref(0);
const sweepActive = ref(false);
const getCenterOfElement = (el: HTMLElement) => {
const { width, height } = el.getBoundingClientRect();
return [width / 2, height / 2];
};
const getEdgeProximity = (el: HTMLElement, x: number, y: number) => {
const [cx, cy] = getCenterOfElement(el);
const dx = x - cx;
const dy = y - cy;
let kx = Infinity;
let ky = Infinity;
if (dx !== 0) kx = cx / Math.abs(dx);
if (dy !== 0) ky = cy / Math.abs(dy);
return Math.min(Math.max(1 / Math.min(kx, ky), 0), 1);
};
const getCursorAngle = (el: HTMLElement, x: number, y: number) => {
const [cx, cy] = getCenterOfElement(el);
const dx = x - cx;
const dy = y - cy;
if (dx === 0 && dy === 0) return 0;
const radians = Math.atan2(dy, dx);
let degrees = radians * (180 / Math.PI) + 90;
if (degrees < 0) degrees += 360;
return degrees;
};
const handlePointerMove = (e: PointerEvent) => {
const card = cardRef.value;
if (!card) return;
const rect = card.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
edgeProximity.value = getEdgeProximity(card, x, y);
cursorAngle.value = getCursorAngle(card, x, y);
};
watch(
() => [props.animated],
() => {
if (!props.animated) return;
const angleStart = 110;
const angleEnd = 465;
sweepActive.value = true;
cursorAngle.value = angleStart;
animateValue({ duration: 500, onUpdate: v => (edgeProximity.value = v / 100) });
animateValue({
ease: easeInCubic,
duration: 1500,
end: 50,
onUpdate: v => {
cursorAngle.value = (angleEnd - angleStart) * (v / 100) + angleStart;
}
});
animateValue({
ease: easeOutCubic,
delay: 1500,
duration: 2250,
start: 50,
end: 100,
onUpdate: v => {
cursorAngle.value = (angleEnd - angleStart) * (v / 100) + angleStart;
}
});
animateValue({
ease: easeInCubic,
delay: 2500,
duration: 1500,
start: 100,
end: 0,
onUpdate: v => (edgeProximity.value = v / 100),
onEnd: () => (sweepActive.value = false)
});
},
{
deep: true,
immediate: true
}
);
const colorSensitivity = computed(() => props.edgeSensitivity + 20);
const isVisible = computed(() => isHovered.value || sweepActive.value);
const borderOpacity = computed(() =>
isVisible.value
? Math.max(0, (edgeProximity.value * 100 - colorSensitivity.value) / (100 - colorSensitivity.value))
: 0
);
const glowOpacity = computed(() =>
isVisible.value ? Math.max(0, (edgeProximity.value * 100 - props.edgeSensitivity) / (100 - props.edgeSensitivity)) : 0
);
const meshGradients = computed(() => buildMeshGradients(props.colors));
const borderBg = computed(() => meshGradients.value.map(g => `${g} border-box`));
const fillBg = computed(() => meshGradients.value.map(g => `${g} padding-box`));
const angleDeg = computed(() => `${cursorAngle.value.toFixed(3)}deg`);
</script>
<template>
<div
ref="cardRef"
@pointermove="handlePointerMove"
@pointerenter="isHovered = true"
@pointerleave="isHovered = false"
:class="`relative grid isolate border border-white/15 ${props.className}`"
:style="{
background: props.backgroundColor,
borderRadius: props.borderRadius + 'px',
transform: 'translate3d(0, 0, 0.01px)',
boxShadow:
'rgba(0,0,0,0.1) 0 1px 2px, rgba(0,0,0,0.1) 0 2px 4px, rgba(0,0,0,0.1) 0 4px 8px, rgba(0,0,0,0.1) 0 8px 16px, rgba(0,0,0,0.1) 0 16px 32px, rgba(0,0,0,0.1) 0 32px 64px'
}"
>
<!-- mesh gradient border -->
<div
class="-z-[1] absolute inset-0 rounded-[inherit]"
:style="{
border: '1px solid transparent',
background: [
`linear-gradient(${props.backgroundColor} 0 100%) padding-box`,
'linear-gradient(rgb(255 255 255 / 0%) 0% 100%) border-box',
...borderBg
].join(', '),
opacity: borderOpacity,
maskImage: `conic-gradient(from ${angleDeg} at center, black ${props.coneSpread}%, transparent ${
props.coneSpread + 15
}%, transparent ${100 - props.coneSpread - 15}%, black ${100 - props.coneSpread}%)`,
WebkitMaskImage: `conic-gradient(from ${angleDeg} at center, black ${props.coneSpread}%, transparent ${
props.coneSpread + 15
}%, transparent ${100 - props.coneSpread - 15}%, black ${100 - props.coneSpread}%)`,
transition: isVisible ? 'opacity 0.25s ease-out' : 'opacity 0.75s ease-in-out'
}"
/>
<!-- mesh gradient fill -->
<div
class="-z-[1] absolute inset-0 rounded-[inherit]"
:style="{
border: '1px solid transparent',
background: fillBg.join(', '),
maskImage: [
'linear-gradient(to bottom, black, black)',
'radial-gradient(ellipse at 50% 50%, black 40%, transparent 65%)',
'radial-gradient(ellipse at 66% 66%, black 5%, transparent 40%)',
'radial-gradient(ellipse at 33% 33%, black 5%, transparent 40%)',
'radial-gradient(ellipse at 66% 33%, black 5%, transparent 40%)',
'radial-gradient(ellipse at 33% 66%, black 5%, transparent 40%)',
`conic-gradient(from ${angleDeg} at center, transparent 5%, black 15%, black 85%, transparent 95%)`
].join(', '),
WebkitMaskImage: [
'linear-gradient(to bottom, black, black)',
'radial-gradient(ellipse at 50% 50%, black 40%, transparent 65%)',
'radial-gradient(ellipse at 66% 66%, black 5%, transparent 40%)',
'radial-gradient(ellipse at 33% 33%, black 5%, transparent 40%)',
'radial-gradient(ellipse at 66% 33%, black 5%, transparent 40%)',
'radial-gradient(ellipse at 33% 66%, black 5%, transparent 40%)',
`conic-gradient(from ${angleDeg} at center, transparent 5%, black 15%, black 85%, transparent 95%)`
].join(', '),
maskComposite: 'subtract, add, add, add, add, add',
WebkitMaskComposite: 'source-out, source-over, source-over, source-over, source-over, source-over',
opacity: borderOpacity * props.fillOpacity,
mixBlendMode: 'soft-light',
transition: isVisible ? 'opacity 0.25s ease-out' : 'opacity 0.75s ease-in-out'
}"
/>
<!-- outer glow -->
<span
class="z-[1] absolute rounded-[inherit] pointer-events-none"
:style="{
inset: `-${props.glowRadius}px`,
maskImage: `conic-gradient(from ${angleDeg} at center, black 2.5%, transparent 10%, transparent 90%, black 97.5%)`,
WebkitMaskImage: `conic-gradient(from ${angleDeg} at center, black 2.5%, transparent 10%, transparent 90%, black 97.5%)`,
opacity: glowOpacity,
mixBlendMode: 'plus-lighter',
transition: isVisible ? 'opacity 0.25s ease-out' : 'opacity 0.75s ease-in-out'
}"
>
<span
class="absolute rounded-[inherit]"
:style="{
inset: `${props.glowRadius}px`,
boxShadow: buildBoxShadow(props.glowColor, props.glowIntensity)
}"
/>
</span>
<!-- content -->
<div class="z-[1] relative flex flex-col overflow-auto">
<slot />
</div>
</div>
</template>
+138
View File
@@ -0,0 +1,138 @@
<template>
<TabbedLayout>
<template #preview>
<div class="h-[500px] overflow-hidden demo-container">
<BorderGlow v-bind="props">
<div class="flex flex-col justify-center items-start p-[2em] min-h-[200px]">
<i class="pi pi-sparkles" style="font-size: 34px; margin-bottom: 12px"></i>
<p class="font-semibold text-[1.4rem] tracking-[-0.5px]">Hover Near the Edges</p>
<p class="mt-1 max-w-[40ch] text-[#a1a1aa] text-[14px]">
Move your cursor close to the card border to see the colored glow effect follow your pointer direction.
</p>
</div>
</BorderGlow>
</div>
<Customize>
<PreviewSlider title="Edge Sensitivity" :min="0" :max="80" :step="1" v-model="edgeSensitivity" />
<PreviewSlider title="Border Radius" :min="0" :max="50" :step="1" v-model="borderRadius" />
<PreviewSlider title="Glow Radius" :min="10" :max="80" :step="1" v-model="glowRadius" />
<PreviewSlider title="Glow Intensity" :min="0.1" :max="3" :step="0.1" v-model="glowIntensity" />
<PreviewSlider title="Cone Spread" :min="5" :max="45" :step="1" v-model="coneSpread" />
<PreviewSwitch title="Animated Intro" v-model="animated" />
<PreviewColor title="Background" v-model="backgroundColor" class="mb-2" />
<div class="flex flex-col gap-0">
<span class="block font-medium text-sm">Gradient Colors</span>
<div class="flex flex-wrap gap-2 px-1 pt-1">
<label
v-for="(color, index) in colors"
:key="index"
class="border-[#222] border-2 rounded-md w-12 h-12 overflow-hidden cursor-pointer"
:style="{ backgroundColor: color }"
>
<input
type="color"
:value="color"
@input="updateColor(index, ($event.target as HTMLInputElement).value)"
class="opacity-0 w-full h-full cursor-pointer"
/>
</label>
</div>
</div>
</Customize>
<PropTable :data="propData" />
</template>
<template #code>
<CodeExample :code-object="borderGlow" />
</template>
<template #cli>
<CliInstallation :command="borderGlow.cli" />
</template>
</TabbedLayout>
</template>
<script setup lang="ts">
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 PreviewSlider from '@/components/common/PreviewSlider.vue';
import PreviewSwitch from '@/components/common/PreviewSwitch.vue';
import PropTable from '@/components/common/PropTable.vue';
import TabbedLayout from '@/components/common/TabbedLayout.vue';
import { borderGlow } from '@/constants/code/Components/borderGlowCode';
import BorderGlow from '@/content/Components/BorderGlow/BorderGlow.vue';
import { computed, ref } from 'vue';
const colors = ref(['#c084fc', '#f472b6', '#38bdf8']);
const edgeSensitivity = ref(30);
const glowColor = ref('40 80 80');
const backgroundColor = ref('#070F07');
const borderRadius = ref(28);
const glowRadius = ref(40);
const glowIntensity = ref(1.0);
const coneSpread = ref(25);
const animated = ref(false);
const props = computed(() => ({
edgeSensitivity: edgeSensitivity.value,
glowColor: glowColor.value,
backgroundColor: backgroundColor.value,
borderRadius: borderRadius.value,
glowRadius: glowRadius.value,
glowIntensity: glowIntensity.value,
coneSpread: coneSpread.value,
animated: animated.value,
colors: colors.value
}));
const updateColor = (index: number, newColor: string) => {
const newColors = [...colors.value];
newColors[index] = newColor;
colors.value = newColors;
};
const propData = [
{ name: 'children', type: 'slot', default: '-', description: 'Content rendered inside the card.' },
{ name: 'className', type: 'string', default: '""', description: 'Additional CSS classes for the outer wrapper.' },
{
name: 'edgeSensitivity',
type: 'number',
default: '30',
description: 'How close the pointer must be to the edge for the glow to appear (0-100).'
},
{
name: 'glowColor',
type: 'string',
default: '"40 80 80"',
description: 'HSL values for the glow color, as "H S L" (e.g. "40 80 80").'
},
{ name: 'backgroundColor', type: 'string', default: '"#070F07"', description: 'Background color of the card.' },
{ name: 'borderRadius', type: 'number', default: '28', description: 'Corner radius of the card in pixels.' },
{
name: 'glowRadius',
type: 'number',
default: '40',
description: 'How far the outer glow extends beyond the card in pixels.'
},
{ name: 'glowIntensity', type: 'number', default: '1.0', description: 'Multiplier for glow opacity (0.1-3.0).' },
{
name: 'coneSpread',
type: 'number',
default: '25',
description: 'Width of the directional cone mask as a percentage (5-45).'
},
{ name: 'animated', type: 'boolean', default: 'false', description: 'Play an intro sweep animation on mount.' },
{
name: 'colors',
type: 'string[]',
default: '[...]',
description: 'Array of 3 hex colors for the mesh gradient border, distributed across positions.'
}
];
</script>