Showcase: Portfolio 3D
Saatnya menggabungkan semua yang sudah dipelajari untuk membuat sebuah mini portfolio 3D — halaman web interaktif yang menampilkan karya-karyamu dalam lingkungan 3D.
Konsep Portfolio 3D
Bayangkan portfolio yang bukan halaman biasa, tapi ruang 3D di mana pengunjung bisa:
- Menjelajahi scene dengan scroll atau mouse
- Mengklik objek untuk melihat detail project
- Menikmati animasi dan transisi yang halus
Struktur Project
portfolio-3d/
src/
App.jsx
components/
Scene.jsx # Scene utama
FloatingCard.jsx # Kartu project yang melayang
HeroText.jsx # Teks 3D judul
ParticleField.jsx # Background partikel
ContactSection.jsx # Section kontak
data/
projects.js # Data portfolio
styles/
global.css
public/
textures/
models/
package.json
Data Projects
// src/data/projects.js
export const projects = [
{
id: 1,
title: "E-Commerce App",
description: "Toko online modern dengan React dan Node.js",
color: "#3498db",
image: "/textures/project1.jpg",
url: "https://project1.example.com",
},
{
id: 2,
title: "Weather Dashboard",
description: "Dashboard cuaca real-time dengan data API",
color: "#2ecc71",
image: "/textures/project2.jpg",
url: "https://project2.example.com",
},
{
id: 3,
title: "Chat Application",
description: "Aplikasi chat real-time dengan WebSocket",
color: "#e74c3c",
image: "/textures/project3.jpg",
url: "https://project3.example.com",
},
];
FloatingCard — Kartu Project 3D
import { useRef, useState } from "react";
import { useFrame } from "@react-three/fiber";
import { Text, RoundedBox } from "@react-three/drei";
function FloatingCard({ project, position, index }) {
const groupRef = useRef();
const [hovered, setHovered] = useState(false);
useFrame((state) => {
const t = state.clock.elapsedTime;
groupRef.current.position.y =
position[1] + Math.sin(t + index * 0.5) * 0.2;
groupRef.current.rotation.y = Math.sin(t * 0.3 + index) * 0.1;
});
return (
<group
ref={groupRef}
position={position}
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
onClick={() => window.open(project.url, "_blank")}
>
<RoundedBox
args={[2.5, 1.5, 0.1]}
radius={0.1}
smoothness={4}
>
<meshStandardMaterial
color={hovered ? "#ffffff" : project.color}
roughness={0.3}
metalness={0.1}
/>
</RoundedBox>
<Text
position={[0, 0.3, 0.06]}
fontSize={0.18}
color={hovered ? project.color : "#ffffff"}
anchorX="center"
anchorY="middle"
maxWidth={2}
>
{project.title}
</Text>
<Text
position={[0, -0.1, 0.06]}
fontSize={0.1}
color="#cccccc"
anchorX="center"
anchorY="middle"
maxWidth={2}
>
{project.description}
</Text>
</group>
);
}
ParticleField — Background Atmosferik
import { useRef, useMemo } from "react";
import { useFrame } from "@react-three/fiber";
import * as THREE from "three";
function ParticleField({ count = 500 }) {
const meshRef = useRef();
const positions = useMemo(() => {
const pos = new Float32Array(count * 3);
for (let i = 0; i < count * 3; i++) {
pos[i] = (Math.random() - 0.5) * 20;
}
return pos;
}, [count]);
useFrame((state) => {
meshRef.current.rotation.y = state.clock.elapsedTime * 0.02;
meshRef.current.rotation.x = state.clock.elapsedTime * 0.01;
});
return (
<points ref={meshRef}>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
count={count}
array={positions}
itemSize={3}
/>
</bufferGeometry>
<pointsMaterial
size={0.02}
color="#ffffff"
transparent
opacity={0.6}
sizeAttenuation
/>
</points>
);
}
Scene Utama — Menyatukan Semua
import { Canvas } from "@react-three/fiber";
import { OrbitControls, Environment, Float } from "@react-three/drei";
import { projects } from "./data/projects";
function Scene() {
return (
<>
<Environment preset="night" />
<ambientLight intensity={0.3} />
<directionalLight position={[5, 5, 5]} intensity={0.8} />
<pointLight position={[-3, 3, -3]} color="#3498db" intensity={0.5} />
<OrbitControls
enableZoom={false}
maxPolarAngle={Math.PI / 2}
minPolarAngle={Math.PI / 4}
/>
<ParticleField count={800} />
{projects.map((project, i) => (
<FloatingCard
key={project.id}
project={project}
position={[(i - 1) * 3.5, 0, 0]}
index={i}
/>
))}
{/* Lantai reflektif */}
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -2, 0]}>
<planeGeometry args={[50, 50]} />
<meshStandardMaterial
color="#0a0a1a"
roughness={0.8}
metalness={0.2}
/>
</mesh>
</>
);
}
function App() {
return (
<div style={{ width: "100vw", height: "100vh", background: "#000" }}>
<Canvas camera={{ position: [0, 2, 8], fov: 60 }}>
<Scene />
</Canvas>
{/* HTML overlay */}
<div style={{
position: "absolute", top: 40, left: 0, right: 0,
textAlign: "center", color: "white", pointerEvents: "none"
}}>
<h1 style={{ fontSize: "2rem", margin: 0 }}>My Portfolio</h1>
<p style={{ opacity: 0.6 }}>Klik kartu untuk melihat project</p>
</div>
</div>
);
}
Tips Deployment
-
Optimasi performa:
- Gunakan
<Suspense>untuk lazy loading model 3D - Kurangi polygon count untuk mobile
- Gunakan
<Preload all />dari drei untuk preload asset
- Gunakan
-
Responsif:
<Canvas dpr={[1, 2]} // device pixel ratio range camera={{ position: [0, 2, 8], fov: 60 }} > -
Fallback untuk device lemah:
import { useDetectGPU } from "@react-three/drei"; function Scene() { const gpu = useDetectGPU(); const isLowEnd = gpu.tier < 2; return ( <> <ParticleField count={isLowEnd ? 100 : 800} /> {/* kurangi quality untuk device lemah */} </> ); } -
Deploy:
- Vercel, Netlify, atau GitHub Pages
- Pastikan static assets (textures, models) terinclude
- Gunakan gzip/brotli compression untuk file 3D
Aksesibilitas: Jangan Lupa Pengguna yang Sensitif terhadap Gerakan
Scene 3D yang keren bisa bikin sebagian orang motion sickness, pusing, atau kesulitan fokus — terutama pengguna dengan gangguan vestibular. Respek preferensi mereka:
import { useEffect, useState } from "react";
function useReducedMotion() {
const [reduced, setReduced] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
setReduced(mq.matches);
const handler = (e) => setReduced(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
return reduced;
}
function Scene() {
const reduced = useReducedMotion();
return (
<>
{/* Matikan auto-rotate & animasi aggressive kalau user minta reduced motion */}
<FloatingCard float={!reduced} />
<ParticleField count={reduced ? 0 : 800} />
<OrbitControls autoRotate={!reduced} />
</>
);
}
Checklist aksesibilitas 3D:
- Respect
prefers-reduced-motion— matikan auto-rotate, float, kamera bergerak otomatis - Beri konten fallback di dalam
<Canvas>untuk screen reader (Canvas tidak bisa di-crawl assistive tech) - Keyboard navigation — pastikan ada cara non-mouse untuk explore (misalnya tombol "Next section" yang menggeser kamera)
- Focus ring yang jelas saat objek 3D di-hover via keyboard — gunakan
onFocus/onBlurdi R3F - Sediakan tombol "Lihat versi 2D" sebagai escape hatch bagi pengguna yang tidak nyaman dengan 3D
<Canvas>
{/* Fallback untuk screen reader dan browser tanpa WebGL */}
<mesh>...</mesh>
</Canvas>
<p className="sr-only">
Portfolio interaktif 3D dengan 3 proyek: Aplikasi X, Website Y, Game Z.
Gunakan tombol panah untuk navigasi, atau klik tombol "Versi Teks" di bawah.
</p>
Prinsip: 3D adalah enhancement, bukan requirement. Semua informasi penting harus tetap bisa diakses tanpa WebGL.
Selamat! Kamu sudah menguasai fondasi creative web development — dari Canvas 2D sampai React Three Fiber. Sekarang waktunya bereksperimen dan membangun pengalaman web yang memukau!