Get in Touch

Need a website, app, or MVP? Let's talk.

info@gexpsoftware.com →

Puerto Jiménez, Costa Rica

info@gexpsoftware.com

© 2026 Marcelo Retana

All guides

How to Build a 3D Interactive Portfolio with Three.js | Full Stack Guide

~5-7 hoursintermediate7 steps

A flat portfolio page gets lost in the noise. A 3D interactive portfolio grabs attention instantly and proves you can ship creative engineering work. This guide walks you through building one with React Three Fiber, Drei utilities, GSAP-driven scroll animations, and a custom shader effect — all deployed as a performant static site.

Share:XLinkedIn

Prerequisites

  • -
    Node.js 20+

    Required for Vite and the React Three Fiber toolchain.

  • -
    Basic React Knowledge

    You should be comfortable with hooks, components, and JSX before working with R3F's declarative 3D scene graph.

  • -
    Familiarity with 3D Concepts

    Understanding of meshes, materials, cameras, and coordinate systems will help. The Three.js docs cover the basics well.

01

Scaffold the Project with Vite and React Three Fiber

Create a Vite + React + TypeScript project and install the core 3D dependencies. React Three Fiber is a React renderer for Three.js — you write JSX instead of imperative Three.js code. Drei gives you pre-built helpers like OrbitControls, Text3D, and environment maps.

bash
npm create vite@latest my-3d-portfolio -- --template react-ts
cd my-3d-portfolio

npm install three @react-three/fiber @react-three/drei
npm install gsap @gsap/react
npm install -D @types/three

Tip: Vite's HMR works well with R3F — your 3D scene updates without a full reload.

Tip: Pin the three.js version in package.json. R3F can lag behind Three.js releases by a few weeks.

02

Create the 3D Scene Layout with Camera and Lighting

Set up your root Canvas component with a perspective camera, ambient and directional lighting, and an environment map for realistic reflections. The Canvas component from R3F handles the WebGL renderer, resize observer, and animation loop automatically. Drei's Environment component loads an HDR image for image-based lighting.

src/components/Scene.tsxtsx
// src/components/Scene.tsx
import { Canvas } from '@react-three/fiber';
import { Environment, PerspectiveCamera, ContactShadows } from '@react-three/drei';
import { Suspense } from 'react';
import { HeroSection } from './HeroSection';
import { ProjectsSection } from './ProjectsSection';

export function Scene() {
  return (
    <Canvas gl={{ antialias: true, alpha: true }} dpr={[1, 2]}>
      <PerspectiveCamera makeDefault position={[0, 0, 10]} fov={45} />
      <ambientLight intensity={0.4} />
      <directionalLight position={[5, 5, 5]} intensity={1.2} castShadow />
      <Suspense fallback={null}>
        <Environment preset="city" />
        <HeroSection />
        <ProjectsSection />
        <ContactShadows
          position={[0, -2, 0]}
          opacity={0.5}
          scale={20}
          blur={2}
        />
      </Suspense>
    </Canvas>
  );
}

Tip: Set dpr={[1, 2]} to cap pixel ratio at 2x — Retina screens don't need 3x for 3D content.

Tip: Always wrap 3D content in Suspense. Textures and models load asynchronously and will suspend the component.

03

Build the Hero Section with Animated 3D Text

Create a hero section using Drei's Text3D component with a custom font. The text floats in 3D space and rotates subtly on mount using R3F's useFrame hook. MeshTransmissionMaterial from Drei gives the text a glass-like refractive appearance that catches the environment lighting.

src/components/HeroSection.tsxtsx
// src/components/HeroSection.tsx
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import { Text3D, Center, Float, MeshTransmissionMaterial } from '@react-three/drei';
import type { Mesh } from 'three';

export function HeroSection() {
  const meshRef = useRef<Mesh>(null);

  useFrame((_, delta) => {
    if (meshRef.current) {
      meshRef.current.rotation.y += delta * 0.1;
    }
  });

  return (
    <group position={[0, 2, 0]}>
      <Float speed={2} rotationIntensity={0.3} floatIntensity={0.5}>
        <Center>
          <Text3D
            ref={meshRef}
            font="/fonts/inter-bold.json"
            size={1.2}
            height={0.3}
            bevelEnabled
            bevelSize={0.02}
            bevelThickness={0.01}
          >
            Hello.
            <MeshTransmissionMaterial
              backside
              samples={16}
              thickness={0.4}
              chromaticAberration={0.2}
              anisotropy={0.3}
              distortion={0.1}
              color="#a0d2db"
            />
          </Text3D>
        </Center>
      </Float>
    </group>
  );
}

Tip: Convert your font to the Three.js JSON format using facetype.js (https://gero3.github.io/facetype.js/).

Tip: Float from Drei adds a gentle hovering animation without any manual math.

04

Add Scroll-Driven Animations with GSAP ScrollTrigger

Wire up GSAP ScrollTrigger to animate 3D objects as the user scrolls down the page. R3F's Canvas sits fixed in the background while an HTML overlay scrolls on top. ScrollTrigger pins and scrubs through timeline-based animations, translating and rotating objects in the 3D scene based on scroll position.

src/components/ProjectsSection.tsxtsx
// src/components/ProjectsSection.tsx
import { useRef, useEffect } from 'react';
import { useThree } from '@react-three/fiber';
import { useGSAP } from '@gsap/react';
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { RoundedBox, Text } from '@react-three/drei';
import type { Group } from 'three';

gsap.registerPlugin(ScrollTrigger);

const projects = [
  { title: 'Project Alpha', color: '#ff6b6b', y: 0 },
  { title: 'Project Beta', color: '#4ecdc4', y: -4 },
  { title: 'Project Gamma', color: '#45b7d1', y: -8 },
];

export function ProjectsSection() {
  const groupRef = useRef<Group>(null);
  const { size } = useThree();

  useGSAP(() => {
    if (!groupRef.current) return;

    gsap.to(groupRef.current.position, {
      y: 8,
      ease: 'none',
      scrollTrigger: {
        trigger: '#projects-scroll',
        start: 'top top',
        end: 'bottom bottom',
        scrub: 1,
      },
    });
  }, [size]);

  return (
    <group ref={groupRef} position={[0, -4, 0]}>
      {projects.map((project, i) => (
        <group key={project.title} position={[0, project.y, 0]}>
          <RoundedBox args={[3, 2, 0.3]} radius={0.1}>
            <meshStandardMaterial color={project.color} />
          </RoundedBox>
          <Text
            position={[0, 0, 0.2]}
            fontSize={0.25}
            color="#ffffff"
            anchorX="center"
            anchorY="middle"
          >
            {project.title}
          </Text>
        </group>
      ))}
    </group>
  );
}

Tip: GSAP ScrollTrigger needs a DOM element as trigger — use an HTML overlay div alongside the Canvas.

Tip: Set scrub: 1 for a smooth one-second delay between scroll and animation.

05

Write a Custom Shader for a Background Effect

Create a custom GLSL shader that renders an animated gradient or noise pattern as the scene background. R3F's shaderMaterial lets you pass uniforms from React state directly into your vertex and fragment shaders. The shader animates over time using a uniform clock value from useFrame.

src/components/GradientBackground.tsxtsx
// src/components/GradientBackground.tsx
import { useRef, useMemo } from 'react';
import { useFrame } from '@react-three/fiber';
import { ShaderMaterial } from 'three';

const vertexShader = `
  varying vec2 vUv;
  void main() {
    vUv = uv;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`;

const fragmentShader = `
  uniform float uTime;
  uniform vec3 uColorA;
  uniform vec3 uColorB;
  varying vec2 vUv;

  // Simplex-style noise
  float hash(vec2 p) {
    return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
  }

  float noise(vec2 p) {
    vec2 i = floor(p);
    vec2 f = fract(p);
    f = f * f * (3.0 - 2.0 * f);
    float a = hash(i);
    float b = hash(i + vec2(1.0, 0.0));
    float c = hash(i + vec2(0.0, 1.0));
    float d = hash(i + vec2(1.0, 1.0));
    return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
  }

  void main() {
    float n = noise(vUv * 3.0 + uTime * 0.15);
    vec3 color = mix(uColorA, uColorB, vUv.y + n * 0.3);
    gl_FragColor = vec4(color, 1.0);
  }
`;

export function GradientBackground() {
  const matRef = useRef<ShaderMaterial>(null);

  const uniforms = useMemo(() => ({
    uTime: { value: 0 },
    uColorA: { value: [0.05, 0.0, 0.15] },
    uColorB: { value: [0.0, 0.1, 0.2] },
  }), []);

  useFrame(({ clock }) => {
    if (matRef.current) {
      matRef.current.uniforms.uTime.value = clock.elapsedTime;
    }
  });

  return (
    <mesh position={[0, 0, -5]} scale={[30, 20, 1]}>
      <planeGeometry />
      <shaderMaterial
        ref={matRef}
        vertexShader={vertexShader}
        fragmentShader={fragmentShader}
        uniforms={uniforms}
      />
    </mesh>
  );
}

Tip: Keep fragment shaders simple for portfolio sites — subtle noise or gradient animations perform well on mobile GPUs.

Tip: Use uniforms for colors so you can tweak them from React without touching GLSL.

06

Add an HTML Overlay for Content and Navigation

Use Drei's Html component to render standard HTML content anchored to 3D positions, and create a scrollable overlay div that drives the scroll animations. The overlay contains your actual portfolio content — bio, project descriptions, contact info — while the 3D scene provides the visual backdrop.

src/App.tsxtsx
// src/App.tsx
import { Scene } from './components/Scene';
import './App.css';

export default function App() {
  return (
    <div className="app">
      <div className="canvas-container">
        <Scene />
      </div>
      <div className="scroll-overlay">
        <section className="hero-section">
          <h1>Marcelo Retana</h1>
          <p>Creative Developer</p>
        </section>
        <section id="projects-scroll" className="projects-section">
          <div className="project-card">
            <h2>Project Alpha</h2>
            <p>A real-time collaboration tool built with WebSockets.</p>
            <a href="https://github.com/user/alpha">View Source</a>
          </div>
          <div className="project-card">
            <h2>Project Beta</h2>
            <p>An AI-powered code review assistant.</p>
            <a href="https://github.com/user/beta">View Source</a>
          </div>
        </section>
        <section className="contact-section">
          <h2>Get in Touch</h2>
          <a href="mailto:hello@example.com">hello@example.com</a>
        </section>
      </div>
    </div>
  );
}

/* App.css */
/* .canvas-container { position: fixed; inset: 0; z-index: 0; }
   .scroll-overlay { position: relative; z-index: 1; pointer-events: none; }
   .scroll-overlay a, .scroll-overlay button { pointer-events: auto; }
   .hero-section, .projects-section, .contact-section { min-height: 100vh; } */

Tip: Set pointer-events: none on the overlay and pointer-events: auto only on interactive elements so scroll passes through to the canvas.

Tip: Use CSS scroll-snap for predictable section stops if your sections have fixed heights.

07

Optimize Performance and Deploy

Optimize your 3D portfolio for production by lazy-loading heavy assets, compressing textures, and reducing draw calls. Use R3F's Perf component during development to monitor frame rate and GPU usage. Deploy to Vercel or Netlify — Vite produces a static build that works with any CDN.

bash
# Install performance monitoring for development
npm install r3f-perf

# Build and preview locally
npm run build
npm run preview

# Deploy to Vercel
npm install -g vercel
vercel --prod

Tip: Use instancedMesh for repeated geometries (particles, grids) — one draw call instead of hundreds.

Tip: Compress textures to KTX2 format with toktx for 4-8x smaller file sizes with GPU decompression.

Tip: Test on a mid-range Android phone — if it runs at 30fps there, it's fine everywhere.

Next Steps

  • -Add 3D model loading with useGLTF to showcase interactive product models or character rigs.
  • -Implement a post-processing pipeline with bloom, depth of field, and vignette effects using @react-three/postprocessing.
  • -Add sound design with Howler.js triggered by scroll position for an immersive experience.
  • -Create a CMS integration so you can update project cards without redeploying.

Need help building this?

I've shipped production projects with these stacks. Let's build yours together.

Let's Talk