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:
keshavananddev
2026-04-24 03:37:24 +00:00
parent b79bf1d4ca
commit d5fde28b15
4 changed files with 236 additions and 120 deletions

View File

@@ -30,3 +30,6 @@ outputType = "webview"
[[ports]]
localPort = 5000
externalPort = 80
[agent]
expertMode = true

View File

@@ -10,28 +10,21 @@
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
</head>
<body>
<main class="stage">
<p class="hello">hi, i'm keshav.</p>
<canvas id="field" aria-hidden="true"></canvas>
<div class="command-row">
<div class="command-box" id="commandBox" role="button" tabindex="0" aria-label="Copy ssh command">
<span class="dollar">$</span><span class="typed" id="typed"></span><span class="caret" id="caret">&#9608;</span>
<button class="copy-btn" id="copyBtn" aria-label="Copy to clipboard">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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" />
</svg>
</button>
</div>
<span class="hint" id="hint">click to copy</span>
<main class="stage">
<div class="command-box" id="commandBox" role="button" tabindex="0" aria-label="Copy ssh command">
<span class="dollar">$</span><span class="typed" id="typed"></span><span class="caret" id="caret">&#9608;</span>
<button class="copy-btn" id="copyBtn" aria-label="Copy to clipboard">
<svg id="copyIcon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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" />
</svg>
</button>
</div>
<a href="/docs.html" class="docs-link" id="docsLink">how it works &rarr;</a>
<a href="/docs.html" class="docs-link">how it works &rarr;</a>
</main>
<footer class="byline">
<span>// hand-written, not vibe-coded</span>
</footer>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -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;

View File

@@ -4,7 +4,7 @@
--dim: #6a6a72;
--line: #1c1c20;
--accent: #a6e3a1;
--accent-soft: rgba(166, 227, 161, 0.18);
--accent-soft: rgba(166, 227, 161, 0.22);
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
}
@@ -14,9 +14,7 @@
box-sizing: border-box;
}
html, body {
height: 100%;
}
html, body { height: 100%; }
body {
font-family: var(--mono);
@@ -26,24 +24,16 @@ body {
text-rendering: optimizeLegibility;
overflow: hidden;
position: relative;
/* very subtle dot grid — static, no JS */
background-image:
radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.045) 1px, transparent 0);
background-size: 22px 22px;
}
/* a single, static, very soft glow behind the content — gives depth without movement */
body::before {
content: "";
#field {
position: fixed;
inset: 0;
background: radial-gradient(
600px 400px at 50% 45%,
rgba(166, 227, 161, 0.06),
transparent 70%
);
pointer-events: none;
width: 100%;
height: 100%;
z-index: 0;
pointer-events: none;
display: block;
}
.stage {
@@ -52,43 +42,30 @@ body::before {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: flex-start;
align-items: center;
justify-content: center;
gap: 1.25rem;
max-width: 560px;
max-width: 580px;
margin: 0 auto;
padding: 2rem 1.75rem;
opacity: 0;
animation: fade 600ms ease-out 80ms forwards;
animation: fade 500ms ease-out 60ms forwards;
}
@keyframes fade {
to { opacity: 1; }
}
.hello {
color: var(--dim);
font-size: 0.9rem;
letter-spacing: 0.01em;
}
.command-row {
width: 100%;
display: flex;
align-items: center;
gap: 0.85rem;
flex-wrap: wrap;
}
.command-box {
flex: 1 1 auto;
min-width: 0;
background: rgba(10, 10, 12, 0.6);
width: 100%;
background: rgba(10, 10, 12, 0.55);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
border: 1px solid var(--line);
border-radius: 4px;
padding: 0.95rem 1.1rem;
font-size: 1rem;
padding: 1.05rem 1.15rem;
font-size: 1.05rem;
font-weight: 500;
color: var(--fg);
display: flex;
@@ -96,7 +73,7 @@ body::before {
gap: 0.6rem;
cursor: pointer;
user-select: none;
transition: border-color 180ms ease, background 180ms ease;
transition: border-color 180ms ease;
position: relative;
white-space: nowrap;
overflow: hidden;
@@ -105,14 +82,9 @@ body::before {
.command-box:hover,
.command-box:focus-visible {
border-color: var(--accent-soft);
background: rgba(12, 14, 12, 0.8);
outline: none;
}
.command-box.copied {
border-color: var(--accent);
}
.dollar {
color: var(--accent);
font-weight: 600;
@@ -123,7 +95,6 @@ body::before {
color: var(--fg);
white-space: pre;
overflow: hidden;
text-overflow: ellipsis;
}
.caret {
@@ -152,28 +123,16 @@ body::before {
display: inline-flex;
align-items: center;
justify-content: center;
transition: color 160ms ease;
flex-shrink: 0;
}
.copy-btn:hover { color: var(--fg); }
.copy-btn.copied { color: var(--accent); }
.copy-btn svg {
width: 16px;
height: 16px;
}
.hint {
color: var(--dim);
font-size: 0.78rem;
transition: color 160ms ease, opacity 200ms ease;
}
.hint.copied {
color: var(--accent);
}
.docs-link {
color: var(--dim);
text-decoration: none;
@@ -181,6 +140,7 @@ body::before {
padding: 0.2rem 0;
border-bottom: 1px solid transparent;
transition: color 160ms ease, border-color 160ms ease;
align-self: center;
}
.docs-link:hover {
@@ -188,33 +148,15 @@ body::before {
border-bottom-color: var(--line);
}
.byline {
position: fixed;
left: 0;
right: 0;
bottom: 1.25rem;
text-align: center;
color: var(--dim);
font-size: 0.72rem;
letter-spacing: 0.02em;
z-index: 1;
opacity: 0;
animation: fade 600ms ease-out 1100ms forwards;
}
@media (max-width: 640px) {
.stage {
padding: 1.5rem 1.25rem;
gap: 1rem;
}
.stage { padding: 1.5rem 1.25rem; }
.command-box {
font-size: 0.9rem;
padding: 0.85rem 0.95rem;
font-size: 0.92rem;
padding: 0.9rem 1rem;
}
.hint { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.stage, .byline { animation: none; opacity: 1; }
.stage { animation: none; opacity: 1; }
.caret { animation: none; }
}