Water Reflection Text
An upside-down copy of the text hangs under its baseline like a reflection on a still lake. It fades with depth, and gentle ripples keep pulling it sideways and shimmering across it.
Still waters
How to use it
- Paste the snippet just before
</head>. - Add
data-water-reflection-textto a single-line heading, with some space below it for the reflection.
Options
Add any of these attributes to the same element.
data-amplitude | How far the ripples push the reflection sideways, in em. Default 0.05. |
data-speed | How fast the ripples run. Default 1. |
data-opacity | How strong the reflection is at the waterline, from 0 to 1. Default 0.55. |
data-length | How far down the reflection reaches, in em. Default 0.9. |
data-tint | A colour washed over the reflection, like the colour of the water. Default none. |
data-color | Colour of the reflection. Default the text’s colour. |
The reflection is drawn on a canvas in the element’s own font and hangs below the element without taking up space, so give it a bottom margin. With reduced motion turned on the water stays still.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Water Reflection Text: a mirrored copy of the text hangs under its
// baseline like a reflection on a lake, fading with depth while ripples
// wobble it sideways and shimmer across it.
// Usage: <h1 data-water-reflection-text>Still waters</h1>
// Optional: data-amplitude (how far the ripples push the reflection
// sideways, in em; default 0.05), data-speed (ripple speed; default 1),
// data-opacity (how strong the top of the reflection is, 0 to 1; default
// 0.55), data-length (how far down the reflection reaches, in em; default
// 0.9), data-tint (colour washed over the reflection, e.g. a water blue;
// default none), data-color (colour of the reflection; default the text
// colour).
// The reflection hangs below the element, so leave some space under it.
var DEFAULTS = { amplitude: 0.05, speed: 1, opacity: 0.55, length: 0.9 };
var css =
'.wrt-line{position:relative;display:inline-block;white-space:nowrap}' +
'.wrt-base{display:inline-block;width:0;height:0;vertical-align:baseline}' +
'.wrt-canvas{position:absolute;pointer-events:none}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function setup(el) {
var d = el.dataset;
var amp = d.amplitude !== undefined ? +d.amplitude : DEFAULTS.amplitude;
var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
var opacity = d.opacity !== undefined ? +d.opacity : DEFAULTS.opacity;
var length = +d.length || DEFAULTS.length;
var text = el.textContent.replace(/\s+/g, ' ').trim();
var line = document.createElement('span');
line.className = 'wrt-line';
while (el.firstChild) line.appendChild(el.firstChild);
// A zero-size marker sitting on the baseline tells us where the water is.
var base = document.createElement('span');
base.className = 'wrt-base';
line.appendChild(base);
var canvas = document.createElement('canvas');
canvas.className = 'wrt-canvas';
canvas.setAttribute('aria-hidden', 'true');
line.appendChild(canvas);
el.appendChild(line);
var ctx = canvas.getContext('2d');
var src = document.createElement('canvas');
var sctx = src.getContext('2d');
var W = 0, H = 0, pad = 0, fs = 16, dpr = 1, t = 0;
// Draw the text once, right way up, with its baseline on the bottom edge
// of the offscreen canvas; frames then copy it out row by row, flipped.
function build() {
var cs = getComputedStyle(el);
fs = parseFloat(cs.fontSize);
dpr = Math.min(2, window.devicePixelRatio || 1);
pad = Math.ceil(fs * (amp * 2 + 0.1));
W = Math.ceil(line.offsetWidth + pad * 2);
H = Math.ceil(fs * length);
canvas.width = src.width = W * dpr;
canvas.height = src.height = H * dpr;
canvas.style.width = W + 'px';
canvas.style.height = H + 'px';
canvas.style.left = -pad + 'px';
canvas.style.top = (base.offsetTop + fs * 0.03) + 'px';
sctx.setTransform(dpr, 0, 0, dpr, 0, 0);
sctx.clearRect(0, 0, W, H);
sctx.font = cs.fontStyle + ' ' + cs.fontWeight + ' ' + cs.fontSize + ' ' + cs.fontFamily;
if ('letterSpacing' in sctx) sctx.letterSpacing = cs.letterSpacing === 'normal' ? '0px' : cs.letterSpacing;
var color = d.color || cs.color;
if (/rgba?\(.*,\s*0\)$/.test(color) || color === 'transparent') color = '#888';
sctx.fillStyle = color;
sctx.textBaseline = 'alphabetic';
sctx.fillText(text, pad, H);
draw();
}
function draw() {
if (!W) return;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.globalCompositeOperation = 'source-over';
ctx.clearRect(0, 0, canvas.width, canvas.height);
var a = amp * fs;
var step = Math.max(1, Math.round(fs / 48));
for (var y = 0; y < H; y += step) {
var depth = y / H;
// Ripples grow as they move away from the waterline.
var dx = a * (0.25 + depth) * (Math.sin(y * 0.32 - t * 3.1) * 0.7 + Math.sin(y * 0.11 + t * 1.7) * 0.3);
var dy = Math.sin(y * 0.2 - t * 2.3) * depth * step * 1.5;
var sy = Math.max(0, Math.min(H - step, H - y - step + dy));
ctx.globalAlpha = 0.86 + 0.14 * Math.sin(y * 0.55 - t * 4);
ctx.drawImage(src, 0, sy * dpr, W * dpr, step * dpr, dx * dpr, y * dpr, W * dpr, step * dpr);
}
ctx.globalAlpha = 1;
// Fade the reflection out with depth.
ctx.globalCompositeOperation = 'destination-in';
var g = ctx.createLinearGradient(0, 0, 0, H * dpr);
g.addColorStop(0, 'rgba(0,0,0,' + opacity + ')');
g.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, W * dpr, H * dpr);
if (d.tint) {
ctx.globalCompositeOperation = 'source-atop';
ctx.globalAlpha = 0.45;
ctx.fillStyle = d.tint;
ctx.fillRect(0, 0, W * dpr, H * dpr);
ctx.globalAlpha = 1;
}
}
build();
if (document.fonts && document.fonts.ready) document.fonts.ready.then(build);
var lastW = 0;
new ResizeObserver(function () {
if (line.offsetWidth === lastW) return;
lastW = line.offsetWidth;
build();
}).observe(line);
if (reduced()) return;
var prev = 0;
var ticker = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
t += (prev ? Math.min(50, now - prev) : 16) / 1000 * speed;
prev = now;
draw();
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { prev = 0; ticker.play(); }
else ticker.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-water-reflection-text]').forEach(function (el) {
if (el._wrt) return;
el._wrt = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><h1 data-water-reflection-text style="margin-bottom: 0.9em">Still waters</h1>
<!-- a bluer lake with bigger ripples: -->
<h1 data-water-reflection-text data-tint="#38bdf8" data-amplitude="0.09">Still waters</h1>