Atlas
Atlas
Role
Solo Frontend Engineer
Type
Self-initiated case study
Product
SaaS marketing site
Stack
Astro 5.12 · Three.js · GSAP · Lenis · Tailwind CSS v4 · TypeScript
Rendering
SSG + Vercel CDN, zero server runtime
Live
atlas.sujen.co
I built Atlas because I wanted to see how far I could push a motion-heavy marketing site without hiding behind a framework runtime. The product story is simple. The interesting part, for me, was keeping the engineering explicit, fast, and maintainable anyway.
The Goal
Atlas needed to communicate its core value within the first scroll: clone any website into an editable workspace. That made it a useful portfolio project too, because the page had to do two jobs at once: sell the product quickly and carry a lot of motion without collapsing into demo code.
I owned the frontend architecture, motion systems, performance model, and accessibility layer. The goal was not visual novelty by itself. I wanted to show that an animation-heavy landing page can still be explicit, debuggable, and production-minded.
The Constraint
Astro SSG compiles to static HTML at build time. There is no framework runtime to hide poor decisions. All client-side interactivity is imperative TypeScript wired through script tags and direct DOM manipulation.
That forces explicit lifecycle management. Every event listener needs a teardown, every RAF callback needs a removal path, and every expensive visual decision needs a reason. Performance characteristics stay visible in the code instead of being blurred by framework abstractions.
Architecture
Three Layers
Astro SSG generates static HTML. AppManager owns the single RAF loop and coordinates Lenis, the cached DOM scroll layer, the WebGL renderer, and route transitions. A Properties singleton holds shared state. No framework store, no extra runtime layer, no ambiguity over who owns the frame.
Dual Lifecycle
WebGL renderers persist across routes because they are expensive to recreate. DOM managers are cheap to create and destroy per route. That split keeps route changes fast while still making teardown paths explicit and testable.
Why it transfers
The transferable part is not the dark theme or the shader work. It is the browser-level discipline: lifecycle ownership, scroll performance, cleanup paths, accessibility under SPA navigation, and knowing when to trade convenience for control.
3D Card Carousel
Why WebGL
The hero needed true perspective foreshortening, per-vertex shader deformation on fast spins, and raycasted hover detection across 8 overlapping cards. CSS perspective() can fake part of that, but it cannot deform mesh vertices, resolve z-depth reliably, or drive shader uniforms like u_spread and u_darken for the interactive spread effect.
How
Three input sources feed a single rotation: mouse or touch drag mapped to velocity, passive wheel events, and a momentum system with speed-dependent friction. Below a snap threshold, a damped spring takes over. The result is one interaction model instead of a pile of unrelated animations.
Preloader to WebGL Handoff
Why
The preloader exists in the DOM. The hero exists in Three.js world space. That handoff crosses rendering contexts, so a normal FLIP animation is not enough. The transition works because the DOM element is converted from screen pixels into world coordinates before the 3D sequence begins.
How
getBoundingClientRect() provides the starting rectangle, then camera projection math converts that into the world-space position and scale for the first mesh. From there, GSAP fans the cards out while the rest of the hero settles into its idle state. The result feels continuous even though it crosses two systems.
Performance Discipline
No framework runtime means no framework overhead, but it also removes the safety net. The performance model has to be deliberate from the first line.
Single RAF loop owns all timing.
AppManager runs one requestAnimationFrame loop. Lenis updates first, then RAFCollection callbacks, then WebGL. Scroll position is always fresh before any consumer reads it.
Cached position tracking.
DomScrollManager wraps getBoundingClientRect() with a needsUpdate flag. Positions recalculate on resize, not every frame. Elements live in a WeakMap for automatic GC when removed from the DOM.
IntersectionObserver with pre-trigger.
10% root margin and five thresholds start animations slightly before elements enter the viewport. No visible pop-in. Off-screen sections pause entirely.
Deterministic cleanup.
Every 100 scroll events, DomScrollManager removes elements no longer in the DOM. Counter-based, not random. Every event listener has a matching teardown path.
Accessibility & Quality
Testing
Playwright with Axe Core runs WCAG 2AA checks, contrast validation, and heading hierarchy tests on every deploy. That matters more on a custom SPA flow, where route transitions can quietly break focus and announcements.
Navigation
Skip-to-content link for keyboard users. Route changes announced via aria-live region. Focus resets to main content after SPA navigation. The WebGL canvas is decorative, so assistive tech can ignore it.
Graceful degradation
If WebGL is unavailable, the page content renders immediately without the 3D scene. The preloader is removed, navigation is shown, and the page remains functional. No blank-screen failure mode.
Known gap
CSS prefers-reduced-motion disables CSS animations, but GSAP timelines and the Three.js RAF loop still run. The fix is straightforward: gate animation entry points on matchMedia and provide static alternatives.
Results
98
Performance
95
Accessibility
100
SEO
0
CLS
Desktop Lighthouse and Core Web Vitals snapshot on the production build.
FCP
512ms
LCP
981ms
Speed Index
1,175ms
TBT
0ms
CLS
0.00
TBT is zero because the main thread does not sit inside long blocking work. The RAF loop yields every frame and the heavier initialization is pushed behind the preloader. Mobile LCP is still penalized by the Three.js bundle during script evaluation, which is the clearest signal for the next optimisation pass.
These numbers confirm the architecture loads fast and stays smooth. They do not prove commercial impact. In a real product setting, the next layer would be CTA event tracking, section-level analytics, and A/B testing the WebGL hero against a simpler alternative.
Production Trade-offs
Three.js bundle (~754KB).
Heavy for 8 textured planes. Raw WebGL would eliminate the dependency but add significant boilerplate for texture loading, raycasting, and postprocessing. For production, I'd evaluate raw WebGL or a lighter abstraction like OGL to cut the bundle by 80%+.
Global mutable state.
The Properties singleton is simple and zero-dependency, but has no dependency injection and no testability without mocking globals. Pragmatic for a single-page site. For a larger application, I'd refactor to an AppContext pattern with explicit dependencies and constructor injection.
prefers-reduced-motion gap.
CSS media query handles transitions and animations, but GSAP timelines and the Three.js RAF loop run regardless. The fix is straightforward: gate all animation entry points on matchMedia('(prefers-reduced-motion: reduce)') and provide static alternatives.
No below-fold code splitting.
All client JS loads upfront. The next optimization step: lazy-load section scripts via dynamic import() triggered by IntersectionObserver, reducing initial parse time for the 13 sections below the hero.
Verlet Pill Chain
Why
The pill chain needed emergent behavior. When one pill moves, connected pills follow through constraint propagation, not through individually scripted motion. GSAP springs or CSS spring() easing could animate individual elements, but they cannot model the coupling between elements: rope tension, asymmetric sway, force transferring through the chain with natural delay.
How
Verlet integration with two points per pill, three constraint-solving iterations per frame. Mouse velocity transfers to nearby points with asymmetric scaling: 1.4x horizontal, 0.4x vertical. This makes the chain sway side-to-side rather than bouncing. Cubic ease-in for return strength creates pendulum-like deceleration. The RAF loop self-stops when all points settle within 0.01px of rest. ~80 lines total.
Canvas Particle Dissolution
Why
The CTA input needed a dissolution effect that scatters individual pixels with gravity and per-particle alpha decay. CSS can animate opacity and transform on DOM elements, but not on individual pixels of rendered text. A DOM-based approach tops out at around 50 elements before layout cost becomes visible. Canvas ImageData buffer writes handle thousands of particles with a single putImageData() per frame.
How
Text is rendered to an offscreen canvas via fillText(), then extracted as ImageData into a Struct-of-Arrays particle representation using typed arrays for cache-friendly iteration. Each particle plots a 3x3 block into the buffer array. One bulk putImageData() per frame, not per-particle draw calls. When all particles reach alpha 0, the canvas is removed and the original text fades back in.
What I'd Change Next
Keep explicit control where it pays off.
Manually orchestrating frames and cleanup paths is verbose, but it makes the system debuggable. I would keep that explicitness around rendering and routing, where hidden work is expensive.
Preserve the architecture, not the novelty.
Avoiding React was never the point by itself. The useful part is the manager pattern, the ownership model, and the clear frame lifecycle. Those would still matter even if the UI stack changed later.
Add abstraction only when the problem repeats.
The Verlet chain and carousel physics are light enough to justify hand-rolled code here. A third major physics system would be the point where abstraction starts paying for itself.
Ship the next production pass around the obvious gaps.
The roadmap is clear: reduced-motion gating, below-fold code splitting, and validating whether the 3D investment moves conversion. Those are the next changes with the highest product value.