Card-Style Form Selects Without a Component Library
The Operations Engine audit form collects three types of qualifying data with constrained choices: the service a prospect wants to focus on, their timeline, and their budget range. All three could have been dropdowns. None of them are.
We built each group as a grid of clickable cards. Rounded border, background shift on selection, native checkbox or radio visible but secondary. The whole card is the click target. The whole thing is native HTML.
Why Cards Over Dropdowns
A dropdown hides every option until clicked. A card grid shows all options at once. For a qualifying form, that distinction matters. We want someone submitting an audit request to read all four service categories before selecting, not pick the first option that matches their initial mental model. Cards force the scan.
Cards also signal weight. A checkbox tick in a corner feels like a quick affirmation. A card you click feels like a decision. On a form whose selections shape the discovery call, that difference in perceived deliberateness is real.
The Pattern
The card is the label element. The actual input sits inside it:
<label
className={`flex cursor-pointer items-start gap-3 rounded-lg border px-3 py-2.5 text-sm transition-colors ${
checked
? "border-accent/60 bg-accent/10 text-foreground"
: "border-border bg-surface/40 text-muted hover:border-accent/40 hover:text-foreground"
}`}
>
<input
type="checkbox"
className="mt-0.5 h-4 w-4 cursor-pointer accent-[#3B82F6]"
checked={checked}
onChange={() => toggleService(s)}
/>
<span>{labelService(s)}</span>
</label>
Because input is a descendant of label, clicking anywhere on the card activates the input. No JavaScript click handler on the outer element needed. Native browser behavior handles it.
The visual state is driven entirely by the React checked prop. Two class strings swap in: border accent, background tint, text color. Three Tailwind variants, no custom CSS.
accent-[#3B82F6] applies to the native checkbox itself, the one spot where the browser renders through the styled card, matching the site's accent instead of defaulting to OS chrome.
The entire group lives inside a fieldset with a legend. Screen readers announce the group label when focus reaches the first option. Standard form semantics, no ARIA work needed.
Multi-Select vs Single-Select
Service categories are multi-select (checkboxes). Timeline and budget are single-select (radios). Same card appearance, different input type:
// Checkboxes: toggle in and out of array
function toggleService(s: Service) {
setServices((prev) =>
prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]
);
}
// Radios: direct setState
onChange={() => setTimeline(t)}
Radio buttons get a shared name attribute: name="timeline", name="budget". The browser enforces mutual exclusion natively. Remove the name and you'd need JavaScript to deselect the previous choice when a new one is picked. Keeping it on the input means the browser does that work for free.
Staying In Sync With the Schema
SERVICE_OPTIONS is an as const tuple in src/lib/validation/operations-engine-lead.ts. The Service type in the form component is (typeof SERVICE_OPTIONS)[number]. The selected array is typed Service[]. The toggle function only accepts values that exist in the schema.
Rename a service slug in the schema and the form component produces a type error immediately. The card grid and the Zod validation on the server stay aligned without manual coordination. One file owns the options. Everything else imports from it.
What We Avoided
No react-select. No headless UI. No custom checkbox component wrapping a hidden input with a div overlay and synthetic keyboard handlers. The native input does all of that already. Tailwind handles the visual state. The browser handles focus, keyboard navigation, and form semantics.
The pattern is not novel. The label-wraps-input trick is in the HTML spec. It just gets buried under the assumption that styled form controls require a component library.