Text Scramble Effect in CSS + JS: Glitch Characters on Hover
Build a text scramble glitch effect from scratch with CSS and vanilla JS — hover triggers random character swaps that resolve back to the real text.
What the Scramble Effect Actually Does
When a user hovers over an element, the text briefly shows random characters — uppercase letters, numbers, symbols — before each character "resolves" back to the real value one by one. It's the Matrix intro, basically, but at a micro scale and tied to a DOM element you control.
The effect has been around since at least 2016 and it's still landing in portfolios and dark-mode landing pages in 2026 because it reads as sophisticated without requiring a single external dependency. You write maybe 60 lines of JS and 10 lines of CSS. That's it.
There are two approaches most devs reach for. The pure-CSS version uses @keyframes, clip-path, and some layering tricks to fake the glitch. The JS version actually randomises the characters in the DOM. Honestly, the CSS-only approach is clever but brittle — it fakes glitch visually without touching the text, which means screen readers still get the real content but you have almost no control over timing per-character. The JS version wins on flexibility every time.
Worth noting: this effect belongs in the same toolkit as other hover animations you might already be using. If you're building a design system with dark themes, check out Empire UI's cyberpunk and aurora style collections — the scramble effect slots right in.
The CSS Layer: Setting Up Your Glitch Base
Before you write a line of JS, get your CSS right. The goal is a monospace font — font-family: 'Courier New', monospace is fine, but JetBrains Mono or IBM Plex Mono at 16px will look sharper. Fixed character width is what makes the scramble readable; proportional fonts cause the text to jump around in width as characters change, which is distracting rather than stylish.
.scramble-text {
font-family: 'JetBrains Mono', 'Courier New', monospace;
font-size: 1rem;
letter-spacing: 0.05em;
cursor: pointer;
display: inline-block;
color: #e2e8f0;
transition: color 0.1s ease;
}
.scramble-text:hover {
color: #a855f7;
}
.scramble-text .char-glitch {
display: inline-block;
color: #f0abfc;
animation: flicker 0.08s infinite;
}
@keyframes flicker {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}The .char-glitch class is what you'll apply to individual <span> elements wrapping each character during the scramble phase. That flickering opacity at 80ms is short enough to read as noise rather than a deliberate blink. If you go above 120ms it starts to look like a loading spinner, which isn't the vibe.
Quick aside: you don't need will-change: contents on the parent here. It's tempting to throw that on, but the browser can't composite text content anyway — it'll just create a new stacking context for no gain. Save will-change for transform and opacity animations.
That said, if you want the effect to also shift position slightly for a more aggressive glitch look, wrap each character span in a second span and add a tiny translateX on the .char-glitch state. Keep it under 2px or it gets illegible fast.
The JavaScript: Building the Scrambler Class
Here's the full implementation. It's a class so you can attach it to multiple elements without global state collisions. The key method is scramble(), which runs an interval, picks random characters from a fixed pool, and counts down revealed characters until the original text is fully restored.
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
class TextScrambler {
constructor(el) {
this.el = el;
this.original = el.textContent;
this.frame = null;
this.resolve = null;
this._bindEvents();
}
_bindEvents() {
this.el.addEventListener('mouseenter', () => this.scramble());
this.el.addEventListener('mouseleave', () => this.restore());
}
scramble() {
let iteration = 0;
const length = this.original.length;
clearInterval(this.frame);
this.frame = setInterval(() => {
const output = this.original
.split('')
.map((char, index) => {
if (index < iteration) return `<span>${char}</span>`;
if (char === ' ') return ' ';
return `<span class="char-glitch">${CHARS[Math.floor(Math.random() * CHARS.length)]}</span>`;
})
.join('');
this.el.innerHTML = output;
if (iteration >= length) clearInterval(this.frame);
iteration += 0.4; // fractional so it feels slower at start
}, 40);
}
restore() {
clearInterval(this.frame);
this.el.textContent = this.original;
}
}
// Init on all matching elements
document.querySelectorAll('[data-scramble]').forEach(el => {
new TextScrambler(el);
});The iteration += 0.4 trick is worth paying attention to. Integer increments make the resolution feel mechanical — each character snaps into place at a fixed rate. Fractional increments mean the first few characters take longer to resolve, which gives the brain time to register the glitch before the text settles. It's a tiny thing that makes a big perceptual difference.
In practice, the 40ms interval (roughly 25fps) is fast enough to look like noise but slow enough for the browser to keep up without dropped frames on mid-range devices. If you drop it to 16ms you're at 60fps and the effect actually looks *smoother* — which paradoxically makes it feel less glitchy. Embrace the slightly lower frame rate here.
One more thing — the mouseleave handler calls restore() which hard-sets textContent. This clears all the <span> elements at once. Some devs prefer to run the resolve animation in reverse on leave, which is a valid choice. Just be aware that if the user moves in and out quickly, you'll be stacking setInterval calls. Always clearInterval before starting a new one.
React Component Version
If you're working in React, the class pattern doesn't translate well — you'd rather hook into useRef and useState and keep the interval in a useEffect. Here's a clean component that handles mount/unmount safely.
import { useRef, useState, useCallback } from 'react';
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
interface ScrambleTextProps {
text: string;
className?: string;
speed?: number; // ms per frame, default 40
}
export function ScrambleText({ text, className = '', speed = 40 }: ScrambleTextProps) {
const [display, setDisplay] = useState(text);
const frameRef = useRef<ReturnType<typeof setInterval> | null>(null);
const iterRef = useRef(0);
const startScramble = useCallback(() => {
iterRef.current = 0;
if (frameRef.current) clearInterval(frameRef.current);
frameRef.current = setInterval(() => {
const output = text
.split('')
.map((char, i) => {
if (i < iterRef.current) return char;
if (char === ' ') return ' ';
return CHARS[Math.floor(Math.random() * CHARS.length)];
})
.join('');
setDisplay(output);
if (iterRef.current >= text.length) {
clearInterval(frameRef.current!);
setDisplay(text);
}
iterRef.current += 0.4;
}, speed);
}, [text, speed]);
const stopScramble = useCallback(() => {
if (frameRef.current) clearInterval(frameRef.current);
setDisplay(text);
}, [text]);
return (
<span
className={`font-mono cursor-pointer ${className}`}
onMouseEnter={startScramble}
onMouseLeave={stopScramble}
>
{display}
</span>
);
}Notice I'm storing display as a plain string here rather than HTML. That means no inline spans for the glitch class, so the CSS flicker animation on individual characters won't apply. You can add it back by rendering an array of <span> elements instead of a string — but for most use cases the character randomisation alone reads as glitch without the flicker.
Look, the React version loses something: you can't apply per-character CSS as easily because innerHTML is off the table. If you need the flickering opacity on individual unresolved characters, render an array of <motion.span> elements (Framer Motion) and drive their opacity with a random value each frame. It's more code but the result is noticeably more polished for hero sections.
If you're using Next.js App Router, add 'use client' at the top. This is purely client-side interactivity.
Performance and Accessibility Concerns
Every setInterval tick in this effect calls setState (React) or writes to innerHTML (vanilla). That's a DOM mutation on every frame. For a single element on a page, it's completely fine. For a nav menu where 10 items all scramble simultaneously on some kind of load animation? You'll see jank, especially on older Android devices.
Stagger your initialisations. If you're auto-playing the scramble on page load rather than tying it to hover, use a delay multiplied by index: setTimeout(() => el.scramble(), index * 150). This keeps your animation budget manageable and actually looks better — sequential resolution reads as intentional choreography.
Accessibility is where a lot of tutorials drop the ball. Changing innerHTML or textContent rapidly means a screen reader might try to announce the random characters. Fix this with aria-label set to the original text on the element, and aria-live="off" so the live region doesn't broadcast every update. Users who rely on assistive tech should get the real text, not @#X$k.
<span
data-scramble
aria-label="Get started"
aria-live="off"
>Get started</span>Worth noting: prefers-reduced-motion should disable the scramble entirely. Wrap your interval logic in a check: if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;. This takes 30 seconds to add and it's the right thing to do. You can pair this with CSS animation performance best practices to keep the rest of your page snappy too.
Styling Variations: Cyberpunk, Neon, and Monochrome
The default look — white text on dark background with purple glitch characters — is a safe starting point. But the real fun is in theme variants. A cyberpunk version uses #00ff41 (classic matrix green) for unresolved characters and #0d1117 as background. A neon pink variant works well on vaporwave-style pages. Monochrome (light grey resolving from dark grey) is surprisingly elegant for minimal portfolios.
/* Cyberpunk variant */
.scramble-text.cyberpunk .char-glitch {
color: #00ff41;
text-shadow: 0 0 8px #00ff41;
}
/* Neon pink variant */
.scramble-text.neon .char-glitch {
color: #ff2d78;
text-shadow: 0 0 12px #ff2d78, 0 0 24px #ff2d7866;
}
/* Monochrome variant */
.scramble-text.mono .char-glitch {
color: #94a3b8;
}The text-shadow with a spread of 8–12px is what sells the neon look. Go above 24px and it starts looking blurry rather than glowing. The double-layer shadow (solid + 66% alpha at larger spread) gives you a soft halo without a separate element. This works really well when you're building components that sit alongside glassmorphism components — the glow complements the frosted background beautifully.
One more thing — if you want the glitch to feel more aggressive, add a slight translateX shift to the unresolved characters. Random values between -2px and 2px on each frame make the characters feel unstable. This is the technique used in a lot of cyberpunk UI aesthetics and it reads well at any font size above 14px.
@keyframes glitch-shift {
0% { transform: translateX(0); }
25% { transform: translateX(-1px); }
75% { transform: translateX(2px); }
100% { transform: translateX(0); }
}
.scramble-text.aggressive .char-glitch {
animation: flicker 0.08s infinite, glitch-shift 0.06s infinite;
color: #f0abfc;
}Putting It Together: A Full Working Demo
Here's the minimal complete setup — HTML, CSS, and JS in one block — so you can drop it into a CodePen or a blank HTML file and see it running immediately. No build step, no dependencies.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Text Scramble Demo</title>
<style>
body {
background: #0a0a0f;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
font-family: 'JetBrains Mono', monospace;
}
.scramble-text {
font-size: 2rem;
color: #e2e8f0;
cursor: pointer;
letter-spacing: 0.08em;
user-select: none;
}
.char-glitch {
color: #a855f7;
animation: flicker 0.08s infinite;
}
@keyframes flicker {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
</style>
</head>
<body>
<h1 class="scramble-text" data-scramble aria-label="Empire UI" aria-live="off">
Empire UI
</h1>
<script>
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
class TextScrambler {
constructor(el) {
this.el = el;
this.original = el.textContent.trim();
this.frame = null;
this.el.addEventListener('mouseenter', () => this.scramble());
this.el.addEventListener('mouseleave', () => this.restore());
}
scramble() {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
let iter = 0;
clearInterval(this.frame);
this.frame = setInterval(() => {
this.el.innerHTML = this.original.split('').map((c, i) => {
if (i < iter) return `<span>${c}</span>`;
if (c === ' ') return ' ';
return `<span class="char-glitch">${CHARS[Math.floor(Math.random() * CHARS.length)]}</span>`;
}).join('');
if (iter >= this.original.length) clearInterval(this.frame);
iter += 0.4;
}, 40);
}
restore() {
clearInterval(this.frame);
this.el.textContent = this.original;
}
}
document.querySelectorAll('[data-scramble]').forEach(el => new TextScrambler(el));
</script>
</body>
</html>That's the whole thing. Hover over the heading, watch it glitch, watch it resolve. The user-select: none on .scramble-text prevents an ugly text-selection flash during rapid mouse movement across the element.
From here you'd pull the JS into its own module, swap the character set for something domain-specific (hex chars only for a technical look, or just uppercase for a mechanical feel), and wire the data-scramble attribute to whatever CMS or component system you're using. The box shadow generator and other tools on Empire UI follow similar patterns — lightweight vanilla interactions that don't need a framework.
FAQ
Not really, not for actual character randomisation. Pure CSS can fake a glitch look with layered pseudo-elements and clip-path animations, but you can't change text content from CSS. If you need zero JS, the CSS approach gives you visual noise but not real character scrambling.
Proportional fonts change width as characters swap — 'W' is much wider than 'i', so the text block jumps in size on every frame. Monospace fonts give every character the same horizontal space, so the container stays stable and the scramble reads as intentional noise rather than layout chaos.
Call scrambler.scramble() immediately in a DOMContentLoaded listener instead of attaching mouse events. Add a per-element delay (setTimeout(() => s.scramble(), i * 200)) when you have multiple elements so they stagger rather than all firing at once.
No. The text starts as real content in the DOM and is only mutated on interaction. Crawlers see the original text on initial parse. Just don't auto-scramble on load in a way that wipes the text before the page is indexed, and you'll be fine.