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:
3
.replit
3
.replit
@@ -30,3 +30,6 @@ outputType = "webview"
|
||||
[[ports]]
|
||||
localPort = 5000
|
||||
externalPort = 80
|
||||
|
||||
[agent]
|
||||
expertMode = true
|
||||
|
||||
27
index.html
27
index.html
@@ -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">█</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">█</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 →</a>
|
||||
<a href="/docs.html" class="docs-link">how it works →</a>
|
||||
</main>
|
||||
|
||||
<footer class="byline">
|
||||
<span>// hand-written, not vibe-coded</span>
|
||||
</footer>
|
||||
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
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;
|
||||
|
||||
102
src/style.css
102
src/style.css
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user