Recently, I've been looking into ways in which we can connect both the web and the physical world. As a web engineer who sometimes does IoT-related projects, such as a lamp that changes colours when you send a message to a Bluesky account, I know that using microcontrollers is a common approach for this. And, of course, this can be great fun, both to develop and use! But, what about creating new things instead of just connecting to existing ones?
In this guide, we'll explore how to create an app that can make business cards which can be customised and 3d printed. We go from scratch: starting with a design and setting up the project, creating its UI using DaisyUI, adding all the interactive elements, and then creating the body and elements of the business card with React Three Fiber. Finally, we connect everything through a Zustand store to let us download the model.
Let's get started!
Notes:
- This guide assumes that you already know how to use React and have a notion of how Tailwind works.
- Some code blocks include comments to make it easier to understand and follow what's going on in them.
- The code of the UI was partially created using Claude. The design and React Three Fiber sections of the code were done by hand.
What we will create
Throughout this guide we will be making the following app, which you can also try here:
It will consist of two parts:
- The left part will contain a form that controls all the different properties that will make up our model: positions, widths, heights, contents of text and QR code, etc.
- The right part will contain a 3d preview of the business card model and a download button for the result. Using the button, we'll be able to download the model as a
.stlfile, one of the standards for 3d printing.
As for the technologies we'll be using, we'll control the shared state of our application through Zustand, use DaisyUI for the User Interface, generate QR codes with node-qrcode, and React Three Fiber to create our models. For this guide, we will be packing everything into a Next.js application, but this can be done with any other React-based framework as we won't be using any Next.js-specific features. Additionally, you can use the final repository for guidance if you need some support.
Project setup
Let's start the development of this project by setting up our dev environment. For this first step you have two options: you can download the starting repo, which is part of the full repository, or follow the manual setup steps described below. If you decide to go for the first option, make sure you are using it in its initial status, the one labelled with the initial-commit tag.
Manual Setup
Packages installation
To prepare our files for the project we will need to create the Next.js app we will be using, install our dependencies, and set up some files. We can do the first two steps by using the following commands:
# Create a Next.js app npx create-next-app@latest rtf-cards --empty --use-npm --ts --tailwind --react-compiler --eslint --app --src-dir --yes cd rtf-cards # Install our dependencies. This is all one single line. npm i @react-three/csg @react-three/drei @react-three/fiber qrcode daisyui zustand three @tailwindcss/postcss # Install qrcode types npm i -D @types/qrcode Adding DaisyUI's config
Now, let's configure our project so DaisyUI's styles are added properly. Add a postcss.config.mjs file into the root of your project and paste the following content in it:
/** @type {import('postcss-load-config').Config} */ const config = { plugins: { '@tailwindcss/postcss': {}, }, }; export default config; Next, make sure your app/global.css looks like this:
@import "tailwindcss"; /* Add this line */ @plugin "daisyui"; Note: If you would like to add any custom theming using DaisyUI so your app looks just how you want it, you can do so in this file! Check out DaisyUI's Theme Generator to personalise the look and feel of your app.
Now we're ready to start creating our UI! You can start the app by using npm run dev in the root folder of your project.
Creating the UI for the form
The form will be the source of truth for our application. It will control the model, its texts, dimensions, and every other part of it. On its side, the 3d scene will only have to read the form's values to generate and render the final model.
Let's start by creating our types and configuring the default values of the application. For this, we'll create a src/types/cardFormTypes.ts in our project, where we will add the following:
export interface CardDimensions { width: number; height: number; thickness: number; } export interface TextBlockState { enabled: boolean; xPosition: number; yPosition: number; thickness: number; fontSize: number; content: string; } export interface QrCodeState { enabled: boolean; xPosition: number; yPosition: number; thickness: number; moduleSize: number; qrContent: string; } /** Shape of the whole card form. */ export interface CardFormState { card: CardDimensions; textBlock: TextBlockState; qrCode: QrCodeState; } export const initialCardFormState: CardFormState = { card: { width: 84, height: 53, thickness: 1 }, textBlock: { enabled: true, xPosition: -40, yPosition: 14, thickness: 1, fontSize: 14, content: "Name Lastname\n\nSoftware Engineer\n\nMaker", }, qrCode: { enabled: true, xPosition: 20, yPosition: -5, thickness: 1, moduleSize: 1.2, qrContent: "https://example.com/", }, }; The 84 by 53 represent something close to the standard size of a card in mm. Although the standard size is closer to 85.6 x 53.98 mm, making it a bit smaller will give us a bit of leeway for the card to fit in a wallet without issues due to its thickness. In addition, the application will always start with something realistic whenever a new user tries it out.
We'll save these as initial values in a Zustand store. This store will include not only these values, but also several functions that will help us manage them to make our life easier later. In the code below, note how we're creating this store and wrapping it in persist, which will save the values to localStorage so they are constantly saved after any changes happen. Additionally, the merge function fills in all the values that a returning visitor's saved data doesn't have.
Add the following content in src/stores/formStore.ts:
import { create } from "zustand"; import { persist } from "zustand/middleware"; import type { CardFormState } from "@/types/cardFormTypes"; import { initialCardFormState } from "@/types/cardFormTypes"; interface FormStateStore { form: CardFormState; setForm: (form: CardFormState) => void; } export const FORM_STATE_STORAGE_KEY = "formState"; /** * Merges a persisted form over the defaults, group by group, so fields added * in later versions still get their default value for returning users. */ export function mergePersistedForm(persisted: unknown): CardFormState { const saved = (persisted as { form?: Partial<CardFormState> } | null)?.form; if (!saved || typeof saved !== "object") return initialCardFormState; const d = initialCardFormState; return { card: { ...d.card, ...saved.card }, textBlock: { ...d.textBlock, ...saved.textBlock }, qrCode: { ...d.qrCode, ...saved.qrCode }, }; } const useFormStateStore = create<FormStateStore>()( persist( (set) => ({ form: initialCardFormState, setForm: (form) => set({ form }), }), { name: FORM_STATE_STORAGE_KEY, partialize: (s) => ({ form: s.form }), merge: (persisted, current) => ({ ...current, form: mergePersistedForm(persisted), }), skipHydration: true, }, ), ); export default useFormStateStore; If you, like me, are using Next.js, we need to ensure that the first render is done without access to localStorage. This is to avoid any mismatches that could happen between the server and the client. For this, we're using the following component on src/components/FormStateHydrator.tsx:
"use client"; import { useEffect } from "react"; import useFormStateStore from "@/stores/formStore"; /** Restores the saved form from localStorage once mounted in the browser. */ export function FormStateHydrator() { useEffect(() => { void useFormStateStore.persist.rehydrate(); }, []); return null; } Next up is the form. For this, we'll create a series of small, reusable components that will make up our inputs. We'll be using some custom implementations to make them look exactly how we want while keeping their standard HTML behaviour. Add the following under src/components/FormInputs.tsx:
"use client"; import { useId, useState } from "react"; import type { ReactNode } from "react"; interface FieldShellProps { label: string; htmlFor: string; className?: string; children: ReactNode; } function FieldShell({ label, htmlFor, className, children }: FieldShellProps) { return ( <div className={`fieldset min-w-0 ${className ?? ""}`}> <label htmlFor={htmlFor} className="label"> {label} </label> {children} </div> ); } export interface NumberFieldProps { label: string; value: number; onValueChange: (value: number) => void; unit?: string; className?: string; } const STEP = 1; const SHIFT_STEP = 10; /** What the field accepts while typing: optional minus sign, digits, one point. */ const NUMERIC_DRAFT = /^-?\d*\.?\d*$/; function parseDraft(draft: string): number { return /^-?\.?$/.test(draft) ? 0 : Number(draft); } /** * Controlled number input with a trailing unit. * * Keeps the text being typed in a local draft: a controlled `type="number"` * input would re-render "1." as "1" and drop the decimal point before the * user can type the fractional digits. Both "." and "," work as the decimal * separator; the parsed number is reported through `onValueChange`. * * Being a text input, it restores the number-input behavior by hand * (WAI-ARIA spinbutton pattern): `role="spinbutton"` with `aria-valuenow`, * and ArrowUp / ArrowDown step by 1 (10 with Shift). */ export function NumberField({ label, value, onValueChange, unit = "mm", className, }: NumberFieldProps) { const id = useId(); const [draft, setDraft] = useState(String(value)); // The value changed from outside (e.g. restored form state): show it. if (parseDraft(draft) !== value) { setDraft(String(value)); } function commit(nextDraft: string) { setDraft(nextDraft); onValueChange(parseDraft(nextDraft)); } return ( <FieldShell label={label} htmlFor={id} className={className}> <label className="input w-full"> <input id={id} type="text" role="spinbutton" aria-valuenow={value} inputMode="decimal" value={draft} onKeyDown={(e) => { if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return; e.preventDefault(); const step = e.shiftKey ? SHIFT_STEP : STEP; const delta = e.key === "ArrowUp" ? step : -step; // Round away float noise commit(String(Math.round((value + delta) * 1e10) / 1e10)); }} onChange={(e) => { const next = e.target.value.replace(",", "."); if (!NUMERIC_DRAFT.test(next)) return; commit(next); }} /> {unit && <span className="label">{unit}</span>} </label> </FieldShell> ); } export interface TextFieldProps { label: string; value: string; onValueChange: (value: string) => void; placeholder?: string; className?: string; } export function TextField({ label, value, onValueChange, placeholder, className, }: TextFieldProps) { const id = useId(); return ( <FieldShell label={label} htmlFor={id} className={className}> <input id={id} type="text" className="input w-full" value={value} placeholder={placeholder} onChange={(e) => onValueChange(e.target.value)} /> </FieldShell> ); } export interface TextAreaFieldProps { label: string; value: string; onValueChange: (value: string) => void; className?: string; } export function TextAreaField({ label, value, onValueChange, className, }: TextAreaFieldProps) { const id = useId(); return ( <FieldShell label={label} htmlFor={id} className={className}> <textarea id={id} className="textarea h-24 w-full" value={value} onChange={(e) => onValueChange(e.target.value)} /> </FieldShell> ); } For the sections that can be hidden, we use a small wrapper around a fieldset. When the switch that controls these sections is off, the fieldset gets disabled and hidden, so the corresponding section disappears. Create the following component on src/components/PropertyGroup.tsx:
"use client"; import type { ReactNode } from "react"; export interface PropertyGroupProps { label: string; legend: string; children?: ReactNode; checked: boolean; onToggleChange: (checked: boolean) => void; className?: string; } /** * A DaisyUI toggle with a label, controlling a fieldset of input fields. * While the toggle is off the fieldset is disabled and hidden. */ export function PropertyGroup({ label, legend, children, checked, onToggleChange, className, }: PropertyGroupProps) { return ( <div className={`flex flex-col gap-3 ${className ?? ""}`}> <label className="flex cursor-pointer items-center gap-2 text-sm"> <input type="checkbox" role="switch" className="toggle" checked={checked} onChange={(event) => onToggleChange(event.target.checked)} /> <span>{label}</span> </label> <fieldset disabled={!checked} hidden={!checked} className="fieldset bg-base-300 border-base-300 rounded-box border p-4" > <legend className="fieldset-legend">{legend}</legend> {children} </fieldset> </div> ); } And, finally, the form that ties it all together. To make sure that all the fields read only from a single group, we create and use a helper with all the different inputs of the form. Add this onto a new src/components/CardForm.tsx file:
"use client"; import { NumberField, TextAreaField, TextField } from "./FormInputs"; import { PropertyGroup } from "./PropertyGroup"; import useFormStateStore from "@/stores/formStore"; export interface CardFormProps { className?: string; } const GRID = Source:https://dev.to/axolodev/creating-3d-printable-business-cards-with-react-three-fiber-1a6o