Why a Solar System?
Most portfolio websites follow the same formula: hero section, skills grid, experience timeline, contact form. Functional, yes. Memorable, rarely. I wanted mine to feel like a different one — something people actually want to explore. A solar system felt like the perfect metaphor: each planet is a section of my portfolio, and the user orbits through them by scrolling or clicking.
The result is ExperienceOrbit — a fully interactive 3D solar system built with React Three Fiber inside a Next.js App Router project, state-managed by Zustand, and styled with Tailwind v4.
Demo link: https://tanteck.net/
Tech Stack:
- Next.js 16 (App Router)
- React Three Fiber + Three.js — the 3D rendering layer
- Zustand — lightweight global state for active planet and drawer
- Framer Motion — HUD animations with AnimatePresence
- Tailwind CSS v4 — utility-first styling
- Orbitron (Google Font) — make the font match with our style
File Structure:
Everything lives under src/containers/experience-orbit/:
src/containers/experience-orbit/
├── config.ts # PlanetConfig type, PLANET_CONFIG, PLANET_ORDER, ZOOM constants
├── index.tsx # Orchestrator — HUD layer, drawer, Suspense wrapper
├── solar-system-scene.tsx # Three.js Canvas with all 3D components
├── planet-components.tsx # PlanetBody mesh (textures, clouds, rings), AsteroidBelt
├── sun-shader.tsx # BoilingSun — custom GLSL ShaderMaterial
├── hooks/
│ ├── use-scene-controls.ts # Wheel, drag, pinch-zoom, FOV, planet focus
│ └── use-drawer-lock.ts # Body overflow lock while drawer is open
└── hud/
├── loading-screen.tsx # Suspense fallback — satellite spinner
├── top-controls.tsx # ModeToggle + GitHub link
├── planet-info.tsx # AnimatePresence planet label / bio / CTA
├── planet-drawer.tsx # Vaul right-side slide-in drawer
├── planet-nav.tsx # Side nav with index numbers and tick-line glow
└── status-bar.tsx # Scroll progress bar
Zustand:
// src/shared/stores/use-cosmos-store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type PlanetType = 'SUN' | 'MERCURY' | 'VENUS' | 'EARTH' | 'MARS' | 'JUPITER' | 'SATURN' | 'URANUS' | 'NEPTUNE' | 'PLUTO';
interface CosmosState {
activePlanet: PlanetType;
setActivePlanet: (planet: PlanetType) => void;
scrollProgress: number;
setScrollProgress: (progress: number) => void;
}
export const useCosmosStore = create<CosmosState>()(
persist(
(set) => ({
activePlanet: 'SUN',
setActivePlanet: (planet) => set({ activePlanet: planet }),
scrollProgress: 0,
setScrollProgress: (progress) => set({ scrollProgress: progress }),
}),
{
name: 'cosmos-store',
}
)
);
// src/shared/stores/use-site-setting-store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type SiteMode = 'normal' | 'universe';
interface SiteSettingState {
mode: SiteMode;
setMode: (mode: SiteMode) => void;
toggleMode: () => void;
openDrawer: boolean;
setOpenDrawer: (open: boolean) => void;
}
export const useSiteSettingStore = create<SiteSettingState>()(
persist(
(set) => ({
mode: 'universe',
setMode: (mode) => set({ mode }),
toggleMode: () => set((state) => ({
mode: state.mode === 'normal' ? 'universe' : 'normal',
})),
openDrawer: false,
setOpenDrawer: (open) => set({ openDrawer: open }),
}),
{
name: 'site-setting-store',
partialize: (state) => ({ mode: state.mode }), // openDrawer always resets on reload
}
)
);
Planet Configuration
All per-planet data lives in a single config.ts file. Each planet has an orbital radius, colour, label, section key, and bio that appears in the HUD panel.
// src/containers/experience-orbit/config.ts
import type { PlanetType } from '@/shared/stores/use-cosmos-store';
export type PlanetConfig = {
radius: number;
color: string;
label: string;
section: string;
description: string;
bio: string;
};
export const PLANET_CONFIG: Record<PlanetType, PlanetConfig> = {
SUN: {
radius: 0,
color: '#ffcc33',
label: 'About Me',
section: 'MyUniverse',
description: 'A star at the center, providing energy to the entire system.',
bio: "Hey, i'm Tan — i build things for the web and lowkey obsess over clean code and good UX."
},
MERCURY: {
radius: 15,
color: '#A5A5A5',
label: 'Tech Stack',
section: 'MyTechStack',
description: 'Smallest planet, closest to the Sun, with extreme temperatures.',
bio: 'My go-to stack. not gatekeeping — this is literally what i use to ship.'
},
VENUS: {
radius: 24,
color: '#E3BB76',
label: 'Core Values',
section: 'PersonalValuation',
description: 'Hottest planet due to a dense, greenhouse-gas-filled atmosphere.',
bio: 'The stuff I actually care about. No corporate fluff, just real values I live by.'
},
EARTH: {
radius: 36,
color: '#2271B3',
label: 'Experiences',
section: 'ExperienceTimeline',
description: 'Our home, the only planet known to support life with liquid water.',
bio: "Places I've been, things I've built. The full arc, no cap."
},
MARS: {
radius: 48,
color: '#E27B58',
label: 'Contact Me',
section: 'ContactSection',
description: 'The "Red Planet," known for its thin atmosphere, deserts, and extinct volcanoes.',
bio: 'Slide into my inbox fr. collabs, opportunities, or just vibes — all welcome.'
},
JUPITER: {
radius: 68,
color: '#D39C7E',
label: 'Comming Soon',
section: 'CommingSoon',
description: 'The largest planet, a gas giant with 95+ moons and a famous "Great Red Spot" storm.',
bio: 'Something big is cooking here. Stay tuned fr fr.'
},
SATURN: {
radius: 90,
color: '#C5AB6E',
label: 'Comming Soon',
section: 'CommingSoon',
description: 'Famous for its extensive, bright ring system and 146+ moons.',
bio: "Still loading... but trust, it's gonna hit different."
},
URANUS: {
radius: 110,
color: '#B5E3E3',
label: 'Comming Soon',
section: 'CommingSoon',
description: 'An ice giant that rotates on its side, with a faint ring system.',
bio: 'Doing its own thing, unbothered. New content incoming, no rush.'
},
NEPTUNE: {
radius: 130,
color: '#6081FF',
label: 'Comming Soon',
section: 'CommingSoon',
description: 'The coldest and farthest planet, known for high-speed winds and deep blue color.',
bio: "Deep in the sauce rn. Launching when it's ready, not before."
},
PLUTO: {
radius: 150,
color: '#CFA78E',
label: 'Comming Soon',
section: 'CommingSoon',
description: 'A dwarf planet in the Kuiper belt, known for its icy surface.',
bio: "Yeah Pluto's a dwarf planet, and this section is still a wip. Respect the process."
}
};
export const PLANET_ORDER: PlanetType[] = [
'SUN',
'MERCURY',
'VENUS',
'EARTH',
'MARS',
'JUPITER',
'SATURN',
'URANUS',
'NEPTUNE',
'PLUTO'
];
export const ZOOM_MIN = 0.3;
export const ZOOM_MAX = 3.0;
The 3D Scene
SolarSystemScene is the React Three Fiber canvas. It is dynamically imported with ssr: false so Three.js never runs on the server. The canvas is wrapped in a React Suspense boundary whose fallback is the loading screen — this is the only gate. Do not also pass a loading prop to dynamic() or the loading UI will fire twice (once from dynamic, again from Suspense).
// src/containers/experience-orbit/index.tsx (excerpt)
const SolarSystemScene = dynamic(
() => import('./solar-system-scene').then((mod) => mod.SolarSystemScene),
{ ssr: false } // ← no loading: prop here
);
<Suspense fallback={<LoadingScreen />}>
<SolarSystemScene locked={isDrawerOpen} />
</Suspense>
Inside the canvas, SolarSystemScene renders:
// src/containers/experience-orbit/solar-system-scene.tsx (simplified)
export const SolarSystemScene = ({ locked }: { locked: boolean }) => {
const { containerRef, fov, zoomLevel, targetProgress,
dragOffset, pointerHandlers, handlePlanetFocus } = useSceneControls(locked);
return (
<div ref={containerRef} className="w-full h-full" {...pointerHandlers}>
<PlanetNav onFocus={handlePlanetFocus} />
<Canvas>
<PerspectiveCamera makeDefault fov={fov} position={[0, 20, 60]} />
<Stars radius={300} depth={60} count={5000} factor={4} />
<ambientLight intensity={0.3} />
{/* Sun at origin */}
<Planet type="SUN" onClick={() => handlePlanetFocus(0)} />
{/* All other planets */}
{PLANET_ORDER.slice(1).map((type, i) => (
<Planet key={type} type={type} onClick={() => handlePlanetFocus(i + 1)} />
))}
{/* Orbit rings */}
{PLANET_ORDER.slice(1).map((type) => (
<OrbitPath key={type} radius={PLANET_CONFIG[type].radius} color="#ffffff" />
))}
<AsteroidBelt />
<CameraRig targetProgress={targetProgress} zoomLevel={zoomLevel} dragOffset={dragOffset} />
<VirtualPilot targetProgress={targetProgress} />
<EffectComposer>
<Bloom luminanceThreshold={1.2} mipmapBlur intensity={0.8} radius={0.4} />
</EffectComposer>
</Canvas>
</div>
);
};
The Sun — Custom GLSL Shader
Rather than a plain texture sphere, the sun uses a custom ShaderMaterial that blends a sinusoidal noise function with the texture to produce a boiling, living surface. A Fresnel term adds the characteristic limb glow.
// Fragment shader (sun-shader.tsx)
float noise(vec3 p) {
return sin(p.x * 10.0 + uTime)
* sin(p.y * 10.0 + uTime * 0.7)
* sin(p.z * 10.0 + uTime * 0.5);
}
void main() {
float n = noise(vPosition * 0.5);
vec4 texColor = texture2D(sunTexture, vUv);
vec3 color1 = vec3(1.0, 0.9, 0.2); // yellow core
vec3 color2 = vec3(1.0, 0.4, 0.0); // orange flares
vec3 noiseColor = mix(color1, color2, n * 0.5 + 0.5);
vec3 finalColor = texColor.rgb + (noiseColor * 0.8);
float fresnel = pow(1.0 - dot(vNormal, vec3(0, 0, 1.0)), 2.0);
finalColor += vec3(1.0, 0.6, 0.2) * fresnel * 2.0;
gl_FragColor = vec4(finalColor * 1.5, 1.0);
}
The uTime uniform is updated every frame via useFrame, and the mesh rotates by 0.003 radians per frame to give the impression of solar rotation.
useFrame((state) => {
if (materialRef.current) {
materialRef.current.uniforms.uTime.value = state.clock.getElapsedTime();
}
if (meshRef.current) {
meshRef.current.rotation.y += 0.003;
}
});
Planet Bodies — Textures, Clouds, and Rings
PlanetBody handles all non-sun planets. Every planet loads its own HD texture map from public/images/textures/. Earth gets a second cloud layer, Saturn gets a ring.
// planet-components.tsx
const TEXTURE_URLS: Record<PlanetType, string> = {
SUN: '/images/textures/sunmap.jpg',
MERCURY: '/images/textures/mercurymap.jpg',
VENUS: '/images/textures/venusmap.jpg',
EARTH: '/images/textures/earthmap1k.jpg',
MARS: '/images/textures/mars_1k_color.jpg',
JUPITER: '/images/textures/jupitermap.jpg',
SATURN: '/images/textures/saturnmap.jpg',
URANUS: '/images/textures/uranusmap.jpg',
NEPTUNE: '/images/textures/neptunemap.jpg',
PLUTO: '/images/textures/plutomap1k.jpg',
};
// Earth: cloud layer at scale 1.01, AdditiveBlending
{type === 'EARTH' && (
<mesh scale={[1.01, 1.01, 1.01]}>
<sphereGeometry args={[1.505, 64, 64]} />
<meshStandardMaterial map={cloud} transparent opacity={0.5}
blending={THREE.AdditiveBlending} />
</mesh>
)}
// Saturn: ring rotated -Math.PI / 2.2
{type === 'SATURN' && (
<mesh rotation={[-Math.PI / 2.2, 0, 0]}>
<ringGeometry args={[2.2, 5, 128]} />
<meshStandardMaterial transparent opacity={0.6} side={THREE.DoubleSide} color="#dfd195" />
</mesh>
)}
Every planet also has a BackSide atmospheric glow mesh at scale 1.15, opacity 0.08.
Asteroid Belt
The asteroid belt sits between Mars (radius 48) and Jupiter (radius 68) using an instancedMesh for performance. Each asteroid is a tiny dodecahedron with a random orbit radius in the 55–63 range.
export const AsteroidBelt = ({ count = 600 }: { count?: number }) => {
const { dummy, particles } = useMemo(() => {
const dummy = new THREE.Object3D();
const particles = [];
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const radius = 55 + Math.random() * 8; // between Mars and Jupiter
particles.push({
x: Math.cos(angle) * radius,
y: (Math.random() - 0.5) * 2,
z: Math.sin(angle) * radius,
scale: 0.05 + Math.random() * 0.15,
});
}
return { dummy, particles };
}, [count]);
// Rotates the belt every frame
useFrame((state) => {
const time = state.clock.getElapsedTime() * 0.1;
particles.forEach((p, i) => {
// each asteroid maintains its relative angle + slow rotation
dummy.position.set(p.x, p.y, p.z);
dummy.scale.setScalar(p.scale);
dummy.updateMatrix();
meshRef.current!.setMatrixAt(i, dummy.matrix);
});
meshRef.current!.instanceMatrix.needsUpdate = true;
});
return (
<instancedMesh ref={meshRef} args={[undefined, undefined, count]}>
<dodecahedronGeometry args={[1, 0]} />
<meshStandardMaterial color="#888888" roughness={0.9} metalness={0.1} />
</instancedMesh>
);
};
Orbital Motion Formula
Each planet's position is computed every frame from elapsed time and its orbital radius. The orbital speed is inversely proportional to radius, loosely approximating Kepler's third law.
useFrame((state) => {
if (groupRef.current) {
const time = state.clock.getElapsedTime();
const orbitalSpeed = 0.5 / (radius + 2);
const angle = time * orbitalSpeed + radius * 10.5; // offset prevents all planets lining up
groupRef.current.position.set(
Math.cos(angle) * (type === 'SUN' ? 0 : radius),
0,
Math.sin(angle) * (type === 'SUN' ? 0 : radius)
);
}
});
Camera Rig — Spherical Tracking with Drag
CameraRig lerps the camera along a sphere whose radius is controlled by zoomLevel. The camera always looks at the origin. Pointer drag offsets the azimuth and elevation via dragOffset.
const CameraRig = ({ targetProgress, zoomLevel, dragOffset }) => {
const { camera } = useThree();
useFrame(() => {
const progress = useCosmosStore.getState().scrollProgress; // no stale closure
const azimuth = progress * Math.PI * 2 + dragOffset.current.x;
const elevation = 0.3 + dragOffset.current.y;
const r = 60 * zoomLevel.current;
camera.position.set(
r * Math.sin(azimuth) * Math.cos(elevation),
r * Math.sin(elevation),
r * Math.cos(azimuth) * Math.cos(elevation)
);
camera.lookAt(0, 0, 0);
});
return null;
};
Why useCosmosStore.getState() instead of the hook? Inside useFrame, React hooks create stale closures — the callback captures the value at render time and never re-reads it. Calling getState() directly reads the current Zustand store state on every frame.
VirtualPilot — Scroll-Driven Navigation
VirtualPilot lerps scrollProgress towards targetProgress and snaps to the nearest planet index to update activePlanet.
const VirtualPilot = ({ targetProgress }) => {
useFrame(() => {
const { scrollProgress, setScrollProgress, setActivePlanet } = useCosmosStore.getState();
const next = scrollProgress + (targetProgress.current - scrollProgress) * 0.05; // lerp
setScrollProgress(next);
// Snap to nearest planet
const idx = Math.round(next * (PLANET_ORDER.length - 1));
const snapped = PLANET_ORDER[Math.max(0, Math.min(idx, PLANET_ORDER.length - 1))];
if (snapped !== useCosmosStore.getState().activePlanet) setActivePlanet(snapped);
});
return null;
};
Scene Controls Hook
All interaction logic is encapsulated in useSceneControls. The hook exposes refs (never triggers re-renders) and stable pointer handlers via useCallback.
// hooks/use-scene-controls.ts — key excerpts
// Plain wheel scroll = navigate planets; Ctrl/Meta wheel = zoom
const handleWheel = (e: WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
zoomLevel.current = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX,
zoomLevel.current - e.deltaY * 0.002));
} else {
targetProgress.current = Math.max(0, Math.min(1,
targetProgress.current + e.deltaY * 0.00025));
}
};
// Planet focus: snap both scrollProgress and activePlanet simultaneously
const handlePlanetFocus = useCallback((index: number) => {
const snapped = index / (PLANET_ORDER.length - 1);
targetProgress.current = snapped;
useCosmosStore.getState().setScrollProgress(snapped);
useCosmosStore.getState().setActivePlanet(PLANET_ORDER[index]);
}, []);
Snapping both scrollProgress and activePlanet simultaneously is important. If you only set targetProgress, the lerp in VirtualPilot will take a few frames to catch up and the camera will visually step through intermediate planets.
The HUD Layer
index.tsx is the orchestrator. It renders the Canvas behind a set of absolutely positioned React DOM overlays. Everything in the HUD must use position: absolute (not fixed) to keep mix-blend-mode working correctly — fixed elements break blend modes because they create a new stacking context.
// src/containers/experience-orbit/index.tsx
export const ExperienceOrbit = () => {
const openDrawer = useSiteSettingStore((s) => s.openDrawer);
const isDrawerOpen = openDrawer !== null;
useDrawerLock(isDrawerOpen);
return (
<div className="relative w-full h-screen overflow-hidden">
{/* 3D Canvas — sole loading gate via Suspense */}
<Suspense fallback={<LoadingScreen />}>
<SolarSystemScene locked={isDrawerOpen} />
</Suspense>
{/* HUD overlays — absolute, not fixed */}
<TopControls />
<PlanetInfoPanel />
<StatusBar />
<PlanetDrawer />
</div>
);
};
Planet Info Panel
PlanetInfoPanel uses AnimatePresence mode="wait" with key={activePlanet} so the old planet's info fades out before the new one fades in.
// hud/planet-info.tsx
<AnimatePresence mode="wait">
<motion.div
key={activePlanet}
initial={{ opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -14 }}
transition={{ duration: 0.35 }}
>
<span className="font-orbitron text-xs text-white/40">
{String(currentIndex + 1).padStart(2, '0')} / {String(PLANET_ORDER.length).padStart(2, '0')}
</span>
<h2 className="text-4xl font-black text-white">{label}</h2>
<p className="text-white/60">{bio}</p>
<button onClick={() => setOpenDrawer(activePlanet)}>Explore</button>
</motion.div>
</AnimatePresence>
Planet Nav Component
The side navigation was originally written as an inline .map() callback that called useCosmosStore directly. This violates the Rules of Hooks — hooks may not be called inside callbacks. The fix is to extract each item into its own component.
// hud/planet-nav.tsx
// ✅ Each item is a component, so the hook call is legal
const PlanetNavItem = ({ type, index, onFocus }) => {
const isActive = useCosmosStore((s) => s.activePlanet === type);
return (
<button onClick={() => onFocus(index)} className="flex items-center gap-3 group py-2">
{/* Index number */}
<span className={cn('text-[8px] tabular-nums w-4 text-right',
isActive ? 'text-blue-400' : 'text-white/20')}>
{String(index + 1).padStart(2, '0')}
</span>
{/* Tick line — active glows blue */}
<div className={cn('h-px rounded-full transition-all duration-500',
isActive
? 'w-6 bg-blue-400 shadow-[0_0_8px_2px_rgba(96,165,250,0.6)]'
: 'w-2 bg-white/20 group-hover:w-4 group-hover:bg-white/40')} />
{/* Planet name */}
<span className={cn('text-[10px] font-black uppercase tracking-[0.25em]',
isActive ? 'text-white' : 'text-white/25 group-hover:text-white/60')}>
{type}
</span>
</button>
);
};
export const PlanetNav = ({ onFocus }) => (
<div className="absolute top-10 md:top-1/2 left-6 md:left-8 md:-translate-y-1/2 z-50
pointer-events-auto font-orbitron! flex flex-col mix-blend-difference">
{/* Vertical track line */}
<div className="absolute left-[1.1rem] top-0 bottom-0 w-px bg-white/5" />
{PLANET_ORDER.map((type, i) => (
<PlanetNavItem key={type} type={type} index={i} onFocus={onFocus} />
))}
</div>
);
Status Bar
A thin progress bar at the bottom of the screen whose width is animated to scrollProgress * 100%. The percentage counter is rendered alongside it.
// hud/status-bar.tsx
const scrollProgress = useCosmosStore((s) => s.scrollProgress);
const pct = Math.round(scrollProgress * 100);
<motion.div
className="h-0.5 bg-blue-400 origin-left"
style={{ scaleX: scrollProgress }}
/>
<span className="text-white/40 text-xs font-orbitron">{pct}%</span>
Planet Drawer
Clicking the "Explore" button opens a right-side Vaul drawer. The drawer renders the active planet's section component from SECTION_MAP.
// hud/planet-drawer.tsx
const SECTION_MAP: Record<string, React.ComponentType> = {
MyUniverse: MyUniverse,
MyTechStack: MyTechStack,
PersonalValuation: PersonalValuation,
ExperienceTimeline:ExperienceTimeline,
ContactSection: ContactSection,
CommingSoon: CommingSoon,
};
<Drawer direction="right" open={isOpen} onOpenChange={(o) => !o && setOpenDrawer(null)}>
<DrawerContent className="max-w-4xl ml-auto h-full backdrop-blur-xl border-l border-white/10">
{SectionComponent && <SectionComponent />}
</DrawerContent>
</Drawer>
Planet Textures - I get them from this site just come and down load them: https://planetpixelemporium.com/planets.html
Conclusion
The solar system UI started as a way to stand out and ended up being one of the most technically interesting things I've built. React Three Fiber makes the 3D layer feel like writing normal React; Zustand keeps state coordination simple without prop drilling; Tailwind v4 handles the HUD styling with a single utility class per concern.
If you want to build something similar, start with the config.ts — define your planet data, orbital radii, and section names before writing any Three.js code. Everything else flows from that single source of truth.
Full source is available on GitHub: https://github.com/tanPhan263/portfolio-website.
Demo Link: https://tanteck.net/
More reference post: https://dev.to/cookiemonsterdev/solar-system-with-threejs-3fe0
Top comments (0)