Web Content Development

Solo Freelancers Pay the Hidden Upkeep of Design Systems

A reusable component library can make a small web app look consistent at launch. Six months later, it can also turn a routine button change into a round of regression testing across screens the client has forgotten about. For a solo freelancer, I would default to a small set of documented patterns, not a full design system. The difference is who pays to keep it correct.

A small design system becomes a second product

“Build it once, reuse it everywhere” leaves out the second half of the bargain: every shared change has to work everywhere. A button component used in a settings form, a modal, and a destructive confirmation saves initial implementation time. It also gives one future edit three places to fail. Without a designer, QA specialist, or maintenance retainer, that testing lands on the person who built it.

I read UX UI Design Best Practices for Modern Web Apps before agreeing on interface conventions, but I disagree with treating consistency as a reason to centralize every visual decision. Centralization earns its keep when a rule is stable and repeatedly used. A one-off pricing panel does not become easier to maintain merely because its padding comes from a token: the token now has a name, a dependency, and a future decision about what else it should change.

The first cost is governance, even when the “team” is one person. Someone must decide whether a new client request is a variant of an existing pattern or an exception. If a client wants a denser table on one screen, changing the shared table may disturb every screen; adding a density prop creates another behavior to support. Neither option is free. A component library does not settle that judgment—it makes the judgment recur whenever the product changes.

The second cost is memory. A component named PrimaryButton sounds self-explanatory until one screen uses it to save a draft and another uses it to delete an account. You then need to remember which combinations of label, color, loading state, and disabled state were approved. A short usage note beside the component is more valuable than a polished component catalogue if it records the decision you would otherwise have to reconstruct six months later.

I would not introduce Storybook solely to present eight components to a client, because the stories, controls, build configuration, and screenshots become another surface to update after each UI change. Storybook can be worthwhile when several people review components independently or when the client funds ongoing visual approval. For a solo build with occasional revisions, a plain route showing the real components in the running app often answers the same review question with less upkeep.

Design tokens reduce repetition but widen the test surface

CSS custom properties are useful for values that truly change together: a text color, a border color, or a spacing unit shared across several screens. They are less useful as a promise that every future visual edit will be one line. Changing –color-surface may improve a card while making an alert indistinguishable from its background. The maintenance work moves from finding repeated values to checking the contexts that consume one value.

Consider a worked example, not a measured defect count: 6 screens × 2 color modes × 3 interaction states = 36 screen-mode-state combinations. You do not need 36 separate automated tests, but the multiplication explains why “just change the token” is a poor estimate. Hover, focus, and disabled treatments can diverge between a mouse-driven dashboard and a keyboard-driven settings form even when both import the same CSS file.

The W3C-published WCAG 2.2 Level AA criterion 1.4.3 generally requires a 4.5:1 contrast ratio for normal text, with specified exceptions. That threshold is a check on rendered color pairs, not on individual tokens. If a client requests a lighter brand color, test the text on each background where it appears; a token name cannot prove that the resulting pair passes. The same practical limit applies to prefers-reduced-motion: defining an animation duration once does not prove every transition respects the user’s setting.

I read 2024-2026 UX advice that wastes solo freelancer time before adding another UX ritual, but I would keep one unfashionable ritual: record which screens a shared change affected. A brief note such as “changed field error color; checked signup, settings, and billing in both modes” gives the next maintainer more useful evidence than an unmaintained token diagram.

Keep token names tied to decisions you can explain. –field-error-text is easier to review than a color scale reference buried inside a form component because its intended use is visible. Conversely, do not create a token for every literal value. If a border radius occurs once, leaving it local means a future client-specific adjustment stays local too.

A dependency can transfer code, not ownership

The choice between native HTML elements with CSS custom properties and Radix UI primitives with React is a maintenance choice, not a contest over which approach is more professional. Native <button> and <dialog> win when the interaction is simple, browser support matches the client’s requirements, and the freelancer wants fewer package updates. Their cost is writing and testing any behavior the browser does not provide in the form the design requires. Radix UI wins when a React 19 app needs complex, repeated interactions—such as menus with keyboard navigation—because maintained primitives can reduce custom interaction code. Its cost is tracking releases, reviewing behavior changes, and testing the styled composition you build around those primitives.

Neither choice excuses skipping keyboard checks. A dialog must have a useful accessible name, and a custom trigger must expose the right state; aria-expanded and aria-controls describe relationships but do not implement them. With native <dialog>, check focus on opening, Escape behavior, and where focus returns on closing. With Radix UI, check those same journeys after styling or nesting the primitive inside app-specific components. The user encounters your assembled interface, not the library’s example page.

Dependencies also create update decisions at inconvenient times. A patch release may be routine, but you still need to run the app, inspect the relevant screens, and decide whether a changed interaction is acceptable. Pinning versions in a lockfile makes an install reproducible; it does not make old code maintenance-free. If the contract ends at launch, either price a support window or hand over a clear update process rather than implying that “using a library” delegates future responsibility to its maintainers.

My default for a small app is native controls first, with a dependency added for a specific interaction I cannot implement and maintain cheaply. That position can be wrong for an app with many intricate menus and popovers, because repeated custom interaction work can exceed the cost of keeping a well-supported primitive library current. The quote should reflect the actual interaction count, not the prestige of the chosen stack.

The quote needs a change budget and a reproducible check

A launch quote should identify shared UI work separately from page work. “Build settings page” sounds bounded; “change the field component used by settings, signup, and billing” carries a different review obligation. I use a provisional 90-minute allowance for reviewing affected screens after a shared pattern change, then tune it against the app’s real size and the client’s appetite for browser coverage. It is an estimating allowance, not a universal benchmark.

Put the allowance in terms the client can approve: which browsers you will check, whether dark mode is included, and whether accessibility fixes caused by new client requests count as new work. At a hypothetical rate of $80 per hour, three hours of unquoted UI upkeep cost $240 in a month. The arithmetic matters because a “small” recurring obligation can consume the margin on a fixed-price project even when no single request looks expensive.

Automation can protect a narrow, expensive-to-repeat journey. The following Playwright test runs with @playwright/test when the app is serving a /settings route, its Playwright baseURL points at that server, and the named controls exist. It checks behavior that can regress after a dialog or trigger is restyled:

import { test, expect } from '@playwright/test';
test('preferences dialog returns focus', async ({ page }) => {
  await page.goto('/settings');
  const trigger = page.getByRole('button', { name: 'Open preferences' });
  await trigger.click();
  const dialog = page.getByRole('dialog', { name: 'Preferences' });
  await expect(dialog).toBeVisible();
  await page.keyboard.press('Escape');
  await expect(dialog).toBeHidden();
  await expect(trigger).toBeFocused();
});

Run it with npx playwright test after starting the app. This test does not certify the whole interface: it protects one agreed interaction and makes a failure visible before delivery. An axe-core scan can catch some detectable accessibility violations, while manual keyboard use still matters for whether the journey makes sense. I would keep the checks that expose plausible regressions and remove tests nobody can explain or maintain.

The handoff should be equally small and executable: where shared styles live, which components are shared, how to run the checks, and what a client should expect to pay when a shared pattern changes. That document is not a miniature design-system website. It is a way to prevent the next “one-line” request from arriving without its testing work attached.

Start with the next shared UI change

Before creating another component or token, take the next requested UI change and list every screen and state it could affect. Time the edit and the checks separately, then put both in the quote or change request. If the affected list is short, keep the implementation local. If it keeps recurring, extract the pattern—and give its future maintenance an owner and a price.