morphiq
componentprism-glasssource: ok · refreshed
Prism Glass
A GPU-accelerated refractive lens for images and rendered textures. Spectral dispersion, uniform frost and a directional rim light, from an SDF-defined shape — no 3D model required. It refracts the texture you give it, not the DOM behind it.
npx shadcn add "https://morphiq.prathampatel.design/r/prism-glass.json"free
Visual style
glass-blur3d
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · heavythreewebglthree@react-three/fiber@react-three/drei
Files5 · source cached 10/8/2026, 3:25:46 AM
packages/prism-glass/src/index.js
/* Both shapes on purpose:
- default, so a registry install can be imported as '@/components/prism-glass'
- named, which is what the published @morphiq/prism-glass package exposes */
export { default } from './PrismGlass.jsx'
export { default as PrismGlass } from './PrismGlass.jsx'
packages/prism-glass/src/PrismGlass.jsx
'use client'
import { useRef, useMemo, useState, useEffect, Suspense } from 'react'
import * as THREE from 'three'
import { Canvas, useFrame, useThree } from '@react-three/fiber'
import { useTexture } from '@react-three/drei'
import { easing } from 'maath'
import { vertexShader, fragmentShader } from './shader.js'
import { loadSdfTexture } from './sdf.js'
const SHAPES = { circle: 0, rect: 1, pill: 1, cursor: 2 }
// Stands in for `image` when the caller supplies a `texture` directly, so the
// loader hook stays unconditional.
const BLANK_PX =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='
function clamp01(n) {
return Math.max(0, Math.min(1, n))
}
// Normalise the `size` prop: number → circle radius; [w,h] → rect/pill half-extents.
function resolveGeometry({ shape, size, radius }) {
if (shape === 'cursor') {
const r = (typeof size === 'number' ? size : 20) / 100
return { shape: 2, radius: r, half: [r, r], corner: r }
}
if (shape === 'circle') {
const r = (typeof size === 'number' ? size : 20) / 100
return { shape: 0, radius: r, half: [r, r], corner: r }
}
const [w, h] = Array.isArray(size) ? size : [size ?? 28, size ?? 34]
const half = [w / 100, h / 100]
const corner =
shape === 'pill' ? Math.min(half[0], half[1]) : (radius ?? 7) / 100
return { shape: 1, radius: Math.min(half[0], half[1]), half, corner }
}
function Surface({ image, mask, texture, optionsRef }) {
const matRef = useRef()
const loaded = useTexture(image)
const tex = texture || loaded
const { viewport, size } = useThree()
const uniforms = useMemo(
() => ({
uTex: { value: null },
uAspect: { value: 1 },
uImgAspect: { value: 1 },
uCenter: { value: new THREE.Vector2(0.5, 0.5) },
uSize: { value: new THREE.Vector2(0.28, 0.34) },
uRadius: { value: 0.2 },
uCorner: { value: 0.07 },
uShape: { value: 0 },
uPx: { value: 0.001 },
uMask: { value: null },
uUseMask: { value: 0 },
uMaskAspect: { value: 1 },
uLightAngle: { value: 0 },
uLightInt: { value: 1 },
uRefraction: { value: 1 },
uDepth: { value: 0.6 },
uDispersion: { value: 0 },
uFrost: { value: 0 },
uSplay: { value: 0 },
uAlpha: { value: 0 },
}),
[]
)
/* The loaded field is keyed by the mask it was built from, so clearing or
swapping the mask drops the old one during render instead of in an effect
that would paint one frame with the stale shape first. */
const [field, setField] = useState({ mask: null, sdf: null })
useEffect(() => {
if (!mask) return
let alive = true
loadSdfTexture(mask)
.then((r) => { if (alive) setField({ mask, sdf: r }) })
.catch(() => { if (alive) setField({ mask, sdf: null }) })
return () => { alive = false }
}, [mask])
const sdf = mask && field.mask === mask ? field.sdf : null
useFrame((state, delta) => {
const u = matRef.current.uniforms
const o = optionsRef.current
const g = resolveGeometry(o)
u.uTex.value = tex
u.uAlpha.value = o.transparent ? 1 : 0
u.uAspect.value = size.width / size.height
u.uPx.value = 1 / Math.max(1, state.gl.domElement.height)
u.uImgAspect.value = tex.image ? tex.image.width / tex.image.height : 1
u.uShape.value = g.shape
u.uUseMask.value = sdf ? 1 : 0
u.uMask.value = sdf ? sdf.texture : null
u.uMaskAspect.value = sdf ? sdf.aspect : 1
u.uRadius.value = g.radius
u.uCorner.value = g.corner
u.uSize.value.set(g.half[0], g.half[1])
u.uLightAngle.value = (o.lightAngle * Math.PI) / 180
u.uLightInt.value = o.lightIntensity / 100
u.uRefraction.value = o.refraction / 100
u.uDepth.value = o.depth / 100
u.uDispersion.value = o.dispersion / 100
u.uFrost.value = o.frost / 100
u.uSplay.value = o.splay / 100
const target =
o.mode === 'cursor'
? [state.pointer.x * 0.5 + 0.5, state.pointer.y * 0.5 + 0.5]
: [clamp01(o.position[0]), clamp01(o.position[1])]
if (o.follow > 0) {
easing.damp2(u.uCenter.value, target, o.follow, delta)
} else {
u.uCenter.value.set(target[0], target[1])
}
})
return (
<mesh scale={[viewport.width, viewport.height, 1]}>
<planeGeometry />
<shaderMaterial
ref={matRef}
uniforms={uniforms}
vertexShader={vertexShader}
fragmentShader={fragmentShader}
transparent
depthWrite={false}
premultipliedAlpha
/>
</mesh>
)
}
/**
* PrismGlass — a programmable liquid-glass refraction surface.
* Renders `image` full-bleed and bends it through a glass lens.
*/
export default function PrismGlass({
image = BLANK_PX,
mask,
texture,
transparent = false,
shape = 'circle',
mode = 'cursor',
size = 20,
radius = 7,
position = [0.5, 0.5],
follow = 0.09,
refraction = 100,
depth = 60,
dispersion = 0,
frost = 0,
splay = 0,
lightAngle = 45,
lightIntensity = 100,
dpr = [1, 2],
className,
style,
...rest
}) {
const options = {
shape, mode, size, radius, position, follow,
refraction, depth, dispersion, frost, splay,
lightAngle, lightIntensity, transparent,
}
// The frame loop reads the latest committed props; seeded with the first
// render so a frame that lands before the sync effect never sees an empty object.
const optionsRef = useRef(options)
useEffect(() => {
optionsRef.current = options
})
return (
<div
className={className}
style={{
position: 'relative',
width: '100%',
height: '100%',
overflow: 'hidden',
...style,
}}
{...rest}
>
<Canvas
gl={{ antialias: true, alpha: true, premultipliedAlpha: true }}
onCreated={({ gl }) => gl.setClearAlpha(0)}
dpr={dpr}
style={{ display: 'block', width: '100%', height: '100%' }}
>
<Suspense fallback={null}>
<Surface image={image} mask={mask} texture={texture} optionsRef={optionsRef} />
</Suspense>
</Canvas>
</div>
)
}
packages/prism-glass/src/shader.js
export const vertexShader = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`
export const fragmentShader = /* glsl */ `
precision highp float;
uniform sampler2D uTex;
uniform float uAspect; // view aspect (w/h)
uniform float uImgAspect; // image aspect (w/h)
uniform vec2 uCenter;
uniform vec2 uSize;
uniform float uRadius;
uniform float uCorner;
uniform float uShape;
uniform float uPx;
uniform sampler2D uMask;
uniform float uUseMask;
uniform float uMaskAspect;
uniform float uLightAngle;
uniform float uLightInt;
uniform float uRefraction;
uniform float uDepth;
uniform float uDispersion;
uniform float uFrost;
uniform float uSplay;
uniform float uAlpha; // 1 = draw only the lens, transparent elsewhere
varying vec2 vUv;
// signed distance to a rounded rectangle (<0 inside)
float sdRound(vec2 p, vec2 b, float r) {
vec2 q = abs(p) - b + r;
return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
}
// map a screen uv to a "cover" sample of the image (no stretch)
vec2 coverUv(vec2 uv) {
vec2 s = (uAspect > uImgAspect)
? vec2(1.0, uImgAspect / uAspect)
: vec2(uAspect / uImgAspect, 1.0);
return (uv - 0.5) * s + 0.5;
}
vec3 sampleBG(vec2 uv, float lod) {
return texture2D(uTex, clamp(coverUv(uv), 0.001, 0.999), lod).rgb;
}
// Visible-spectrum response for t in [0,1] (red → violet) built from broad,
// heavily OVERLAPPING Gaussian lobes. Overlap is the point: no value of t
// ever yields a pure primary, so neighbouring wavelengths blend into a
// continuous iridescence instead of banding into discrete colour channels.
vec3 spectrum(float t) {
float r = exp(-pow((t - 0.16) / 0.42, 2.0));
float g = exp(-pow((t - 0.50) / 0.42, 2.0));
float b = exp(-pow((t - 0.84) / 0.42, 2.0));
return vec3(r, g, b);
}
/* ---- cursor arrow: exact polygon SDF from the source SVG ----
Vertices are the SVG path, centred and normalised so the arrow's height
is 1.0, with Y flipped (SVG is y-down). Unrolled for 4 edges so there is
no dynamic array indexing — compiles everywhere. */
const vec2 A0 = vec2(-0.412371, 0.500000);
const vec2 A1 = vec2( 0.411402, -0.065866);
const vec2 A2 = vec2(-0.085669, -0.065866);
const vec2 A3 = vec2(-0.334205, -0.496340);
float segD2(vec2 p, vec2 a, vec2 b) {
vec2 e = b - a;
vec2 w = p - a;
vec2 q = w - e * clamp(dot(w, e) / dot(e, e), 0.0, 1.0);
return dot(q, q);
}
// even-odd crossing test, division-free (handles horizontal edges safely)
bool flipSide(vec2 p, vec2 a, vec2 b) {
vec2 e = b - a;
vec2 w = p - a;
bvec3 c = bvec3(p.y >= a.y, p.y < b.y, e.x * w.y > e.y * w.x);
return all(c) || all(not(c));
}
float sdArrow(vec2 p) {
float d = min(min(segD2(p, A0, A1), segD2(p, A1, A2)),
min(segD2(p, A2, A3), segD2(p, A3, A0)));
float s = 1.0;
if (flipSide(p, A0, A1)) s = -s;
if (flipSide(p, A1, A2)) s = -s;
if (flipSide(p, A2, A3)) s = -s;
if (flipSide(p, A3, A0)) s = -s;
return s * sqrt(d);
}
float sdf(vec2 p) {
// A mask overrides the built-in shapes: sample a signed-distance texture so
// any silhouette (curves included) behaves like an analytic SDF.
if (uUseMask > 0.5) {
float fullH = 2.0 * uRadius;
vec2 half_ = vec2(fullH * uMaskAspect, fullH) * 0.5;
vec2 muv = p / (2.0 * half_) + 0.5;
if (muv.x < 0.0 || muv.x > 1.0 || muv.y < 0.0 || muv.y > 1.0) return fullH;
return (texture2D(uMask, muv).r - 0.5) * fullH;
}
if (uShape > 1.5) {
float k = 2.0 * uRadius; // arrow height spans 2*uRadius
return sdArrow(p / k) * k;
}
return (uShape < 0.5) ? length(p) - uRadius : sdRound(p, uSize, uCorner);
}
void main() {
// aspect-corrected space so corners stay round
vec2 q = vUv - uCenter;
vec2 qa = vec2(q.x * uAspect, q.y);
// uShape: 0 = circle (uRadius), 1 = rounded rect / pill (uSize + uCorner)
float d = sdf(qa);
// Antialias the silhouette. d is a true distance, so one pixel of it is
// uPx. Shade the transition band too and blend by coverage below — a hard
// d > 0.0 cutoff is what makes the outline look pixelated.
float aa = max(uPx * 1.5, 1e-6);
if (d > aa) {
// Outside the lens: either the plain backdrop, or nothing at all when
// the surface is meant to sit over live page content.
gl_FragColor = (uAlpha > 0.5)
? vec4(0.0)
: vec4(sampleBG(vUv, 0.0), 1.0);
return;
}
// DEPTH = reach: how far the refraction band extends in from the border.
// low depth → thin edge band; high depth → wider band but the CENTER
// still stays clear (matches the reference even at depth 100).
float mn = (uShape > 0.5 && uShape < 1.5) ? min(uSize.x, uSize.y) : uRadius;
float bw = mix(mn * 0.04, mn * 0.52, uDepth);
float edge = -d; // 0 at border → grows inward
float t = clamp(edge / bw, 0.0, 1.0); // 0 border, 1 inner (flat center)
float slope = pow(1.0 - t, 1.8); // strong at rim, 0 in the centre
// outward normal = SDF gradient, WIDE stencil so it rotates smoothly around
// the corners instead of flipping along the diagonals (kills the "X" seam).
// Outward normal via a wide-stencil gradient. The wide stencil is what
// makes the direction rotate smoothly around rounded-rect corners instead
// of flipping along the diagonals (which would show as an "X" seam).
float eps = 0.03;
vec2 grad = vec2(
sdf(qa + vec2(eps, 0.0)) - sdf(qa - vec2(eps, 0.0)),
sdf(qa + vec2(0.0, eps)) - sdf(qa - vec2(0.0, eps))
);
grad = normalize(grad + 1e-6);
// normal & tangent, in UV space (aspect-aware)
vec2 nrm = normalize(vec2(grad.x / uAspect, grad.y));
vec2 tng = vec2(-nrm.y, nrm.x);
float qt = dot(q, tng); // tangential position from the panel centre
// REFRACTION is the MASTER strength — it gates the entire effect. At 0 the
// glass is invisible no matter what Depth/Splay/Dispersion are.
// • base NORMAL refraction bends the content in the band (subtle).
// • SPLAY adds TANGENTIAL spread (stretch along the edge).
// DEPTH scales the warp MAGNITUDE (quadratic → gentle mid-range, bold and
// liquid-metal at 100), not just the reach.
vec2 dispNormal = -nrm * slope * (0.02 + uDepth * uDepth * 0.26);
vec2 dispTangential = -tng * qt * slope * uSplay * 0.85;
vec2 disp = (dispNormal + dispTangential) * uRefraction;
// DISPERSION scales with the LOCAL WARP magnitude, so bold colour bands
// spread through the whole distorted zone (not a thin line on the rim).
// Quadratic response: ~invisible at 25, bold and saturated at 100.
float dsp = uDispersion * uDispersion * uRefraction;
float ds = dsp * (length(disp) * 0.38 + slope * 0.008);
vec2 dd = nrm * ds;
// frost = clean uniform mip blur; a touch of blur scaled to the warp size
// keeps big displacements reading as smooth liquid glass (not fine ripples).
// extra blur where dispersion is active merges the colour split into broad
// smooth iridescent bands instead of fine multicoloured noise.
float lod = slope * 0.6 + length(disp) * 5.0 + dsp * 1.3;
// SPECTRAL dispersion: integrate many samples across the visible spectrum,
// each displaced by a wavelength-dependent amount and weighted by its
// spectral colour. This yields a continuous chromatic texture (real prism
// behaviour) rather than three separated R/G/B ghost layers.
vec3 col = vec3(0.0);
vec3 wsum = vec3(0.0);
for (int i = 0; i < 28; i++) {
float t = float(i) / 27.0;
vec3 w = spectrum(t);
vec2 off = dd * (t - 0.5) * 2.0;
col += sampleBG(vUv + disp + off, lod) * w;
wsum += w;
}
col /= max(wsum, vec3(1e-4));
// FROST: a uniform diffusing layer across the WHOLE glass — a constant
// radius disc blur (golden-angle spiral for even, streak-free coverage).
// Deliberately independent of slope/warp so the frosting is even everywhere,
// the way a real etched/frosted layer on the lens behaves.
if (uFrost > 0.001) {
float r = uFrost * 0.055;
float flod = lod + uFrost * 4.5; // mip blur carries most of the diffusion
// so the 16 taps never read as ghosts
vec3 acc = vec3(0.0);
for (int i = 0; i < 16; i++) {
float fi = float(i);
float a = fi * 2.39996323; // golden angle
float rad = r * sqrt((fi + 0.5) / 16.0); // uniform disc density
vec2 o = vec2(cos(a) / uAspect, sin(a)) * rad;
acc += sampleBG(vUv + disp + o, flod);
}
col = mix(col, acc / 16.0, clamp(uFrost * 1.35, 0.0, 1.0));
}
// LIGHT: a bright rim STROKE at the very border, INDEPENDENT of refraction
// (it shows even with refraction 0). Intensity = brightness; Angle = which
// side of the rim catches the light, with a faint base glow all around.
float rimW = mn * 0.06; // stroke width from border
float rim = 1.0 - smoothstep(0.0, rimW, edge); // 1 at border → 0 inward
vec2 Ldir = vec2(cos(uLightAngle), sin(uLightAngle));
float facing = max(dot(nrm, Ldir), 0.0);
float topBias = max(nrm.y, 0.0); // the top of the rim always catches a bit more
float rimLight = rim * uLightInt * (0.16 + 0.6 * facing + 0.28 * topBias);
col += rimLight;
// coverage: 1 well inside, 0 just outside, smooth across one pixel
float cov = 1.0 - smoothstep(-aa, aa, d);
if (uAlpha > 0.5) {
// Premultiplied, so the lens composites cleanly over whatever is behind
// it in the DOM instead of carrying its own copy of the backdrop.
gl_FragColor = vec4(col * cov, cov);
return;
}
col = mix(sampleBG(vUv, 0.0), col, cov);
gl_FragColor = vec4(col, 1.0);
}
`
packages/prism-glass/src/sdf.js
import * as THREE from 'three'
/* Turn any silhouette (SVG / PNG) into a signed-distance-field texture, so the
shader can treat an arbitrary shape exactly like an analytic SDF.
Encoding: r = 0.5 on the outline, <0.5 inside, >0.5 outside, in units of the
padded image height. Bilinear filtering of an SDF keeps edges sharp. */
const SQRT2 = Math.SQRT2
// two-pass chamfer distance transform; `seed[i] === 1` means distance 0
function chamfer(seed, W, H) {
const d = new Float32Array(W * H)
d.fill(1e9)
for (let i = 0; i < W * H; i++) if (seed[i]) d[i] = 0
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const i = y * W + x
let v = d[i]
if (x > 0) v = Math.min(v, d[i - 1] + 1)
if (y > 0) v = Math.min(v, d[i - W] + 1)
if (x > 0 && y > 0) v = Math.min(v, d[i - W - 1] + SQRT2)
if (x < W - 1 && y > 0) v = Math.min(v, d[i - W + 1] + SQRT2)
d[i] = v
}
}
for (let y = H - 1; y >= 0; y--) {
for (let x = W - 1; x >= 0; x--) {
const i = y * W + x
let v = d[i]
if (x < W - 1) v = Math.min(v, d[i + 1] + 1)
if (y < H - 1) v = Math.min(v, d[i + W] + 1)
if (x < W - 1 && y < H - 1) v = Math.min(v, d[i + W + 1] + SQRT2)
if (x > 0 && y < H - 1) v = Math.min(v, d[i + W - 1] + SQRT2)
d[i] = v
}
}
return d
}
function build(img, res) {
// rasterise at `res` tall for precision, with padding so outside distances grow
const ar = img.width / img.height
const iw = Math.max(1, Math.round(res * ar))
const ih = res
const pad = Math.round(Math.max(iw, ih) * 0.35)
const W = iw + pad * 2
const H = ih + pad * 2
const c = document.createElement('canvas')
c.width = W
c.height = H
const ctx = c.getContext('2d', { willReadFrequently: true })
ctx.clearRect(0, 0, W, H)
ctx.drawImage(img, pad, pad, iw, ih)
const px = ctx.getImageData(0, 0, W, H).data
// opaque = inside; if the art has no alpha, fall back to "not white"
let maxA = 0
for (let i = 3; i < px.length; i += 4) if (px[i] > maxA) maxA = px[i]
const useAlpha = maxA > 8
const inside = new Uint8Array(W * H)
const outside = new Uint8Array(W * H)
for (let i = 0; i < W * H; i++) {
const o = i * 4
const on = useAlpha
? px[o + 3] > 127
: px[o + 3] > 127 && (px[o] + px[o + 1] + px[o + 2]) / 3 < 240
inside[i] = on ? 1 : 0
outside[i] = on ? 0 : 1
}
const dOut = chamfer(inside, W, H) // outside → nearest shape pixel
const dIn = chamfer(outside, W, H) // inside → nearest background pixel
// encode, flipping vertically (canvas is y-down, GL textures are y-up)
const data = new Uint8Array(W * H)
for (let y = 0; y < H; y++) {
const src = (H - 1 - y) * W
const dst = y * W
for (let x = 0; x < W; x++) {
const i = src + x
const signed = inside[i] ? -dIn[i] : dOut[i]
const enc = 0.5 + signed / H
data[dst + x] = Math.max(0, Math.min(255, Math.round(enc * 255)))
}
}
const texture = new THREE.DataTexture(data, W, H, THREE.RedFormat)
texture.minFilter = THREE.LinearFilter
texture.magFilter = THREE.LinearFilter
texture.wrapS = texture.wrapT = THREE.ClampToEdgeWrapping
texture.needsUpdate = true
return { texture, aspect: W / H }
}
export function loadSdfTexture(url, res = 256) {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
try { resolve(build(img, res)) } catch (e) { reject(e) }
}
img.onerror = reject
img.src = url
})
}
packages/prism-glass/types/index.d.ts
import type { ComponentPropsWithoutRef } from 'react'
export interface PrismGlassProps extends ComponentPropsWithoutRef<'div'> {
/** Image URL rendered full-bleed behind (and refracted by) the glass. */
image?: string
/** A three.js texture to refract instead of `image` — a render target's
* texture, for scene glass. Its sampler settings are left as you set them. */
texture?: { readonly isTexture: true }
/** Let the page show through where the lens is not, instead of drawing
* `image` full-bleed behind it. */
transparent?: boolean
/** Silhouette (SVG or PNG) used as the lens outline. Overrides `shape`.
* Converted to a signed-distance field on load, so curves work. */
mask?: string
/** Lens outline. `pill` is a rounded rect with fully-rounded ends. */
shape?: 'circle' | 'rect' | 'pill' | 'cursor'
/** `cursor` follows the pointer; `static` sits at `position`. */
mode?: 'cursor' | 'static'
/** circle/cursor: radius i.e. half-height (0-100). rect/pill: `[width, height]`. */
size?: number | [number, number]
/** Corner radius (0-100) for `shape="rect"`. Ignored otherwise. */
radius?: number
/** Position in `static` mode, normalised `[x, y]` from 0 to 1. */
position?: [number, number]
/** Follow easing. 0 snaps instantly; higher trails more. */
follow?: number
/** Master strength (0-100). At 0 the glass is invisible. */
refraction?: number
/** Reach of the refraction band and the warp magnitude (0-100). */
depth?: number
/** Spectral colour separation (0-100). */
dispersion?: number
/** Uniform frosting across the lens (0-100). */
frost?: number
/** Tangential stretch along the edge (0-100). */
splay?: number
/** Direction of the rim light, in degrees. */
lightAngle?: number
/** Rim light brightness (0-100). */
lightIntensity?: number
/** Device pixel ratio passed to the renderer. */
dpr?: number | [number, number]
}
export declare function PrismGlass(props: PrismGlassProps): JSX.Element
export default PrismGlass