Frost Text
Ice crystals creep across the letters from left to right, branching like frost on a window, and little glints sparkle over the frozen words. Loop it and the frost thaws away and grows back, different every time.
Deep freeze
How to use it
- Paste the snippet just before
</head>. - Add
data-frost-textto a heading. It looks best in a pale blue on a dark background.
Options
Add any of these attributes to the same element.
data-loop | Thaw and freeze again, with fresh crystals each time. |
data-duration | How long the frost takes to creep across. Default 2600 ms. |
data-hold | How long it stays frozen before thawing, when looping. Default 2800 ms. |
data-color | Colour of the letters. Default the text colour. |
data-frost | Colour of the crystals and glints. Default #ffffff. |
data-glints | Sparkles per second. Default 5. |
Each word is drawn on a canvas in the element’s own font (screen readers still get the text). With reduced motion turned on the words show fully frosted, without sparkles.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Frost Text: ice crystals creep across the letters from left to right,
// branching like frost on a window, and little glints sparkle over them.
// Loop it and the frost thaws away and grows back.
// Usage: <h1 data-frost-text>Frozen</h1>
// Optional: data-loop (thaw and freeze again), data-duration (ms for the
// frost to creep across; default 2600), data-hold (ms it stays frozen
// before thawing, when looping; default 2800), data-color (colour of the
// letters; default the text colour), data-frost (colour of the crystals;
// default #ffffff), data-glints (sparkles per second; default 5).
// The text is drawn on a canvas per word, using the element's font.
var DEFAULTS = { duration: 2600, hold: 2800, frost: '#ffffff', glints: 5 };
var css =
'.frt-word{display:inline-block;vertical-align:top}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
var PAD = 0.18; // room round each word for glints, in em
// Any CSS colour as [r, g, b], so the canvas can mix and fade it.
function rgb(color, el) {
var probe = document.createElement('span');
probe.style.color = color;
probe.style.display = 'none';
el.appendChild(probe);
var m = getComputedStyle(probe).color.match(/[\d.]+/g) || [255, 255, 255];
el.removeChild(probe);
return [+m[0], +m[1], +m[2]];
}
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
function mix(a, b, k) {
return [0, 1, 2].map(function (i) { return Math.round(a[i] * (1 - k) + b[i] * k); });
}
function setup(el) {
var d = el.dataset;
var duration = +d.duration || DEFAULTS.duration;
var hold = d.hold !== undefined ? +d.hold : DEFAULTS.hold;
var frostRGB = rgb(d.frost || DEFAULTS.frost, el);
var frost = rgba(frostRGB, 1);
var glintRate = d.glints !== undefined ? +d.glints : DEFAULTS.glints;
var loop = d.loop !== undefined && d.loop !== 'false';
var still = reduced();
var text = el.textContent.replace(/\s+/g, ' ').trim();
el.setAttribute('aria-label', text);
el.textContent = '';
var words = text.split(' ').map(function (w, i) {
if (i) {
// A zero-size space, so lines can still break between words.
var gap = document.createElement('span');
gap.style.fontSize = '0';
gap.textContent = ' ';
el.appendChild(gap);
}
var cv = document.createElement('canvas');
cv.className = 'frt-word';
cv.setAttribute('aria-hidden', 'true');
el.appendChild(cv);
return { text: w, cv: cv, ctx: cv.getContext('2d'), mask: document.createElement('canvas'), ice: document.createElement('canvas') };
});
var fs = 16, lh = 20, dpr = 1, totalW = 1;
var thaw = 900;
var cycle = duration + 900 + hold + thaw;
var clock = 0;
// Frost for one word: branches, each a list of short segments that appear
// at set times, so the ice seems to grow.
function grow(w) {
var segs = [];
var unit = fs;
function branch(x, y, ang, len, depth, t) {
var steps = depth === 2 ? 5 : depth === 1 ? 3 : 1;
var step = len / steps;
var speed = unit * (depth === 2 ? 1.1 : 1.6) / 1000; // px per ms
for (var i = 0; i < steps; i++) {
ang += (Math.random() - 0.5) * 0.25;
var nx = x + Math.cos(ang) * step, ny = y + Math.sin(ang) * step;
var t1 = t + step / speed;
segs.push({ x0: x, y0: y, x1: nx, y1: ny, t: t1, wdt: unit * (0.016 + depth * 0.012), a: 0.55 + depth * 0.2 });
if (depth > 0 && i < steps - 1 || depth === 2) {
var side = Math.random() < 0.5 ? 1 : -1;
branch(nx, ny, ang + side * Math.PI / 3, len * 0.42, depth - 1, t1);
if (Math.random() < 0.7) branch(nx, ny, ang - side * Math.PI / 3, len * 0.36, depth - 1, t1);
}
x = nx; y = ny; t = t1;
}
}
var seeds = Math.max(3, Math.round(w.textW / (unit * 0.26)));
for (var s = 0; s < seeds; s++) {
var sx = w.pad + Math.random() * w.textW;
var sy = w.pad + lh * (0.2 + Math.random() * 0.6);
// Frost creeps in from the left of the whole line.
var t0 = (w.offset + sx - w.pad) / totalW * duration * 0.85 + Math.random() * duration * 0.15;
var arms = 3 + Math.floor(Math.random() * 3);
var a0 = Math.random() * Math.PI * 2;
for (var k = 0; k < arms; k++) {
branch(sx, sy, a0 + k * Math.PI * 2 / arms + (Math.random() - 0.5) * 0.6, unit * (0.28 + Math.random() * 0.22), 2, t0);
}
segs.push({ dot: true, x0: sx, y0: sy, r: unit * 0.45, t: t0 });
}
segs.sort(function (a, b) { return a.t - b.t; });
w.segs = segs;
w.next = 0;
var g = w.ice.getContext('2d');
g.setTransform(1, 0, 0, 1, 0, 0);
g.clearRect(0, 0, w.ice.width, w.ice.height);
g.setTransform(dpr, 0, 0, dpr, 0, 0);
g.lineCap = 'round';
}
// Draw onto the ice layer every segment that has grown by time t.
function freezeTo(w, t) {
var g = w.ice.getContext('2d');
g.strokeStyle = frost;
while (w.next < w.segs.length && w.segs[w.next].t <= t) {
var s = w.segs[w.next++];
if (s.dot) {
// A soft haze where each crystal starts.
var grad = g.createRadialGradient(s.x0, s.y0, 0, s.x0, s.y0, s.r);
grad.addColorStop(0, rgba(frostRGB, 0.35));
grad.addColorStop(1, rgba(frostRGB, 0));
g.globalAlpha = 1;
g.fillStyle = grad;
g.fillRect(s.x0 - s.r, s.y0 - s.r, s.r * 2, s.r * 2);
continue;
}
g.globalAlpha = s.a;
g.lineWidth = s.wdt;
g.beginPath();
g.moveTo(s.x0, s.y0);
g.lineTo(s.x1, s.y1);
g.stroke();
}
g.globalAlpha = 1;
}
function build() {
var cs = getComputedStyle(el);
fs = parseFloat(cs.fontSize);
lh = cs.lineHeight === 'normal' ? fs * 1.2 : parseFloat(cs.lineHeight);
dpr = Math.min(2, window.devicePixelRatio || 1);
var font = cs.fontStyle + ' ' + cs.fontWeight + ' ' + cs.fontSize + ' ' + cs.fontFamily;
var base = rgb(d.color || cs.color, el);
var pad = Math.round(fs * PAD);
var x = 0, spaceW = 0;
words.forEach(function (w) {
var m = w.ctx;
m.font = font;
if ('letterSpacing' in m) m.letterSpacing = cs.letterSpacing === 'normal' ? '0px' : cs.letterSpacing;
spaceW = Math.max(m.measureText(' ').width, fs * 0.24);
var mt = m.measureText(w.text);
w.textW = mt.width;
w.pad = pad;
w.cssW = Math.ceil(w.textW + pad * 2);
w.cssH = Math.ceil(lh + pad * 2);
w.offset = x;
x += w.textW + spaceW;
var asc = mt.fontBoundingBoxAscent || fs * 0.9, desc = mt.fontBoundingBoxDescent || fs * 0.25;
var baseline = pad + (lh - (asc + desc)) / 2 + asc;
[w.cv, w.mask, w.ice].forEach(function (c) {
c.width = Math.round(w.cssW * dpr);
c.height = Math.round(w.cssH * dpr);
});
w.cv.style.width = w.cssW + 'px';
w.cv.style.height = w.cssH + 'px';
w.cv.style.margin = (-pad) + 'px';
if (w !== words[0]) w.cv.style.marginLeft = (spaceW - pad) + 'px';
// The letters, drawn once in their base colour with a cold sheen.
var g = w.mask.getContext('2d');
g.setTransform(dpr, 0, 0, dpr, 0, 0);
g.font = font;
if ('letterSpacing' in g) g.letterSpacing = m.letterSpacing;
var grad = g.createLinearGradient(0, pad, 0, pad + lh);
grad.addColorStop(0, rgba(mix(base, [255, 255, 255], 0.3), 1));
grad.addColorStop(0.55, rgba(base, 1));
grad.addColorStop(1, rgba(mix(base, [10, 42, 85], 0.2), 1));
g.fillStyle = grad;
g.fillText(w.text, pad, baseline);
w.ctx.setTransform(1, 0, 0, 1, 0, 0);
w.glints = [];
});
totalW = Math.max(1, x - spaceW);
words.forEach(function (w) {
grow(w);
freezeTo(w, still ? Infinity : clock);
draw(w, 1);
});
}
function draw(w, iceAlpha) {
var c = w.ctx;
c.setTransform(1, 0, 0, 1, 0, 0);
c.clearRect(0, 0, w.cv.width, w.cv.height);
c.globalCompositeOperation = 'source-over';
c.drawImage(w.mask, 0, 0);
// Ice only where there are letters.
c.globalCompositeOperation = 'source-atop';
c.globalAlpha = iceAlpha;
c.drawImage(w.ice, 0, 0);
c.globalAlpha = 1;
c.globalCompositeOperation = 'source-over';
c.setTransform(dpr, 0, 0, dpr, 0, 0);
w.glints.forEach(function (gl) {
var r = gl.age / gl.life;
var k = Math.sin(Math.PI * r) * iceAlpha;
var s = fs * 0.16 * gl.size * k;
c.globalAlpha = k;
c.strokeStyle = frost;
c.lineWidth = Math.max(1, fs * 0.018);
c.beginPath();
c.moveTo(gl.x - s, gl.y); c.lineTo(gl.x + s, gl.y);
c.moveTo(gl.x, gl.y - s); c.lineTo(gl.x, gl.y + s);
c.stroke();
var glow = c.createRadialGradient(gl.x, gl.y, 0, gl.x, gl.y, s * 0.6);
glow.addColorStop(0, frost);
glow.addColorStop(1, rgba(frostRGB, 0));
c.fillStyle = glow;
c.fillRect(gl.x - s, gl.y - s, s * 2, s * 2);
});
c.globalAlpha = 1;
}
function addGlint() {
// Sparkle at the tip of a crystal that has already grown.
var w = words[Math.floor(Math.random() * words.length)];
if (!w.next) return;
var s = w.segs[Math.floor(Math.random() * w.next)];
if (s.dot) return;
w.glints.push({ x: s.x1, y: s.y1, age: 0, life: 650 + Math.random() * 500, size: 0.7 + Math.random() * 0.6 });
}
build();
if (document.fonts && document.fonts.ready) document.fonts.ready.then(build);
var lastW = el.clientWidth;
new ResizeObserver(function () {
if (Math.abs(el.clientWidth - lastW) < 1 && fs === parseFloat(getComputedStyle(el).fontSize)) return;
lastW = el.clientWidth;
build();
}).observe(el);
if (still) return;
var prev = 0, owed = 0;
var ticker = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = prev ? Math.min(50, now - prev) : 16;
prev = now;
clock += dt;
var t = clock;
if (loop && t >= cycle) {
clock = t = 0;
words.forEach(function (w) { w.glints = []; grow(w); });
}
var iceAlpha = 1;
if (loop && t > cycle - thaw) iceAlpha = Math.max(0, (cycle - t) / thaw);
if (t > duration * 0.4) {
owed += glintRate * dt / 1000 * Math.min(1, (t - duration * 0.4) / duration);
while (owed >= 1) { addGlint(); owed--; }
}
words.forEach(function (w) {
freezeTo(w, t);
for (var i = w.glints.length - 1; i >= 0; i--) {
w.glints[i].age += dt;
if (w.glints[i].age >= w.glints[i].life) w.glints.splice(i, 1);
}
draw(w, iceAlpha);
});
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { prev = 0; ticker.play(); }
else ticker.pause();
}, { threshold: 0.2 }).observe(el);
}
function init() {
document.querySelectorAll('[data-frost-text]').forEach(function (el) {
if (el._frt) return;
el._frt = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><h1 data-frost-text data-loop style="color: #8ec9ff">Deep freeze</h1>