Still effects with an SVG filter

A title card sits over a stippled sky, and a photo beside it is printed as a halftone. Both are SVG filters on HTML that does not change, since effect() takes only an image, a video or a canvas. The live example runs the stipple alone, and the halftone's code follows it.

  • The stipple puts ink where fine noise from feTurbulence is higher than the brightness under it.
  • The halftone compares a field of dot cones instead, white at each center and black at each cell's corners, so dark areas get large dots. The scene draws the field once as a PNG on a canvas and sets it on the feImage itself, since a binding does not accept an SVG data: URL in an attribute.
<div id="stage" data-composition-id="night-card"     data-start="0" data-duration="4" data-width="1280" data-height="720" data-fps="30">
    <svg width="0" height="0" style="position: absolute" aria-hidden="true"><defs>
        <filter id="stipple" x="0" y="0" width="100%" height="100%" color-interpolation-filters="sRGB">
            <feColorMatrix in="SourceGraphic" type="luminanceToAlpha" result="lum"/>
            <feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="1" seed="7" result="noiseRGB"/>
            <feColorMatrix in="noiseRGB" type="luminanceToAlpha" result="noiseRaw"/>
            <feComponentTransfer in="noiseRaw" result="noise"><feFuncA type="linear" slope="3.2" intercept="-1.1"/></feComponentTransfer>
            <feComposite in="noise" in2="lum" operator="arithmetic" k2="1" k3="-1" k4="0.5" result="diff"/>
            <feComponentTransfer in="diff" result="ink"><feFuncA type="linear" slope="16" intercept="-8"/></feComponentTransfer>
            <feFlood flood-color="#132a4a" result="inkColour"/>
            <feComposite in="inkColour" in2="ink" operator="in" result="dots"/>
            <feFlood flood-color="#f6efe2" result="paper"/>
            <feMerge><feMergeNode in="paper"/><feMergeNode in="dots"/></feMerge>
        </filter>
    </defs></svg>

    <div class="layer" data-component="night-card" data-start="0" data-duration="4">
        <div class="sky" data-bind-style="sky"></div>
        <h1 class="title">Night Shift</h1>
    </div>
</div>
const { scene } = wildflower.kinetic;

scene('night-card', {
    computed: {
        // The filter is on only inside the clip's window: the clip starts at 0, and
        // after it local holds at its length, so remaining is 0 there.
        on() { return this.remaining > 0; },
        sky() { return { filter: this.on ? 'url(#stipple)' : 'none' }; },
    },
});
html, body { margin: 0; background: #0a0b10; }

#stage { position: relative; overflow: hidden; background: #0a0a10; }

.layer { position: absolute; inset: 0; }

.sky { position: absolute; inset: 0; background: linear-gradient(#0a0a10, #6b6b78 45%, #f4f4f4); }
.title { position: absolute; inset: 0; display: grid; place-items: center; margin: 0; color: #c8452d; font: 800 120px system-ui, sans-serif; }

[data-composition-id]:not([data-kinetic-ready]) { visibility: hidden; }
Still effects with an SVG filter

The halftone's stylesheet needs .photo { position: absolute; right: 0; top: 0; width: 50%; height: 100%; }.

<svg width="0" height="0" style="position: absolute" aria-hidden="true"><defs>
    <filter id="halftone" x="0" y="0" width="100%" height="100%" color-interpolation-filters="sRGB">
        <feColorMatrix in="SourceGraphic" type="luminanceToAlpha" result="lum"/>
        <feImage id="dot-field" x="0" y="0" width="640" height="720" preserveAspectRatio="none" result="fieldRGB"/>
        <feColorMatrix in="fieldRGB" type="luminanceToAlpha" result="field"/>
        <feComposite in="field" in2="lum" operator="arithmetic" k2="1" k3="-1" k4="0.45" result="diff"/>
        <feComponentTransfer in="diff" result="ink"><feFuncA type="linear" slope="14" intercept="-7"/></feComponentTransfer>
        <feFlood flood-color="#1b1530" result="inkColour"/>
        <feComposite in="inkColour" in2="ink" operator="in" result="dots"/>
        <feFlood flood-color="#f3ead8" result="paper"/>
        <feMerge><feMergeNode in="paper"/><feMergeNode in="dots"/></feMerge>
    </filter>
</defs></svg>

<div class="layer" data-component="night-card" data-start="0" data-duration="4">
    <div class="sky" data-bind-style="sky"></div>
    <img class="photo" src="tree.jpg" alt="" data-bind-style="photo">
    <h1 class="title">Night Shift</h1>
</div>
// A field of dot cones on a grid turned by `angle`, `cell` px apart, as a PNG.
function dotField(w, h, cell, angle) {
    const c = document.createElement('canvas');
    c.width = w; c.height = h;
    const g = c.getContext('2d'), img = g.createImageData(w, h);
    const a = angle * Math.PI / 180, cos = Math.cos(a), sin = Math.sin(a), r = cell * Math.SQRT1_2;
    for (let y = 0, i = 0; y < h; y++) {
        for (let x = 0; x < w; x++, i += 4) {
            const u = x * cos + y * sin, v = y * cos - x * sin;
            const d = Math.hypot(u - (Math.floor(u / cell) + 0.5) * cell, v - (Math.floor(v / cell) + 0.5) * cell);
            img.data[i] = img.data[i + 1] = img.data[i + 2] = Math.round(255 * Math.max(0, 1 - d / r));
            img.data[i + 3] = 255;
        }
    }
    g.putImageData(img, 0, 0);
    return c.toDataURL('image/png');
}

scene('night-card', {
    init() {
        document.getElementById('dot-field').setAttribute('href', dotField(640, 720, 12, 45));
    },
    computed: {
        // Each filter is on only inside the clip's window: the clip starts at 0, and
        // after it local holds at its length, so remaining is 0 there.
        on() { return this.remaining > 0; },
        sky() { return { filter: this.on ? 'url(#stipple)' : 'none' }; },
        photo() { return { filter: this.on ? 'url(#halftone)' : 'none' }; },
    },
});

Two rules keep filters like these cheap and correct.

  • A filter reruns in full whenever the filtered element or a filter parameter changes, so keep these for stills. The title is a sibling of the filtered elements, so it moves without rerunning either. For an effect that changes every frame, use effect() on an image, a video or a canvas.
  • A filter with feFlood, feImage or feTurbulence paints even inside a clip hidden with visibility: hidden. Kinetic hides a clip outside its window so nothing paints. A runtime that hides with visibility alone would let the stippled sky show over the scenes before and after the card, so the scene turns each filter on only inside the clip's window (on here).

No demo uses this pattern yet.