Building a React Design System Inside an Existing Production App

How to evolve a production React application into a design system with Material UI, semantic design tokens, theme overrides, shared components, and responsive defaults.

Building a design system is not just about creating polished buttons or documenting colors in Figma.

In a real production application, a design system needs to solve practical frontend problems:

  • inconsistent UI across screens;
  • repeated styling logic;
  • fragile responsive behavior;
  • duplicated components;
  • hard-to-maintain utility classes;
  • accessibility gaps;
  • visual bugs that appear as the product grows.

In this article, I will explain how I approached the creation of a real design system in an existing React application using Material UI, design tokens, and shared components.

The examples are simplified and generic, but they reflect architectural decisions that matter in production frontend codebases.

This was not a greenfield design system built in isolation. It was an incremental effort inside an application that was already production-ready, with existing screens, established user flows, and styling decisions already spread across the codebase.

The Starting Point: Styling Everywhere

Before introducing the design system, the application already had working production screens. The UI was mostly styled directly inside feature components.

A typical component could look like this:

export function CustomerCard() {
  return (
    <div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
      <div className="flex items-center gap-3">
        <div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 text-blue-700">
          <UserIcon />
        </div>

        <div>
          <h2 className="text-lg font-semibold text-gray-800">
            Customer Profile
          </h2>
          <p className="text-sm text-gray-500">
            Additional information
          </p>
        </div>
      </div>

      <div className="mt-4 border-t border-gray-100 pt-4">
        Content goes here
      </div>
    </div>
  )
}

This works well at the beginning. Utility-first styling is fast, expressive, and great for prototyping.

But as the application grows, styling decisions begin to spread across dozens of components.

The Problem

The problem was not Tailwind, CSS-in-JS, or any specific styling tool.

The real problem was that product-level UI decisions were being made locally, over and over again.

For example:

<div className="rounded-xl border border-gray-200 bg-white p-4" />

Then somewhere else:

<div className="rounded-lg border border-gray-300 bg-white p-6" />

And in another area:

<div className="rounded-2xl border border-gray-100 bg-[#f7f7f7] p-4" />

Over time, the UI starts to drift.

You get different border radius values, different gray tones, different spacing, different card layouts, and different responsive behavior.

The main issues were:

  • no single source of truth for colors;
  • no consistent spacing scale;
  • repeated card and header patterns;
  • inconsistent button typography;
  • icons behaving differently across screens;
  • responsive fixes implemented screen by screen;
  • harder refactors because styles were embedded directly in feature code.

A design system should reduce this kind of entropy.

The Goal

The goal was not to remove every existing style overnight.

The goal was to move core UI decisions into a shared system:

  • design tokens;
  • MUI theme overrides;
  • reusable layout components;
  • consistent card components;
  • responsive typography;
  • standardized buttons;
  • reusable icon containers;
  • safer defaults.

Instead of each screen deciding how a card, title, button, or icon should behave, the design system would provide those decisions.

Feature code should focus on the product problem, not on reinventing layout mechanics.

Step 1: Create Semantic Design Tokens

The first step was defining tokens.

Design tokens are named values that represent design decisions. Instead of using raw colors and sizes everywhere, the application uses shared, semantic values.

export const colors = {
  primary: {
    300: '#007298',
    500: '#0577A5',
    600: '#045F83'
  },
  neutral: {
    0: '#FFFFFF',
    50: '#F5F5F6',
    100: '#EEF1F4',
    200: '#E6E8EA',
    300: '#D9DEE3',
    500: '#6C747A',
    700: '#25282C'
  },
  success: {
    background: '#D1ECC6',
    text: '#294D19'
  },
  error: {
    background: '#ECC6CC',
    text: '#6D1222'
  }
} as const

Then surfaces can be defined separately:

export const surfaces = {
  page: colors.neutral[0],
  card: colors.neutral[0],
  muted: colors.neutral[50],
  divider: colors.neutral[200],
  border: colors.neutral[300]
} as const

This makes the code easier to understand.

Instead of this:

<Box bgcolor="#F5F5F6" />

You can use this:

<Box bgcolor={colors.neutral[50]} />

Or better, when the meaning is layout-related:

<Box bgcolor={surfaces.muted} />

That distinction matters. Good tokens describe intention, not only color values.

Step 2: Connect Tokens to the MUI Theme

Once tokens exist, they should be connected to the MUI theme.

import { createTheme } from '@mui/material/styles'
import { colors } from './tokens/colors'
import { typography } from './tokens/typography'
import { components } from './components'

export const theme = createTheme({
  palette: {
    primary: {
      main: colors.primary[500],
      dark: colors.primary[600]
    },
    text: {
      primary: colors.neutral[700],
      secondary: colors.neutral[500]
    },
    divider: colors.neutral[200]
  },
  typography,
  components: components()
})

This turns isolated visual choices into a system.

Now MUI components can use values from the design system:

<Button color="primary">Search</Button>
<Typography color="text.secondary">Additional information</Typography>
<Divider />

The important part is that the values no longer belong to individual screens. They belong to the UI foundation.

Step 3: Standardize Typography

Typography is one of the easiest places for inconsistency to appear.

Before the design system, font sizes were often controlled locally:

<p className="text-sm text-gray-500">Description</p>
<h2 className="text-lg font-semibold">Title</h2>

That works, but it makes global consistency hard.

A better approach is defining typography variants:

export const typography = {
  fontFamily:
    '"Open Sans", "Inter", system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif',

  h5: {
    fontSize: '1.438rem',
    fontWeight: 600,
    '@media (max-width:600px)': {
      fontSize: '1.25rem'
    }
  },

  h6: {
    fontSize: '1.2rem',
    fontWeight: 500,
    lineHeight: 1.25,
    '@media (max-width:600px)': {
      fontSize: '1.125rem'
    }
  },

  body1: {
    fontSize: '1.1rem',
    fontWeight: 400,
    lineHeight: 1.5,
    '@media (max-width:600px)': {
      fontSize: '1rem'
    }
  },

  body2: {
    fontSize: '1rem',
    fontWeight: 400,
    lineHeight: 1.45,
    '@media (max-width:600px)': {
      fontSize: '0.9375rem'
    }
  }
}

This creates a simple rule:

  • desktop keeps the original visual scale;
  • mobile gets slightly reduced typography;
  • components do not need to solve font sizing manually.

Usage becomes predictable:

<Typography variant="h5">
  Financial Behavior
</Typography>

<Typography variant="body1" color="text.secondary">
  Additional information
</Typography>

Step 4: Standardize Buttons

Buttons are one of the most visible parts of an application.

If every screen customizes button font size, padding, radius, and icon spacing, the product quickly becomes inconsistent.

A centralized MUI override helps:

export const muiButton = () => ({
  defaultProps: {
    disableElevation: true,
    variant: 'contained',
    size: 'medium'
  },

  styleOverrides: {
    root: ({ theme }) => ({
      borderRadius: 8,
      textTransform: 'none',
      paddingBlock: 11,
      paddingInline: 16,
      minHeight: 40,
      whiteSpace: 'nowrap',
      fontSize: theme.typography.pxToRem(16),
      fontWeight: 600,

      [theme.breakpoints.down('sm')]: {
        fontSize: theme.typography.pxToRem(14)
      }
    }),

    startIcon: {
      marginRight: 8
    },

    endIcon: {
      marginLeft: 8
    }
  }
})

Now buttons behave consistently:

<Button startIcon={<SearchIcon />}>
  Search
</Button>

No screen needs to repeat button typography or spacing rules.

Step 5: Create Shared Components

The biggest improvement came from creating shared UI components.

A common pattern in the app was a content panel:

  • title;
  • content area;
  • standard or highlighted variant;
  • responsive behavior.

Instead of implementing that repeatedly, we created a shared ContentPanel.

type ContentPanelProps = {
  title: string
  children?: React.ReactNode
  variant?: 'standard' | 'highlighted'
}

export function ContentPanel({
  title,
  children,
  variant = 'standard'
}: ContentPanelProps) {
  return (
    <Card variant={variant === 'highlighted' ? 'elevation' : 'outlined'}>
      <ContentPanelHeader title={title} />
      <CardContent>{children}</CardContent>
    </Card>
  )
}

This is where a design system becomes more than visual tokens.

It starts encoding reusable layout decisions.

Step 6: Move Responsive Behavior Into the System

One of the trickiest parts was the panel header.

The header needed to handle:

  • short titles;
  • long titles;
  • mobile layout;
  • desktop layout.

A simplified version:

function ContentPanelHeader({ title }: ContentPanelHeaderProps) {
  const shouldWrapTitleOnMobile = title.length >= 20

  return (
    <CardHeader
      disableTypography
      title={
        <Box sx={{ maxWidth: '100%', minWidth: 0 }}>
          <Typography
            variant="h5"
            sx={{
              overflowWrap: 'anywhere',
              whiteSpace: {
                xs: shouldWrapTitleOnMobile ? 'normal' : 'nowrap',
                sm: 'normal'
              }
            }}
          >
            {title}
          </Typography>
        </Box>
      }
    />
  )
}

The important detail is not the exact code.

The important idea is this:

Responsive behavior belongs in the design system, not scattered across every screen.

Step 7: Keep Icons Consistent

Icons are another common source of visual inconsistency.

Instead of manually styling icons everywhere:

<div className="h-10 w-10 rounded-full bg-blue-100">
  <SearchIcon />
</div>

Create a small shared wrapper for common icon treatment:

function IconContainer({ children }) {
  return (
    <Box
      sx={{
        width: 40,
        height: 40,
        flexShrink: 0,
        borderRadius: '999px',
        bgcolor: colors.neutral[100],
        color: colors.primary[300],
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
        '& svg': {
          fontSize: { xs: 22, sm: 28 }
        }
      }}
    >
      {children}
    </Box>
  )
}

This avoids repeating icon sizes, shapes, colors, and responsive rules.

It also prevents small production bugs, like circular backgrounds becoming oval in flex layouts.

That kind of detail matters in production.

Step 8: Migrate Gradually

A common mistake is trying to rewrite the entire UI at once.

That usually fails.

In an application that is already in production, a rewrite is usually the riskiest path. The design system has to evolve while the product continues to move.

A better strategy is gradual migration:

  1. Identify repeated UI patterns.
  2. Create shared components.
  3. Replace one section at a time.
  4. Keep compatibility with old components.
  5. Move tokens and typography into the theme.
  6. Reduce local styling over time.

For example, instead of rewriting every card, start with one high-impact section:

<ContentPanel
  title="Previous Queries"
  variant="highlighted"
>
  <PreviousQueriesContent />
</ContentPanel>

Then reuse the same structure elsewhere.

Real Gains

The benefits were practical, not theoretical.

Less Duplicated Styling

Before:

<div className="rounded-xl border border-gray-200 bg-white p-4" />

Every screen made its own decision.

After:

<ContentPanel title="...">
  ...
</ContentPanel>

The system owns the layout.

Better Responsive Behavior

Instead of fixing mobile bugs one screen at a time, the design system handles:

  • smaller mobile typography;
  • smaller mobile icons;
  • icon-only actions;
  • long title wrapping;
  • consistent button sizing.

More Accessible Interactions

When a button uses only an icon, it still needs an accessible label:

<IconButton aria-label="Search">
  <SearchIcon />
</IconButton>

The design system should make this the default pattern.

Safer Component Evolution

When the panel header behavior changes, every section using ContentPanelHeader benefits.

This is the real value of shared components.

Cleaner Product Code

Feature components become easier to read:

<ContentPanel
  title="Income"
  variant="highlighted"
>
  <IncomeSummary />
</ContentPanel>

The feature code focuses on business logic, not layout mechanics.

Lessons Learned

A real design system is not created in one commit.

It evolves through repeated product problems:

  • a title breaks on mobile;
  • a button has the wrong size;
  • spacing is inconsistent between screens;
  • typography feels slightly different across sections;
  • a panel needs a safer responsive default;
  • a color is used directly instead of coming from tokens;
  • a repeated layout pattern appears in multiple places.

Each issue is an opportunity to move a local decision into shared infrastructure.

The key question is:

Is this a one-time fix, or is this a pattern the system should own?

If it is a pattern, it belongs in the design system.

Final Thoughts

A design system is not just a visual layer. It is frontend architecture.

Using React, MUI, design tokens, and shared components, you can create a UI foundation that is:

  • more consistent;
  • easier to maintain;
  • more responsive;
  • more accessible;
  • safer to evolve;
  • less dependent on duplicated styling.

Utility-first styling is excellent for speed and experimentation. But in a large product, the most important UI decisions need to live somewhere centralized.

That is where a real design system starts.

Not in a color palette.

Not only in a Figma file.

But in reusable decisions that make the product harder to break.


Elmano Neto - Senior Software Engineer

Comments