Skip to content

Instantly share code, notes, and snippets.

@schonert
Created August 30, 2024 11:53
Show Gist options
  • Select an option

  • Save schonert/6c4e1885fa9eb74b333b3e5398a81bc6 to your computer and use it in GitHub Desktop.

Select an option

Save schonert/6c4e1885fa9eb74b333b3e5398a81bc6 to your computer and use it in GitHub Desktop.
TextReveal.tsx
import { useState } from "react";
interface TextEncryptedProps {
text: string | number;
className?: string;
duration?: number;
delay?: number;
useTextCharacters?: boolean;
}
const CHARACTERS =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
function randomizeText(text: string, useTextCharacters = true) {
const chars = useTextCharacters
? text.split("").filter((c) => c != " ")
: CHARACTERS;
return text
.split("")
.map((c) =>
c == " " ? " " : chars[Math.floor(Math.random() * chars.length)]
)
.join("");
}
function animationRunner(
duration: number,
callback: (progress: number) => void
) {
let animationFrameId: number;
let startTime = 0;
let previousProgress: number = 0;
const animate = (timestamp: number) => {
startTime = startTime || timestamp;
let progress = (timestamp - startTime) / duration;
progress = cubicInOut(progress);
progress = parseFloat(progress.toFixed(2));
if (progress > 1) {
return callback(1);
}
// Only callback if progress has changed
if (progress != previousProgress) {
callback(progress);
}
previousProgress = progress;
animationFrameId = requestAnimationFrame(animate);
};
animationFrameId = requestAnimationFrame(animate);
return () => cancelAnimationFrame(animationFrameId);
}
function revealText(
element: HTMLElement,
text: string,
duration: number,
useTextCharacters = true,
callback: () => void
) {
const abortAnimation = animationRunner(duration, (progress) => {
const charIndex = Math.floor(progress * text.length);
const newText = text.slice(0, charIndex);
const remainderText = randomizeText(
text.slice(newText.length),
useTextCharacters
);
const highlightChunkSize = Math.max(Math.ceil(remainderText.length / 3), 3);
const highlightedText = remainderText.slice(
0,
Math.max(highlightChunkSize, 3)
);
const dimmedHighlightedText = remainderText.slice(
highlightedText.length,
highlightedText.length + highlightChunkSize
);
const dimmedText = remainderText.slice(
dimmedHighlightedText.length + highlightedText.length,
-1
);
if (!element) {
return abortAnimation();
}
if (progress === 1) {
callback();
element.innerHTML = `<span>${text}</span>`;
} else {
element.innerHTML = `
<span>${newText}</span><span style="opacity: 0.9;">${highlightedText}</span><span style="opacity:0.3; font-size: 0.93em">${dimmedHighlightedText}</span><span style="opacity:0.2; font-size: 0.9em;">${dimmedText}</span>`;
}
});
return abortAnimation;
}
export function TextReveal({
text,
duration = 750,
delay = 0,
className,
useTextCharacters = true,
}: TextEncryptedProps) {
const [completed, setCompleted] = useState("");
function startReveal(element: HTMLParagraphElement) {
const animateText = text.toString();
if (element && completed !== animateText) {
const box = element.getBoundingClientRect();
element.style.height = `${box.height}px`;
element.style.width = `${box.width}px`;
element.style.overflow = "clip";
const placeholderText = randomizeText(animateText, useTextCharacters);
element.innerHTML = `<span style="opacity: 0.15;">${placeholderText}</span>`;
setTimeout(() => {
revealText(element, animateText, duration, useTextCharacters, () => {
setCompleted(animateText);
element.style.height = "";
element.style.width = "";
element.style.overflow = "";
});
}, delay);
}
}
return (
<p ref={startReveal} key={text} className={className}>
<span style={{ opacity: 0 }}>{text}</span>
</p>
);
}
function cubicInOut(progress: number) {
return progress < 0.5
? 4 * progress * progress * progress
: 1 - Math.pow(-2 * progress + 2, 3) / 2;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment