A product configurator with a live 3D preview
A personal project I designed and built: a Next.js builder for choosing options, seeing them on a 3D model and watching the price as you go, with a basket that explains any price that changed since.
- tech stack
- my role
- Design and build
- context
- Personal project
- timeline
- June 2026
I designed and built this in June 2026 as a personal project: a builder for a made-to-order product. You pick from a set of options and colours, turn the result around on a 3D model, and add it to a basket with the price worked out as you go. The code is in a private repository, and this write-up sticks to how the front end works.
The problem
A configurator asks a lot of one screen. Options depend on each other, and every change has to reach the preview and the price at once. Someone who can’t see the preview, or who only uses a keyboard, still needs to know what they’ve built and what it costs. And a price can change between adding an item and coming back to the basket, so the basket has to be able to say why a total moved.
My approach
A catalogue the interface reads
Each option is identified by its category and its value, and that pair is the only key anywhere: pricing, the summary, the preview and the basket all use it. A small registry lists the categories in display order, with the input each one uses (radio buttons or a select), whether it’s required, and an optional rule for when it appears. One category only shows when the chosen base includes the part it applies to, for example.
The options panel, the summary, the price and the step that fills in defaults all loop over that registry, so adding an option is a data change. Required categories start on their first option, and the selected option decides how many colour pickers appear.
The 3D preview
The preview is three.js inside a React component. React owns the <canvas> and its lifecycle, and a small renderer class owns the scene, camera, lights and orbit controls. The component creates the renderer once, resizes it with a ResizeObserver, passes it each new configuration, and tears everything down on unmount:
useEffect(() => {
const scene = new SceneRenderer(canvasRef.current!);
const observer = new ResizeObserver(([entry]) => scene.resize(entry.contentRect));
observer.observe(canvasRef.current!.parentElement!);
sceneRef.current = scene;
return () => {
observer.disconnect();
scene.dispose();
};
}, []);
useEffect(() => {
sceneRef.current?.show(config);
}, [config]); That keeps three.js out of React’s render cycle. Swapping a model disposes the old geometry and materials, so clicking through options doesn’t pile up GPU memory. For now the model is built from simple shapes, one group per part, each coloured from your picks, with its surface roughness set by the material finish you chose. The pixel ratio is capped at 2, and the orbit controls drop their easing when someone has asked their system for reduced motion.
The canvas has role="img" and a label built from a plain-text summary of the configuration. The same summary sits under it in a polite live region, so a screen reader hears each change as it happens.
Themes generated from TypeScript
The design tokens are TypeScript objects. A palette holds the raw colour steps, and the semantic layer (backgrounds, text, buttons, inputs, links) can only point at a palette step, because its type is a template literal built from the palette’s keys. A mistyped token is a compile error. Every interactive token spells out its default, hover, focus, active and disabled states.
Before every dev and build, a script compiles the theme files and flattens each object into custom properties scoped to a data-theme attribute:
const toDeclarations = (value: object, prefix = ""): string[] =>
Object.entries(value).flatMap(([key, child]) => {
const name = prefix ? `${prefix}-${key}` : key;
return typeof child === "object" ? toDeclarations(child, name) : [`--${name}: ${child};`];
});
// { colour: { button: { hover: "…" } } } → --colour-button-hover: …; Type sizes and spacing come from a fluid(min, max) helper that writes a clamp() between two pixel sizes, converted to rem so they follow the reader’s font size. Components only use the generated properties, so the light and dark themes share every stylesheet.
Words in one place
All the interface text lives in a next-intl dictionary, served from localised routes, and one commit moved the strings out of more than 40 files. Only English is configured so far. The message keys are typed from the dictionary, so a missing key fails the type check, and counts use ICU plurals (“1 item”, “3 items”).
Option names and descriptions live in the dictionary as well, and the builder leaves out any option that has no translation. That doubles as a way to stage an option before it’s ready.
The basket that explains itself
When you add an item, the basket stores a snapshot of what each chosen option cost at that moment. On the basket page, each item is compared with today’s prices:
const diffPrices = (selections: string[], snapshot: Record<string, number>, current: Map<string, number>) =>
selections.flatMap((id) => {
const now = current.get(id);
if (now === undefined) return [{ id, removed: true }];
const was = snapshot[id];
return was !== undefined && was !== now ? [{ id, was, now }] : [];
}); An option whose price changed shows “was £x, now £y” on its item, a withdrawn option is flagged as no longer available, and a warning at the top of the page says that something has changed. Prices are whole numbers of pence, formatted with Intl.NumberFormat only for display. Product and basket data are RxJS streams that share their latest value, so the builder, the basket page and the count in the header always show the same thing.
Checkout itself isn’t built yet. Its button is disabled, with a note underneath saying so.
Accessibility
Accessibility got its own pass, and I switched ESLint to the full jsx-a11y recommended rules.
- On narrow screens the options panel becomes a modal dialog, with a focus trap, Escape to close and focus handed back to the button that opened it. On wide screens, where it sits beside the preview, it’s an ordinary column.
- The options area scrolls by itself, so it’s focusable and has a name, and keyboard users can scroll it.
- Options are native radio buttons in a
fieldsetwith alegend, and each description is tied to its radio witharia-describedby. The colour picker pairs the native colour input with a text field for the hex value. - The running total sits in a polite live region.
- Every basket control names the item it acts on, by type and position in the list. Removing an item moves focus to its neighbour, or to the link to start building if the basket is now empty.
- Status messages hide their visible text from assistive technology and put a copy into a hidden live region on the next frame. Live regions announce changes to content they already hold, so filling the region after it exists is what gets the message read out.
- Input borders in both themes moved a step further from the background, for more contrast.
I tested the builder across browsers.
Challenges
The theme switch was wrong for anyone following their system setting. It read the stored choice, which was “system”, instead of the theme that had actually been applied. Reading the resolved theme fixed it, and the switch only renders after hydration, so the server never draws a guess.
Editing an item from the basket opens the builder with the saved configuration, but the catalogue may have moved on since. The builder normalises the configuration against the current options before it reaches the preview or the price, so a withdrawn option falls back to the first one still available.
lesson learnt
Record what someone agreed to
Storing each price at the moment of adding is what lets the basket be honest. When a price changes later, the basket can say exactly which option changed and by how much.