Cyanotype bloom
A branching botanical contact print develops from pale paper into deep cobalt as exposure increases.

Develop the print with the exposure slider
Subframe Original · MIT
Add to your project
Component source
Standalone React component. Styles are included.
React only. No additional packages.
Render the default export inside a container with a defined height. Its optional active prop stops movement when false. This example works with React 19; Next.js projects can use it as a client component.
View source code
"use client"
/*
* Copyright (c) 2026 Subframe
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { useEffect, useId, useRef, useState } from "react"
const FRAGMENT = `
precision highp float;
uniform vec2 u_resolution;
uniform float u_time;
uniform float u_control;
float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
float noise(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);return mix(mix(hash(i),hash(i+vec2(1,0)),f.x),mix(hash(i+vec2(0,1)),hash(i+vec2(1)),f.x),f.y);}
void main(){
vec2 uv=gl_FragCoord.xy/u_resolution;
vec2 p=(gl_FragCoord.xy-.5*u_resolution)/u_resolution.y;
float grain=hash(floor(uv*u_resolution*.65));
float cloud=noise(p*5.)*.5+noise(p*13.)*.25+noise(p*29.)*.13;
float exposure=smoothstep(.02,1.,u_control);
float front=(uv.y+cloud*.15)/1.1;
float wet=smoothstep(front-.10,front+.13,u_control);
vec3 paper=vec3(.89,.88,.77),blue=mix(vec3(.16,.42,.50),vec3(.018,.115,.38),exposure);
vec3 c=mix(paper,blue,wet);
c+=(grain-.5)*.035;
float edge=smoothstep(.47,.50,abs(uv.y-.5))+smoothstep(.47,.50,abs(uv.x-.5));
c=mix(c,paper,clamp(edge*.23,0.,.3));
gl_FragColor=vec4(c,1.);
}
`
// One vector path per specimen keeps the fine branch structure sharp at any preview scale.
function specimenPath(lean: number) {
const paths: string[] = []
const point = (x: number, y: number) =>
`${(x * Math.cos(lean) - y * Math.sin(lean)).toFixed(2)},${(x * Math.sin(lean) + y * Math.cos(lean)).toFixed(2)}`
paths.push(`M0,0 Q${point(7, -95)} ${point(0, -224)}`)
for (let i = 0; i < 21; i++) {
const t = i / 21,
y = -14 - t * 201,
span = 9 + Math.sin(t * Math.PI) * 54 * (1 - t * 0.35)
for (const side of [-1, 1]) {
const x0 = Math.sin(t * Math.PI) * 3,
rise = 10 + (1 - t) * 16
paths.push(`M${point(x0, y)} Q${point(side * span * 0.5, y - 5)} ${point(side * span, y - rise)}`)
for (let n = 1; n <= 8; n++) {
const f = n / 9,
x = x0 + side * span * f,
by = y - rise * f
const length = (3 + (1 - t) * 7) * (1 - f * 0.6)
paths.push(
`M${point(x, by)} Q${point(x + side * length * 0.3, by - length)} ${point(
x + side * length,
by - length * 1.2,
)}`,
)
paths.push(
`M${point(x, by)} Q${point(x + side * length * 0.6, by + length * 0.45)} ${point(
x + side * length,
by + length * 0.2,
)}`,
)
}
}
}
return paths.join(" ")
}
const SPECIMENS = [specimenPath(-0.48), specimenPath(0.08), specimenPath(0.52)]
export default function CyanotypeBloomExample({ active = true }: { active?: boolean }) {
const maskId = useId()
const canvasRef = useRef<HTMLCanvasElement>(null)
const runningRef = useRef(active)
const syncRef = useRef<() => void>(() => {})
const timeRef = useRef(3)
const contextLeaseRef = useRef(0)
const controlRef = useRef(0.72)
const [control, setControl] = useState(0.72)
const paused = true
const [available, setAvailable] = useState(false)
const [, setReduced] = useState(false)
runningRef.current = active && !paused
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const gl = canvas.getContext("webgl", { alpha: false, antialias: false, powerPreference: "low-power" })
if (!gl) return
const lease = ++contextLeaseRef.current
let disposed = false
let frame = 0
let last = 0
let lost = false
const preference = window.matchMedia("(prefers-reduced-motion: reduce)")
let reduce = preference.matches
setReduced(reduce)
const shaders: WebGLShader[] = []
const compile = (type: number, code: string) => {
const shader = gl.createShader(type)
if (!shader) return null
shaders.push(shader)
gl.shaderSource(shader, code)
gl.compileShader(shader)
return gl.getShaderParameter(shader, gl.COMPILE_STATUS) ? shader : null
}
const vertex = compile(
gl.VERTEX_SHADER,
"attribute vec2 a_position; void main(){gl_Position=vec4(a_position,0.0,1.0);}",
)
const fragment = compile(gl.FRAGMENT_SHADER, FRAGMENT)
const program = gl.createProgram()
const buffer = gl.createBuffer()
if (!vertex || !fragment || !program || !buffer) {
shaders.forEach((shader) => gl.deleteShader(shader))
gl.deleteProgram(program)
gl.deleteBuffer(buffer)
gl.getExtension("WEBGL_lose_context")?.loseContext()
return
}
gl.attachShader(program, vertex)
gl.attachShader(program, fragment)
gl.linkProgram(program)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
shaders.forEach((shader) => gl.deleteShader(shader))
gl.deleteProgram(program)
gl.deleteBuffer(buffer)
gl.getExtension("WEBGL_lose_context")?.loseContext()
return
}
gl.useProgram(program)
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW)
const position = gl.getAttribLocation(program, "a_position")
gl.enableVertexAttribArray(position)
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0)
const resolution = gl.getUniformLocation(program, "u_resolution")
const time = gl.getUniformLocation(program, "u_time")
const adjustment = gl.getUniformLocation(program, "u_control")
const draw = () => {
if (disposed || lost) return
gl.uniform2f(resolution, canvas.width, canvas.height)
gl.uniform1f(time, reduce ? 3 : timeRef.current)
gl.uniform1f(adjustment, controlRef.current)
gl.drawArrays(gl.TRIANGLES, 0, 6)
}
const tick = (now: number) => {
if (disposed || lost || reduce || !runningRef.current || document.hidden) {
frame = 0
last = 0
return
}
if (last) timeRef.current += Math.min((now - last) / 1000, 0.05)
last = now
draw()
frame = requestAnimationFrame(tick)
}
const sync = () => {
cancelAnimationFrame(frame)
frame = 0
last = 0
if (disposed || lost) return
draw()
if (runningRef.current && !reduce && !document.hidden) frame = requestAnimationFrame(tick)
}
const resize = () => {
const { width, height } = canvas.getBoundingClientRect()
if (!width || !height) return
// Size the drawing buffer from displayed pixels, including ancestor scale.
const ratio = Math.min(window.devicePixelRatio || 1, 2, Math.sqrt(3600000 / (width * height)))
const nextWidth = Math.max(1, Math.round(width * ratio))
const nextHeight = Math.max(1, Math.round(height * ratio))
if (canvas.width === nextWidth && canvas.height === nextHeight) return
canvas.width = nextWidth
canvas.height = nextHeight
gl.viewport(0, 0, canvas.width, canvas.height)
draw()
}
const updatePreference = () => {
reduce = preference.matches
setReduced(reduce)
sync()
}
const contextLost = (event: Event) => {
event.preventDefault()
lost = true
cancelAnimationFrame(frame)
setAvailable(false)
}
// Keep the static artwork after context loss. Remount to create fresh GPU resources.
canvas.addEventListener("webglcontextlost", contextLost)
preference.addEventListener("change", updatePreference)
document.addEventListener("visibilitychange", sync)
const observer = new ResizeObserver(resize)
// ResizeObserver alone cannot see a transform on the preview composition.
const transforms = new MutationObserver(resize)
for (let node: HTMLElement | null = canvas; node; node = node.parentElement) {
observer.observe(node)
transforms.observe(node, { attributes: true, attributeFilter: ["style", "class"] })
}
window.addEventListener("resize", resize)
syncRef.current = sync
resize()
setAvailable(true)
sync()
const releaseContext = () => {
if (contextLeaseRef.current === lease) gl.getExtension("WEBGL_lose_context")?.loseContext()
}
return () => {
disposed = true
cancelAnimationFrame(frame)
syncRef.current = () => {}
observer.disconnect()
transforms.disconnect()
window.removeEventListener("resize", resize)
canvas.removeEventListener("webglcontextlost", contextLost)
preference.removeEventListener("change", updatePreference)
document.removeEventListener("visibilitychange", sync)
gl.deleteBuffer(buffer)
gl.deleteProgram(program)
shaders.forEach((shader) => gl.deleteShader(shader))
// Defer context release so React Strict Mode can reacquire this canvas.
queueMicrotask(releaseContext)
}
}, [])
useEffect(() => {
syncRef.current()
}, [active, paused, control])
return (
<div
className="sg-cyanotype-bloom"
style={{
position: "relative",
isolation: "isolate",
width: "100%",
height: "100%",
minHeight: 240,
overflow: "hidden",
background: "#1946a1",
}}
>
<div className="sg-cyanotype-bloom-fallback" aria-hidden="true" />
<canvas
ref={canvasRef}
role="img"
aria-label="A botanical silhouette emerges through the grain of a deep blue contact print."
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", opacity: available ? 1 : 0 }}
/>
<svg
aria-hidden="true"
viewBox="0 0 600 375"
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
pointerEvents: "none",
opacity: Math.pow(control, 0.38),
}}
>
<defs>
<linearGradient id={`${maskId}-development`} x1="0" y1="1" x2="0" y2="0">
<stop offset={Math.max(0, control * 1.16 - 0.16)} stopColor="white" />
<stop offset={Math.min(1, control * 1.16 + 0.04)} stopColor={control === 1 ? "white" : "black"} />
</linearGradient>
<mask id={`${maskId}-specimen`}>
<rect width="600" height="375" fill={`url(#${maskId}-development)`} />
</mask>
</defs>
<g mask={`url(#${maskId}-specimen)`} fill="#e2ebd8" stroke="#e2ebd8" strokeWidth=".6" strokeLinecap="round">
{SPECIMENS.map((d, index) => (
<path key={index} d={d} transform={`translate(${260 + index * 35} ${index === 1 ? 313 : 287})`} />
))}
</g>
</svg>
{available && (
<label className="sg-cyanotype-bloom-control">
<span>Exposure</span>
<input
type="range"
disabled={!active}
min="0"
max="1"
step="0.01"
aria-valuetext={`${Math.round(control * 100)}% exposed`}
value={control}
onChange={(event) => {
const value = Number(event.currentTarget.value)
controlRef.current = value
setControl(value)
}}
/>
</label>
)}
<style>{`
.sg-cyanotype-bloom *{box-sizing:border-box}
.sg-cyanotype-bloom-fallback{position:absolute;inset:0;background:radial-gradient(ellipse at 38% 35%,#f3f0e055,transparent 55%),linear-gradient(145deg,#1946a1,#27313b)}
.sg-cyanotype-bloom-control{position:absolute;bottom:14px;left:14px;display:flex;align-items:center;gap:10px;padding:0 12px;min-height:44px;border-radius:22px;background:#f5f4f0;color:#222624;font:11px/1.2 Arial,sans-serif;box-shadow:0 2px 8px #0002}
.sg-cyanotype-bloom-control input{width:190px;max-width:38vw;height:44px;margin:0;accent-color:#353b37;cursor:pointer}
.sg-cyanotype-bloom-pause{position:absolute;right:14px;bottom:14px;display:grid;place-items:center;width:44px;height:44px;padding:0;border:0;border-radius:50%;color:#222624;background:#f5f4f0;box-shadow:0 2px 8px #0002;cursor:pointer}
.sg-cyanotype-bloom-pause:focus-visible,.sg-cyanotype-bloom-control input:focus-visible{outline:2px solid #386bd8;outline-offset:3px}
.sg-cyanotype-bloom-pause:disabled{opacity:.5;cursor:default}
`}</style>
</div>
)
}
Where it works well
Explore a direct material-development reveal for print, photography and generative artwork.
Reduced motion
There is no autonomous animation loop. Exposure directly redraws the paper field and botanical reveal.
Things to know
Stylized exposure preview, not a photographic chemistry simulation. Original vector specimens over a native WebGL1 paper field; remount after context loss.