mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-04-21 09:34:39 -06:00
feat: added <BorderGlow /> component
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user