Fixing inconsistent buttons and colours across a React codebase
How to find duplicate buttons and hard-coded colours in a React app, build one canonical Button on design tokens, migrate safely and use lint rules to keep it consistent.
By The M1rror team8 min read
Every React codebase that lives long enough grows a collection of buttons. There is the shared Button, the PrimaryButton written before it existed, the SubmitBtn in the checkout flow, and forty raw <button> elements with their own Tailwind classes or CSS Modules. Colours follow the same path: a theme file, plus hex codes pasted into style props and class names whenever someone needed a slightly different blue. Users notice, even if they cannot say what is wrong. This guide shows how to find the duplication, design one component that replaces it, migrate without a big-bang rewrite and add checks so the problem does not come back.
Why this happens
It is rarely carelessness. Inconsistent components usually come from reasonable local decisions:
- The shared component did not support a case (an icon, a loading state, a link that looks like a button), so someone wrote their own.
- A deadline made copying faster than extending.
- Nobody knew the shared component existed, or where it lived.
- Colours were never named, so there was nothing to reach for except a hex code.
Which means the fix is not just "delete the duplicates". It is to make the canonical component cover the real cases, make it easy to find, and make the wrong thing harder than the right thing.
The Nielsen Norman Group's usability heuristic on consistency and standards puts the user-facing reason simply: users should not have to wonder whether different words, situations or actions mean the same thing. Two primary buttons that look different invite exactly that question.
Step 1: Measure the problem
Start with a quick inventory. These commands are rough but surprisingly effective:
# Files rendering a raw <button>
grep -rlE "<button[\s>]" src --include=*.tsx | wc -l
# Components that look like buttons
grep -rhoE "(function|const) \w*(Button|Btn)\w*" src --include=*.tsx | sort | uniq -c
# Hard-coded colours in TSX (style props, class names, styled-components)
grep -rnoE "#[0-9a-fA-F]{3,8}\b" src --include=*.tsx | wc -l
# Tailwind arbitrary colour values
grep -rhoE "(bg|text|border)-\[#[0-9a-fA-F]{3,8}\]" src | sort | uniq -c | sort -rnThen look at the results visually. Build a scratch page that renders every button variant you can find side by side, or take screenshots of each. Seeing six "primary" buttons in a row, each with a different height, radius and shade, makes the case for consolidation better than any metric.
Make a list of what each variant is actually for. You will usually find the real needs are small: a primary action, a secondary action, a quiet or ghost action, a destructive action, and perhaps a link styled as a button. Plus two or three sizes, icon support and a loading state.
Step 2: Name the colours first
Before building the component, give your colours names. A button built on raw hex values just moves the problem.
Define semantic tokens as CSS custom properties, named by purpose:
:root {
--color-accent: #4f46e5;
--color-accent-hover: #4338ca;
--color-on-accent: #ffffff;
--color-danger: #dc2626;
--color-danger-hover: #b91c1c;
--color-surface: #ffffff;
--color-surface-hover: #f4f4f5;
--color-border: #d4d4d8;
--color-text: #18181b;
--color-focus: #4f46e5;
}If you use Tailwind, map these into your theme so classes like bg-accent resolve to the variables rather than to fixed values. Either way, the component will only ever reference names. For more on structuring these, see design tokens explained for developers.
Check each foreground and background pair against WCAG 2.2: 4.5:1 for normal text, 3:1 for large text, and 3:1 for the visual information that identifies a component and its states, such as a secondary button's border or the focus indicator.
Step 3: Build one Button that covers the real cases
Now write the component. The goal is a small, explicit API: a fixed set of variants and sizes, not a color prop that accepts anything.
// src/components/ui/button.tsx
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import styles from "./button.module.css";
type Variant = "primary" | "secondary" | "ghost" | "danger";
type Size = "sm" | "md" | "lg";
export type ButtonProps = ComponentPropsWithoutRef<"button"> & {
variant?: Variant;
size?: Size;
loading?: boolean;
icon?: ReactNode;
};
export function Button({
variant = "secondary",
size = "md",
loading = false,
icon,
disabled,
children,
className,
type = "button",
...rest
}: ButtonProps) {
return (
<button
type={type}
className={[styles.button, styles[variant], styles[size], className]
.filter(Boolean)
.join(" ")}
disabled={disabled || loading}
aria-busy={loading || undefined}
{...rest}
>
{loading ? <span className={styles.spinner} aria-hidden="true" /> : icon}
{children}
</button>
);
}A few decisions in there are worth calling out:
typedefaults to"button". A<button>inside a form defaults to submitting it, which causes surprising bugs when a plain action button ends up in a form.- Loading disables the button and shows a spinner in place of the icon while keeping the label visible, so people still know which action is in progress.
- Variants are a closed union. If a new need appears, it becomes a new variant through review, not a one-off override.
classNamestill passes through for layout (margins, grid placement), but not for colour. You enforce that with lint rules below.
The styles reference only tokens:
/* button.module.css */
.button {
display: inline-flex;
align-items: center;
gap: var(--space-2);
border-radius: var(--radius-md);
font-weight: 500;
border: 1px solid transparent;
}
.button:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
.sm { height: 32px; padding-inline: var(--space-3); font-size: var(--text-sm); }
.md { height: 40px; padding-inline: var(--space-4); font-size: var(--text-sm); }
.lg { height: 48px; padding-inline: var(--space-6); font-size: var(--text-md); }
.primary { background: var(--color-accent); color: var(--color-on-accent); }
.primary:hover:not(:disabled) { background: var(--color-accent-hover); }
.secondary { background: var(--color-surface); color: var(--color-text); border-color: var(--color-border); }
.secondary:hover:not(:disabled) { background: var(--color-surface-hover); }
.ghost { background: transparent; color: var(--color-text); }
.ghost:hover:not(:disabled) { background: var(--color-surface-hover); }
.danger { background: var(--color-danger); color: var(--color-on-accent); }
.danger:hover:not(:disabled) { background: var(--color-danger-hover); }
.button:disabled { opacity: 0.5; cursor: not-allowed; }:focus-visible shows the focus ring for keyboard users without drawing it on every mouse click, which removes the usual excuse for outline: none.
Icon-only buttons and links
Two cases cause most of the "I had to write my own" duplicates, so handle them explicitly.
An icon-only button needs an accessible name. Make it a separate component whose types require a label, so it cannot be forgotten:
type IconButtonProps = Omit<ButtonProps, "children" | "icon"> & {
icon: ReactNode;
label: string;
};
export function IconButton({ icon, label, ...rest }: IconButtonProps) {
return <Button {...rest} icon={icon} aria-label={label} />;
}A link that looks like a button should still be an <a>, because it navigates. Export the class names (or a helper that builds them) so your router's link component can share the exact same styles, rather than wrapping a <button> in a link.
Step 4: Migrate without a big bang
With the component in place, move call sites over in small batches.
- Mark the old components as deprecated. A JSDoc
@deprecatedtag onPrimaryButtonand friends makes editors flag every usage, which nudges people during normal work. - Turn old components into thin wrappers. Re-implement
PrimaryButtonas(props) => <Button variant="primary" {...props} />. Every existing usage instantly looks right, and you can remove the wrappers later. - Replace raw
<button>elements by area, one feature or route per pull request, so visual review stays manageable. - Replace hard-coded colours as you go, using the token names from step 2.
Each pull request should come with before and after screenshots of the affected screens. Small visual differences are the point; they just need to be the ones you intended.
Step 5: Make the wrong thing hard
Without guard rails, new duplicates appear within weeks. Lint rules are the cheapest guard rail.
ESLint's no-restricted-syntax lets you ban specific syntax using AST selectors, with a custom message for each:
// eslint.config.js (excerpt)
export default [
{
files: ["src/**/*.tsx"],
ignores: ["src/components/ui/**"],
rules: {
"no-restricted-syntax": [
"error",
{
selector: "JSXOpeningElement[name.name='button']",
message: "Use <Button> or <IconButton> from components/ui.",
},
{
selector: "Literal[value=/^#[0-9a-fA-F]{3,8}$/]",
message: "Use a colour token instead of a hex value.",
},
],
},
},
];For stylesheets, Stylelint's color-no-hex rule disallows hex colours outside your token file. Add a no-restricted-imports rule for the deprecated components once most usages are gone.
Lint rules catch syntax, but not everything that makes a UI inconsistent: off-scale spacing, a new component that re-implements an existing one, missing loading or error states. M1rror's pull request design checks look for those against your approved design system, on added lines only, after a static scan of your repository that reads files without running them.
Apply the same pattern to everything else
Buttons are the obvious starting point because they appear everywhere, but the same steps work for every recurring component. Inputs, selects and textareas should share the button's control heights and radius so that a form row lines up. Cards should share one padding value and one elevation. Badges and status pills should pair colour with an icon and a label, so that state is never communicated by colour alone. Modals should share one width scale, one header layout and one footer button order.
Work through them in order of how often they appear on screen. For each one, inventory the variants, agree on a closed API, rebuild it on tokens, migrate through wrappers and add a lint rule. After two or three components the process becomes routine, and new team members start reaching for the shared kit by default because it is simply the easiest option.
Step 6: Keep designers in the loop
Consistency is easier to defend when the people who care about the visual result have agreed to it. A simple gallery route that renders every variant, size and state of your core components gives designers and clients something concrete to review. When they approve it, that page becomes the reference, and "should this be a new variant?" becomes a quick conversation instead of a silent fork. If reviewers do not have accounts in your tools, a guest review link is one way to collect their comments and approval.
Wrap-up
Inconsistent buttons and colours in React are a symptom of a shared component that did not cover real needs and colours that were never named. Measure the duplication, name your colours as tokens, build one Button with a closed set of variants and sizes, handle icon-only and link cases explicitly, migrate through deprecation and thin wrappers, and add lint rules so the wrong thing is harder than the right thing. Then apply the same process to inputs, cards and modals. For the bigger picture, see how to turn your existing CSS into a design system.