How it works
Light from a height map
SVG filters can light an image as if it were a 3D surface. The trick is to treat one channel as height: the filter works out which way every pixel faces, and shades it accordingly.
The idea: normals from a height map
The lighting primitives read the alpha channel of their input as a height
map: alpha 0 is the ground, alpha 1 is surfaceScale px above it. For every
pixel the browser looks at its neighbors (a 3×3 Sobel kernel) to estimate the slope in x and
y, and turns the slope into a surface normal, the direction the surface faces at
that point:
N = normalize(−s · ∂A/∂x, −s · ∂A/∂y, 1)
On flat ground the normal points straight up, (0, 0, 1). On a slope it tilts
away from uphill, and a larger surfaceScale (s) tilts it further. Then classic
shading models are evaluated per pixel with the unit vector L from the pixel to the
light:
- diffuse
kd · (N · L) · lighting-color. Lambert's law: a face pointing at the light is brightest, one at a right angle to it is black. Matte plaster.- specular
ks · (N · H)exponent · lighting-color, whereHis halfway betweenLand the viewer (straight above, at infinity). Only faces that mirror the light into the eye light up: the glossy glint on the edges of the letters.
The height map is what you feed in. Here there are two, used alone or mixed (Relief):
<feTurbulence type="fractalNoise">, the same noise as on the noise page. Its alpha channel is a cloudy field around 0.5; lit, it reads as plaster at low frequencies and as paper grain at high ones.<feGaussianBlur in="SourceAlpha">: the element's own shape. Opaque letters on a transparent background are a height map already, but a sheer cliff: the slope is only one pixel wide. Blurring it turns the edges into ramps, and the ramps catch the light. Shape bevel is the blur, so it controls how wide the bevel is.
With both, an arithmetic composite mixes them: height = (1 − amount) · shape + amount · noise. The textured ground stays
textured, and the letters stand on it.
Lights: point, spot, distant
Each lighting primitive takes exactly one light source as its child. This is the one for your current settings, at the resting position:
<fePointLight x="0" y="0" z="320" /> - fePointLight
- A bulb at
x, y, z.Lchanges across the surface, so the light falls off at the far side and highlights move with it. Elevation isz: a low light rakes across the surface and shows every bump; a high one flattens it. - feSpotLight
- A point light with a cone, aimed at
pointsAtX/Y/Z(here the center of the surface). OutsidelimitingConeAngleit's dark;specularExponenton the light (not the primitive) would make the cone's falloff softer. - feDistantLight
- Parallel rays, like the sun: just an
azimuth(direction in the plane, degrees clockwise from +x, since y points down) and anelevationabove the horizon. Every pixel gets the sameL, so flat areas are evenly lit and only slopes differ. The demo derives both angles from where the pointer is relative to the center.
Light coordinates are in the filter's user space. With the filter applied to an
HTML element via CSS, that is CSS px measured from the element's top-left corner. The
filter's primitiveUnits default (userSpaceOnUse) keeps it that way; with primitiveUnits="objectBoundingBox" they'd be fractions of the box instead, and
so would z and stdDeviation, which is rarely what you want. Px
coordinates also mean the light doesn't follow when the element is resized, so the component
stores the resting position as a percentage and converts it with the size measured by a ResizeObserver (Svelte's bind:clientWidth).
Compositing onto a surface color
The lighting primitives output light, not a lit material: a full-size image in lighting-color, darker where the surface faces away. To get colored plaster, <feFlood> fills the region with the surface color and an arithmetic <feComposite> with k1="1" multiplies the two (result =
k1·a·b). The specular highlights are added on top (k2="1" k3="1"), so
they can brighten the surface color past what diffuse light could. The element's own colors
are thrown away; only its alpha was used.
<svg width="0" height="0" aria-hidden="true">
<filter id="lit" x="0" y="0" width="100%" height="100%"
color-interpolation-filters="sRGB">
<!-- height map 1: noise (its alpha channel) -->
<feTurbulence type="fractalNoise" baseFrequency="0.04"
numOctaves="4" seed="4" result="noise" />
<!-- height map 2: the element's own shape, with soft slopes -->
<feGaussianBlur in="SourceAlpha" stdDeviation="3" result="shape" />
<feComposite in="shape" in2="noise" operator="arithmetic"
k2="0.75" k3="0.25" result="height" />
<feFlood flood-color="#e8dfd0" result="surface" />
<feDiffuseLighting in="height" surfaceScale="5"
diffuseConstant="1"
lighting-color="#fff4e0" result="diffuse">
<fePointLight x="0" y="0" z="320" />
</feDiffuseLighting>
<feComposite in="surface" in2="diffuse" operator="arithmetic" k1="1"
result="lit" />
<feSpecularLighting in="height" surfaceScale="5"
specularConstant="0.6"
specularExponent="24"
lighting-color="#fff4e0" result="gloss">
<fePointLight x="0" y="0" z="320" />
</feSpecularLighting>
<feComposite in="lit" in2="gloss" operator="arithmetic" k2="1" k3="1" />
</filter>
</svg> Moving the light
The light is an element in the DOM, so moving it means changing its attributes. The demo
listens to pointermove, but applies at most one update per animation frame:
mice can report several hundred moves per second, and each update re-renders the filter.
When the pointer leaves, the light eases back to its resting position. A click or tap parks
it where you clicked.
const light = document.querySelector('#lit fePointLight');
let frame = 0;
surface.addEventListener('pointermove', (e) => {
if (frame) return; // at most one update per frame
frame = requestAnimationFrame(() => {
frame = 0;
const r = surface.getBoundingClientRect();
// user space = CSS px from the filtered element's top-left corner
light.setAttribute('x', e.clientX - r.left);
light.setAttribute('y', e.clientY - r.top);
});
}); Accessibility
- Pointer-free path: focus Move and use the arrow keys (Page Up/Down for height, Shift for bigger steps), or use the sliders. The position is announced. Without a pointer, the light rests at the position set by the sliders.
- Embossed text is low contrast by nature: it is the surface color on the surface color,
made visible only by shading, which disappears under some light angles. It's fine for
decoration; never put real content in it. Here it's
aria-hidden, and the surface has a text alternative instead. - Nothing moves on its own. The light only moves with the pointer or keys, and it eases back instead of jumping, so the surface doesn't change all at once.
Performance
Lighting filters are among the most expensive SVG primitives: per pixel, a Sobel kernel, a normalization and, for specular, a power function, and browsers don't cache intermediate results between renders. Changing a light attribute re-runs the whole chain, turbulence included. Measured on this page in Chromium (1265×800 CSS px at device pixel ratio 2, on a busy machine, so treat the numbers as rough): about 80–90 frames per second while the light sits still, 15–30 while it follows the pointer, and about 75 when the same pointer updates run without the filter. So the filter is the bottleneck, not the event handling. Keep lit areas small, throttle updates to one per frame (done here), don't animate the light on its own, and consider pre-rendering the turbulence to an image if the texture never changes.
Browser support
feDiffuseLighting, feSpecularLighting and the three light sources
are part of SVG 1.1 and implemented in every engine, and so is applying an SVG filter to an
HTML element with filter: url(#id). The rendering differs in details: with the
same numbers, Firefox 156 draws noticeably darker shadows and a harsher texture than
Chromium. The interaction was tested in Chromium; in Firefox only the static rendering was
checked. Safari is untested.