An interactive 3D developer portfolio that still loads on the phone in your pocket.
Table of Contents
A personal portfolio built around a real-time 3D scene: a workstation model in the hero, a rotating globe beside the contact form, a point-cloud starfield, and thirteen technology badges rendered as faceted 3D balls. It started as a Three.js tutorial follow-along on Vite and has since been rebuilt on the Next.js App Router.
The hard part was never the 3D, it was shipping it. A scene that feels effortless on a desktop GPU can be unusable on a mid-range phone, so the front end is built around one decision: measure the device first, then choose what to render. Capable hardware gets the full interactive scene. Everything else gets a pre-rendered still of that same scene, framed identically, at a fraction of the bytes.
That gate cut the mobile first load from 17.59 MB across 131 requests to 0.37 MB across 73, with no visible change to the design.
| Framework | Next.js 16.3 (App Router, Turbopack) · React 19.2 |
| 3D | three.js 0.185 · @react-three/fiber 9.7 · @react-three/drei 10.7 |
| Styling | Tailwind CSS 4.3 (CSS-first @theme) · Poppins via next/font |
| Animation | Motion 13.1 |
| Contact | EmailJS (browser SDK, no backend) |
| Tooling | oxlint 1.80 · TypeScript 5.9 (strict) · sharp |
Capability-tiered 3D. On mount the app reads prefers-reduced-motion, pointer coarseness, viewport size, Save-Data, effective connection type, deviceMemory, hardwareConcurrency and a single memoised WebGL probe, and resolves a render tier of full, lite or static. Each scene declares what it costs; the policy layer decides what actually mounts.
Pre-rendered posters instead of multi-megabyte models. The hero, the globe and all thirteen technology badges have alpha-WebP stills captured from the real scenes, at the same camera, transform and aspect the live canvas would have used. Together they are about 232 KB standing in for roughly 19 MB of glTF, and because they sit in the same slot with object-contain the layout never shifts.
Opt-in escape hatch. Gated scenes offer a Load interactive 3D control, so a phone visitor who wants the real thing can have it.
WebGL contexts kept in budget. Canvases mount only while their section is near the viewport, and unmount on a delay so a fast scroll does not create and destroy a context in a few frames. Peak live contexts went from sixteen, the point where browsers start dropping the oldest, to four or fewer.
Real loading and error states. Every canvas sits behind its own retryable error boundary, so a failed texture degrades to a poster instead of blanking the page. Alongside it: determinate in-canvas progress, layout-matched skeletons for cards, timeline entries and badges, and inline SVG illustrations for the WebGL-unavailable, data-saver, scene-error and 404 cases.
Contact form as a state machine. idle -> sending -> success | error, with per-field validation, focus moved to the first invalid field, a honeypot, a draft preserved on failure and a direct mailto fallback. Delivery sits behind an interface, so the EmailJS call can be swapped for a Route Handler without touching the form.
Accessibility. Skip link, keyboard-operable mobile menu with aria-expanded and Escape/outside-tap dismissal, IntersectionObserver scroll-spy driving aria-current, visible focus rings for keyboard users only, and prefers-reduced-motion honoured across CSS, Motion and the 3D loops.
SEO and PWA surface. Full metadata, OpenGraph and Twitter cards, JSON-LD Person, plus generated sitemap.xml, robots.txt and manifest.webmanifest.
Responsive to the full breakpoint scale. xs 450px, then Tailwind's sm 640, md 768, lg 1024, xl 1280 and 2xl 1536. A single Container primitive owns the measure and widens with the viewport (80rem, then 84rem at xl, 96rem at 2xl), so a 1920px display uses 80% of its width rather than 67%. The contact form and globe split at lg instead of xl, which stops 1024-1280px collapsing into one tall column.
Asset pipeline. scripts/optimize-assets.mjs caps texture resolution, re-encodes with sharp and minifies the glTF JSON. It is idempotent via a manifest, so JPEGs are never re-compressed twice.
- Node.js 20.9 or newer (required by Next.js 16)
- npm (the repo ships
package-lock.json)
node --version- Clone the repository
git clone https://github.com/omunite215/Project_3DPortfolio.git- Navigate to the project directory
cd Project_3DPortfolio- Install dependencies
npm install- (Optional) Point the contact form at your own inbox
cp .env.example .env.localEvery variable is optional - with no .env.local the app builds and runs against the original configuration. See .env.example for the EmailJS keys and the canonical site URL.
- Start the development server
npm run devThe site is served at http://localhost:3000.
npm run dev # development server (Turbopack)
npm run build # production build
npm run start # serve the production build
npm run lint # oxlint
npm run lint:fix # oxlint with autofix
npm run typecheck # tsc --noEmit
npm run check # lint + typecheck
npm run optimize:assets # downsample and re-encode the glTF texturesChanging the content width. The ladder lives in app/styles/globals.css as --container-content, --container-content-xl and --container-content-2xl, consumed by app/components/layout/Container.tsx. Nothing else hardcodes a max-width.
Editing content. Everything on the page - services, technologies, work history, social profiles and projects - is data in app/constants/index.ts. Add a project there and a card appears; add a technology and a badge appears.
Adding a technology badge. Drop the icon in public/tech/, add the entry to technologies, then place a matching still at public/posters/tech/<icon-name>.webp so constrained devices have something to show. Without it the badge falls back to a line illustration rather than breaking.
Forcing a render tier while developing. The tier is derived from the real environment, so the quickest way to exercise the constrained path is Chrome DevTools - enable Emulate CSS prefers-reduced-motion, or throttle to Slow 3G, and reload. Device emulation alone triggers the lite tier.
Re-running the asset pipeline. npm run optimize:assets skips anything already recorded in scripts/assets-optimized.json. Pass --dry to preview:
node scripts/optimize-assets.mjs --dry- Draco or Meshopt compression for the two
scene.binfiles (5.8 MB of geometry, the largest remaining payload) - Share one WebGL context across the thirteen technology badges via drei
View, removing the per-badge canvas on desktop - KTX2 / Basis supercompressed textures with transcoding on the GPU
- Filter projects by tag
- Case-study pages per project instead of a single card
- Move the contact form to a Route Handler so the EmailJS identifiers leave the client bundle
- Automated Lighthouse CI budget to keep the mobile payload from regressing
- Playwright visual-regression suite over the baseline screenshots
- Light theme
See the open issues for a full list of proposed features and known issues.
Contributions make the open-source community a great place to learn and build. Any contributions you make are greatly appreciated.
- Fork the project
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a pull request
Distributed under the MIT License. See LICENSE for more information.
Om Patel
Project link: https://github.com/omunite215/Project_3DPortfolio
- Best README Template
- Shields.io
- Img Shields for tech-stack logos
- JavaScript Mastery - the tutorial this portfolio originally grew out of
- poimandres for
@react-three/fiber,dreiandmaath - Workstation and planet models sourced from Sketchfab