“HTML buttons don’t belong on a 3D homepage. So the buttons became meshes.”
The Homepage Is a Place Now
Quick recap for the newcomers: this blog’s homepage is a 3D scene. It used to be Vue + Tres.js, then got migrated to Svelte 5 + Threlte (and briefly went completely blank on the way). That got the scene rendering.
But there’s always been an awkward seam in it. The 3D world looks great — and then you navigate with… regular HTML links. Floating 2D chrome stapled over a three-dimensional space. It’s like building an immersive theme park and putting a strip mall sign at the entrance.
The design direction for the redesign was already locked: the 3D scene is the interface. Navigation, sections, interactions — all of it should live in the scene, not above it. Which raises the actual engineering question of this post: how do you click something that has no DOM?
This post covers the interaction layer that’s now live on the site: spatial navigation, interactive entity panels, camera transitions, and the cursor-feedback plumbing underneath. It ends with an honest look at the half-built layer still sitting in the working tree — because devlogs that only show the shiny bits are just marketing.
First Problem: What Is The Mouse Even Touching?
In DOM-land, hover is free. :hover and the browser handles it. In a 3D scene, the browser has no idea what your meshes are — it just knows a <canvas> got moused over. If you want “cursor changes to pointer when I’m over the Blog orb,” you have to do the math.
That math is raycasting: shoot an invisible ray from the camera through the mouse position and see which meshes it intersects. The scene’s raycaster does exactly that, every relevant frame:
- Track the mouse position over the canvas
- Cast a ray from the camera through that position
- Check intersection against a Set of clickable meshes — not the whole scene, because raycasting everything is how you turn 60fps into 12
- Expose the hover state so parent components can react
The clickable set is explicit and registered — components opt in to being hoverable:
// Register meshes as clickable — an opt-in Set, not scene-wide raycasting
const meshes = new Set<THREE.Object3D>();
setClickableMeshes(meshes);
// Read hover state from anywhere downstream
const hovered = getHoveredMeshes();
Why a Set and not “raycast everything”: performance is the obvious answer, but the design answer matters more. In a scene where every object could be clickable, the user learns nothing. When only the things that do something respond to hover, hover itself becomes navigation — the scene teaches you its own rules by blinking back.
Second Problem: The Cursor Has Opinions Now
Raycasting tells you what is hovered. It doesn’t change the cursor, animate anything, or make the user feel anything. That’s a separate job, and it turned out to be the piece everything else plugs into: a tiny cursor store.
// src/lib/stores/cursor.ts — programmatic cursor control
setHover(); // cursor becomes a pointer, hover state on
removeHover(); // back to default, hover state off
Two functions. That’s the whole API. But because it’s a store rather than scattered style.cursor = 'pointer' calls, every interactive thing in the scene speaks the same language:
- Raycaster calls it when hover enters/leaves a registered mesh
- Interactive components call it for their own hover logic
- Global CSS reads the state and swaps the cursor + glow effects
This store has a small history, incidentally. During the migration it briefly held a $state() rune inside a plain .ts file — which is exactly the kind of thing Svelte 5 forgives at compile time and punishes at hydration. That bug got its own post. The lesson stuck: stores stay rune-free, components hold the reactivity.

Third Problem: Buttons Made Of Geometry
With hover detection and cursor state in place, the actual “buttons” become a wrapper component: ClickableModel. It takes anything renderable in the scene and turns it into something that behaves like a UI element:
<ClickableModel
onclick={() => navigateTo('/projects')}
hoverScale={1.1}
label="Projects"
>
<Your3DModel />
</ClickableModel>
What that wrapper gives you:
- Hover scale animation — the mesh swells ~5% (configurable) with a smooth interpolation, not a snap
- Pointer cursor via the cursor store, automatically
- Click handler integration — one prop, wired to real navigation
- Loading states — a CSS spinner while the model streams in, so the scene doesn’t flash holes
- Custom labels — floating text for the object, so the scene can label itself
This is the pattern that makes the whole idea scale: a 3D object becomes a component with props, same mental model as <button onclick={...}>. The scene graph starts feeling like a component tree, because it is one.
Imagine a screenshot here: the homepage scene with the cursor hovering a clickable object mid-scale-animation, pointer cursor visible.
What’s happening in this screenshot:
- The hovered object is mid-hover-scale (the ~5% swell)
- Cursor shows the custom pointer state from the cursor store
- A navigation label floats beside the target section
Fourth Problem: Sections You Can Fly To
A 3D homepage with spatial navigation has a UX question that flat sites never face: when the user “goes to the Blog,” where does the camera go?
The answer is the camera transition system. Sections of the site are positions in the scene — Blog and Projects each have a coordinate in 3D space. Navigating isn’t a page-jump anymore; it’s a smooth camera flight:
// Fly the camera to a section: where it goes, and where it looks
startCameraTransition(
[3, 3, 7], // target position
[4.5, 2, -1.5] // target lookAt
);
The transition eases between the current camera transform and the target — position and orientation — so moving between sections feels like walking through a building rather than teleporting between rooms. Navigation labels (positioned in 3D space, hoverable via everything above) are the doors.
Imagine a screenshot here: mid-transition frame where the camera is flying between the Blog and Projects zones, both labeled in 3D space.
What’s happening in this screenshot:
- Camera is mid-flight between section zones
- Both section labels are visible as in-scene navigation
- The scene composition shifts as the lookAt target eases toward the destination
This is also, quietly, the biggest difference from a normal portfolio: the homepage now has geography. Blog is over there. Projects are over here. Users get a mental map of the site, for free, from the camera’s journey.
The Polish Layer: Particles and Glow
Interactions feel dead without feedback, so global.css grew a small feedback kit:
- Click particles — a burst container that fires on interaction, so clicks land with a little visible punctuation
- Hover glow effects — hovered meshes get a glow treatment, not just a scale change
- Custom cursor styles — the pointer state gets its own visual identity in-scene
- Navigation label styles — the floating section labels get typography that matches the synthwave theme
- Model loading indicator — an animated spinner so async model loads don’t read as broken
None of these are individually interesting. Together they’re the difference between “I clicked a mesh and a page loaded” and “I touched the scene and it reacted.”

Status Check: What’s Live vs. What’s On The Bench
Here’s the part most devlogs skip, and it’s the part that keeps one honest.
Live on the site right now (deployed and verified in the build):
- ✅ Spatial navigation between section zones
- ✅ InteractiveEntity panels wired into
World.svelte - ✅ Camera navigation with transitions
- ✅ TSL particle system
- ✅ The cursor store helpers + 3D interaction CSS from this layer
Built but not yet wired into the scene (in the repo now, tested, waiting for integration):
- 🔲
ClickableModel.svelte— the wrapper from the examples above, generalized - 🔲
Raycaster.svelte— the standalone hover-tracking component - 🔲
HoverHighlight,CursorFeedback,InteractionGuide— the feedback trio - 🔲
InteractiveScene— the orchestrator that ties them together
The interaction concepts are live; the generalized component library version of them is the next pass. That’s not a confession, it’s a roadmap — the wired-in pieces prove the pattern works in production, and the loose components are the cleaned-up API extracted from that proof.
Why this order? Because the fastest way to find out your interaction API is wrong is to wire it into a real scene with real content. The production wiring comes first; the elegant abstraction gets extracted second. Abstractions extracted from working code fit. Abstractions designed upfront fit the code you wished you’d written.
What We Learned
1. Raycast against a registry, not the world. A Set of clickable meshes keeps hover cheap and — more importantly — keeps “clickable” a meaningful property. If everything responds to hover, nothing does.
2. Centralize cursor state early. Two store functions (setHover/removeHover) replaced cursor management being re-implemented in every interactive component. It’s the plumbing every later feature assumed.
3. Make 3D objects feel like components. <ClickableModel onclick={...}> is the whole design thesis: if the mental model matches DOM UI, the scene becomes maintainable by the same brain that maintains the rest of the site.
4. Camera transitions give a flat site geography. Section navigation as camera flight turns the homepage into a place. The labels are just the signage.
5. Ship the concept, then extract the library. Wiring interactions into the real scene first, generalizing into components second, means the API is carved from evidence instead of speculation.
What’s Next
The unwired layer gets its integration pass: ClickableModel wrapping the real section objects, the raycaster running scene-wide, loading states on every model, hover glow tuned against the dark theme. When that lands, the homepage stops having “3D with some interactions” and just… is the interface.
And somewhere after that: the Blender side of the house — baked camera flythroughs exported to glTF and flown by the same transition system. The scene’s about to get a film crew.
“Every interactive object in the scene now speaks the same two-word language: hover, click. It took a raycaster, a two-function store, and the discipline to not let HTML buttons anywhere near my homepage. The DOM had its chance. It built a strip mall.”
— Cleetus 🤡
P.S. — The hover-scale is 5% because 10% felt like a jump scare and 2% felt like nothing. Interaction design is just vibe calibration with extra steps.
P.P.S. — If you’re building this yourself: opt-in clickable sets, centralized cursor store, camera transitions as first-class navigation. Steal the order, not just the code.
#threlte #svelte #3d #webgl #blogfolio #interactiondesign
