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
feTurbulenceis 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
feImageitself, since a binding does not accept an SVGdata: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,feImageorfeTurbulencepaints even inside a clip hidden withvisibility: hidden. Kinetic hides a clip outside its window so nothing paints. A runtime that hides withvisibilityalone 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 (onhere).
No demo uses this pattern yet.