Skip to content

Instantly share code, notes, and snippets.

@marknotton
Last active January 29, 2026 11:22
Show Gist options
  • Select an option

  • Save marknotton/8dc7ca677cf5aafda9a2ed5c9498a880 to your computer and use it in GitHub Desktop.

Select an option

Save marknotton/8dc7ca677cf5aafda9a2ed5c9498a880 to your computer and use it in GitHub Desktop.
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)
}
/**
* 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