Improve homepage by adding interactive cursor effects and animation
Implement a canvas-based, cursor-reactive dot field background and update the typewriter animation to include a scramble effect. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 6def8112-39d2-4641-b93b-f39108179f33 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 3df64324-7337-48d0-9099-7bab54585b37 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/42ae33dd-8759-4196-85a5-434465c72ece/6def8112-39d2-4641-b93b-f39108179f33/8hzGboW Replit-Helium-Checkpoint-Created: true
This commit is contained in:
224
src/main.ts
224
src/main.ts
@@ -6,43 +6,69 @@ const typedEl = document.getElementById('typed') as HTMLSpanElement;
|
||||
const caretEl = document.getElementById('caret') as HTMLSpanElement;
|
||||
const commandBox = document.getElementById('commandBox') as HTMLDivElement;
|
||||
const copyBtn = document.getElementById('copyBtn') as HTMLButtonElement;
|
||||
const hintEl = document.getElementById('hint') as HTMLSpanElement;
|
||||
const copyIcon = document.getElementById('copyIcon') as unknown as SVGSVGElement;
|
||||
|
||||
// Typewriter intro — small, intentional, then the caret keeps blinking.
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
function type(i = 0) {
|
||||
if (i > COMMAND.length) return;
|
||||
typedEl.textContent = COMMAND.slice(0, i);
|
||||
// slight humanized rhythm
|
||||
const next = 28 + Math.random() * 38;
|
||||
setTimeout(() => type(i + 1), next);
|
||||
/* ---------- Typewriter with a small "decoder" scramble ---------- */
|
||||
|
||||
const SCRAMBLE = '!<>-_\\/[]{}—=+*^?#abcdef0123456789';
|
||||
|
||||
function typeWithScramble(target: string, onDone?: () => void) {
|
||||
let resolved = '';
|
||||
let i = 0;
|
||||
|
||||
function nextChar() {
|
||||
if (i >= target.length) {
|
||||
typedEl.textContent = resolved;
|
||||
onDone?.();
|
||||
return;
|
||||
}
|
||||
let cycles = target[i] === ' ' ? 0 : 2 + Math.floor(Math.random() * 2);
|
||||
function flicker() {
|
||||
if (cycles <= 0) {
|
||||
resolved += target[i];
|
||||
typedEl.textContent = resolved;
|
||||
i++;
|
||||
setTimeout(nextChar, 22 + Math.random() * 30);
|
||||
return;
|
||||
}
|
||||
const ch = SCRAMBLE[Math.floor(Math.random() * SCRAMBLE.length)];
|
||||
typedEl.textContent = resolved + ch;
|
||||
cycles--;
|
||||
setTimeout(flicker, 28);
|
||||
}
|
||||
flicker();
|
||||
}
|
||||
nextChar();
|
||||
}
|
||||
|
||||
if (reduceMotion) {
|
||||
typedEl.textContent = COMMAND;
|
||||
} else {
|
||||
// small lead-in delay so the page settles first
|
||||
setTimeout(() => type(), 380);
|
||||
setTimeout(() => typeWithScramble(COMMAND), 280);
|
||||
}
|
||||
|
||||
// Copy on click / keypress
|
||||
/* ---------- Silent copy (no flashy feedback, just a brief icon swap) ---------- */
|
||||
|
||||
const COPY_PATH = 'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z';
|
||||
const CHECK_PATH = 'M5 13l4 4L19 7';
|
||||
|
||||
function setIcon(d: string) {
|
||||
const path = copyIcon.querySelector('path');
|
||||
if (path) path.setAttribute('d', d);
|
||||
}
|
||||
|
||||
let copyResetTimer: number | undefined;
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(COMMAND);
|
||||
commandBox.classList.add('copied');
|
||||
copyBtn.classList.add('copied');
|
||||
hintEl.classList.add('copied');
|
||||
const original = hintEl.textContent;
|
||||
hintEl.textContent = 'copied';
|
||||
setTimeout(() => {
|
||||
commandBox.classList.remove('copied');
|
||||
copyBtn.classList.remove('copied');
|
||||
hintEl.classList.remove('copied');
|
||||
hintEl.textContent = original;
|
||||
}, 1400);
|
||||
setIcon(CHECK_PATH);
|
||||
window.clearTimeout(copyResetTimer);
|
||||
copyResetTimer = window.setTimeout(() => setIcon(COPY_PATH), 1200);
|
||||
} catch {
|
||||
// silent — clipboard can be blocked in some contexts
|
||||
/* clipboard can be blocked; do nothing */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,3 +83,155 @@ copyBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
copy();
|
||||
});
|
||||
|
||||
/* ---------- Cursor-reactive dot field ---------- */
|
||||
|
||||
const canvas = document.getElementById('field') as HTMLCanvasElement;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
type Dot = {
|
||||
bx: number; // base x (CSS px)
|
||||
by: number; // base y (CSS px)
|
||||
ox: number; // offset x (current)
|
||||
oy: number; // offset y (current)
|
||||
};
|
||||
|
||||
let dots: Dot[] = [];
|
||||
let dpr = Math.max(1, window.devicePixelRatio || 1);
|
||||
let w = 0;
|
||||
let h = 0;
|
||||
|
||||
const SPACING = 26;
|
||||
const RADIUS = 170; // influence radius around cursor
|
||||
const PUSH = 18; // max displacement in px
|
||||
|
||||
// Cursor position (CSS px). Lerped for smoothness.
|
||||
let targetX = -9999;
|
||||
let targetY = -9999;
|
||||
let curX = -9999;
|
||||
let curY = -9999;
|
||||
let active = false;
|
||||
|
||||
function build() {
|
||||
dpr = Math.max(1, window.devicePixelRatio || 1);
|
||||
w = window.innerWidth;
|
||||
h = window.innerHeight;
|
||||
canvas.width = Math.floor(w * dpr);
|
||||
canvas.height = Math.floor(h * dpr);
|
||||
canvas.style.width = w + 'px';
|
||||
canvas.style.height = h + 'px';
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
dots = [];
|
||||
// pad outside viewport so the grid feels infinite during displacement
|
||||
const pad = SPACING;
|
||||
for (let y = -pad; y < h + pad; y += SPACING) {
|
||||
for (let x = -pad; x < w + pad; x += SPACING) {
|
||||
dots.push({ bx: x, by: y, ox: 0, oy: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onMove(x: number, y: number) {
|
||||
targetX = x;
|
||||
targetY = y;
|
||||
active = true;
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', (e) => onMove(e.clientX, e.clientY), { passive: true });
|
||||
window.addEventListener('mouseleave', () => { active = false; });
|
||||
window.addEventListener('touchmove', (e) => {
|
||||
if (e.touches[0]) onMove(e.touches[0].clientX, e.touches[0].clientY);
|
||||
}, { passive: true });
|
||||
window.addEventListener('touchend', () => { active = false; });
|
||||
window.addEventListener('resize', build);
|
||||
|
||||
build();
|
||||
|
||||
const BASE_RGB = '230, 230, 230';
|
||||
const ACCENT_RGB = '166, 227, 161';
|
||||
const BASE_ALPHA = 0.11;
|
||||
const PEAK_ALPHA = 0.9;
|
||||
|
||||
function frame() {
|
||||
// smooth cursor lerp
|
||||
if (!active) {
|
||||
// ease cursor off-screen so dots settle smoothly when mouse leaves
|
||||
targetX = -9999;
|
||||
targetY = -9999;
|
||||
}
|
||||
curX += (targetX - curX) * 0.18;
|
||||
curY += (targetY - curY) * 0.18;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const r2 = RADIUS * RADIUS;
|
||||
|
||||
for (let i = 0; i < dots.length; i++) {
|
||||
const d = dots[i];
|
||||
const dx = d.bx - curX;
|
||||
const dy = d.by - curY;
|
||||
const d2 = dx * dx + dy * dy;
|
||||
|
||||
let tx = 0, ty = 0;
|
||||
let strength = 0;
|
||||
|
||||
if (d2 < r2) {
|
||||
const dist = Math.sqrt(d2) || 0.0001;
|
||||
// smooth falloff (ease-out-cubic style)
|
||||
const t = 1 - dist / RADIUS;
|
||||
strength = t * t;
|
||||
// push dots away from cursor
|
||||
tx = (dx / dist) * strength * PUSH;
|
||||
ty = (dy / dist) * strength * PUSH;
|
||||
}
|
||||
|
||||
// smooth toward target offset
|
||||
d.ox += (tx - d.ox) * 0.18;
|
||||
d.oy += (ty - d.oy) * 0.18;
|
||||
|
||||
const px = d.bx + d.ox;
|
||||
const py = d.by + d.oy;
|
||||
|
||||
// alpha + color blend
|
||||
const alpha = BASE_ALPHA + (PEAK_ALPHA - BASE_ALPHA) * strength;
|
||||
// size also grows slightly near cursor
|
||||
const size = 1 + strength * 1.6;
|
||||
|
||||
if (strength > 0.04) {
|
||||
// blend toward accent near cursor
|
||||
const blend = strength;
|
||||
const r = Math.round(230 * (1 - blend) + 166 * blend);
|
||||
const g = Math.round(230 * (1 - blend) + 227 * blend);
|
||||
const b = Math.round(230 * (1 - blend) + 161 * blend);
|
||||
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
} else {
|
||||
ctx.fillStyle = `rgba(${BASE_RGB}, ${alpha})`;
|
||||
}
|
||||
|
||||
// draw a tiny square — sharper than circles at this size, also faster
|
||||
ctx.fillRect(px - size / 2, py - size / 2, size, size);
|
||||
}
|
||||
|
||||
// a single soft glow that follows the cursor — adds presence
|
||||
if (active) {
|
||||
const grad = ctx.createRadialGradient(curX, curY, 0, curX, curY, RADIUS);
|
||||
grad.addColorStop(0, `rgba(${ACCENT_RGB}, 0.10)`);
|
||||
grad.addColorStop(1, `rgba(${ACCENT_RGB}, 0)`);
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(curX - RADIUS, curY - RADIUS, RADIUS * 2, RADIUS * 2);
|
||||
}
|
||||
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
if (!reduceMotion) {
|
||||
requestAnimationFrame(frame);
|
||||
} else {
|
||||
// static render: just dim dots, no displacement
|
||||
ctx.fillStyle = `rgba(${BASE_RGB}, ${BASE_ALPHA})`;
|
||||
for (const d of dots) ctx.fillRect(d.bx, d.by, 1, 1);
|
||||
}
|
||||
|
||||
// keep tsc happy about caretEl import (used via DOM)
|
||||
void caretEl;
|
||||
|
||||
Reference in New Issue
Block a user