Digital Product Strategy

From Zero to Design System Should a CTO Build or Buy?

A CTO choosing how to improve an application’s UX faces an early build-or-buy decision: who will own the behavior of its controls? I would buy established interaction components and build the task-specific experience in-house. That position is testable before a design-system investment: put one consequential workflow on a working screen, then see which parts your team can safely maintain.

Buy the interaction primitives, but keep task decisions in-house

Start by selecting one workflow that has a clear beginning and an observable result. A request-approval screen works well: a reviewer opens a pending request, checks its details, approves it, and sees confirmation. The team can agree on what a correct result looks like without first settling the appearance of every screen.

For this pilot, compare two named options. Radix UI plus in-house styling wins when the product has a distinctive interface and engineers can maintain its visual components; its cost is the time needed to style, document, and test each composed control. Material UI with MUI X Pro wins when the workflow needs a capable data grid quickly and the team accepts the vendor’s component conventions; its costs include per-developer licensing for Pro and the work of adapting those conventions to the product. Compare the current license terms during procurement rather than putting an assumed price into the architecture decision.

I would start this approval pilot with Radix UI’s dialog and a small, ordinary request list. I would not build a dialog primitive in-house, because focus trapping, Escape-key dismissal, and focus restoration create ongoing regression work without distinguishing the approval task. I also would not purchase a grid license before seeing whether reviewers need grid-specific operations: a short list may be easier to scan and operate.

The team should still own the approval rules. Write down who may approve, which request states permit approval, what confirmation means, and what happens if the server rejects the action. A component vendor cannot make those decisions because they depend on the product’s permissions and data. Keep that contract in an application-level function or service, where it can be tested independently of a particular UI library.

The guide UX Design Principles for Modern Software Interfaces is useful during screen critique, but I would not use a principles guide as a purchasing specification: it cannot establish who fixes a broken keyboard interaction in a future release. Put that ownership question in the decision record beside the license, upgrade path, and support arrangements.

One approval journey is enough to establish the first working setup

Give the pilot an intentionally narrow boundary. The reviewer starts with a pending request, opens its details, activates “Approve,” confirms the action, and returns to a list that shows the new status. Include one server failure, such as a request that another reviewer has already approved. Without that failure, the team can produce a persuasive demo while leaving the most consequential message and recovery path undecided.

Use React 19 with TypeScript and Vite to make the screen runnable locally. Add Radix UI for the confirmation dialog and Mock Service Worker (MSW 2) to intercept the pilot’s API calls. Define a GET endpoint for the list and a POST endpoint for approval in the mock handlers; make the POST return both success and an HTTP 409 conflict in separate test cases. This is a local contract for the pilot, not a substitute for agreeing on the eventual backend response shape.

Keep the request data deliberately small: an identifier, requester, summary, current status, and the fact needed to make the decision. Do not copy production records into the mock, because realistic-looking personal data offers little benefit when the question is whether the interaction works. If the live API is available later, swap the mock handler for an API client behind the same typed request and response definitions.

The companion guide UX Design Principles For Modern Software Applications belongs in the acceptance review rather than the vendor shortlist, because application-level guidance cannot reveal whether this particular approval error names the request and offers a useful next action. Ask a reviewer to complete the task without narration. Note where they pause, what they think “Approve” will do, and whether they understand a conflict response.

Treat 5 reviewers as a recruiting goal for this pilot, not a claim about statistical confidence. Record each person’s completed or abandoned task and the point of confusion. Those observations help choose the next change; they do not justify a broad conclusion about all users. If reviewers consistently need to inspect a field that the screen hides, change the task design before debating colors or purchasing another component.

The first screen should demonstrate behavior before it earns polish

Create the Vite React–TypeScript project, install @radix-ui/react-dialog, and replace src/App.tsx with the following runnable first screen. Start Vite with npm run dev. This deliberately unstyled dialog lets the team inspect opening, closing, naming, and keyboard behavior before presentation work obscures those questions.

import * as Dialog from '@radix-ui/react-dialog';
export default function App() {
  return <Dialog.Root>
    <Dialog.Trigger>Review request</Dialog.Trigger>
    <Dialog.Portal>
      <Dialog.Content aria-describedby="review-help">
        <Dialog.Title>Review request</Dialog.Title>
        <p id="review-help">Check the details before approving.</p>
        <Dialog.Close>Close</Dialog.Close>
      </Dialog.Content>
    </Dialog.Portal>
  </Dialog.Root>;
}

This is a running interaction, not a finished approval flow. Next, connect the trigger to a selected request, put its decision-relevant details in the dialog, and add an explicit confirmation action that calls the typed approval function. Show a pending state while the POST is in flight. On success, update the visible status from the response; on HTTP 409, keep the reviewer on the screen and explain that the request changed. Do not silently retry an approval, because repeating a consequential action after a conflict could conceal a changed decision context.

Set the confirmation control’s disabled state only for the period in which a submission is pending, and expose that state in text. Otherwise, a reviewer may interpret a temporarily inert button as a permission failure. Move focus to the error message when a failed submission requires attention, and make sure closing the dialog returns focus to a sensible control in the list. Check these behaviors with a keyboard rather than inferring them from the component’s documentation.

Use WCAG 2.2 AA as an acceptance standard and the WAI-ARIA Authoring Practices dialog pattern as a behavior reference. Neither can decide whether “Approve” is the right business action, but both give the team concrete questions about names, focus, and operation. Establish a 10-second timeout as a value to tune for the pilot’s simulated slow response; the point is to make waiting and failure visible, not to claim that real requests normally take that long.

Only after the task survives that check should the team add spacing, typography, and visual hierarchy. Put the styles in application-owned tokens rather than editing the installed library, because a dependency update should not require recovering local changes from package files. Document the few token values the pilot actually uses; an exhaustive token taxonomy would create maintenance work before there is evidence of repeated needs.

A failing test is better procurement evidence than a polished demo

Install Playwright and @axe-core/playwright for the pilot. Write a Playwright test that opens the request, confirms approval, waits for the mocked response, and checks the resulting status. Write another that makes MSW return HTTP 409 and verifies the explanation and available next action. Add a keyboard test for opening the dialog, closing it with Escape, and returning focus. Run axe-core on both the list and open-dialog states; its results are a useful automated check, not proof that the task is understandable.

Make the checks fail on purpose once. Remove the dialog title, break the focus return, and change the conflict handler to return an error without explanatory text. If the suite remains green, it is not yet protecting the purchase decision. Record the failing behavior and the fix in the decision record so the CTO can see what the chosen components provide and what the product team still owns.

Give the pilot a budget assumption of 10 engineer-days, including the mock API, screen, tests, and a reviewer session. Treat that figure as a planning constraint to revisit when the team estimates its own codebase, not as a published implementation benchmark. Set 90% task completion as a provisional pilot gate, then examine every failure individually: a small pilot cannot make that percentage a reliable population estimate.

At the decision meeting, compare the options against observed work. If the team spent its time implementing list capabilities that MUI X Pro already supplies, price the license against that engineering effort and an upgrade plan. If most time went into permission rules, conflict handling, and wording, a grid purchase will not remove the hard work. Review the vendor’s release policy, license scope, accessibility documentation, and the team’s ability to replace a component without changing the approval contract.

The next decision should start with a request, not a catalog

Tomorrow, ask a product owner to choose one pending request and describe exactly how approval should end and fail. Have an engineer turn that description into the typed API contract and runnable screen before anyone commits to a library across the application. The resulting keyboard test, conflict case, and reviewer observations will give you a defensible reason to buy more—or to stop buying.