Skip to content

Interested in AI, automation, blockchain, web and apps

Seoul, KR--:-- GMT
Let’s Talk

Work/Interactive/3D/KO

3D Scroll Interaction Recruiting Landing Site

3D Scroll Interaction Recruiting Landing Site

A serverless recruiting page you feel by scrolling

Selling an environment, not a product

The job was a recruiting landing page. What it had to present was not a product but an environment: the workspace, the equipment setup, the support staff, the outside counseling. Written out as a list, that is the eleven-row table sitting in CardList.jsx.

There is no server and no database. Applications had to be collected without a backend, and the posts already on Instagram and the Naver blog had to show up inside the site, current.

This writeup covers two things: running 3D and DOM off one scroll axis, and integrating thirty-seven tiles by hand. Sending the application form through EmailJS, the section that merges the two social feeds, and the lock gate that keeps scrolling disabled until you press and hold each deserve their own writeup, so I am setting them aside here.

System boundary and external dependencies
System boundary and external dependencies

One scroll axis for 3D and DOM

Nine sections stacked on a single drei ScrollControls axis, with section length expressed as a page-count constant

The 3D scene and the DOM overlay had to share one scroll. In constants/scroll.js I write only how many pages each section gets, and the total page count (17 on desktop) plus each section's 0..1 range fall out automatically. Any component learns its own range from a single SCROLL_SECTIONS.section5.start. On mobile the card-flip section flew by too fast under a finger, so section5 alone went from 3 pages to 6. That branch is decided once from navigator.userAgent at module load time, and that is the part I keep regretting.

Split into two Canvases, and let DPR float between 1.0 and 1.5 based on frame rate

The background gradient has to stay up at all times, but its tone mapping and DPR policy differ from the main scene. Cram both into one canvas and the background plane keeps getting dragged around by the scene's layer masks. In App.jsx I split a zIndex: 0 background canvas from a zIndex: 1 main canvas and put PerformanceMonitor on the main one only. Two WebGL contexts is not free. In exchange, the background is one plane with nothing but a fragment shader, so it costs almost nothing. MeshTransmissionMaterial redraws the scene per refraction, so on mobile I dropped samples from 8 to 1 and resolution from 128 to 16. I would not give up the glass look, so I gave up image quality instead.

Static S3 + CloudFront deploy, with Lambda only at the two places that need data

I decided not to stand up an always-on server. But the Instagram Graph API token cannot live in the browser and Naver blog images block hotlinking, so post fetching and image proxying got peeled off into two Lambdas behind API Gateway. Everything else is a static file. GitHub Actions reacts only to a feature/deploy push, and every deploy syncs the whole bucket with --delete and then invalidates /*. That shape does not suit frequent deploys, but with one person deploying, the simplicity was worth more.

Deployment and infrastructure
Deployment and infrastructure

Nine sections from one number

This site effectively has one route. Nine sections run vertically under /, and every screen derives from a single number: the scroll offset. The flow goes like this. ScrollHandler reads the scroll container that ScrollControls created, once per frame, and pushes it into zustand's scrollStore. Components inside the canvas compute their own section progress in useFrame to toggle visible, interpolate positions, and the DOM overlays outside the canvas (the application form, the social feed) use that same value to compute y, opacity, even borderRadius. Since 3D and DOM read off the same ruler, there is nothing for them to drift apart on.

Core data model
Core data model

Removing the physics engine and integrating thirty-seven tiles by hand

Tiles scattered across a sphere surface get pushed away by the mouse and return to place, and a long press makes them spread and then burst. It looks like physics, so reaching for a physics engine is the natural move. package.json still carries @react-three/rapier and @react-three/cannon to this day (neither is imported anywhere in the code). But a rigid-body engine is good at resolving collisions, not at "returning to exactly the original pose". Give every tile a RigidBody and you end up hand-feeding a force that pulls toward the target quaternion every frame, which means using the engine and fighting it at the same time.

I deleted the engine and kept only the terms useInstancedTilePhysics actually needs. The tiles are one InstancedMesh, and the state is position, quaternion, velocity, angular velocity, nothing else. Each frame applies exactly one of (1) mouse repulsion, (2) spread while held, (3) explosion, (4) restoring force back to the original pose, then damping, then writes the matrix. Nine scratch vectors are built once with useMemo and reused, and any tile whose linear and angular velocity are both under 1e-6, with no mouse influence and not in the exploded state, gets skipped entirely in the loop. On a frame where nothing moved, even instanceMatrix.needsUpdate is never set.

What the effect needed was not collision resolution but the sum of two forces, push and restore. Writing only those makes the code shorter, and in exchange I can point the restore target directly at the original tile's position and quaternion, so the moment your hand leaves, everything lands back exactly on the sphere surface. The loop being essentially free while the screen is still matters just as much: because of the hold gate, this is the section where people sit and stare for several seconds.

Drawing the sticker into the paper texture instead of laying it on top

Putting a plane mesh or a Decal at the click point.

I turned the paper texture into a CanvasTexture and drew the sticker straight onto the uv coordinate of the click intersection.

Baked into UV space, the sticker moves with the paper even as vertices ripple, and the mesh stays a single mesh.

What a user can do

Hold the screen for 2.5 seconds before scrolling opens
Hold the screen for 2.5 seconds before scrolling opens

Land straight in a section from a hash in the address bar
Land straight in a section from a hash in the address bar

Jump to one of the nine sections from the side menu
Jump to one of the nine sections from the side menu

Click the paper plane to stamp a platform logo sticker
Click the paper plane to stamp a platform logo sticker

Read WE ARE, MISSION, and PRINCIPLE in order
Read WE ARE, MISSION, and PRINCIPLE in order

Fan out the four business cards and flip them over
Fan out the four business cards and flip them over

Sweep across the eleven benefit cards
Sweep across the eleven benefit cards

Fill in the form and send a creator application
Fill in the form and send a creator application

Page through Instagram and blog posts in one row
Page through Instagram and blog posts in one row

Trace the route from the subway station to the office
Trace the route from the subway station to the office

Turn the background music on and off
Turn the background music on and off

Check GLB materials and lights on the development screen
Check GLB materials and lights on the development screen

1 / 1

Paying for per frame state writes

The thing I regret most is pushing the scroll value into zustand every frame. ScrollHandler calls setScroll on every useFrame, and the DOM overlays subscribing to it re-render sixty times a second. There are few enough overlays that it holds up today, but if I rebuilt it I would flow continuous values through a motion value or a ref and leave zustand only the discrete events, like entering and leaving a section.

SCROLL_SECTIONS being computed once from navigator.userAgent at module load and never again is the other problem. Narrow the window or rotate a tablet and the section lengths stay put. To do responsive properly, that table has to be state, not a constant.

The assets are, honestly, neglected. public/ still holds a 4k HDR and seventeen GLBs, some of them unused. InstancedTiles passes a nonexistent /images/studio_small_03_1k.hdr while also passing preset: 'city', so what actually gets fetched is drei's remote preset. The minimum 2-second delay and the 10-second forced-completion timeout baked into the loading screen were a stopgap to hide that weight.

There is not one line of test. In particular, the duplicate check between Instagram posts and blog posts runs on a single rule, "three or more consecutive characters match", so a common word overlapping makes a perfectly good post vanish quietly. And if the Lambda response schema changes, the only way to notice is to open the page. One set of contract tests and a few unit tests on the dedup rule would have cut down the "why isn't yesterday's post showing" questions.

Read next

Cross-Chain Identity Verification for Wallets and Socials

HumanPass — 2025