Skip to content

Commit f2a6be3

Browse files
committed
solved hydration errors
1 parent 0ebcdd7 commit f2a6be3

5 files changed

Lines changed: 201 additions & 5 deletions

File tree

app/page.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import TimelineDemo from "@/components/timeline-demo";
22
import PixelatedCanvasDemo from "@/components/pixelated-canvas-demo";
3+
import EncryptedTextDemoSecond from "@/components/encrypted-text-demo-2";
34

45
export default function Home() {
56
return (
67
<main>
7-
<PixelatedCanvasDemo/>
8+
<div className="p-8 flex flex-col md:flex-row items-center">
9+
<PixelatedCanvasDemo />
10+
<EncryptedTextDemoSecond/>
11+
</div>
812
<TimelineDemo/>
913
</main>
1014
);
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"use client"
2+
3+
import { EncryptedText } from "@/components/ui/encrypted-text";
4+
import React from "react";
5+
6+
export default function EncryptedTextDemoSecond() {
7+
return (
8+
<p className="mx-auto max-w-lg py-10 text-left text-3xl">
9+
<EncryptedText
10+
text="Hello I'm Kathrina"
11+
encryptedClassName="text-neutral-500"
12+
revealedClassName="dark:text-white text-black"
13+
revealDelayMs={50}
14+
/>
15+
</p>
16+
);
17+
}

components/pixelated-canvas-demo.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,12 @@ export default function PixelatedCanvasDemo() {
2424
}, []);
2525

2626
return (
27-
<div ref={containerRef} className="mt-8 w-full max-w-125">
27+
<div ref={containerRef} className="w-full max-w-125">
2828
<PixelatedCanvas
2929
src="/avatar.jpeg"
3030
width={canvasSize}
3131
height={canvasSize}
32-
cellSize={6}
32+
cellSize={4}
3333
dotScale={0.9}
3434
shape="square"
3535
backgroundColor="#000000"

components/ui/encrypted-text.tsx

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
"use client";
2+
import React, { useEffect, useRef, useState } from "react";
3+
import { motion, useInView } from "motion/react";
4+
import { cn } from "@/lib/utils";
5+
6+
type EncryptedTextProps = {
7+
text: string;
8+
className?: string;
9+
/**
10+
* Time in milliseconds between revealing each subsequent real character.
11+
* Lower is faster. Defaults to 50ms per character.
12+
*/
13+
revealDelayMs?: number;
14+
/** Optional custom character set to use for the gibberish effect. */
15+
charset?: string;
16+
/**
17+
* Time in milliseconds between gibberish flips for unrevealed characters.
18+
* Lower is more jittery. Defaults to 50ms.
19+
*/
20+
flipDelayMs?: number;
21+
/** CSS class for styling the encrypted/scrambled characters */
22+
encryptedClassName?: string;
23+
/** CSS class for styling the revealed characters */
24+
revealedClassName?: string;
25+
};
26+
27+
const DEFAULT_CHARSET =
28+
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-={}[];:,.<>/?";
29+
30+
function generateRandomCharacter(charset: string): string {
31+
const index = Math.floor(Math.random() * charset.length);
32+
return charset.charAt(index);
33+
}
34+
35+
function generateGibberishPreservingSpaces(
36+
original: string,
37+
charset: string,
38+
): string {
39+
if (!original) return "";
40+
let result = "";
41+
for (let i = 0; i < original.length; i += 1) {
42+
const ch = original[i];
43+
result += ch === " " ? " " : generateRandomCharacter(charset);
44+
}
45+
return result;
46+
}
47+
48+
export const EncryptedText: React.FC<EncryptedTextProps> = ({
49+
text,
50+
className,
51+
revealDelayMs = 50,
52+
charset = DEFAULT_CHARSET,
53+
flipDelayMs = 50,
54+
encryptedClassName,
55+
revealedClassName,
56+
}) => {
57+
const ref = useRef<HTMLSpanElement>(null);
58+
const isInView = useInView(ref, { once: true });
59+
const [isClient, setIsClient] = useState(false);
60+
61+
const [revealCount, setRevealCount] = useState<number>(0);
62+
const animationFrameRef = useRef<number | null>(null);
63+
const startTimeRef = useRef<number>(0);
64+
const lastFlipTimeRef = useRef<number>(0);
65+
const scrambleCharsRef = useRef<string[]>(
66+
text ? generateGibberishPreservingSpaces(text, charset).split("") : [],
67+
);
68+
69+
useEffect(() => {
70+
setIsClient(true);
71+
}, []);
72+
73+
useEffect(() => {
74+
if (!isInView || !isClient) return;
75+
76+
// Reset state for a fresh animation whenever dependencies change
77+
const initial = text
78+
? generateGibberishPreservingSpaces(text, charset)
79+
: "";
80+
scrambleCharsRef.current = initial.split("");
81+
startTimeRef.current = performance.now();
82+
lastFlipTimeRef.current = startTimeRef.current;
83+
setRevealCount(0);
84+
85+
let isCancelled = false;
86+
87+
const update = (now: number) => {
88+
if (isCancelled) return;
89+
90+
const elapsedMs = now - startTimeRef.current;
91+
const totalLength = text.length;
92+
const currentRevealCount = Math.min(
93+
totalLength,
94+
Math.floor(elapsedMs / Math.max(1, revealDelayMs)),
95+
);
96+
97+
setRevealCount(currentRevealCount);
98+
99+
if (currentRevealCount >= totalLength) {
100+
return;
101+
}
102+
103+
// Re-randomize unrevealed scramble characters on an interval
104+
const timeSinceLastFlip = now - lastFlipTimeRef.current;
105+
if (timeSinceLastFlip >= Math.max(0, flipDelayMs)) {
106+
for (let index = 0; index < totalLength; index += 1) {
107+
if (index >= currentRevealCount) {
108+
if (text[index] !== " ") {
109+
scrambleCharsRef.current[index] =
110+
generateRandomCharacter(charset);
111+
} else {
112+
scrambleCharsRef.current[index] = " ";
113+
}
114+
}
115+
}
116+
lastFlipTimeRef.current = now;
117+
}
118+
119+
animationFrameRef.current = requestAnimationFrame(update);
120+
};
121+
122+
animationFrameRef.current = requestAnimationFrame(update);
123+
124+
return () => {
125+
isCancelled = true;
126+
if (animationFrameRef.current !== null) {
127+
cancelAnimationFrame(animationFrameRef.current);
128+
}
129+
};
130+
}, [isInView, isClient, text, revealDelayMs, charset, flipDelayMs]);
131+
132+
if (!text) return null;
133+
134+
// Only render the animated version on client to prevent hydration mismatch
135+
if (!isClient) {
136+
return (
137+
<motion.span
138+
ref={ref}
139+
className={cn(className)}
140+
aria-label={text}
141+
role="text"
142+
>
143+
{text}
144+
</motion.span>
145+
);
146+
}
147+
148+
return (
149+
<motion.span
150+
ref={ref}
151+
className={cn(className)}
152+
aria-label={text}
153+
role="text"
154+
>
155+
{text.split("").map((char, index) => {
156+
const isRevealed = index < revealCount;
157+
const displayChar = isRevealed
158+
? char
159+
: char === " "
160+
? " "
161+
: (scrambleCharsRef.current[index] ??
162+
generateRandomCharacter(charset));
163+
164+
return (
165+
<span
166+
key={index}
167+
className={cn(isRevealed ? revealedClassName : encryptedClassName)}
168+
>
169+
{displayChar}
170+
</span>
171+
);
172+
})}
173+
</motion.span>
174+
);
175+
};

components/ui/timeline.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export const Timeline = ({ data }: { data: TimelineEntry[] }) => {
5050
<div ref={ref} className="relative max-w-7xl mx-auto pb-20">
5151
{data.map((item, index) => (
5252
<div
53-
key={index}
53+
key={`${item.title}-${index}`}
5454
className="flex justify-start pt-10 md:pt-40 md:gap-10"
5555
>
5656
<div className="sticky flex flex-col md:flex-row z-40 items-center top-40 self-start max-w-xs lg:max-w-sm md:w-full">
@@ -81,7 +81,7 @@ export const Timeline = ({ data }: { data: TimelineEntry[] }) => {
8181
height: heightTransform,
8282
opacity: opacityTransform,
8383
}}
84-
className="absolute inset-x-0 top-0 w-[2px] bg-gradient-to-t from-purple-500 via-blue-500 to-transparent from-[0%] via-[10%] rounded-full"
84+
className="absolute inset-x-0 top-0 w-[2px] bg-gradient-to-t from-white via-slate-100 to-transparent from-[0%] via-[1%] rounded-full"
8585
/>
8686
</div>
8787
</div>

0 commit comments

Comments
 (0)