Last active
January 29, 2026 11:22
-
-
Save marknotton/8dc7ca677cf5aafda9a2ed5c9498a880 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import MouseTracker from './mouse-tracker.js' | |
| // @see https://codepen.io/marknotton/pen/GgqEwwG | |
| /** | |
| * Liquid Glass Custom Element | |
| */ | |
| class LiquidGlassElement extends HTMLElement { | |
| static defaults = { | |
| edgeSize: 10, | |
| displacementScale: 55, | |
| blurAmount: 1.4, | |
| saturation: 1.2, | |
| easeType: 'sqrt', | |
| easeExponent: 0.4, | |
| intensity: 127, | |
| specularEnabled: true, | |
| specularEdgeSize: '1.5px', | |
| specularAngle: '180deg', | |
| specularOpacity: 0.3, | |
| enableSpecularUi: false, | |
| enableRelativeSpecularUi: false, | |
| showPreview: false, | |
| previewPosition: 'top-right', | |
| previewScale: 0.5, | |
| } | |
| static stylesInjected = false | |
| static propertiesRegistered = false | |
| static get observedAttributes() { | |
| return [ | |
| 'edge-size', | |
| 'displacement-scale', | |
| 'blur-amount', | |
| 'saturation', | |
| 'ease-type', | |
| 'ease-exponent', | |
| 'intensity', | |
| 'specular-enabled', | |
| 'specular-edge-size', | |
| 'specular-angle', | |
| 'specular-opacity', | |
| 'enable-specular-ui', | |
| 'enable-relative-specular-ui', | |
| 'show-preview', | |
| 'preview-position', | |
| 'preview-scale', | |
| ] | |
| } | |
| constructor() { | |
| super() | |
| this.inner = null | |
| this.specular = null | |
| this.canvas = null | |
| this.svg = null | |
| this.resizeObserver = null | |
| this.filterId = `liquid-glass-${Math.random().toString(36).slice(2, 9)}` | |
| this.customEase = null | |
| this.mouseTrackerPropertyName = null | |
| } | |
| connectedCallback() { | |
| LiquidGlassElement.registerProperties() | |
| LiquidGlassElement.injectStyles() | |
| this.init() | |
| } | |
| disconnectedCallback() { | |
| this.destroy() | |
| } | |
| attributeChangedCallback(name, oldValue, newValue) { | |
| if (oldValue !== newValue) { | |
| if (name === 'enable-specular-ui' || name === 'enable-relative-specular-ui') { | |
| this.updateMouseTracker() | |
| } | |
| if (this.svg) { | |
| this.update() | |
| } | |
| } | |
| } | |
| static registerProperties() { | |
| if (LiquidGlassElement.propertiesRegistered) return | |
| if (!('registerProperty' in CSS)) { | |
| console.warn('CSS.registerProperty not supported') | |
| return | |
| } | |
| const properties = [ | |
| { name: '--liquid-glass-specular-angle', syntax: '<angle>', inherits: true, initialValue: '180deg' }, | |
| { name: '--liquid-glass-specular-opacity', syntax: '<number>', inherits: true, initialValue: '0.5' }, | |
| { name: '--liquid-glass-specular-edge', syntax: '<length>', inherits: true, initialValue: '1.5px' }, | |
| ] | |
| properties.forEach(prop => { | |
| try { CSS.registerProperty(prop) } catch (e) {} | |
| }) | |
| LiquidGlassElement.propertiesRegistered = true | |
| } | |
| static injectStyles() { | |
| if (LiquidGlassElement.stylesInjected) return | |
| const style = document.createElement('style') | |
| style.id = 'liquid-glass-styles' | |
| style.textContent = ` | |
| *:has(> liquid-glass) { | |
| position: relative; | |
| isolation: isolate; | |
| } | |
| liquid-glass { | |
| position: absolute; | |
| inset: 0; | |
| z-index: -1; | |
| pointer-events: none; | |
| border-radius: inherit; | |
| } | |
| liquid-glass > glass-inner { | |
| display: block; | |
| position: absolute; | |
| inset: 0; | |
| overflow: hidden; | |
| pointer-events: none; | |
| border-radius: inherit; | |
| } | |
| liquid-glass > specular-edge { | |
| display: block; | |
| position: absolute; | |
| inset: 0; | |
| pointer-events: none; | |
| border-radius: inherit; | |
| padding: var(--liquid-glass-specular-edge, 1.5px); | |
| background: conic-gradient( | |
| from var(--liquid-glass-specular-angle, 180deg), | |
| rgba(255, 255, 255, var(--liquid-glass-specular-opacity, 0.5)), | |
| transparent, | |
| transparent, | |
| rgba(255, 255, 255, var(--liquid-glass-specular-opacity, 0.5)) | |
| ); | |
| -webkit-mask: | |
| linear-gradient(#fff 0 0) content-box, | |
| linear-gradient(#fff 0 0); | |
| -webkit-mask-composite: xor; | |
| mask: | |
| linear-gradient(#fff 0 0) content-box, | |
| linear-gradient(#fff 0 0); | |
| mask-composite: exclude; | |
| } | |
| ` | |
| document.head.appendChild(style) | |
| LiquidGlassElement.stylesInjected = true | |
| } | |
| setEaseFunction(fn) { | |
| if (typeof fn === 'function') { | |
| this.customEase = fn | |
| this.update() | |
| } | |
| } | |
| getConfig() { | |
| const defaults = LiquidGlassElement.defaults | |
| return { | |
| edgeSize: this.hasAttribute('edge-size') ? parseFloat(this.getAttribute('edge-size')) : defaults.edgeSize, | |
| displacementScale: this.hasAttribute('displacement-scale') ? parseFloat(this.getAttribute('displacement-scale')) : defaults.displacementScale, | |
| blurAmount: this.hasAttribute('blur-amount') ? parseFloat(this.getAttribute('blur-amount')) : defaults.blurAmount, | |
| saturation: this.hasAttribute('saturation') ? parseFloat(this.getAttribute('saturation')) : defaults.saturation, | |
| easeType: this.getAttribute('ease-type') || defaults.easeType, | |
| easeExponent: this.hasAttribute('ease-exponent') ? parseFloat(this.getAttribute('ease-exponent')) : defaults.easeExponent, | |
| intensity: this.hasAttribute('intensity') ? parseFloat(this.getAttribute('intensity')) : defaults.intensity, | |
| specularEnabled: this.hasAttribute('specular-enabled') ? this.getAttribute('specular-enabled') !== 'false' : defaults.specularEnabled, | |
| specularEdgeSize: this.getAttribute('specular-edge-size') || defaults.specularEdgeSize, | |
| specularAngle: this.getAttribute('specular-angle') || defaults.specularAngle, | |
| specularOpacity: this.hasAttribute('specular-opacity') ? parseFloat(this.getAttribute('specular-opacity')) : defaults.specularOpacity, | |
| enableSpecularUi: this.hasAttribute('enable-specular-ui') && this.getAttribute('enable-specular-ui') !== 'false', | |
| enableRelativeSpecularUi: this.hasAttribute('enable-relative-specular-ui') && this.getAttribute('enable-relative-specular-ui') !== 'false', | |
| showPreview: this.hasAttribute('show-preview') ? this.getAttribute('show-preview') !== 'false' : defaults.showPreview, | |
| previewPosition: this.getAttribute('preview-position') || defaults.previewPosition, | |
| previewScale: this.hasAttribute('preview-scale') ? parseFloat(this.getAttribute('preview-scale')) : defaults.previewScale, | |
| } | |
| } | |
| ease(t) { | |
| if (this.customEase) return this.customEase(t) | |
| const config = this.getConfig() | |
| switch (config.easeType) { | |
| case 'smoothstep': return t * t * (3 - 2 * t) | |
| case 'smootherstep': return t * t * t * (t * (t * 6 - 15) + 10) | |
| case 'sqrt': return Math.pow(t, config.easeExponent) | |
| case 'linear': | |
| default: return t | |
| } | |
| } | |
| init() { | |
| this.createInner() | |
| this.createSpecularElement() | |
| this.createCanvas() | |
| this.createSvg() | |
| this.update() | |
| this.updateMouseTracker() | |
| const parent = this.parentElement | |
| if (parent) { | |
| this.resizeObserver = new ResizeObserver(() => this.update()) | |
| this.resizeObserver.observe(parent) | |
| } | |
| } | |
| updateMouseTracker() { | |
| const config = this.getConfig() | |
| // Clean up existing tracker if any | |
| if (this.mouseTrackerPropertyName) { | |
| MouseTracker.remove(this.mouseTrackerPropertyName) | |
| this.mouseTrackerPropertyName = null | |
| } | |
| // Reset specular background to default | |
| if (this.specular) { | |
| this.specular.style.removeProperty('background') | |
| } | |
| const shouldTrack = config.specularEnabled && (config.enableSpecularUi || config.enableRelativeSpecularUi) | |
| if (shouldTrack) { | |
| // Always use unique property name per element | |
| this.mouseTrackerPropertyName = `liquid-glass-specular-angle-${this.filterId}` | |
| const isRelative = config.enableRelativeSpecularUi | |
| MouseTracker.set( | |
| this.mouseTrackerPropertyName, | |
| ({ angle }) => `${angle}deg`, | |
| { | |
| target: this, | |
| origin: isRelative ? (this.parentElement || this) : 'viewport', | |
| removeOnBlur: true, | |
| } | |
| ) | |
| // Always update specular-edge to use the tracked property | |
| if (this.specular) { | |
| this.specular.style.setProperty( | |
| 'background', | |
| `conic-gradient( | |
| from var(--${this.mouseTrackerPropertyName}, var(--liquid-glass-specular-angle, 180deg)), | |
| rgba(255, 255, 255, var(--liquid-glass-specular-opacity, 0.5)), | |
| transparent, | |
| transparent, | |
| rgba(255, 255, 255, var(--liquid-glass-specular-opacity, 0.5)) | |
| )` | |
| ) | |
| } | |
| } | |
| } | |
| createInner() { | |
| this.inner = document.createElement('glass-inner') | |
| this.appendChild(this.inner) | |
| } | |
| createSpecularElement() { | |
| this.specular = document.createElement('specular-edge') | |
| this.appendChild(this.specular) | |
| } | |
| createCanvas() { | |
| this.canvas = document.createElement('canvas') | |
| this.canvas.id = `${this.filterId}-displacement` | |
| const config = this.getConfig() | |
| if (config.showPreview) { | |
| const positions = { | |
| 'top-right': 'top:20px;right:20px;', | |
| 'top-left': 'top:20px;left:20px;', | |
| 'bottom-right': 'bottom:20px;right:20px;', | |
| 'bottom-left': 'bottom:20px;left:20px;', | |
| } | |
| this.canvas.style.cssText = ` | |
| position: fixed; | |
| ${positions[config.previewPosition]} | |
| z-index: 9999; | |
| pointer-events: none; | |
| background: #111; | |
| ` | |
| document.body.appendChild(this.canvas) | |
| } | |
| } | |
| updatePreviewPosition(width, height) { | |
| const config = this.getConfig() | |
| if (!config.showPreview || !this.canvas) return | |
| const scale = config.previewScale | |
| this.canvas.style.width = `${width * scale}px` | |
| this.canvas.style.height = `${height * scale}px` | |
| } | |
| createSvg() { | |
| this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') | |
| this.svg.id = `${this.filterId}-svg` | |
| this.svg.style.cssText = 'position:absolute;width:0;height:0;pointer-events:none;' | |
| document.body.appendChild(this.svg) | |
| } | |
| generateDisplacementMap(width, height, radius) { | |
| this.canvas.width = width | |
| this.canvas.height = height | |
| const ctx = this.canvas.getContext('2d') | |
| const imageData = ctx.createImageData(width, height) | |
| const data = imageData.data | |
| const config = this.getConfig() | |
| const { edgeSize, intensity } = config | |
| for (let y = 0; y < height; y++) { | |
| for (let x = 0; x < width; x++) { | |
| const i = (y * width + x) * 4 | |
| const distLeft = x | |
| const distRight = width - 1 - x | |
| const distTop = y | |
| const distBottom = height - 1 - y | |
| let inShape = true | |
| if (x < radius && y < radius) { | |
| const dx = radius - x, dy = radius - y | |
| if (dx * dx + dy * dy > radius * radius) inShape = false | |
| } | |
| if (x > width - radius && y < radius) { | |
| const dx = x - (width - radius), dy = radius - y | |
| if (dx * dx + dy * dy > radius * radius) inShape = false | |
| } | |
| if (x < radius && y > height - radius) { | |
| const dx = radius - x, dy = y - (height - radius) | |
| if (dx * dx + dy * dy > radius * radius) inShape = false | |
| } | |
| if (x > width - radius && y > height - radius) { | |
| const dx = x - (width - radius), dy = y - (height - radius) | |
| if (dx * dx + dy * dy > radius * radius) inShape = false | |
| } | |
| if (!inShape) { | |
| data[i] = 128 | |
| data[i + 1] = 128 | |
| data[i + 2] = 0 | |
| data[i + 3] = 0 | |
| continue | |
| } | |
| let r = 128, g = 128 | |
| if (distLeft < edgeSize) { | |
| const t = 1 - this.ease(distLeft / edgeSize) | |
| r = 128 + (intensity * t) | |
| } else if (distRight < edgeSize) { | |
| const t = 1 - this.ease(distRight / edgeSize) | |
| r = 128 - (intensity * t) | |
| } | |
| if (distTop < edgeSize) { | |
| const t = 1 - this.ease(distTop / edgeSize) | |
| g = 128 + (intensity * t) | |
| } else if (distBottom < edgeSize) { | |
| const t = 1 - this.ease(distBottom / edgeSize) | |
| g = 128 - (intensity * t) | |
| } | |
| data[i] = r | |
| data[i + 1] = g | |
| data[i + 2] = 0 | |
| data[i + 3] = 255 | |
| } | |
| } | |
| ctx.putImageData(imageData, 0, 0) | |
| return this.canvas.toDataURL() | |
| } | |
| updateFilter(width, height, displacementMap) { | |
| const config = this.getConfig() | |
| const { blurAmount, displacementScale, saturation } = config | |
| this.svg.innerHTML = ` | |
| <defs> | |
| <filter id="${this.filterId}" color-interpolation-filters="sRGB" | |
| filterUnits="userSpaceOnUse" | |
| x="0" y="0" width="${width}" height="${height}"> | |
| <feGaussianBlur in="SourceGraphic" stdDeviation="${blurAmount}" result="blur" /> | |
| <feImage | |
| href="${displacementMap}" | |
| x="0" y="0" | |
| width="${width}" height="${height}" | |
| preserveAspectRatio="none" | |
| result="map" | |
| /> | |
| <feDisplacementMap | |
| in="blur" | |
| in2="map" | |
| scale="${displacementScale}" | |
| xChannelSelector="R" | |
| yChannelSelector="G" | |
| result="displaced" | |
| /> | |
| <feColorMatrix | |
| in="displaced" | |
| type="saturate" | |
| values="${saturation}" | |
| /> | |
| </filter> | |
| </defs> | |
| ` | |
| } | |
| updateSpecular() { | |
| const config = this.getConfig() | |
| if (config.specularEnabled && this.specular) { | |
| this.specular.style.display = '' | |
| this.style.setProperty('--liquid-glass-specular-edge', config.specularEdgeSize) | |
| this.style.setProperty('--liquid-glass-specular-angle', config.specularAngle) | |
| this.style.setProperty('--liquid-glass-specular-opacity', config.specularOpacity) | |
| } else if (this.specular) { | |
| this.specular.style.display = 'none' | |
| } | |
| } | |
| update() { | |
| const parent = this.parentElement | |
| if (!parent) return | |
| const rect = parent.getBoundingClientRect() | |
| const computedStyle = getComputedStyle(parent) | |
| const radius = parseFloat(computedStyle.borderRadius) || 0 | |
| const width = Math.round(rect.width) | |
| const height = Math.round(rect.height) | |
| if (width === 0 || height === 0) return | |
| const displacementMap = this.generateDisplacementMap(width, height, radius) | |
| this.updateFilter(width, height, displacementMap) | |
| this.updatePreviewPosition(width, height) | |
| this.updateSpecular() | |
| if (this.inner) { | |
| this.inner.style.backdropFilter = `url(#${this.filterId})` | |
| this.inner.style.webkitBackdropFilter = `url(#${this.filterId})` | |
| } | |
| } | |
| destroy() { | |
| if (this.mouseTrackerPropertyName) { | |
| MouseTracker.remove(this.mouseTrackerPropertyName) | |
| } | |
| if (this.resizeObserver) this.resizeObserver.disconnect() | |
| if (this.canvas?.parentNode) this.canvas.parentNode.removeChild(this.canvas) | |
| if (this.svg?.parentNode) this.svg.parentNode.removeChild(this.svg) | |
| } | |
| } | |
| if (!customElements.get("liquid-glass")) { | |
| customElements.define('liquid-glass', LiquidGlassElement) | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Generic Mouse Tracker Singleton | |
| */ | |
| class MouseTracker { | |
| static #enabled = false | |
| static #properties = new Map() | |
| static #mouseMoveHandler = null | |
| static #blurHandler = null | |
| static #lastMouseEvent = null | |
| constructor() { | |
| throw new Error('MouseTracker is a singleton. Use static methods.') | |
| } | |
| static get enabled() { | |
| return MouseTracker.#enabled | |
| } | |
| static set enabled(value) { | |
| const shouldEnable = Boolean(value) | |
| if (shouldEnable === MouseTracker.#enabled) return | |
| MouseTracker.#enabled = shouldEnable | |
| shouldEnable ? MouseTracker.#attach() : MouseTracker.#detach() | |
| } | |
| static #calculateAngle(toX, toY, fromX, fromY) { | |
| const dx = toX - fromX | |
| const dy = toY - fromY | |
| return Math.round(Math.atan2(dy, dx) * 180 / Math.PI) | |
| } | |
| /** | |
| * Register a custom property to be updated on mouse move | |
| * @param {string} propertyName - CSS custom property name (without --) | |
| * @param {Function} callback - Function that receives {x, y, angle, ...} and returns the value | |
| * @param {Object} options - Configuration options | |
| * @param {HTMLElement} options.target - Target element for the property (default: document.documentElement) | |
| * @param {HTMLElement|'viewport'|'cursor'} options.origin - Origin point for angle calculation: | |
| * - 'viewport': angle from viewport center to cursor (default) | |
| * - HTMLElement: angle from element center to cursor | |
| * - 'cursor': angle from cursor to target element center (light follows cursor) | |
| * @param {boolean} options.removeOnBlur - Remove property when window loses focus (default: true) | |
| */ | |
| static set(propertyName, callback, options = {}) { | |
| const { | |
| target = document.documentElement, | |
| origin = 'viewport', | |
| removeOnBlur = true, | |
| } = options | |
| MouseTracker.#properties.set(propertyName, { | |
| callback, | |
| target, | |
| origin, | |
| removeOnBlur, | |
| }) | |
| if (!MouseTracker.#enabled) { | |
| MouseTracker.enabled = true | |
| } | |
| if (MouseTracker.#lastMouseEvent) { | |
| MouseTracker.#updateProperty(propertyName, MouseTracker.#lastMouseEvent) | |
| } | |
| } | |
| static remove(propertyName) { | |
| const prop = MouseTracker.#properties.get(propertyName) | |
| if (prop) { | |
| prop.target.style.removeProperty(`--${propertyName}`) | |
| MouseTracker.#properties.delete(propertyName) | |
| } | |
| if (MouseTracker.#properties.size === 0) { | |
| MouseTracker.enabled = false | |
| } | |
| } | |
| static clear() { | |
| MouseTracker.#properties.forEach((prop, name) => { | |
| prop.target.style.removeProperty(`--${name}`) | |
| }) | |
| MouseTracker.#properties.clear() | |
| MouseTracker.enabled = false | |
| } | |
| static #updateProperty(name, event) { | |
| const prop = MouseTracker.#properties.get(name) | |
| if (!prop) return | |
| const cursorX = event.clientX | |
| const cursorY = event.clientY | |
| let angle, originX, originY | |
| if (prop.origin === 'cursor') { | |
| // Angle FROM cursor TO element center | |
| const rect = prop.target.getBoundingClientRect() | |
| const elementX = rect.left + rect.width / 2 | |
| const elementY = rect.top + rect.height / 2 | |
| originX = cursorX | |
| originY = cursorY | |
| angle = MouseTracker.#calculateAngle(elementX, elementY, cursorX, cursorY) | |
| } else if (prop.origin instanceof HTMLElement) { | |
| // Angle FROM element center TO cursor | |
| const rect = prop.origin.getBoundingClientRect() | |
| originX = rect.left + rect.width / 2 | |
| originY = rect.top + rect.height / 2 | |
| angle = MouseTracker.#calculateAngle(cursorX, cursorY, originX, originY) | |
| } else { | |
| // Angle FROM viewport center TO cursor | |
| originX = window.innerWidth / 2 | |
| originY = window.innerHeight / 2 | |
| angle = MouseTracker.#calculateAngle(cursorX, cursorY, originX, originY) | |
| } | |
| const data = { | |
| x: cursorX, | |
| y: cursorY, | |
| angle, | |
| originX, | |
| originY, | |
| deltaX: cursorX - originX, | |
| deltaY: cursorY - originY, | |
| } | |
| const value = prop.callback(data) | |
| prop.target.style.setProperty(`--${name}`, value) | |
| } | |
| static #attach() { | |
| if (MouseTracker.#mouseMoveHandler) return | |
| MouseTracker.#mouseMoveHandler = (event) => { | |
| MouseTracker.#lastMouseEvent = event | |
| MouseTracker.#properties.forEach((_, name) => { | |
| MouseTracker.#updateProperty(name, event) | |
| }) | |
| } | |
| MouseTracker.#blurHandler = () => { | |
| MouseTracker.#properties.forEach((prop, name) => { | |
| if (prop.removeOnBlur) { | |
| prop.target.style.removeProperty(`--${name}`) | |
| } | |
| }) | |
| } | |
| window.addEventListener('mousemove', MouseTracker.#mouseMoveHandler, { passive: true }) | |
| window.addEventListener('blur', MouseTracker.#blurHandler) | |
| } | |
| static #detach() { | |
| if (MouseTracker.#mouseMoveHandler) { | |
| window.removeEventListener('mousemove', MouseTracker.#mouseMoveHandler) | |
| MouseTracker.#mouseMoveHandler = null | |
| } | |
| if (MouseTracker.#blurHandler) { | |
| window.removeEventListener('blur', MouseTracker.#blurHandler) | |
| MouseTracker.#blurHandler = null | |
| } | |
| MouseTracker.#lastMouseEvent = null | |
| MouseTracker.#properties.forEach((prop, name) => { | |
| prop.target.style.removeProperty(`--${name}`) | |
| }) | |
| } | |
| } | |
| export default MouseTracker |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment