Skip to content

[BUG]: LetterGlitch smooth colour transition freezes after one frame #1069

Description

@FelipeKreulich

Describe the issue

I was using LetterGlitch as a page background and noticed that turning smooth on and off barely changed anything. Turns out the colour fade does start, but it stops on the very first frame — a letter ends up about 5% of the way to its target colour and stays there until the next glitch tick swaps it out for something else.

So smooth={true} is more or less inert right now.

Root cause

In handleSmoothTransitions:

const startRgb = hexToRgb(letter.color);
const endRgb = hexToRgb(letter.targetColor);
if (startRgb && endRgb) {
  letter.color = interpolateColor(startRgb, endRgb, letter.colorProgress);
  needsRedraw = true;
}

letter.color starts out as a hex string from getRandomColor(), but the line inside the if overwrites it with interpolateColor(...), which returns rgb(r, g, b). On the next frame hexToRgb("rgb(46, 77, 62)") matches neither the shorthand nor the 6-digit regex, so it returns null, the if never runs again, and the colour is stuck. colorProgress still climbs all the way to 1, and needsRedraw stays false.

targetColor isn't affected, since it's always reassigned straight from getRandomColor() and stays hex. It's only the start of the interpolation that gets poisoned.

Same code in all four variants:

  • src/content/Backgrounds/LetterGlitch/LetterGlitch.jsx:146
  • src/tailwind/Backgrounds/LetterGlitch/LetterGlitch.jsx:146
  • src/ts-default/Backgrounds/LetterGlitch/LetterGlitch.tsx:166
  • src/ts-tailwind/Backgrounds/LetterGlitch/LetterGlitch.tsx:166

I think this is easy to miss because the canvas still animates perfectly well — updateLetters() swaps characters every glitchSpeed ms and calls drawLetters() regardless of any of this. The only thing missing is the fade between colours, which is subtle enough that it just reads as "smooth doesn't do much".

Reproduction Link

https://reactbits.dev/backgrounds/letter-glitch

Steps to reproduce

The two functions don't touch the DOM, so you can watch this happen in plain Node without React or a browser. Both are copied verbatim from the component:

const hexToRgb = hex => {
  const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
  hex = hex.replace(shorthandRegex, (_m, r, g, b) => r + r + g + g + b + b);
  const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
  return result
    ? { r: parseInt(result[1], 16), g: parseInt(result[2], 16), b: parseInt(result[3], 16) }
    : null;
};

const interpolateColor = (start, end, factor) => {
  const result = {
    r: Math.round(start.r + (end.r - start.r) * factor),
    g: Math.round(start.g + (end.g - start.g) * factor),
    b: Math.round(start.b + (end.b - start.b) * factor)
  };
  return `rgb(${result.r}, ${result.g}, ${result.b})`;
};

// one letter, stepped through handleSmoothTransitions frame by frame
const letter = { color: '#2b4539', targetColor: '#61dca3', colorProgress: 0 };
let redraws = 0;

for (let frame = 1; frame <= 20; frame++) {
  if (letter.colorProgress < 1) {
    letter.colorProgress += 0.05;
    if (letter.colorProgress > 1) letter.colorProgress = 1;

    const startRgb = hexToRgb(letter.color);
    const endRgb = hexToRgb(letter.targetColor);
    if (startRgb && endRgb) {
      letter.color = interpolateColor(startRgb, endRgb, letter.colorProgress);
      redraws++;
    }
    console.log(
      `frame ${String(frame).padStart(2)}  progress ${letter.colorProgress.toFixed(2)}` +
      `  color ${letter.color.padEnd(18)}  ${startRgb ? 'ok' : 'hexToRgb(letter.color) -> null'}`
    );
  }
}

console.log(`\nfinal colour:   ${letter.color}`);
console.log(`expected:       rgb(97, 220, 163)  (= #61dca3)`);
console.log(`frames redrawn: ${redraws} of 20`);

Using the component's own default colours (#2b4539#61dca3):

frame  1  progress 0.05  color rgb(46, 77, 62)     ok
frame  2  progress 0.10  color rgb(46, 77, 62)     hexToRgb(letter.color) -> null
frame  3  progress 0.15  color rgb(46, 77, 62)     hexToRgb(letter.color) -> null
...
frame 20  progress 1.00  color rgb(46, 77, 62)     hexToRgb(letter.color) -> null

final colour:   rgb(46, 77, 62)
expected:       rgb(97, 220, 163)  (= #61dca3)
frames redrawn: 1 of 20

One frame out of twenty actually paints.

Possible fix

Keeping the two interpolation endpoints in hex and letting letter.color be render-only fixes it, since the parser is then never handed its own output:

   letters.current = Array.from({ length: totalLetters }, () => ({
     char: getRandomChar(),
     color: getRandomColor(),
+    startColor: getRandomColor(),
     targetColor: getRandomColor(),
     colorProgress: 1
   }));
       letters.current[index].char = getRandomChar();
+      letters.current[index].startColor = letters.current[index].targetColor;
       letters.current[index].targetColor = getRandomColor();
-        const startRgb = hexToRgb(letter.color);
+        const startRgb = hexToRgb(letter.startColor);
         const endRgb = hexToRgb(letter.targetColor);

The other way round would be to have interpolateColor return {r, g, b} and only format it into a string at the ctx.fillStyle assignment — slightly bigger diff, but no extra field. Happy to open a PR with whichever you'd rather have.

Validations

  • I have checked other issues to see if my issue was already reported or addressed

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions