Updated glass surface component.

This commit is contained in:
msavulescu
2025-07-21 18:42:17 +03:00
parent 50e1a195a0
commit 730d9a7b35
2 changed files with 227 additions and 67 deletions

View File

@@ -1,16 +1,8 @@
<template> <template>
<div <div ref="containerRef" :class="[glassSurfaceClasses, focusVisibleClasses, className]" :style="containerStyles">
ref="containerRef" <svg class="w-full h-full pointer-events-none absolute inset-0 opacity-0 -z-10" xmlns="http://www.w3.org/2000/svg">
class="glass-surface"
:class="{
'glass-surface--svg': supportsSVGFilters,
'glass-surface--fallback': !supportsSVGFilters
}"
:style="containerStyle"
>
<svg class="glass-surface__filter" xmlns="http://www.w3.org/2000/svg">
<defs> <defs>
<filter :id="filterId" colorInterpolationFilters="sRGB" x="0%" y="0%" width="100%" height="100%"> <filter :id="filterId" color-interpolation-filters="sRGB" x="0%" y="0%" width="100%" height="100%">
<feImage ref="feImageRef" x="0" y="0" width="100%" height="100%" preserveAspectRatio="none" result="map" /> <feImage ref="feImageRef" x="0" y="0" width="100%" height="100%" preserveAspectRatio="none" result="map" />
<feDisplacementMap ref="redChannelRef" in="SourceGraphic" in2="map" id="redchannel" result="dispRed" /> <feDisplacementMap ref="redChannelRef" in="SourceGraphic" in2="map" id="redchannel" result="dispRed" />
@@ -18,9 +10,9 @@
in="dispRed" in="dispRed"
type="matrix" type="matrix"
values="1 0 0 0 0 values="1 0 0 0 0
0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0
0 0 0 1 0" 0 0 0 1 0"
result="red" result="red"
/> />
@@ -29,9 +21,9 @@
in="dispGreen" in="dispGreen"
type="matrix" type="matrix"
values="0 0 0 0 0 values="0 0 0 0 0
0 1 0 0 0 0 1 0 0 0
0 0 0 0 0 0 0 0 0 0
0 0 0 1 0" 0 0 0 1 0"
result="green" result="green"
/> />
@@ -40,9 +32,9 @@
in="dispBlue" in="dispBlue"
type="matrix" type="matrix"
values="0 0 0 0 0 values="0 0 0 0 0
0 0 0 0 0 0 0 0 0 0
0 0 1 0 0 0 0 1 0 0
0 0 0 1 0" 0 0 0 1 0"
result="blue" result="blue"
/> />
@@ -52,11 +44,15 @@
</filter> </filter>
</defs> </defs>
</svg> </svg>
<div class="w-full h-full flex items-center justify-center p-2 rounded-[inherit] relative z-10">
<slot />
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, type CSSProperties, useTemplateRef, onMounted, computed } from 'vue'; import { ref, type CSSProperties, useTemplateRef, onMounted, computed, watch, nextTick, onUnmounted } from 'vue';
interface GlassSurfaceProps { interface GlassSurfaceProps {
width?: string | number; width?: string | number;
@@ -109,7 +105,7 @@ const props = withDefaults(defineProps<GlassSurfaceProps>(), {
displace: 0.5, displace: 0.5,
backgroundOpacity: 0, backgroundOpacity: 0,
saturation: 1, saturation: 1,
distortionScale: -80, distortionScale: -180,
redOffset: 0, redOffset: 0,
greenOffset: 10, greenOffset: 10,
blueOffset: 20, blueOffset: 20,
@@ -120,14 +116,32 @@ const props = withDefaults(defineProps<GlassSurfaceProps>(), {
style: () => ({}) style: () => ({})
}); });
// Generate unique IDs for SVG elements const isDarkMode = ref(false);
const generateUniqueId = (prefix: string): string => {
return `${prefix}-${Math.random().toString(36).substring(2, 15)}`; const updateDarkMode = () => {
if (typeof window === 'undefined') return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
isDarkMode.value = mediaQuery.matches;
const handler = (e: MediaQueryListEvent) => {
isDarkMode.value = e.matches;
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}; };
const filterId = ref(generateUniqueId('glass-surface-filter')); // Generate unique IDs for SVG elements
const redGradId = ref(generateUniqueId('red-grad')); const generateUniqueId = () => {
const blueGradId = ref(generateUniqueId('blue-grad')); return Math.random().toString(36).substring(2, 15);
};
const uniqueId = generateUniqueId();
const filterId = `glass-filter-${uniqueId}`;
const redGradId = `red-grad-${uniqueId}`;
const blueGradId = `blue-grad-${uniqueId}`;
const containerRef = useTemplateRef<HTMLDivElement>('containerRef'); const containerRef = useTemplateRef<HTMLDivElement>('containerRef');
const feImageRef = useTemplateRef<SVGSVGElement>('feImageRef'); const feImageRef = useTemplateRef<SVGSVGElement>('feImageRef');
@@ -136,6 +150,8 @@ const greenChannelRef = useTemplateRef<SVGSVGElement>('greenChannelRef');
const blueChannelRef = useTemplateRef<SVGSVGElement>('blueChannelRef'); const blueChannelRef = useTemplateRef<SVGSVGElement>('blueChannelRef');
const gaussianBlurRef = useTemplateRef<SVGSVGElement>('gaussianBlurRef'); const gaussianBlurRef = useTemplateRef<SVGSVGElement>('gaussianBlurRef');
let resizeObserver: ResizeObserver | null = null;
const generateDisplacementMap = () => { const generateDisplacementMap = () => {
const rect = containerRef.value?.getBoundingClientRect(); const rect = containerRef.value?.getBoundingClientRect();
const actualWidth = rect?.width || 400; const actualWidth = rect?.width || 400;
@@ -145,18 +161,18 @@ const generateDisplacementMap = () => {
const svgContent = ` const svgContent = `
<svg viewBox="0 0 ${actualWidth} ${actualHeight}" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 ${actualWidth} ${actualHeight}" xmlns="http://www.w3.org/2000/svg">
<defs> <defs>
<linearGradient id="${redGradId.value}" x1="100%" y1="0%" x2="0%" y2="0%"> <linearGradient id="${redGradId}" x1="100%" y1="0%" x2="0%" y2="0%">
<stop offset="0%" stop-color="#0000"/> <stop offset="0%" stop-color="#0000"/>
<stop offset="100%" stop-color="red"/> <stop offset="100%" stop-color="red"/>
</linearGradient> </linearGradient>
<linearGradient id="${blueGradId.value}" x1="0%" y1="0%" x2="0%" y2="100%"> <linearGradient id="${blueGradId}" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#0000"/> <stop offset="0%" stop-color="#0000"/>
<stop offset="100%" stop-color="blue"/> <stop offset="100%" stop-color="blue"/>
</linearGradient> </linearGradient>
</defs> </defs>
<rect x="0" y="0" width="${actualWidth}" height="${actualHeight}" fill="black"></rect> <rect x="0" y="0" width="${actualWidth}" height="${actualHeight}" fill="black"></rect>
<rect x="0" y="0" width="${actualWidth}" height="${actualHeight}" rx="${props.borderRadius}" fill="url(#${redGradId.value})" /> <rect x="0" y="0" width="${actualWidth}" height="${actualHeight}" rx="${props.borderRadius}" fill="url(#${redGradId})" />
<rect x="0" y="0" width="${actualWidth}" height="${actualHeight}" rx="${props.borderRadius}" fill="url(#${blueGradId.value})" style="mix-blend-mode: ${props.mixBlendMode}" /> <rect x="0" y="0" width="${actualWidth}" height="${actualHeight}" rx="${props.borderRadius}" fill="url(#${blueGradId})" style="mix-blend-mode: ${props.mixBlendMode}" />
<rect x="${edgeSize}" y="${edgeSize}" width="${actualWidth - edgeSize * 2}" height="${actualHeight - edgeSize * 2}" rx="${props.borderRadius}" fill="hsl(0 0% ${props.brightness}% / ${props.opacity})" style="filter:blur(${props.blur}px)" /> <rect x="${edgeSize}" y="${edgeSize}" width="${actualWidth - edgeSize * 2}" height="${actualHeight - edgeSize * 2}" rx="${props.borderRadius}" fill="hsl(0 0% ${props.brightness}% / ${props.opacity})" style="filter:blur(${props.blur}px)" />
</svg> </svg>
`; `;
@@ -165,55 +181,199 @@ const generateDisplacementMap = () => {
}; };
const updateDisplacementMap = () => { const updateDisplacementMap = () => {
feImageRef.value?.setAttribute('href', generateDisplacementMap()); if (feImageRef.value) {
feImageRef.value.setAttribute('href', generateDisplacementMap());
}
}; };
onMounted(() => { const supportsSVGFilters = () => {
updateDisplacementMap(); if (typeof window === 'undefined' || typeof navigator === 'undefined') return false;
if (containerRef.value) { const isWebkit = /Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent);
const resizeObserver = new ResizeObserver(() => { const isFirefox = /Firefox/.test(navigator.userAgent);
setTimeout(updateDisplacementMap, 0);
}); if (isWebkit || isFirefox) {
resizeObserver.observe(containerRef.value); return false;
} }
[ const div = document.createElement('div');
div.style.backdropFilter = `url(#${filterId})`;
return div.style.backdropFilter !== '';
};
const supportsBackdropFilter = () => {
if (typeof window === 'undefined') return false;
return CSS.supports('backdrop-filter', 'blur(10px)');
};
const containerStyles = computed(() => {
const baseStyles: Record<string, string | number> = {
...props.style,
width: typeof props.width === 'number' ? `${props.width}px` : props.width,
height: typeof props.height === 'number' ? `${props.height}px` : props.height,
borderRadius: `${props.borderRadius}px`,
'--glass-frost': props.backgroundOpacity,
'--glass-saturation': props.saturation
};
const svgSupported = supportsSVGFilters();
const backdropFilterSupported = supportsBackdropFilter();
if (svgSupported) {
return {
...baseStyles,
background: isDarkMode.value
? `hsl(0 0% 0% / ${props.backgroundOpacity})`
: `hsl(0 0% 100% / ${props.backgroundOpacity})`,
backdropFilter: `url(#${filterId}) saturate(${props.saturation})`,
boxShadow: isDarkMode.value
? `0 0 2px 1px color-mix(in oklch, white, transparent 65%) inset,
0 0 10px 4px color-mix(in oklch, white, transparent 85%) inset,
0px 4px 16px rgba(17, 17, 26, 0.05),
0px 8px 24px rgba(17, 17, 26, 0.05),
0px 16px 56px rgba(17, 17, 26, 0.05),
0px 4px 16px rgba(17, 17, 26, 0.05) inset,
0px 8px 24px rgba(17, 17, 26, 0.05) inset,
0px 16px 56px rgba(17, 17, 26, 0.05) inset`
: `0 0 2px 1px color-mix(in oklch, black, transparent 85%) inset,
0 0 10px 4px color-mix(in oklch, black, transparent 90%) inset,
0px 4px 16px rgba(17, 17, 26, 0.05),
0px 8px 24px rgba(17, 17, 26, 0.05),
0px 16px 56px rgba(17, 17, 26, 0.05),
0px 4px 16px rgba(17, 17, 26, 0.05) inset,
0px 8px 24px rgba(17, 17, 26, 0.05) inset,
0px 16px 56px rgba(17, 17, 26, 0.05) inset`
};
} else {
if (isDarkMode.value) {
if (!backdropFilterSupported) {
return {
...baseStyles,
background: 'rgba(0, 0, 0, 0.4)',
border: '1px solid rgba(255, 255, 255, 0.2)',
boxShadow: `inset 0 1px 0 0 rgba(255, 255, 255, 0.2),
inset 0 -1px 0 0 rgba(255, 255, 255, 0.1)`
};
} else {
return {
...baseStyles,
background: 'rgba(255, 255, 255, 0.1)',
backdropFilter: 'blur(12px) saturate(1.8) brightness(1.2)',
WebkitBackdropFilter: 'blur(12px) saturate(1.8) brightness(1.2)',
border: '1px solid rgba(255, 255, 255, 0.2)',
boxShadow: `inset 0 1px 0 0 rgba(255, 255, 255, 0.2),
inset 0 -1px 0 0 rgba(255, 255, 255, 0.1)`
};
}
} else {
if (!backdropFilterSupported) {
return {
...baseStyles,
background: 'rgba(255, 255, 255, 0.4)',
border: '1px solid rgba(255, 255, 255, 0.3)',
boxShadow: `inset 0 1px 0 0 rgba(255, 255, 255, 0.5),
inset 0 -1px 0 0 rgba(255, 255, 255, 0.3)`
};
} else {
return {
...baseStyles,
background: 'rgba(255, 255, 255, 0.25)',
backdropFilter: 'blur(12px) saturate(1.8) brightness(1.1)',
WebkitBackdropFilter: 'blur(12px) saturate(1.8) brightness(1.1)',
border: '1px solid rgba(255, 255, 255, 0.3)',
boxShadow: `0 8px 32px 0 rgba(31, 38, 135, 0.2),
0 2px 16px 0 rgba(31, 38, 135, 0.1),
inset 0 1px 0 0 rgba(255, 255, 255, 0.4),
inset 0 -1px 0 0 rgba(255, 255, 255, 0.2)`
};
}
}
}
});
const glassSurfaceClasses =
'relative flex items-center justify-center overflow-hidden transition-opacity duration-[260ms] ease-out';
const focusVisibleClasses = computed(() => {
return isDarkMode.value
? 'focus-visible:outline-2 focus-visible:outline-[#0A84FF] focus-visible:outline-offset-2'
: 'focus-visible:outline-2 focus-visible:outline-[#007AFF] focus-visible:outline-offset-2';
});
const updateFilterElements = () => {
const elements = [
{ ref: redChannelRef, offset: props.redOffset }, { ref: redChannelRef, offset: props.redOffset },
{ ref: greenChannelRef, offset: props.greenOffset }, { ref: greenChannelRef, offset: props.greenOffset },
{ ref: blueChannelRef, offset: props.blueOffset } { ref: blueChannelRef, offset: props.blueOffset }
].forEach(({ ref, offset }) => { ];
ref.value?.setAttribute('scale', (props.distortionScale + offset).toString());
ref.value?.setAttribute('xChannelSelector', props.xChannel); elements.forEach(({ ref, offset }) => {
ref.value?.setAttribute('yChannelSelector', props.yChannel); if (ref.value) {
ref.value.setAttribute('scale', (props.distortionScale + offset).toString());
ref.value.setAttribute('xChannelSelector', props.xChannel);
ref.value.setAttribute('yChannelSelector', props.yChannel);
}
}); });
if (gaussianBlurRef.value) { if (gaussianBlurRef.value) {
gaussianBlurRef.value.setAttribute('stdDeviation', props.displace.toString()); gaussianBlurRef.value.setAttribute('stdDeviation', props.displace.toString());
} }
});
const supportsSVGFilters = () => {
const ua = navigator.userAgent;
const isWebkit = /Safari/.test(ua) && !/Chrome/.test(ua);
const isFirefox = /Firefox/.test(ua);
if (isWebkit || isFirefox) return false;
const div = document.createElement('div');
div.style.backdropFilter = `url(#${filterId.value})`;
return div.style.backdropFilter !== '';
}; };
const containerStyle = computed(() => ({ const setupResizeObserver = () => {
...props.style, if (!containerRef.value || typeof ResizeObserver === 'undefined') return;
width: typeof props.width === 'number' ? `${props.width}px` : props.width,
height: typeof props.height === 'number' ? `${props.height}px` : props.height, resizeObserver = new ResizeObserver(() => {
borderRadius: `${props.borderRadius}px`, setTimeout(updateDisplacementMap, 0);
'--glass-frost': props.backgroundOpacity, });
'--glass-saturation': props.saturation,
'--filter-id': `url(#${filterId.value})` resizeObserver.observe(containerRef.value);
})); };
watch(
[
() => props.width,
() => props.height,
() => props.borderRadius,
() => props.borderWidth,
() => props.brightness,
() => props.opacity,
() => props.blur,
() => props.displace,
() => props.distortionScale,
() => props.redOffset,
() => props.greenOffset,
() => props.blueOffset,
() => props.xChannel,
() => props.yChannel,
() => props.mixBlendMode
],
() => {
updateDisplacementMap();
updateFilterElements();
}
);
watch([() => props.width, () => props.height], () => {
setTimeout(updateDisplacementMap, 0);
});
onMounted(() => {
const cleanup = updateDarkMode();
nextTick(() => {
updateDisplacementMap();
updateFilterElements();
setupResizeObserver();
});
onUnmounted(() => {
if (cleanup) cleanup();
if (resizeObserver) {
resizeObserver.disconnect();
}
});
});
</script> </script>
<style scoped> <style scoped>

View File

@@ -96,7 +96,7 @@ const brightness = ref(50);
const opacity = ref(0.93); const opacity = ref(0.93);
const blur = ref(11); const blur = ref(11);
const displace = ref(0.5); const displace = ref(0.5);
const distortionScale = ref(-80); const distortionScale = ref(-180);
const redOffset = ref(0); const redOffset = ref(0);
const greenOffset = ref(10); const greenOffset = ref(10);
const blueOffset = ref(20); const blueOffset = ref(20);