Ghost In The Code is a digital identity system for a fictional high-immersion agency in the gaming and creative tech space. The design brief was simple and uncompromising: the interface itself is the brand. Every scroll, hover, and page load should feel like entering a world — not visiting a website. Built with React 19 and zero animation libraries. Every effect hand-rolled.

Most sites use animation to decorate content. This one uses content to justify the animation.
The first design decision — and the one everything else follows from — was to treat atmosphere as a first-class deliverable, not a layer applied on top. The electric yellow (#fcee0a), the noir background (#0a0a0c), the scan-line overlay: none of these are decorative. They are the brand. A visitor who lands on Ghost In The Code should feel the identity before they read a single word.
That constraint shaped every UX choice that followed. Scroll reveals are not subtle fades — they're deliberate, slightly slow, cinematic. The HUD corner brackets that appear on hover don't just signal interactivity; they deepen the fiction that you're navigating a real terminal. The data stream sidebar isn't content — it's peripheral atmosphere, visible enough to register, unreadable enough not to distract.
The palette is a three-color system: yellow for action and emphasis, teal for data and information, red for warnings and destructive states. This isn't aesthetic preference — it's information design. A user scanning the interface can orient themselves by color alone, the way you'd read a HUD in an actual game.
Technical implementation follows ↓
No animation libraries. No shortcuts. Every effect is hand-rolled.
| Framework | React 19 + Vite Fast HMR during heavy CSS iteration and clean CSS Modules support. The component tree grew to ~46 files across atmosphere/, layout/, sections/, and ui/ — Vite's build performance made that scale manageable without a monorepo. |
| Architecture | Fully componentized + barrel exports Every section, atmospheric effect, and UI primitive lives in its own component with a co-located CSS Module. Six index.js barrel files keep imports clean across the tree. All copy, data arrays, and site-level constants are centralized in siteConfig.js — changing the agency name or service list is a one-file edit. |
| Animation | CSS Modules + Canvas API Zero npm installs beyond React and React-DOM. All effects — glitch flash, CRT sweep, scan-line overlay, scroll reveals, HUD corner brackets — are pure CSS keyframe animations. GPU-composited, zero JavaScript cost. The particle network is a requestAnimationFrame canvas loop managed in a custom useParticles hook. |
| Scroll | Intersection Observer A reusable useInView hook drives all scroll-triggered reveals. No scroll event listeners, no layout thrash. Each Reveal component wraps children with configurable delay and threshold — the staggered section entrances are a single prop change per component. |
The atmosphere had to be felt, not noticed. Jank breaks the fiction.
The site layers multiple concurrent effects: an animated particle network on canvas, a CSS scan-line overlay running at full viewport, a CRT sweep line animating continuously, a data stream sidebar ticking character-by-character, and scroll-triggered reveals firing across every section. Each effect is cheap in isolation — together they could easily saturate the main thread on mid-range mobile hardware.
The particle network stays off the main thread by keeping particle count capped and using canvas compositing rather than DOM nodes. The data stream sidebar uses a setInterval tick with a direct canvas write — no React state updates, no re-renders. Scan-lines and the CRT sweep are pure CSS, GPU-composited. Scroll reveals fire on the browser's idle callback via IntersectionObserver, not scroll events.
// useParticles — self-contained canvas animation loop
export function useParticles(canvasRef, count = 80) {
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
let raf;
const particles = Array.from({ length: count }, () => ({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
vx: (Math.random() - 0.5) * 0.4,
vy: (Math.random() - 0.5) * 0.4,
}));
const tick = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
particles.forEach((p) => {
p.x += p.vx; p.y += p.vy;
if (p.x < 0 || p.x > canvas.width) p.vx *= -1;
if (p.y < 0 || p.y > canvas.height) p.vy *= -1;
});
// draw edges between nearby particles
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [canvasRef, count]);
}