Metal emblem
An editable letter becomes a machined silver relief with directional highlights and a graphite surround.

Change the mark, move the light or hold it in place.
Subframe Original · MIT
Add to your project
Component source
Standalone React component. Styles are included.
React only. No additional packages.
Render the default export inside a container with a defined height. Its optional active prop stops movement when false. This example works with React 19; Next.js projects can use it as a client component.
View source code
/**
* MIT License
*
* Copyright (c) 2026 Subframe
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
"use client"
import { useEffect, useRef, useState } from "react"
function useReduced() {
const [reduced, setReduced] = useState(false)
useEffect(() => {
const q = matchMedia("(prefers-reduced-motion: reduce)")
const sync = () => setReduced(q.matches)
sync()
q.addEventListener("change", sync)
return () => q.removeEventListener("change", sync)
}, [])
return reduced
}
const metal = `precision highp float;varying vec2 uv;uniform sampler2D glyph;uniform vec2 resolution;uniform vec2 light;uniform float time;
float heightAt(vec2 q){vec2 h=texture2D(glyph,q).rg;return dot(h,vec2(65280.,255.))/65535.;}
void main(){vec2 p=(uv-.5)*vec2(resolution.x/resolution.y,1.)*.98+.5;float h=heightAt(p),e=.005;vec2 grad=vec2(heightAt(p+vec2(e,0.))-heightAt(p-vec2(e,0.)),heightAt(p+vec2(0.,e))-heightAt(p-vec2(0.,e)));vec3 n=normalize(vec3(-grad*5.,.18));vec3 l=normalize(vec3(light*.8+vec2(sin(time*.33)*.25,0.),1.));float spec=pow(max(0.,dot(reflect(-l,n),vec3(0.,0.,1.))),22.);float band=sin((n.x*.46+n.y*.3+p.x*.13+time*.018)*22.);float metalTone=.34+band*.27+max(0.,dot(n,l))*.22;vec3 silver=vec3(metalTone*.96,metalTone,metalTone*1.04)+spec*.62;float bevel=smoothstep(.1,.78,h);float grain=fract(sin(dot(gl_FragCoord.xy,vec2(12.9898,78.233)))*43758.5453);vec3 bg=vec3(.16,.17,.18)+(grain-.5)*.018;float shadow=heightAt(p+vec2(-.017,.027))*(1.-bevel);bg*=1.-shadow*.55;gl_FragColor=vec4(mix(bg,silver,bevel),1.);}`
export default function MetalEmblem({ active = true }: { active?: boolean }) {
const canvas = useRef<HTMLCanvasElement>(null),
[text, setText] = useState("M"),
[playing, setPlaying] = useState(true),
[failed, setFailed] = useState(false),
reduced = useReduced(),
live = useRef({ active, playing, reduced, text }),
control = useRef<{ update: () => void; wake: () => void; stop: () => void } | null>(null)
live.current = { active, playing, reduced, text }
useEffect(() => {
const c = canvas.current!,
gl = c.getContext("webgl", { antialias: false, alpha: false })
if (!gl) {
setFailed(true)
return
}
let dead = false,
raf = 0,
last = 0,
time = 0
const shaders: WebGLShader[] = []
const compile = (kind: number, code: string) => {
const s = gl.createShader(kind)!
gl.shaderSource(s, code)
gl.compileShader(s)
shaders.push(s)
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw Error("Compile failed")
return s
}
const p = gl.createProgram()!
let b: WebGLBuffer, t: WebGLTexture
try {
gl.attachShader(
p,
compile(
gl.VERTEX_SHADER,
"attribute vec2 position;varying vec2 uv;void main(){uv=position*.5+.5;gl_Position=vec4(position,0.,1.);}",
),
)
gl.attachShader(p, compile(gl.FRAGMENT_SHADER, metal))
gl.linkProgram(p)
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw Error("Link failed")
gl.useProgram(p)
b = gl.createBuffer()!
gl.bindBuffer(gl.ARRAY_BUFFER, b)
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW)
const a = gl.getAttribLocation(p, "position")
gl.enableVertexAttribArray(a)
gl.vertexAttribPointer(a, 2, gl.FLOAT, false, 0, 0)
t = gl.createTexture()!
gl.bindTexture(gl.TEXTURE_2D, t)
for (const axis of [gl.TEXTURE_WRAP_S, gl.TEXTURE_WRAP_T]) gl.texParameteri(gl.TEXTURE_2D, axis, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
} catch {
gl.deleteProgram(p)
shaders.forEach((s) => gl.deleteShader(s))
setFailed(true)
return
}
const shape = document.createElement("canvas")
shape.width = shape.height = 1024
const ctx = shape.getContext("2d", { willReadFrequently: true })!,
u = {
resolution: gl.getUniformLocation(p, "resolution"),
time: gl.getUniformLocation(p, "time"),
light: gl.getUniformLocation(p, "light"),
}
let light = { x: -0.4, y: 0.65 }
const paint = () => {
if (dead || gl.isContextLost()) return
gl.viewport(0, 0, c.width, c.height)
gl.uniform2f(u.resolution, c.width, c.height)
gl.uniform2f(u.light, light.x, light.y)
gl.uniform1f(u.time, time)
gl.drawArrays(gl.TRIANGLES, 0, 6)
}
const update = () => {
ctx.setTransform(2, 0, 0, 2, 0, 0)
ctx.fillStyle = "#000"
ctx.fillRect(0, 0, 512, 512)
ctx.fillStyle = "#fff"
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.font = `900 ${live.current.text.length > 1 ? 235 : 340}px Arial`
ctx.fillText(live.current.text || "M", 256, 269)
// Keep bevel heights in floating point while blurring, then encode 16 bits.
// An 8-bit blurred mask produces visible bands in a reflective normal field.
const size = shape.width
const pixels = ctx.getImageData(0, 0, size, size).data
let heights = new Float32Array(size * size)
let scratch = new Float32Array(heights.length)
for (let i = 0; i < heights.length; i++) heights[i] = pixels[i * 4] / 255
const radius = 9
for (let pass = 0; pass < 3; pass++) {
for (const vertical of [false, true]) {
const stride = vertical ? size : 1
for (let line = 0; line < size; line++) {
const start = vertical ? line : line * size
let sum = 0
for (let k = -radius; k <= radius; k++) sum += heights[start + Math.max(0, k) * stride]
for (let at = 0; at < size; at++) {
scratch[start + at * stride] = sum / (radius * 2 + 1)
sum += heights[start + Math.min(size - 1, at + radius + 1) * stride]
sum -= heights[start + Math.max(0, at - radius) * stride]
}
}
;[heights, scratch] = [scratch, heights]
}
}
const encoded = new Uint8Array(size * size * 4)
for (let i = 0; i < heights.length; i++) {
const height = Math.round(Math.max(0, Math.min(1, heights[i])) * 65535)
encoded[i * 4] = height >> 8
encoded[i * 4 + 1] = height & 255
encoded[i * 4 + 3] = 255
}
gl.bindTexture(gl.TEXTURE_2D, t)
// Typed-array uploads do not support UNPACK_FLIP_Y_WEBGL.
const row = size * 4
for (let y = 0; y < size / 2; y++) {
const opposite = (size - y - 1) * row
for (let x = 0; x < row; x++) {
const at = y * row + x
const value = encoded[at]
encoded[at] = encoded[opposite + x]
encoded[opposite + x] = value
}
}
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, size, size, 0, gl.RGBA, gl.UNSIGNED_BYTE, encoded)
paint()
}
const size = () => {
const r = c.getBoundingClientRect(),
d = Math.min(devicePixelRatio || 1, 2, Math.sqrt(3600000 / Math.max(1, r.width * r.height)))
const nextWidth = Math.max(1, Math.round(r.width * d))
const nextHeight = Math.max(1, Math.round(r.height * d))
if (c.width !== nextWidth || c.height !== nextHeight) {
c.width = nextWidth
c.height = nextHeight
}
paint()
}
const tick = (now: number) => {
raf = 0
if (
!live.current.active ||
!live.current.playing ||
live.current.reduced ||
document.hidden ||
gl.isContextLost()
)
return
time += Math.min(0.04, (now - last) / 1000 || 0.016)
last = now
paint()
raf = requestAnimationFrame(tick)
}
const wake = () => {
if (
!raf &&
live.current.active &&
live.current.playing &&
!live.current.reduced &&
!document.hidden &&
!gl.isContextLost()
) {
last = 0
raf = requestAnimationFrame(tick)
}
}
const stop = () => {
cancelAnimationFrame(raf)
raf = 0
}
const move = (e: PointerEvent) => {
const r = c.getBoundingClientRect()
light = { x: ((e.clientX - r.left) / r.width) * 2 - 1, y: 1 - ((e.clientY - r.top) / r.height) * 2 }
paint()
}
const lost = (e: Event) => {
e.preventDefault()
stop()
setFailed(true)
}
const ro = new ResizeObserver(size)
const transforms = new MutationObserver(size)
for (let node: HTMLElement | null = c; node; node = node.parentElement) {
ro.observe(node)
transforms.observe(node, { attributes: true, attributeFilter: ["style", "class"] })
}
window.addEventListener("resize", size)
c.addEventListener("pointermove", move)
c.addEventListener("webglcontextlost", lost)
const visibility = () => (document.hidden ? stop() : wake())
document.addEventListener("visibilitychange", visibility)
control.current = { update, wake, stop }
size()
update()
wake()
return () => {
dead = true
control.current = null
stop()
ro.disconnect()
transforms.disconnect()
window.removeEventListener("resize", size)
c.removeEventListener("pointermove", move)
c.removeEventListener("webglcontextlost", lost)
document.removeEventListener("visibilitychange", visibility)
gl.deleteTexture(t)
gl.deleteBuffer(b)
gl.deleteProgram(p)
shaders.forEach((s) => gl.deleteShader(s))
shape.width = 0
shape.height = 0
}
}, [])
useEffect(() => {
control.current?.update()
}, [text])
useEffect(() => {
if (active && playing && !reduced) control.current?.wake()
else control.current?.stop()
}, [active, playing, reduced])
return (
<section className="sf-metal-emblem" inert={!active}>
<div className="me-fallback" aria-hidden="true">
{text}
</div>
<canvas
ref={canvas}
role="img"
aria-label={`Silver relief of ${text || "M"}`}
style={{ visibility: failed ? "hidden" : "visible" }}
/>
<header>
<span>STAMP / STUDIO</span>
<span>Ag</span>
</header>
<footer>
<label>
Mark{" "}
<input
aria-label="Emblem letters"
value={text}
maxLength={2}
onChange={(e) => setText(e.target.value.replace(/[^a-z0-9]/gi, "").toUpperCase())}
/>
</label>
<button type="button" disabled={reduced} onClick={() => setPlaying(!playing)}>
{playing && !reduced ? "Hold light" : "Move light"}
</button>
</footer>
<style>{`.sf-metal-emblem{height:100%;width:100%;position:relative;overflow:hidden;background:#292b2e;color:#d7dadd;font-family:Arial,sans-serif}.sf-metal-emblem canvas{position:absolute;inset:0;height:100%;width:100%;display:block}.me-fallback{position:absolute;inset:0;display:grid;place-items:center;font:900 160px Arial;color:#bdc4cb}.sf-metal-emblem header{position:absolute;top:20px;left:22px;right:22px;display:flex;justify-content:space-between;pointer-events:none;font:9px Arial;letter-spacing:.1em;color:#b0b6bb}.sf-metal-emblem header span:last-child{font:italic 18px Georgia;letter-spacing:0}.sf-metal-emblem footer{position:absolute;bottom:16px;left:20px;right:20px;display:flex;align-items:center;justify-content:space-between;font:10px Arial}.sf-metal-emblem label{display:flex;align-items:center;gap:10px;color:#9ca5ab}.sf-metal-emblem input{font:12px Arial;color:#e1e5e7;background:#202225;border:1px solid #737b8155;border-radius:3px;width:48px;padding:8px;box-sizing:border-box;height:34px}.sf-metal-emblem button{background:transparent;color:#b9c1c6;border:1px solid #737b8155;border-radius:3px;padding:0 12px;height:34px;position:relative;font:10px Arial;cursor:pointer}.sf-metal-emblem button:before{content:'';position:absolute;inset:-5px}.sf-metal-emblem button:disabled{opacity:.4}.sf-metal-emblem button:focus-visible,.sf-metal-emblem input:focus-visible{outline:2px solid white;outline-offset:3px}`}</style>
</section>
)
}
Where it works well
Experiment with material light on a typographic mark without importing a 3D mesh.
Reduced motion
The automatic light is held still; mark editing and direct lighting control remain.
Things to know
A procedural glyph height mask produces a 2.5D surface, not extruded geometry. Native WebGL 1 falls back to the editable flat mark.