Sunflower UI
Search
Copy Code
Light Mode
click me
halftone-dots.tsx
prompt.md
styles.css
Collapse
Reset
Copy Code
Download
Run
1
"use client";
2
3
import React, { useEffect, useRef, useState } from "react";
4
import { Upload } from "lucide-react";
5
import { cn } from "@/lib/utils";
6
7
const svgRocket = `data:image/svg+xml;utf8,${encodeURIComponent(`
8
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
9
<defs>
10
<linearGradient id="g1" x1="0" y1="0" x2="0" y2="1">
11
<stop offset="0%" stop-color="#ec4899" />
12
<stop offset="100%" stop-color="#8b5cf6" />
13
</linearGradient>
14
</defs>
15
<path d="M100 20 C100 20, 130 60, 130 110 L150 150 L130 150 L130 170 L70 170 L70 150 L50 150 L70 110 C70 60, 100 20, 100 20 Z" fill="url(#g1)"/>
16
<circle cx="100" cy="80" r="15" fill="#fff" />
17
</svg>
18
`)}`;
19
20
const svgServers = `data:image/svg+xml;utf8,${encodeURIComponent(`
21
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
22
<defs>
23
<linearGradient id="g2" x1="0" y1="0" x2="0" y2="1">
24
<stop offset="0%" stop-color="#06b6d4" />
25
<stop offset="100%" stop-color="#3b82f6" />
26
</linearGradient>
27
</defs>
28
<rect x="30" y="40" width="140" height="32" rx="6" fill="url(#g2)"/>
29
<rect x="30" y="85" width="140" height="32" rx="6" fill="url(#g2)"/>
30
<rect x="30" y="130" width="140" height="32" rx="6" fill="url(#g2)"/>
31
<circle cx="50" cy="56" r="5" fill="#fff" />
32
<circle cx="50" cy="101" r="5" fill="#fff" />
33
<circle cx="50" cy="146" r="5" fill="#fff" />
34
</svg>
35
`)}`;
36
37
const svgBridge = `data:image/svg+xml;utf8,${encodeURIComponent(`
38
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
39
<defs>
40
<linearGradient id="g3" x1="0" y1="0" x2="0" y2="1">
41
<stop offset="0%" stop-color="#f97316" />
42
<stop offset="100%" stop-color="#b91c1c" />
43
</linearGradient>
44
</defs>
45
<path d="M0 140 Q 100 60, 200 140 L 200 160 L 0 160 Z" fill="url(#g3)"/>
46
<rect x="50" y="40" width="12" height="120" fill="url(#g3)"/>
47
<rect x="138" y="40" width="12" height="120" fill="url(#g3)"/>
48
<path d="M0 160 L 200 160 L 200 170 L 0 170 Z" fill="url(#g3)" opacity="0.8"/>
49
</svg>
50
`)}`;
51
52
const TABS = [
53
{ id: "rocket", label: "Rocket", src: svgRocket },
54
{ id: "servers", label: "Servers", src: svgServers },
55
{ id: "bridge", label: "Bridge", src: svgBridge },
56
];
57
58
class Dot {
59
origX: number;
60
origY: number;
61
x: number;
62
y: number;
63
color: string;
64
baseRadius: number;
65
radius: number;
66
isBg: boolean;
67
vx: number;
68
vy: number;
69
isFalling: boolean;
70
71
constructor(x: number, y: number, color: string, radius: number, isBg: boolean) {
72
this.origX = x;
73
this.origY = y;
74
this.x = x;
75
this.y = y;
76
this.color = color;
77
this.baseRadius = radius;
78
this.radius = radius;
79
this.isBg = isBg;
80
this.vx = 0;
81
this.vy = 0;
82
this.isFalling = false;
83
}
84
85
update(time: number, ripples: { x: number; y: number; startTime: number }[], isBurst: boolean, width: number, height: number) {
86
if (this.isFalling) {
87
this.vy += 0.5; // gravity
88
this.x += this.vx;
89
this.y += this.vy;
90
return;
91
}
92
93
if (isBurst && !this.isBg) {
94
this.isFalling = true;
95
this.vx = (Math.random() - 0.5) * 15;
96
this.vy = (Math.random() - 0.5) * 15 - (Math.random() * 10);
97
return;
98
} else if (isBurst && this.isBg) {
99
// Background dots just fade or fall slightly
100
this.isFalling = true;
101
this.vx = (Math.random() - 0.5) * 2;
102
this.vy = Math.random() * 5;
103
}
104
105
let dx = 0;
106
let dy = 0;
107
let dr = 0;
108
109
for (const r of ripples) {
110
const dist = Math.hypot(this.origX - r.x, this.origY - r.y);
111
// Wave equation
112
const elapsed = time - r.startTime;
113
const wavePhase = (elapsed * 0.015) - (dist * 0.05);
114
115
if (wavePhase > 0 && wavePhase < Math.PI * 2) {
116
const amplitude = Math.sin(wavePhase) * (150 / (dist + 10));
117
dx += ((this.origX - r.x) / dist) * amplitude;
118
dy += ((this.origY - r.y) / dist) * amplitude;
119
dr += (amplitude * 0.05);
120
}
121
}
122
123
this.x += (this.origX + dx - this.x) * 0.15;
124
this.y += (this.origY + dy - this.y) * 0.15;
125
this.radius = Math.max(0.1, this.baseRadius + dr);
126
}
127
128
draw(ctx: CanvasRenderingContext2D) {
129
ctx.beginPath();
130
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
131
ctx.fillStyle = this.color;
132
ctx.fill();
133
}
134
}
135
136
const Tracker = ({ className }: { className?: string }) => (
137
<div className={cn("absolute w-2.5 h-2.5 grid grid-cols-2 grid-rows-2 gap-[2px]", className)}>
138
<div className="w-1 h-1 bg-[#007AFF] dark:bg-[#3B82F6] rounded-full shadow-[0_0_6px_rgba(59,130,246,0.6)]" />
139
<div className="w-1 h-1 bg-[#007AFF] dark:bg-[#3B82F6] rounded-full shadow-[0_0_6px_rgba(59,130,246,0.6)]" />
140
<div className="w-1 h-1 bg-[#007AFF] dark:bg-[#3B82F6] rounded-full shadow-[0_0_6px_rgba(59,130,246,0.6)]" />
141
<div className="w-1 h-1" />
142
</div>
143
);
144
145
export function HalftoneDots() {
146
const [activeTab, setActiveTab] = useState(TABS[0].id);
147
const canvasRef = useRef<HTMLCanvasElement>(null);
148
const dotsRef = useRef<Dot[]>([]);
149
const ripplesRef = useRef<{ x: number; y: number; startTime: number }[]>([]);
150
const clickCountRef = useRef(0);
151
const lastClickRef = useRef(0);
152
const burstRef = useRef(false);
153
const currentSrcRef = useRef(TABS[0].src);
154
const fileInputRef = useRef<HTMLInputElement>(null);
155
const animationFrameRef = useRef<number>(0);
156
157
const loadImageToDots = (src: string) => {
158
const img = new Image();
159
img.crossOrigin = "anonymous";
160
img.onload = () => {
161
const isDark = document.documentElement.classList.contains("dark");
162
const bgColor = isDark ? "rgba(255, 255, 255, 0.1)" : "rgba(0, 0, 0, 0.08)";
163
164
const gridCanvas = document.createElement("canvas");
165
const GRID_SIZE = 50;
166
gridCanvas.width = GRID_SIZE;
167
gridCanvas.height = GRID_SIZE;
168
const ctx = gridCanvas.getContext("2d", { willReadFrequently: true });
169
if (!ctx) return;
170
171
ctx.clearRect(0, 0, GRID_SIZE, GRID_SIZE);
172
173
const scale = Math.min(GRID_SIZE / img.width, GRID_SIZE / img.height) * 0.8; // 80% to leave padding
174
const w = img.width * scale;
175
const h = img.height * scale;
176
const dx = (GRID_SIZE - w) / 2;
177
const dy = (GRID_SIZE - h) / 2;
178
179
ctx.drawImage(img, dx, dy, w, h);
180
181
const imageData = ctx.getImageData(0, 0, GRID_SIZE, GRID_SIZE);
182
const data = imageData.data;
183
184
const newDots: Dot[] = [];
185
const SPACING = 400 / GRID_SIZE; // 8px
186
187
for(let y = 0; y < GRID_SIZE; y++) {
188
for(let x = 0; x < GRID_SIZE; x++) {
189
const i = (y * GRID_SIZE + x) * 4;
190
const a = data[i+3];
191
let color = bgColor;
192
let radius = 1;
193
let isBg = true;
194
195
if (a > 20) {
196
const r = data[i];
197
const g = data[i+1];
198
const b = data[i+2];
199
// Filter out pure white backgrounds from JPEGs
200
if (r > 240 && g > 240 && b > 240) {
201
color = bgColor;
202
} else {
203
color = `rgba(${r},${g},${b},${a/255})`;
204
radius = 2.8;
205
isBg = false;
206
}
207
}
208
209
// Add some random jitter to origin for organic feel
210
const jitterX = (Math.random() - 0.5) * 0.5;
211
const jitterY = (Math.random() - 0.5) * 0.5;
212
newDots.push(new Dot(x * SPACING + 4 + jitterX, y * SPACING + 4 + jitterY, color, radius, isBg));
213
}
214
}
215
216
dotsRef.current = newDots;
217
};
218
img.src = src;
219
};
220
221
useEffect(() => {
222
loadImageToDots(currentSrcRef.current);
223
224
// Setup render loop
225
const render = (time: number) => {
226
const canvas = canvasRef.current;
227
if (!canvas) return;
228
const ctx = canvas.getContext("2d");
229
if (!ctx) return;
230
231
ctx.clearRect(0, 0, canvas.width, canvas.height);
232
233
// Clean old ripples
234
ripplesRef.current = ripplesRef.current.filter(r => time - r.startTime < 2000);
235
236
dotsRef.current.forEach(dot => {
237
dot.update(time, ripplesRef.current, burstRef.current, canvas.width, canvas.height);
238
dot.draw(ctx);
239
});
240
241
animationFrameRef.current = requestAnimationFrame(render);
242
};
243
244
animationFrameRef.current = requestAnimationFrame(render);
245
246
return () => {
247
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
248
};
249
}, []);
250
251
// Watch theme changes to re-render background dots colors correctly
252
useEffect(() => {
253
const observer = new MutationObserver((mutations) => {
254
mutations.forEach((m) => {
255
if (m.attributeName === 'class') {
256
loadImageToDots(currentSrcRef.current);
257
}
258
});
259
});
260
observer.observe(document.documentElement, { attributes: true });
261
return () => observer.disconnect();
262
}, []);
263
264
const handleCanvasClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
265
const canvas = canvasRef.current;
266
if (!canvas) return;
267
const rect = canvas.getBoundingClientRect();
268
269
// Calculate click pos relative to actual canvas resolution
270
const scaleX = canvas.width / rect.width;
271
const scaleY = canvas.height / rect.height;
272
const cx = (e.clientX - rect.left) * scaleX;
273
const cy = (e.clientY - rect.top) * scaleY;
274
275
ripplesRef.current.push({ x: cx, y: cy, startTime: performance.now() });
276
277
const now = Date.now();
278
if (now - lastClickRef.current < 400) {
279
clickCountRef.current += 1;
280
} else {
281
clickCountRef.current = 1;
282
}
283
lastClickRef.current = now;
284
285
if (clickCountRef.current >= 4) {
286
burstRef.current = true;
287
setTimeout(() => {
288
burstRef.current = false;
289
clickCountRef.current = 0;
290
loadImageToDots(currentSrcRef.current); // Reset
291
}, 2500);
292
}
293
};
294
295
const handleTabClick = (tab: typeof TABS[0]) => {
296
setActiveTab(tab.id);
297
currentSrcRef.current = tab.src;
298
loadImageToDots(tab.src);
299
burstRef.current = false;
300
};
301
302
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
303
const file = e.target.files?.[0];
304
if (!file) return;
305
const url = URL.createObjectURL(file);
306
setActiveTab("upload");
307
currentSrcRef.current = url;
308
loadImageToDots(url);
309
burstRef.current = false;
310
};
311
312
return (
313
<div className="flex flex-col items-center justify-center w-full min-h-[500px] gap-10 font-matter">
314
<div className="relative p-6">
315
<Tracker className="top-0 left-0" />
316
<Tracker className="top-0 right-0 rotate-90" />
317
<Tracker className="bottom-0 right-0 rotate-180" />
318
<Tracker className="bottom-0 left-0 -rotate-90" />
319
320
<canvas
321
ref={canvasRef}
322
width={400}
323
height={400}
324
onClick={handleCanvasClick}
325
className="cursor-pointer w-full max-w-[320px] sm:max-w-[400px] h-auto aspect-square active:scale-[0.99] transition-transform select-none"
326
/>
327
328
{/* Click me hint */}
329
<div className="absolute top-[-30px] left-[-20px] -rotate-12 select-none pointer-events-none">
330
<span className="text-[18px] font-season italic text-zinc-600 dark:text-[#86868B]">click me</span>
331
<svg className="w-8 h-8 ml-4 mt-1 text-zinc-400 dark:text-[#86868B]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
332
<path d="M10 2 Q 5 15, 20 20" strokeLinecap="round" />
333
<path d="M15 22 L 21 21 L 18 15" strokeLinecap="round" strokeLinejoin="round" />
334
</svg>
335
</div>
336
</div>
337
338
<div className="flex items-center gap-1.5 p-1.5 bg-white/80 dark:bg-gradient-to-b dark:from-[#1C1C1E] dark:to-[#0A0A0C] border border-black/10 dark:border-white/10 dark:border-t-white/20 rounded-full shadow-lg dark:shadow-[0_12px_32px_rgba(0,0,0,0.6),inset_0_1px_0_rgba(255,255,255,0.12)] backdrop-blur-xl">
339
{TABS.map((tab) => (
340
<button
341
key={tab.id}
342
onClick={() => handleTabClick(tab)}
343
className={cn(
344
"px-4 py-1.5 rounded-full text-[13px] font-medium transition-all",
345
activeTab === tab.id
346
? "bg-black text-white dark:bg-white dark:text-black font-semibold shadow-sm"
347
: "text-zinc-600 dark:text-[#86868B] hover:text-zinc-900 dark:hover:text-[#F5F5F7] hover:bg-black/5 dark:hover:bg-white/5"
348
)}
349
>
350
{tab.label}
351
</button>
352
))}
353
354
<div className="w-[1px] h-4 bg-black/10 dark:bg-white/10 mx-0.5" />
355
356
<button
357
onClick={() => fileInputRef.current?.click()}
358
className="p-2 rounded-full text-zinc-600 dark:text-[#86868B] hover:text-zinc-900 dark:hover:text-[#F5F5F7] hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
359
title="Upload custom image"
360
>
361
<Upload size={16} />
362
</button>
363
<input
364
type="file"
365
ref={fileInputRef}
366
onChange={handleFileUpload}
367
accept="image/*"
368
className="hidden"
369
/>
370
</div>
371
</div>
372
);
373
}
374