design example · embeddable engine
A generative colour field that lives on one rule: every pixel sits on the arc between two hues locked exactly 180° apart, computed in Oklab so the transition never turns to grey mud. Below: the live engine, four tunings, and everything needed to embed it.
Named after Chevreul's law of simultaneous contrast (1839) and Sonia Delaunay, born in Hradyzk, who called her Paris studio Atelier Simultané.
The engine is a single dependency-free script exposing window.Opponent. Point it at a canvas; it handles DPR, resize, tab-hide, context loss, and adaptive quality on its own. This entire page runs on it — five instances.
<canvas id="bg" aria-hidden="true"></canvas>
<script src="opponent-engine.js"></script>
<script>
var field = Opponent.mount(document.getElementById('bg'), {
preset: 'ink', // dark tuning, made to sit behind light text
speed: 0.8
});
</script>
#bg{
position: fixed;
inset: 0;
width: 100%;
height: 100%;
z-index: -1;
pointer-events: none;
}
mount() returns null without WebGL; keep a plain CSS background behind the canvas as fallback.ink, and add a scrim (rgba(0,0,0,.25)) if contrast runs tight.field.setSpeed(0.15) — or 0 to hold a still frame.A preset is five uniforms and a speed — add one by appending to PRESETS at the top of the engine; the shader never changes.
| mount option | default | meaning |
|---|---|---|
preset | signal | signal · fog · ink · vivid |
hue | random | starting hue A in degrees; B is always A+180 |
speed | 1 | multiplier on all motion, including the ~5-minute hue walk; 0 = still |
chroma | 1 | saturation multiplier (0.1–2) on top of the preset |
seed | random | deterministic phase, for tests and reproducible stills |
dprCap | 1.5 | maximum devicePixelRatio rendered |
onHue(a,b,cssA,cssB) | — | fires when the rounded degree changes; css args are gamut-fitted rgb() strings |
| instance | |
|---|---|
start() stop() hold() destroy() | lifecycle; hold() toggles the clock, destroy() removes every listener |
setPreset(n) cyclePreset() | switch tunings live |
setHue(d) nudgeHue(d) | place or turn the complement axis |
setSpeed(x) setChroma(x) | live multipliers |
hues() | current pair as {a, b} degrees |
The full page (opponent.html) also reads URL params — ?preset= &hue= &speed= &chroma= &seed= &ui=off, hash form works too — and binds keys: space hold, p palette, f fullscreen, arrows turn the wheel.
cos(h−100°) term lifts yellow-greens and deepens blues, because sRGB holds chroma at high lightness for yellow and low for blue. Without it the mid-arc turns khaki.