"use client"
import { useEffect, useRef } from "react"
import * as THREE from "three"
import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js"
import { mergeVertices } from "three/addons/utils/BufferGeometryUtils.js"
const FOV = 30
const CAMERA_Z = 18
const LENS_Z = 1.4
const LENS_WIDTH = 4.6
const LENS_HEIGHT = 2.4
const LENS_MAX_COVERAGE = 0.85
const BEVEL = 0.5
const PIXELS_PER_UNIT = 51.2
const FOLLOW_PER_FRAME = 0.06
const FRAME_MS = 1000 / 60
const MAX_TEXTURE_SIZE = 4096
const FONT_SIZE = 44
const LINE_HEIGHT = 80
const TEXT = "Liquid Glass "
const SURFACE = { light: "oklch(0.985 0 0)", dark: "oklch(0.145 0 0)" }
const INK = { light: "oklch(0.145 0 0)", dark: "oklch(0.985 0 0)" }
const BLOB_LIGHTNESS = { light: 0.82, dark: 0.47 }
const BLOBS = [
{ hue: 275, chroma: { light: 0.072, dark: 0.236 }, x: 0.27, y: 0.29 },
{ hue: 346, chroma: { light: 0.101, dark: 0.159 }, x: 0.76, y: 0.45 },
{ hue: 181, chroma: { light: 0.118, dark: 0.068 }, x: 0.45, y: 0.74 },
]
interface BackdropOptions {
dark: boolean
width: number
height: number
}
function createBackdropTexture({ dark, width, height }: BackdropOptions) {
const canvas = document.createElement("canvas")
canvas.width = Math.min(Math.round(width), MAX_TEXTURE_SIZE)
canvas.height = Math.min(Math.round(height), MAX_TEXTURE_SIZE)
const ctx = canvas.getContext("2d")
const texture = new THREE.CanvasTexture(canvas)
texture.colorSpace = THREE.SRGBColorSpace
if (!ctx) return texture
const mode = dark ? "dark" : "light"
ctx.fillStyle = SURFACE[mode]
ctx.fillRect(0, 0, canvas.width, canvas.height)
const lightness = BLOB_LIGHTNESS[mode]
const radius = Math.max(canvas.width, canvas.height) * 0.37
for (const blob of BLOBS) {
const centerX = blob.x * canvas.width
const centerY = blob.y * canvas.height
const color = `oklch(${lightness} ${blob.chroma[mode]} ${blob.hue}`
const gradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, radius)
gradient.addColorStop(0, `${color})`)
gradient.addColorStop(1, `${color} / 0)`)
ctx.fillStyle = gradient
ctx.fillRect(0, 0, canvas.width, canvas.height)
}
ctx.fillStyle = INK[mode]
ctx.font = `600 ${FONT_SIZE}px ui-sans-serif, system-ui, sans-serif`
const wordWidth = ctx.measureText(TEXT).width
const line = TEXT.repeat(Math.ceil(canvas.width / wordWidth) + 1)
const rows = Math.ceil(canvas.height / LINE_HEIGHT)
for (let row = 0; row <= rows; row++) {
const offset = row % 2 === 0 ? -wordWidth * 0.3 : -wordWidth * 0.8
ctx.fillText(line, offset, LINE_HEIGHT * row + FONT_SIZE + 12)
}
return texture
}
function createLensGeometry() {
const radius = (LENS_HEIGHT - BEVEL * 2) / 2
const x = (LENS_WIDTH - BEVEL * 2) / 2 - radius
const shape = new THREE.Shape()
shape.absarc(x, 0, radius, -Math.PI / 2, Math.PI / 2)
shape.absarc(-x, 0, radius, Math.PI / 2, (Math.PI * 3) / 2)
const extruded = new THREE.ExtrudeGeometry(shape, {
depth: 0.35,
bevelEnabled: true,
bevelThickness: BEVEL,
bevelSize: BEVEL,
bevelSegments: 12,
curveSegments: 48,
})
extruded.deleteAttribute("normal")
extruded.deleteAttribute("uv")
const geometry = mergeVertices(extruded)
geometry.computeVertexNormals()
geometry.center()
return geometry
}
function createLensMaterial() {
return new THREE.MeshPhysicalMaterial({
transmission: 1,
thickness: 1.6,
roughness: 0.03,
ior: 1.5,
dispersion: 5,
clearcoat: 1,
clearcoatRoughness: 0.15,
})
}
function createScene(container: HTMLDivElement) {
const renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.toneMapping = THREE.ACESFilmicToneMapping
container.appendChild(renderer.domElement)
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(FOV, 1, 0.1, 50)
camera.position.z = CAMERA_Z
const pmrem = new THREE.PMREMGenerator(renderer)
const environment = pmrem.fromScene(new RoomEnvironment()).texture
scene.environment = environment
const darkQuery = window.matchMedia("(prefers-color-scheme: dark)")
const backdropMaterial = new THREE.MeshBasicMaterial()
const backdrop = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), backdropMaterial)
scene.add(backdrop)
const backdropHeight = 2 * Math.tan((FOV * Math.PI) / 360) * CAMERA_Z
function updateBackdrop() {
const backdropWidth = backdropHeight * camera.aspect
backdrop.scale.set(backdropWidth, backdropHeight, 1)
backdropMaterial.map?.dispose()
backdropMaterial.map = createBackdropTexture({
dark: darkQuery.matches,
width: backdropWidth * PIXELS_PER_UNIT,
height: backdropHeight * PIXELS_PER_UNIT,
})
}
const lens = new THREE.Mesh(createLensGeometry(), createLensMaterial())
lens.position.z = LENS_Z
scene.add(lens)
const target = new THREE.Vector2()
const position = new THREE.Vector2()
const halfHeight = Math.tan((FOV * Math.PI) / 360) * (CAMERA_Z - LENS_Z)
function onPointerMove(event: PointerEvent) {
const rect = container.getBoundingClientRect()
target.set(
((event.clientX - rect.left) / rect.width) * 2 - 1,
1 - ((event.clientY - rect.top) / rect.height) * 2,
)
}
function onPointerLeave() {
target.set(0, 0)
}
let raf = 0
let previousTime: number | undefined
function tick(time: number) {
const elapsed = Math.min(time - (previousTime ?? time - FRAME_MS), FRAME_MS * 4)
previousTime = time
const follow = 1 - (1 - FOLLOW_PER_FRAME) ** (elapsed / FRAME_MS)
const halfWidth = halfHeight * camera.aspect
const rangeX = Math.max(halfWidth - (LENS_WIDTH * lens.scale.x) / 2, 0)
const rangeY = Math.max(halfHeight - (LENS_HEIGHT * lens.scale.y) / 2, 0)
position.x += (target.x * rangeX - position.x) * follow
position.y += (target.y * rangeY - position.y) * follow
lens.position.set(position.x, position.y + Math.sin(time * 0.0009) * 0.1, LENS_Z)
lens.rotation.set(
-position.y * 0.1 + Math.sin(time * 0.0007) * 0.06,
position.x * 0.1 + Math.cos(time * 0.0005) * 0.06,
0,
)
renderer.render(scene, camera)
raf = requestAnimationFrame(tick)
}
const observer = new ResizeObserver(() => {
const width = container.clientWidth
const height = container.clientHeight
if (!width || !height) return
renderer.setSize(width, height)
camera.aspect = width / height
camera.updateProjectionMatrix()
const visibleWidth = 2 * halfHeight * camera.aspect
lens.scale.setScalar(Math.min(1, (visibleWidth * LENS_MAX_COVERAGE) / LENS_WIDTH))
updateBackdrop()
renderer.render(scene, camera)
})
observer.observe(container)
function onThemeChange() {
updateBackdrop()
renderer.render(scene, camera)
}
darkQuery.addEventListener("change", onThemeChange)
const isStatic = window.matchMedia("(prefers-reduced-motion: reduce)").matches
if (!isStatic) {
container.addEventListener("pointermove", onPointerMove)
container.addEventListener("pointerleave", onPointerLeave)
raf = requestAnimationFrame(tick)
}
return () => {
cancelAnimationFrame(raf)
observer.disconnect()
darkQuery.removeEventListener("change", onThemeChange)
container.removeEventListener("pointermove", onPointerMove)
container.removeEventListener("pointerleave", onPointerLeave)
backdropMaterial.map?.dispose()
backdropMaterial.dispose()
backdrop.geometry.dispose()
lens.geometry.dispose()
lens.material.dispose()
environment.dispose()
pmrem.dispose()
renderer.dispose()
renderer.domElement.remove()
}
}
export function LiquidGlass() {
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const container = containerRef.current
if (!container) return
return createScene(container)
}, [])
return <div ref={containerRef} style={{ position: "absolute", inset: 0 }} />
}