Pantera-X
A marketing agency's site where every service is represented by a 3D object I modelled and rendered myself — then animated in two dimensions, so the whole thing costs no WebGL at runtime.
- Role: Solo — design, 3D modelling and rendering, frontend, database, admin panel, deployment.
- Stack: Next.js 15, React 19, TypeScript, Supabase Postgres via Drizzle ORM, Framer Motion, Tailwind, Google Sheets API.
- Status: Live in production at panterax.agency. Paid client work.
The problem
Pantera-X is a marketing and creative agency. They already had a site, built by a previous developer, and wanted to replace it.
Two things had to be true of the replacement. It had to look like the work of an agency that sells brand strategy and design — a generic template undercuts the pitch before anyone reads a word. And it had to be a site the agency could keep current on their own: new portfolio pieces and changes to the service list shouldn't require calling a developer.
What I built
A marketing site with a hand-built motion system. Seven services, each with its own rendered 3D object, floating and reacting to the cursor.
An admin panel over the content. Portfolio projects and services are CRUD-managed against Supabase Postgres through Drizzle, so the agency edits its own catalogue.
A lead path that ends where the client already works. The contact form writes straight into a Google Sheet.
Design decisions
Making it 3D without paying for 3D
Every service on the site is represented by a physical object — a chess piece, a laptop, a phone, an envelope. I modelled and rendered all of them myself in Blender.
The decision that matters is what happens to them next: they ship as rendered .webp stills, not as models. There is no WebGL context, no scene graph and no 3D runtime on the page. A marketing site's job is to load fast on a phone, and the visual payoff of a rendered object is almost entirely in the render — the lighting, the material, the angle — not in the ability to orbit it. Baking that to an image keeps the payoff and drops the cost.
What brings them to life is a custom hook, usePieceMotion, which gives each piece two independent behaviours composed into one transform:
- A bob. Position and rotation are driven off
useAnimationFramewith sine and cosine on the elapsed time, with separate amplitudes for each axis and a slower divisor for rotation so the turn lags the drift rather than matching it. - A repel. The piece pushes away from the cursor inside a radius, capped at a maximum offset and lerped toward its target so it eases rather than snaps.
Each piece takes a phase offset, which is the detail that makes the effect work at all: without it every object on the screen bobs in lockstep and the page reads as one animation rather than several objects. The two behaviours are kept as separate motion values and summed into the final x/y, so a piece being pushed by the cursor keeps bobbing while it's pushed.
The chess metaphor
The agency's framing is strategy, so the hero pieces are chess pieces, and the services are laid out as boxes that open on interaction — MagnetBox, which expands from two-thirds to full size and fades its copy in.
Mobile and desktop invert the trigger. On desktop a box opens on hover and the copy fades in immediately. On a phone there is no hover, so the box opens on tap and the copy is delayed behind the container's own animation, because text appearing before the box that contains it finishes growing reads as a glitch rather than a reveal.
Deep dive: leads land in a spreadsheet, because that's what was asked for
The contact form doesn't write to the Postgres database the rest of the site uses. It appends a row to a Google Sheet.
This was the client's requirement, not my architectural preference — worth stating plainly, because it would be easy to dress up after the fact as a clever minimal-infrastructure call.
It's still a good fit for the situation, and the implementation is where the care went. The form is validated with Conform + Zod on the server, inside a server action, so a submission is type-checked before anything is appended — a spreadsheet has no schema to enforce, which makes validation at the boundary matter more, not less. Each row carries the enquiry fields plus an ISO timestamp, and the action returns a typed result that either replays field-level errors into the form or resets it with a confirmation.
The honest trade is that a sheet has no deduplication, no status field and no audit trail. For an agency fielding a modest volume of enquiries into a document their team already has open, that's an acceptable shape — and it's the one the client asked for.
Trade-offs and rough edges
Admin auth is a hand-rolled cookie session (/api/admin/login, /logout, /cleanup) rather than Supabase Auth, despite Supabase already being in the stack for data. There's no strong reason for it — it works, but using the auth system already present would be the more defensible choice and is what I'd reach for now.
@splinetool/react-spline is still in package.json and imported nowhere. It's left over from an approach abandoned in favour of the Blender-render route above. Dead dependency, worth removing.
Results
Live in production, replacing the agency's previous developer-built site. The agency updates its own portfolio and service list through the admin panel, and enquiries arrive in a spreadsheet their team already works out of, without a developer in the loop.
Built and shipped solo, including the 3D assets.