GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Glitch Text Effect: 9 Canvas Pixel Glitch Scenes

How does glitch text break a word? 02 pushes horizontal slices by hash into row tears, 03 drags bright pixels into a smear, and 09 breaks hard on press and

Glitch Text Is Not Drawing Letters Broken on Purpose but a Calculation That Moves and Mixes the Pixels of a Word Drawing on a Timetable: Making a Glitch Text Effect Starts by Choosing Which Value Slips, When and by How Much

Auto-plays · click a tile to jump to its section · all nine in one zip

Nine scenes stand on one base. A 480x300 canvas gets a single word painted once on an offscreen canvas, getImageData reads it into a pixel array base, and each frame reads that array, writes the result into out and lifts it with putImageData. Nothing external is loaded, and a grep of the nine pages finds no random-number call, no date call, no fetch and no outside address, so the same input paints the same picture each time.

A hash, not a random number, seeds the shaking

A random number would tear different bands on each frame, and the picture would keep trembling without a pause. The scenes use one hash2(a, b) that turns integer multiplies and shifts into a value from 0 up to but not including 1 and returns the same value for the same input, so which band tears in which time step is already decided.

    // Hash - the same (a, b) gives the same value from 0 up to but not including 1. No random function is used
    function hash2(a, b) {
        let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
        h = Math.imul(h ^ (h >>> 13), 1274126177);
        h = h ^ (h >>> 16);
        return (h >>> 0) / 4294967296;
    }
    // 1D value noise - each integer cell has a hash value and the cells in between are joined smoothly
    function noise1(x, seed) {
        const i = Math.floor(x);
        const f = x - i;
        const u = f * f * (3 - 2 * f);
        return lerp(hash2(i, seed), hash2(i + 1, seed), u);
    }

Only the rows that hold the word are computed

loadWord draws the word, scans the pixel array once and finds the row range rowTop to rowBot where a pixel differs from the background by more than 24. The word spans rows 95 to 192 in 02 and rows 109 to 193 in 03, so 03 runs on 85 of the 300 rows and leaves the rest untouched.

    // Source pixel buffer - the word painted once and read back. Each glitch reads this base and writes out
    let base = new Uint8ClampedArray(W * H * 4);
    let rowTop = 0;
    let rowBot = H - 1;
    const img = wkCtx.createImageData(W, H);
    const out = img.data;
    function loadWord(paint) {
        wordCtx.fillStyle = rgba(BGC, 1);
        wordCtx.fillRect(0, 0, W, H);
        paint(wordCtx);
        base = wordCtx.getImageData(0, 0, W, H).data;
        // Row range that holds the word - the glitch calculation runs only on these rows
        let top = -1;
        let bot = -1;
        for (let y = 0; y < H; y += 1) {
            let hit = false;
            for (let x = 0; x < W && !hit; x += 2) {
                const p = (y * W + x) * 4;
                if (Math.abs(base[p] - BGC[0]) + Math.abs(base[p + 1] - BGC[1]) + Math.abs(base[p + 2] - BGC[2]) > 24) {
                    hit = true;
                }
            }
            if (hit) {
                if (top < 0) {
                    top = y;
                }
                bot = y;
            }
        }
        rowTop = Math.max(0, top - 2);
        rowBot = Math.min(H - 1, bot + 2);
    }

A timetable drives the demo, and a press adds a step

Scenes move without input, and the first change lands at 0.25 seconds. A press on the stage or an arrow key raises the break strength by one step, and when the tab is hidden the loop stops and the first frame after the return is dropped, so a few seconds cannot pile into one frame.

Glitch Text Effect: 9 Scenes — a Color Channel Split, Row Tears, a Brightness-Cut Pixel Smear, a Grid Block Burst, a Ripple With Different Color Phases, Look-alike Letter Swaps, Rolling Scan Lines, a Corrupted Byte Range and a Break Strength That Settles After a Press

Why this order of nine, and what the render measured

Scene 01 opens because it stacks three sheets without touching the pixel array, and 02 and 03 are the two basic axes that move the array by band and by row. Scenes 04 and 08 overwrite parts of the screen, 05 and 07 spread by channel and by bleed, 06 swaps shapes, and 09 handles the break strength itself.

Scene Cumulative area Moving frames Mean intensity
01 Channel split 18.4% 22/23 91.3
02 Row tears 9.5% 7/23 153.8
03 Pixel smear 4.9% 11/23 38.3
04 Block burst 15.8% 8/23 150.9
05 Color ripple 13.4% 23/23 157.2
06 Letter swaps 3.8% 6/23 149.9
07 Scan roll 18.0% 23/23 124.5
08 Byte damage 14.9% 16/23 119.6
09 Decay on press 14.2% 12/23 78.6

The widest footprint belongs to 01 at 18.4% and the narrowest to 06 at 3.8%. Scene 02 tears only when the time step changes, so it moved in 7/23 frames, 06 swaps seven times per lap and moved in 6/23, and the ripple in 05 and the band in 07 keep going without a pause, so both read 23/23.

01A Word Split Into Red, Green and Blue Sheets

The fewest lines that give letter edges a color fringe. Three sheets of the word, each holding one channel of paper minus background, are stacked with lighter blending, so the overlap returns exactly to the original color and only the edges split.

A Word Split Into Red, Green and Blue Sheets
Open in a new tab

01 · A Word Split Into Red, Green and Blue Sheets — Measured area 18.401% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

SPLIT_PXlightertint
    // One sheet = the word in only one channel of paper minus background - three sheets add up to exactly the paper color
    function makeTint(ch) {
        const c = document.createElement('canvas');
        c.width = W;
        c.height = H;
        const x = c.getContext('2d');
        const tint = [0, 0, 0];
        tint[ch] = PAPER[ch] - BGC[ch];
        if (x) {
            paintWord(x, WORD, 150, rgba(tint, 1), 0, 0);
        }
        return c;
    }
        ctx.globalCompositeOperation = 'lighter';
        for (let ch = 0; ch < 3; ch += 1) {
            const a = spin + ch * TAU / 3;
            ctx.drawImage(tints[ch], Math.round(Math.cos(a) * splitNow), Math.round(Math.sin(a) * splitNow * 0.6));
        }
        ctx.globalCompositeOperation = 'source-over';

The sheets sit 120 degrees apart and move SPLIT_PX 6 times the strength, so against strokes that are mostly 27 to 32px wide at the middle row the gap between sheets at strength 1 is 10.4px, narrower than one stroke.

02Horizontal Slices That Tear Row by Row

Reproducing a screen tear whose rows slip at different moments. The word drawing is cut into bands SLICE_H 5px tall and each band is pushed sideways by a hash, but the push is redrawn only once per 0.16 seconds, so it jumps in cuts instead of shaking.

Horizontal Slices That Tear Row by Row
Open in a new tab

02 · Horizontal Slices That Tear Row by Row — Measured area 9.508% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password

SLICE_HhashdrawImage
    // Bands that tear at time step k and their push - noise picks them so neighbor bands tear together, and a hash sets how far each one is pushed
    function tearBands(k, gain, sliceH) {
        const list = [];
        const b0 = Math.floor(rowTop / sliceH);
        const b1 = Math.floor(rowBot / sliceH);
        for (let b = b0; b <= b1; b += 1) {
            if (noise1(b * sliceH / 30 + k * 0.9, 17) < 1 - 0.5 * Math.min(1, gain)) {
                continue;
            }
            const off = Math.round((hash2(b, k) - 0.5) * 2 * TEAR_AMP * gain);
            if (off !== 0) {
                list.push({ b: b, off: off });
            }
        }
        return list;
    }
            const list = tearBands(Math.floor(sceneT / STEP_S), gain, SLICE_H);
            for (let i = 0; i < list.length; i += 1) {
                const y = list[i].b * SLICE_H;
                ctx.fillStyle = rgba(BGC, 1);
                ctx.fillRect(0, y, W, SLICE_H);
                ctx.drawImage(wordCv, 0, y, W, SLICE_H, list[i].off, y, W, SLICE_H);
                tornCount += 1;
                tornMax = Math.max(tornMax, Math.abs(list[i].off));
            }

The bands that tear are picked by noise1, whose neighbors answer together, so the tears clump into stripes, and the push is capped at TEAR_AMP 30px with 9.3 of 20 bands (46%) torn per time step, averaged over 200 steps.

03Bright Pixels Dragged Sideways Into a Smear

An effect that drags pixels by a brightness cut, which is a calculation on the pixel array rather than a property tween. In each row the runs brighter than LUMA_CUT 120 are found and sorted from dark to bright, and the color at the end of each run is dragged to the right.

Bright Pixels Dragged Sideways Into a Smear
Open in a new tab

03 · Bright Pixels Dragged Sideways Into a Smear — Measured area 4.937% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

LUMA_CUTgetImageDatasmear
    function sortRun(o, a, b) {
        const px = [];
        for (let x = a; x < b; x += 1) {
            const p = o + x * 4;
            px.push([lumaAt(p), base[p], base[p + 1], base[p + 2]]);
        }
        px.sort(function (u, v) { return u[0] - v[0]; });
        for (let i = 0; i < px.length; i += 1) {
            const p = o + (a + i) * 4;
            out[p] = px[i][1];
            out[p + 1] = px[i][2];
            out[p + 2] = px[i][3];
        }
    }

Which rows drag is fixed by a hash of the row pair, so a row grows and shrinks in the same place as the strength moves, and at strength 1, 18,196 pixels pass the cut and 48 of 85 rows drag, 75.1px on average and 119px at most.

04Grid Blocks Jumping Out in a Burst

A breakup effect where screen pieces slip at set intervals. The screen is split into a BLOCK_PX 16px grid, hash-picked cells are overwritten by pixels copied from other cells, and the moments follow a burst table of 0.0, 0.25, 0.55, 0.9 and 1.3 seconds with the clean word resting between them.

Grid Blocks Jumping Out in a Burst
Open in a new tab

04 · Grid Blocks Jumping Out in a Burst — Measured area 15.841% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

BLOCK_PXbursthash
    function burstStep() {
        const since = sceneT - trigT;
        if (since < 0 || since >= BURST_END_S) {
            return -1;
        }
        let j = 0;
        for (let i = 0; i < BURST_AT.length; i += 1) {
            if (since >= BURST_AT[i]) {
                j = i;
            }
        }
        return j;
    }
    // Cells overwritten in burst step j - only cells in word rows whose hash is under frac, and where to read from is also set by hash
    function pickBlocks(j, n, frac) {
        const list = [];
        const cy0 = Math.floor(rowTop / BLOCK_PX);
        const cy1 = Math.floor(rowBot / BLOCK_PX);
        for (let cy = cy0; cy <= cy1; cy += 1) {
            for (let cx = 0; cx < COLS; cx += 1) {
                const id = cy * COLS + cx;
                if (hash2(id, j * 97 + n * 13) >= frac) {
                    continue;
                }
                const sx = cx * BLOCK_PX + (Math.floor(hash2(id, j + 5) * 13) - 6) * BLOCK_PX;
                const sy = cy * BLOCK_PX + (Math.floor(hash2(id, j + 8) * 5) - 2) * BLOCK_PX;
                // About one cell in four gets extra red and takes on a pink tint
                list.push({ cx: cx, cy: cy, sx: sx, sy: sy, add: hash2(id, j + 11) < 0.25 ? 60 : 0 });
            }
        }
        return list;
    }

The source cell lies within 6 columns sideways and 2 rows up or down, about one cell in four gets 60 added to red for a pink tint, and the share overwritten climbs from 0.12 to 0.408 with the strength step across four bursts per lap.

05A Sine Ripple With a Different Phase per Color

When you want a lens-like fringe laid over the lettering. Each row gets a sideways push from a sine curve, and the red, green and blue channels are offset in phase by WAVE_PHASE 0.7 each, so the three ride the same ripple a little later and the colors spread apart as if through a lens.

A Sine Ripple With a Different Phase per Color
Open in a new tab

05 · A Sine Ripple With a Different Phase per Color — Measured area 13.426% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

WAVE_PHASEWAVE_AMPrenderRows
        const amp = WAVE_AMP * Math.min(1.5, gainNow());
        gapNow = 0;
        if (amp > 0.05) {
            for (let y = rowTop; y <= rowBot; y += 1) {
                const ph = y * TAU / WAVE_LEN + sceneT * WAVE_RAD_S;
                dxR[y] = amp * Math.sin(ph);
                dxG[y] = amp * Math.sin(ph + WAVE_PHASE);
                dxB[y] = amp * Math.sin(ph + 2 * WAVE_PHASE);
                gapNow = Math.max(gapNow, Math.abs(dxR[y] - dxB[y]));
            }
            renderRows(rowTop, rowBot, dxR, dxG, dxB);
        }
    // Read one row with a different sideways offset per channel into tmp (length W x 3) - a fractional offset still mixes the two neighbor pixels
    function rowSample(y, dR, dG, dB, tmp) {
        const o = y * W * 4;
        const d = [dR, dG, dB];
        for (let c = 0; c < 3; c += 1) {
            for (let x = 0; x < W; x += 1) {
                let sx = x - d[c];
                if (sx < 0) {
                    sx = 0;
                }
                else if (sx > W - 1.001) {
                    sx = W - 1.001;
                }
                const i0 = Math.floor(sx);
                const f = sx - i0;
                const p = o + i0 * 4 + c;
                tmp[x * 3 + c] = base[p] * (1 - f) + base[p + 4] * f;
            }
        }
    }

rowSample mixes two neighbor pixels when the offset is fractional, so the split stays soft, and one ripple spans 64 rows or 0.9 seconds with a red-to-blue gap of at most 14.2px at strength 1.

06Letters Flicking to Look-alike Shapes One by One

A conversion effect that swaps characters for look-alikes, shown as a picture. For each letter a look-alike is picked from SWAP_SET by hash and drawn only at set moments, while the cell width stays CELL_W 66px so the line length does not change.

Letters Flicking to Look-alike Shapes One by One
Open in a new tab

06 · Letters Flicking to Look-alike Shapes One by One — Measured area 3.829% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password

SWAP_SETFLICKER_MSfixedCell
    // One swap - a hash picks the letters, and the strength step sets how many cells swap (1, 2 or 3)
    function swapNow() {
        eventN += 1;
        lastSwapT = sceneT;
        const n = setting();
        for (let k = 0; k < n; k += 1) {
            const cell = Math.floor(hash2(eventN, k + 3) * WORD.length);
            const set = SWAP_SET[WORD[cell]];
            swapGlyph[cell] = set[Math.floor(hash2(eventN, k + 41) * set.length)];
            swapAt[cell] = sceneT;
        }
    }
    function fixedCell(i, c, ox, oy, fillStyle) {
        const swapped = sceneT - swapAt[i] < FLICKER_MS / 1000 && swapGlyph[i] !== '';
        c.font = '900 ' + WORD_PX + 'px ' + WORD_FONT;
        c.textAlign = 'center';
        c.textBaseline = 'middle';
        c.fillStyle = fillStyle;
        c.fillText(swapped ? swapGlyph[i] : WORD[i], ROW_X + i * CELL_W + ox, H / 2 + 6 + oy);
    }

A swapped letter returns after FLICKER_MS 120, which is 7.2 frames at 60fps, and the swapped cell is drawn split into three channels, with seven swaps per lap and 1, 2 or 3 cells per swap by strength step.

07Scan Lines and Bleed Rolling Down the Screen

Laying stripes and blur like an old display. Dark lines ROLL_PX 3px apart cover the screen and the bright word bleeds sideways, then one thick band rolls from top to bottom at 130px per second, shifting only its own rows hard and tripling the bleed length.

Scan Lines and Bleed Rolling Down the Screen
Open in a new tab

07 · Scan Lines and Bleed Rolling Down the Screen — Measured area 17.951% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ROLL_PXBLEED_PXBAND_H
    function roll() {
        bandOn = true;
        bandY = -BAND_H;
    }
    function update(dt) {
        if (!bandOn) {
            return;
        }
        bandY += BAND_SPEED * dt;
        if (bandY > H) {
            bandOn = false;
        }
    }
        for (let y = rowTop; y <= rowBot; y += 1) {
            // Rows inside the band shift sideways by the sine weight w and the bleed gets longer
            const u = (y - bandY) / BAND_H;
            const w = !still && bandOn && u > 0 && u < 1 ? Math.sin(Math.PI * u) : 0;
            if (w > 0.05) {
                bandRows += 1;
            }
            const shift = w * 20 * LVL_MUL[lvl] * Math.sin(y * 0.4 + sceneT * 18);
            dxAll[y] = shift;
            rowSample(y, shift - 3 * w, shift, shift + 3 * w, rowTmp);
            const bleed = bleedBase + Math.round(w * bleedBase * 2);

The band covers 21.3% of the screen and takes 2.8 seconds to cross it, only 1.28 of them over the word rows, and one row in three is darkened to 0.66 times its brightness.

08A Pixel Buffer Range Overwritten From Elsewhere

A color-streak texture like a broken file. The pixel buffer is treated as one byte row, a COPY_LEN 1200 byte range is overwritten from another place with copyWithin, and some ranges then get a single channel filled with 255.

A Pixel Buffer Range Overwritten From Elsewhere
Open in a new tab

08 · A Pixel Buffer Range Overwritten From Elsewhere — Measured area 14.923% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

COPY_LENUint8ClampedArraybyteRange
    // One byte range - overwrite COPY_LEN bytes starting at dst with the values read at src, then fill channel ch (0 red, 1 green, 2 blue) with 255
    function byteRange(dst, src, ch, flood) {
        bytes.copyWithin(dst, src, src + COPY_LEN);
        if (!flood) {
            return;
        }
        for (let p = dst + ch; p < dst + COPY_LEN; p += 4) {
            bytes[p] = 255;
        }
    }
            const k = Math.floor(sceneT / STEP_S);
            const rowBytes = W * 4;
            const rows = rowBot - rowTop + 1;
            const n = Math.max(1, Math.round(4 * Math.min(1.5, gain)));
            for (let r = 0; r < n; r += 1) {
                const row = rowTop + Math.floor(hash2(r, k * 7 + 1) * rows);
                const dst = row * rowBytes + Math.floor(hash2(r, k + 90) * (rowBytes - COPY_LEN));
                // The source row is a nearby row above or below, and a byte offset of 0 to 3 pushes the channels against each other
                const rowAt = clamp(row + Math.floor(hash2(r, k + 33) * 11) - 5, rowTop, rowBot);
                const src = clamp(rowAt * rowBytes + Math.floor(hash2(r, k + 60) * (rowBytes - COPY_LEN)) + Math.floor(hash2(r, k + 71) * 4), 0, H * rowBytes - COPY_LEN);
                // Overwrite the same range 2 to 7 rows down so it becomes a horizontally cut band
                const tall = 2 + Math.floor(hash2(r, k + 5) * 6);
                const flood = hash2(r, k + 17) < 0.5;

A pixel row is 1,920 bytes, so 1200 bytes is 62.5% of a row, and repeating the range 2 to 7 rows down makes a thick band cut sideways, with range positions moving only to hash-picked places at 0.15 second steps and 2, 4 or 6 ranges by strength step.

09A Hard Break on Press That Settles Down

How to attach it to a button or title that breaks only on click. The word stays clean most of the time, and on a press or a timetable signal the break strength starts at 1 and shrinks by DECAY 0.92 on each 1/30 second tick until it falls below TRIGGER_MIN 0.05 and the original returns.

A Hard Break on Press That Settles Down
Open in a new tab

09 · A Hard Break on Press That Settles Down — Measured area 14.229% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

DECAYTRIGGER_MINstrength
    function update(dt) {
        if (strength <= 0) {
            return;
        }
        acc += dt;
        while (acc >= TICK_S && strength > 0) {
            acc -= TICK_S;
            strength *= DECAY;
            if (strength < TRIGGER_MIN) {
                strength = 0;
            }
        }
    }
            for (let y = rowTop; y <= rowBot; y += 1) {
                const b = Math.floor(y / 5);
                // A stronger break moves more bands, and the push width grows with strength
                const moved = hash2(b, k + hitN * 31) < Math.min(1, strength * 0.7 * m);
                const dx = moved ? (hash2(b, k + 7) - 0.5) * 2 * SHIFT_AMP * strength * m : 0;
                dxR[y] = dx - SPLIT_AMP * strength * m;
                dxG[y] = dx;
                dxB[y] = dx + SPLIT_AMP * strength * m;
            }
            renderRows(rowTop, rowBot, dxR, dxG, dxB);

Tying the band shift and the color split to one strength value makes both fade at the same speed, and reaching 0 takes 36 ticks or 1.2 seconds, with a half-life of 0.277 seconds and 0.286 left after 0.5 seconds.

When You Build Glitch Text in Code, How Many Values Turn One Word Into a Broken Screen?

One word is one pixel array, and each scene adds one more value that reads it. Scenes 03, 04, 05, 07, 08 and 09 read base and write the result into out, while 01, 02 and 06 draw with drawImage and lighter blending.

The values that move one scene

Value Where it comes from What it does
base loadWord, which paints the word and reads it back The pixel array every scene reads
hash2(a, b) Integer multiplies and shifts The seed that decides where a band, cell or row breaks
SPLIT_PX, SLICE_H, LUMA_CUT Scenes 01, 02 and 03 Sheet gap, band height and the brightness cut for the smear
DECAY, TRIGGER_MIN Scene 09 The shrink factor and the floor that ends the break
LVL_MUL Strength steps 0.5, 1 and 1.7 The multiplier a press applies to each scene's strength

Neighbors that read the same pixels through a different lens

The glitch transition post covers the short breakup between two screens, while this one covers a word that keeps breaking and sticking on a timetable. The calculation that samples a glyph's pixels and rebuilds a shape continues in glyph pixel sampling typography, and the property-only route to moving letters stays in CSS text animation.

Paint the Word Once, Pick Bands or Cells by Hash From Its Pixel Array to Move and Lay the Color Channels on Separately: the Order in Which One Glitch Text Scene Is Drawn

Four steps through one frame

  1. loadWord paints the word on an offscreen canvas, reads it as base and finds the rows that hold the word.
  2. The time step number comes from Math.floor(sceneT / STEP_S), so the same hash input repeats inside one step.
  3. Each scene picks bands, cells or rows, computes a push, a copy or a sort, and writes it into out or draws it with drawImage.
  4. putImageData lifts the result, and the gauge under the stage draws each constant name with the setting after the strength step is applied.

If this kind of calculation is new to you, read hash2 and loadWord first, since the other scenes sit on those two functions.

One strength step reshapes the whole scene

A press cycles LVL_MUL through 0.5, 1 and 1.7, and the value multiplies the constants of the scene. At the timetable peak the sheet travel radius in 01 is 6.6, 13.2 and 22.4px by step, and the cell share in 04 is 0.12, 0.24 and 0.408, so one place changes and the screen changes by more than threefold.

Putting It on a Real Screen Like a Glitch Text Maker: Building Without a Library, Laying It on a Title and When the Scene Changes

Building on one canvas without a library

index.html alone holds the canvas, the pause button and the status line, and the calculation uses only the getImageData array and an integer hash. It suits someone who wants the break on a web title more than someone looking for a glitch text generator that hands out copyable text, so you only need to lift out the scene clock and the break calculation. No font file is loaded either, because the word is painted from the list "Arial Black", "Segoe UI Black", Impact, system-ui and shrunk when it is wider than MAX_W 392.

On a title, leave the original word in the page

On a real title or button, save the performance.now() value at the moment of the event, such as a press, a hover or the end of loading, as trigT and recompute the strength from the elapsed time on each frame. Multiplying a value by the gap between frames would stretch the decay on a slow device. Paint the DOM text onto a canvas, reuse the same calculation, and keep the original DOM text visible while the canvas rides over it as an aria-hidden decoration.

One timetable decides when a scene changes

The break moments come from a list of time and action pairs, which works as an on and off list in 02 and as a send-a-signal list in 04 and 09. When you attach it to an error state or a loading effect, put the event into that timetable slot and the scene and the spoken sentence stay in step.

Where Glitch Text Breaks: a Trap of Large Pixel Arrays That Slow a Tall Screen, a Flicker That Hurts Because the Hash Changes Every Frame and a Stretch Where the Word Cannot Be Read

A tall screen grows the array fourfold

The pixel count is width times height, so 144,000 pixels at 480x300 become 576,000 at 960x600, four times as many. Looping over only the word rows cuts the work in 03 to 85 rows, but a structure that doubles the whole canvas pays for the array copy first. The answer in this source is to calculate at 480x300 and use the devicePixelRatio scale, capped at 2, only to enlarge the drawn picture.

Without a time step a different band tears on each frame

If the push is hashed from the band number alone, a small drift of the scene clock changes the input and a different band tears on each frame. This post mixes the time step number into the hash, so the same bands hold the same place for 0.16 seconds.

Keeping it easy on the eyes

Running one lap as consecutive 30fps frames and measuring the share of pixels whose brightness jumps by 10% or more between neighbor frames gives a peak of 10.8% in 09 (the first pressed frame), 10.3% in 02 and 7.2% in 04. Frames above 5% number 7 each in 02 and 09 and 15 in 04 per lap, no frame covers more than 25% of the screen, and the mean brightness change is at most 2.9 per frame out of 255.

A long unreadable stretch is a bug, not an effect

The word is hard to read in 07 only for the 1.28 seconds the band takes to pass over it, and 09 settles in 1.2 seconds. Holding the break strength high makes the viewer wait for the word instead of watching an effect, so this post sets the ratio of clean to broken time in the timetable.

The finished sources of the nine scenes and the React version sit in the zip below, and typing ejerp5wd when you extract it opens the archive.

Change the Split Width, the Slice Height and the Smear Cut and How Much Does the Feel Shift? SPLIT_PX, SLICE_H and LUMA_CUT Compared

Name Changed value Feel
A wider color split 01 SPLIT_PX 6 → 16 The gap between the three sheets opens from 10.4px to 27.7px, about the stroke width (mostly 27 to 32px), while lowering it only tints the edges
Thinner slices 02 SLICE_H 5 → 2 The bands the word spans rise from 20 to 50 and the bands torn at one moment from 9.3 to 23.1 on average, so the tear looks like a fine ripple, while raising it leaves thick layers slipping like steps
A lower smear cut 03 LUMA_CUT 120 → 60 The shadow copy, about brightness 111, is dragged as well and the target pixels rise from 18,196 to 24,314 (34% more), while raising it stretches only the brightest strokes at the top

What to look at before changing a value

The sheet gap in 01 is SPLIT_PX times √3, so even at 16 the split stays about one stroke wide. In 02 thinner bands keep the 46% tear ratio and the 30px cap, so only the grain gets finer. In 03 a lower cut drags the shadow layer too and blurs the scene further, so you give up some contrast against the clean word.

One Clean Word Under Reduced Motion: Accessibility Through a Sentence for the Scene and the Current Break Strength

A reduced-motion setting draws one original word

When prefers-reduced-motion is on, the page starts no loop and draws a single original word with no glitch. A press then changes only the strength step dots and the setting values on the gauge below.

A broken word is not read aloud

The canvas aria-label holds the original word and a description of the scene, not the broken picture. The status line keeps aria-live at off, so numbers that change on each frame are not read, and speakLine writes into a role="status" paragraph only when the mode changes at the start and end of a break and when a person presses, pauses or restarts.

Pause and restart from the keyboard

The stage has role="application" and tabindex 0, so once it has focus Enter, Space and P pause and R restarts from the beginning. The right arrow, the up arrow, T and a press on the stage break the word once and raise the strength step by one, and a press that is held breaks only as far as it is pressed before it settles.

A hidden tab pauses the demo

When the tab is hidden the loop stops, and on return the time gap of the first frame is dropped, so no frame exceeds the DT_MAX cap of 0.05 seconds.

Glitch Text FAQ: Building Without a Library, How It Differs From a Text Converter and What You Need Before Putting It on the Web

Can I build glitch text without a library?

Yes. The canvas 2D calls getImageData, putImageData and drawImage plus requestAnimationFrame run the nine scenes, and no external script is loaded.

How is this different from a glitch text maker that converts what I type?

A converter swaps letters for look-alike symbols and hands you the result to copy, while this code moves the pixels of a word drawing on a timetable so it breaks on screen. Scene 06 shows the swap a converter makes as a picture, and the other eight break the word at the pixel level.

What do I need before putting it on the web?

The script sits inside one index.html file with no fetch call and no outside address, so there is no server-side work to prepare. The calculation runs on 480x300 pixels and the devicePixelRatio scale is cut at 2, so a high-density display does not add pixels to compute.

For the pixel array read, see the MDN getImageData page, for blending modes the globalCompositeOperation page, for the animation loop requestAnimationFrame and for the motion setting prefers-reduced-motion.

Enter the archive password

The password is inside this article. You will find it as you read.