Why Hardcoding Colors Is Technical Debt
Welcome to Part 1 of the Flutter Theming Foundations series. This is the part that sells the rest: before we touch ThemeData, ColorScheme, or dark mode, you need to feel the problem that a theme system solves. If you've ever shipped a Flutter app where the brand color is typed out forty times, this one's for you.
The one-line pitch: a theme is a single source of truth for how your app looks. Hardcoded values are forty sources of truth that disagree with each other.
The scene of the crime
Here's code that works, ships, and demos perfectly:
Container(
color: Color(0xFF6750A4), // brand purple
child: Text(
'Checkout',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
)
Nothing is wrong here. The button looks fine. The problem is what happens next — across a real app with 60 screens, this exact pattern gets copy-pasted hundreds of times. Each copy is an independent decision frozen in place. And decisions that should be made once are now made everywhere.
Analogy — the spice rack vs. the recipe card. Hardcoding colors is like writing "add 4g of salt" inside every step of a 200-step recipe. A theme is writing "add a pinch" and defining a pinch once at the top. When the chef decides a pinch is now 3g, they change one line — not 200.
Technical debt, defined
Technical debt is the future cost you take on by choosing the quick solution now instead of the right one. Like financial debt, it charges interest: every change later costs more than it should. Hardcoded styling is one of the purest forms of UI debt because the interest is so visible.
Let's price the interest with concrete scenarios.
Scenario 1 — "Make the brand purple a bit darker"
Design tweaks the brand color by one shade. With hardcoding:
# Find every place the old purple appears…
grep -rn "0xFF6750A4" lib/
# 73 matches across 41 files. Now edit each — and pray you find them all.
You'll miss some. A few are written as Color(0xff6750a4) (lowercase), one as Color.fromARGB(255, 103, 80, 164), and two screens used Colors.deepPurple "because it looked close." The redesign ships half-applied, and QA finds the stragglers weeks later.
With a theme, the same change is one line:
// One constant feeds the whole app.
const seed = Color(0xFF5A3E96); // was 0xFF6750A4
Scenario 2 — "We need dark mode"
This is the scenario that ends the hardcoding debate. A Text with color: Colors.black is invisible on a dark background. With hardcoded colors, dark mode isn't a feature you add — it's an audit of every widget in the app, because each one has baked in an assumption ("the background is white") that is now false.
With a theme, dark mode is two pre-built color sets and a single toggle (we build exactly this in Part 5). Your widgets don't change at all — they were already asking the theme "what's the text color here?" instead of asserting "it's black."
Scenario 3 — "The client wants their own branding"
A whitelabel deal lands: same app, three different brands. Hardcoded, that's three forks of the codebase that drift apart forever. Themed, it's three ThemeData objects and a config flag.
Remember: every hardcoded color is a tiny bet that this value will never change. Across a real app you make thousands of those bets. You will lose enough of them to hurt.
What "a theme" actually means
A theme flips the relationship between a widget and its styling. Instead of the widget declaring its color, it asks for one:
// ❌ Hardcoded — the widget asserts an absolute value.
Text('Checkout', style: TextStyle(color: Color(0xFF6750A4)));
// ✅ Themed — the widget asks the theme for the right value *here*.
Text('Checkout', style: TextStyle(color: Theme.of(context).colorScheme.primary));
Theme.of(context) walks up the widget tree and returns the nearest ThemeData — the central style object every Material app carries. We'll dissect it fully in Part 2. For now the mental model is enough:
Analogy — the spreadsheet cell. A hardcoded color is typing
103.99into a cell. A themed color is typing=Prices!B2— a reference. Change the one source cell and every formula that points at it updates. (If you've read the Riverpod series, this "cells reference other cells" model is exactly how providers think, too.)
The widget asking "what's colorScheme.primary here?" gets a context-sensitive answer: purple in light mode, a lighter purple in dark mode, the client's teal in the whitelabel build. The widget never has to know.
The hidden costs beyond color changes
Even if your colors never changed, hardcoding still bleeds you in less obvious ways.
| Hidden cost | What hardcoding does | What a theme does |
| --- | --- | --- |
| Consistency | 6 "almost-grey" greys that no one chose on purpose | One colorScheme.outline, used everywhere |
| Accessibility | Contrast ratios are accidental; some fail WCAG | Seed-based schemes are generated to meet contrast (Part 3) |
| Onboarding | New devs guess which purple to use | One named token tells them |
| Design handoff | Design speaks in tokens; code speaks in hex | Both speak the same language |
| Reviews | "Is #6750A4 the right purple?" in every PR | The token is the answer |
That second row matters more than it looks. When everyone reaches for Colors.grey and friends, you don't get one grey — you get grey, grey[600], Colors.black54, and 0xFF9E9E9E, all on the same screen, none of them intentional. A theme makes the intentional choice the easy choice.
"But it's just a small app"
The two most expensive words in software are "just temporarily." Apps grow; the throwaway prototype becomes the production app because it works. The cost of adding a theme later scales with the app's size — the cost of starting with one is basically zero. A fresh flutter create already gives you a ThemeData; you're not adding infrastructure, you're choosing to use the infrastructure that's already there.
// flutter create already wrote this. The theme exists. Use it.
MaterialApp(
theme: ThemeData(colorSchemeSeed: Colors.deepPurple),
home: const HomePage(),
)
Rule of thumb: the right moment to adopt a theme is the first time you type a color. The second-best moment is now.
What this series will build
By the end of these six parts you'll go from scattered hex codes to a coherent design foundation:
- Part 2 —
ThemeDatadeep dive: the central style object and the properties you actually use. - Part 3 —
ColorScheme& Material You: seed colors, the 30-ish color roles, and why you stop hand-picking hex. - Part 4 —
TextTheme& typography: a type scale your whole app shares. - Part 5 — Light & dark mode: two schemes, one toggle, done right.
- Part 6 — 100-question mastery bank: test yourself with hints and solutions.
Then the sibling series, Mastering Riverpod: Theming, makes that theme switchable, persistent, and reactive.
We're teaching Flutter with Material 3 (the default since Flutter 3.16, and the default you get from
flutter createin 2026). Everything here assumesuseMaterial3is on — which it is, unless you turned it off.
Practice Challenges
Challenge 1 — Spot the debt. A teammate writes AppBar(backgroundColor: Color(0xFF6750A4)) on every screen. Name two future changes that this makes painful.
Show solution
Any change to the brand color (you must find and edit every screen), and dark mode (a hardcoded light-mode color won't adapt). A third: a whitelabel/rebrand needs the value changed in dozens of places instead of one.
Challenge 2 — Translate to a reference. Rewrite Text('Hi', style: TextStyle(color: Colors.black)) so it asks the theme instead of asserting black.
Show solution
Text('Hi', style: TextStyle(color: Theme.of(context).colorScheme.onSurface));
onSurface is "the color of text/icons drawn on a surface" — black-ish in light mode, white-ish in dark mode. The widget no longer assumes a light background. (More on these roles in Part 3.)
Challenge 3 — Count the greys. Open any screen you've built and list every grey-ish color literal you find. How many distinct values? Did you choose each one on purpose?
Show solution
Most real screens turn up 3–6 "greys" no one deliberately chose (Colors.grey, grey[700], black54, a stray hex). That spread is exactly the consistency debt a single themed token (colorScheme.outline, onSurfaceVariant) eliminates.
Challenge 4 — Price the change. Estimate the cost (in files touched) of "darken the brand color by one shade" for (a) a hardcoded app and (b) a themed app.
Show solution
(a) Hardcoded: every file that typed the color — easily 10–50 files, plus the risk of missing variants written differently. (b) Themed: one file (the seed/ColorScheme definition). The ratio is the "interest rate" on the debt.
Challenge 5 — The prototype trap. Argue against "it's just a prototype, I'll theme it later."
Show solution
Prototypes that work become production apps. flutter create already ships a ThemeData, so the infrastructure cost of starting themed is ~zero, while the cost of retrofitting a theme grows with every screen you add. Cheapest moment to adopt a theme is the first color you type.
Questions to test yourself
Q1 (basic). In one sentence, what is the core idea of a theme system?
Show answer
A single source of truth for an app's visual style, which widgets reference (ask for) rather than hardcode — so a style change happens in one place and propagates everywhere.
Q2 (basic). What does Theme.of(context) return?
Show answer
The nearest ThemeData in the widget tree (found by walking up from context). It's the central object holding the app's colors, text styles, and component defaults — covered in Part 2.
Q3 (intermediate). Why is dark mode the scenario that most clearly exposes hardcoded colors?
Show answer
Every hardcoded color bakes in an assumption about the background (e.g. "text is black because the background is white"). Dark mode falsifies that assumption everywhere at once, so adding it becomes an audit of every widget — unless colors were themed, in which case the widgets already ask for the context-appropriate value.
Q4 (intermediate). Give two costs of hardcoding that have nothing to do with a color changing.
Show answer
Consistency (you accumulate several near-identical greys no one chose on purpose) and accessibility (contrast ratios become accidental rather than guaranteed). Also valid: slower onboarding and a code/design language mismatch.
Q5 (intermediate). Why is "I'll add a theme later" usually a bad trade?
Show answer
The retrofit cost grows with the app's size (more screens = more literals to migrate), while starting themed costs almost nothing because flutter create already provides a ThemeData. You're not adding infrastructure, just choosing to use it.
Q6 (advanced). Hardcoding can look DRY if you define const brandPurple = Color(0xFF6750A4) and reuse it. Why is that still inferior to a theme?
Show answer
A shared constant fixes the duplication problem but not the context problem: brandPurple is still one absolute value, so it can't be purple in light mode and a lighter purple in dark mode, can't vary per whitelabel brand, and carries no semantic meaning (is it the button color? the link color?). A theme provides values that are context-sensitive (light/dark, brand) and role-named (primary, surface, onSurface) — see Part 3.
Wrapping up
- Hardcoded styling is technical debt that charges interest on every future change.
- A theme inverts control: widgets ask for a color (
Theme.of(context)...) instead of asserting one. - The debt shows up as painful rebrands, an impossible dark mode, scattered inconsistency, and accidental accessibility failures.
- The infrastructure is already there (
flutter createships aThemeData) — adopting a theme costs ~nothing now and a lot later.
In Part 2 we open up the central object itself: ThemeData — every property you actually need to know.