The light follows the pointer; click or tap to park it

Arrow keys move the light, Page Up and Page Down raise and lower it. Hold Shift for bigger steps.

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, where H is halfway between L and 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):

  1. <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.
  2. <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. L changes across the surface, so the light falls off at the far side and highlights move with it. Elevation is z: 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). Outside limitingConeAngle it's dark; specularExponent on 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 an elevation above the horizon. Every pixel gets the same L, 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.

Back to the demo ↑