Earlier today, Brad Woods Digital Garden made it to the front page of HN. I had never heard of this website before, so I clicked on the link and explored it. As it turns out, it’s the web development blog of Brad Woods. It’s also one of the most detailed and high-effort webpages I’ve seen in quite a while, and the blog content is high-quality and very interesting. But what really stood out to me was how most of the text on the page initially appears scrambled, and then gets “solved” in a quirky scramble animation.
This effect is awesome, and I wanted to see how it would look on my website. I initially planned to just steal Woods’ animation by nabbing the code from my Firefox cache, but he’s minified and obfuscated it in a way that would take more effort to unravel than I’m willing to expend.
My Implementation
So instead I just reverse engineered it. I wanted it to be a CSS keyframe animation that I could call within a class, but this kind of content manipulation isn’t really doable in CSS. Instead I used the “animationstart” event in JavaScript. Here’s the full JavaScript function I came up with. It’s un-obfuscated because that’s how normal developers publish code.
(function () {
document.addEventListener('animationstart', function (e) {
if (e.animationName === 'scramble-text' && e.target instanceof HTMLElement) {
const element = e.target;
const originalText = element.textContent;
if (!originalText) return;
const SCRAMBLE_CHARS = 'abcdefghijklmnopqrstuvwxyz0123456789';
const REVEAL_DELAY = 10;
const SOLVE_DELAY = 60;
const SCRAMBLE_SPEED = 50;
const HIDDEN_CHAR = '\u2007';
let charStates = Array.from(originalText).map(char => ({
final: char,
state: 'hidden',
}));
element.textContent = Array(originalText.length + 1).join(HIDDEN_CHAR);
const totalRevealTime = (charStates.length - 1) * REVEAL_DELAY;
const totalAnimationDuration = totalRevealTime + ((charStates.length - 1) * SOLVE_DELAY);
const startTime = performance.now();
charStates.forEach((charState, i) => {
setTimeout(() => {
charState.state = (charState.final.trim() === '') ? 'solved' : 'scrambled';
}, i * REVEAL_DELAY);
setTimeout(() => {
charState.state = 'solved';
}, totalRevealTime + (i * SOLVE_DELAY));
});
let lastScrambleUpdate = 0;
let prevOutput = [];
const update = (currentTime) => {
const elapsed = currentTime - startTime;
if (elapsed >= totalAnimationDuration + SOLVE_DELAY) {
element.textContent = originalText;
return;
}
const scrambleIntervalPassed = (currentTime - lastScrambleUpdate) >= SCRAMBLE_SPEED;
let currentOutput = [];
for (let i = 0; i < charStates.length; i++) {
const charState = charStates[i];
switch (charState.state) {
case 'hidden':
currentOutput[i] = HIDDEN_CHAR;
break;
case 'scrambled':
currentOutput[i] = scrambleIntervalPassed
? SCRAMBLE_CHARS[Math.floor(Math.random() * SCRAMBLE_CHARS.length)]
: prevOutput[i];
break;
case 'solved':
currentOutput[i] = charState.final;
break;
}
}
if (scrambleIntervalPassed) {
lastScrambleUpdate = currentTime;
}
element.textContent = currentOutput.join('');
prevOutput = currentOutput;
requestAnimationFrame(update);
};
requestAnimationFrame(update);
}
}, { passive: true });
})();
You use it by defining a CSS keyframe named scramble-text that doesn’t do anything substantial, and the function will hijack that keyframe and add the text scramble animation. Here’s an example implementation in CSS.
.scramble-me {
animation: scramble-text 1s;
}
@keyframes scramble-text {
from {
opacity: 0.99;
}
to {
opacity: 1;
}
}
Demo
I realize that you, dear reader, probably aren’t invested enough to actually test out my code, so I’ve made a little demo with CodePen.
Conclusion
I love this effect. I added it to the “Ahoy!” heading on my home page, and I think it looks really cool. Feel free to use my code however you like.
~Ethan