3D portfolio
Build a one-page portfolio that could only belong to this person. The site that produced this skill was built around a DJ's record crate: every project is a record sleeve you flick through, and the record spins when it is in front. Example: https://alessio-simoya.vercel.app
Do not copy that concept. Follow the process and find theirs.
1. Collect the inputs first
Ask for these before designing. Build with real content from the start.
- Projects: for each one, a short brief of facts: what it is, their role, dates, what it did, the stack, a live link, and a list of things that must not be claimed.
- Experience: company, title, dates, place, and one result per job.
- Skills: grouped, with where each was proven.
- Life outside work: hobbies, teams, what they cook, where they have been. This is where the concept comes from, so ask for more than they offer.
- Assets: logos, product screenshots, videos, Rive or Lottie files. Ask where the project folders live and search them.
- Links: LinkedIn, GitHub and so on. Never publish their email address unless they say to.
2. Find the one concept
Pick the most characteristic thing in the person's world and make it the hero, as a real 3D object people can play with. A DJ gets a crate of records. A climber might get a wall of holds; a baker, a tray of loaves. Each project becomes one item in that object.
Spend the boldness there. Keep the rest of the page quiet: flat colour fields, one display typeface with character, one plain body face, four or five named colours.
3. Stack
- Vite, React and TypeScript in strict mode
- three.js for 3D, written directly (no scene framework needed)
- Rive only if the person already has
.rivfiles; lazy-load its wasm - tRPC and zod for the API, as a Vercel function at
api/trpc/[trpc].ts - Resend for email, Cloudflare Turnstile for spam, Neon Postgres for records
- Self-hosted variable fonts through
@fontsource-variable/*
4. How the 3D is put together
Use one fixed, full-screen WebGL canvas with pointer-events: none, and
draw every 3D view into the rectangle of a DOM placeholder:
const r = el.getBoundingClientRect();
renderer.setViewport(r.left, innerHeight - r.bottom, r.width, r.height);
renderer.setScissor(r.left, innerHeight - r.bottom, r.width, r.height);
renderer.render(scene, camera);
Clear once per frame, turn the scissor test on, and skip any element that is off screen. This gives many 3D objects down the page for the cost of one context, and they scroll with the layout.
Give each small object a "rig": a group, a camera position, a look-at point and
a tick(t) function driven by one shared clock. Build rigs lazily and show one
at a time. Draw textures on a 2D canvas (logos, labels, patterns) so there are
no model files to load.
Things that went wrong and how to avoid them:
- A ground-shadow plane larger than the element shows as a hard rectangle. Size it to fit inside the view.
- Rotate a group, then spin a child, when you need "spin about its own axis".
- Sprites and transparent planes draw after solid meshes; a glow under an object should be a flat plane below it, not a camera-facing sprite.
5. Sections that worked
- Hero: name set very large, a sticker that cycles through their roles, one sentence, and the interactive object. A coloured disc behind it changes with the item in focus.
- Work: each case study opens on a band in that brand's colour with its real logo, then a sideways reel of images, video and animation, three short highlights, a few large figures and the stack as chips. Long text goes behind "Read the full story".
- Experience: one row per job with one large number each.
- Journey: schools or milestones, each with its own animated object.
- Skills: rows of moving type, not a table.
- Off the clock: one animated object per interest.
- Travels: places as fridge magnets on a steel door that can be dragged.
- Say hello: the contact form.
A floating pill header marks the current section with an
IntersectionObserver.
6. House rules
- Facts only. Use what the person supplied. If a section needs something they did not give, leave it out. Keep each project's "do not claim" list in the content file as a comment.
- No vanity counts. Commits, lines of code, number of pages or models read as immature. Show the product and what it did.
- Show less text. If a screen is mostly paragraphs, turn it into a visual.
- Respect other people's work. Do not rebuild a paid template from its preview, and do not copy a brand's product design (a named match ball, for example); draw an original in the same spirit. Use only images that are freely licensed, and credit them on the page.
- Screenshots must not show real customers' names, amounts or faces.
- Motion has an off switch. Honour
prefers-reduced-motion, add a "Pause motion" button, and give a flat fallback when WebGL is missing. - Put all content in one typed file (
src/content/portfolio.ts) so the person can edit words without touching components.
7. The contact form
One tRPC procedure, contact.send, with zod input. In order, it:
- returns success silently if the hidden honeypot field is filled;
- applies a small per-address rate limit;
- verifies the Turnstile token with Cloudflare, and refuses if it fails;
- emails the owner through Resend, with the sender as
reply_to; - saves every message to a
messagestable; - only if the visitor ticked an unticked-by-default "keep me posted" box,
saves them to a
subscriberstable (with the consent wording and time) and to a Resend audience.
Never add people to a mailing list without that tick.
Environment variables: RESEND_API_KEY, ENQUIRY_TO_EMAIL,
ENQUIRY_FROM_EMAIL, RESEND_AUDIENCE_ID, DATABASE_URL,
TURNSTILE_SECRET, and the public site key for the browser.
Things that went wrong:
- The
fromvalue may already beName <address>. Wrapping it again makes Resend answer 422. Use it as given when it contains<. - Vite only exposes variables that start with a prefix in
envPrefix. If the site key arrives under another name, add that prefix. - A Turnstile widget only works on hostnames listed for it in Cloudflare.
- In production, with no Turnstile secret, refuse to send. Use Cloudflare's published test keys only in local development.
- Add a
CONTACT_DRY_RUN=1switch, ignored in production, so the whole path can be tested without sending email. - To run the same handler under
vite dev, add a small plugin that loads the function withserver.ssrLoadModuleand passes it aRequest. - Build email HTML with tables and inline styles, and escape every field.
8. Check your work as you go
After each round of changes:
- run the build;
- take Playwright screenshots of every section at 1440 and 390 pixels wide
(launch Chromium with
--use-gl=swiftshaderso WebGL renders headless); - confirm
document.documentElement.scrollWidth - innerWidthis 0; - look at the screenshots yourself before saying it is done.
Say plainly what was not checked: real phones, Safari, the feel of the motion, real email delivery.
9. Ship it
git init, with.env*and.vercelignored. Scan staged files for keys before the first commit.vercel link, add the environment variables,vercel --prod.- Check the live page loads without failed requests and that the API rejects a request with no Turnstile token.
- Ask the owner to send one real message through the live form.
Starter prompt
Give the person this to fill in:
Build my 3D portfolio using the 3d-portfolio skill.
Name and one-line description of me:
Roles to cycle in the hero (4 to 6):
Projects (for each: facts, my role, dates, link, what not to claim):
Experience (company, title, dates, one result each):
Skills, grouped:
Schools or milestones:
Interests outside work (be specific):
Places I have been:
Where my logos, screenshots and animations live on this machine:
Links to show (LinkedIn, GitHub and so on):
Things I never want on the site: