| name | flutter-ui-theming |
| description | Guidelines for building Flutter/Dart UI, theming, and reusable components. Use this skill whenever working on Flutter UI — creating or styling widgets, components, screens, buttons, pills, badges, inputs, navigation bars, drawers, dialogs; setting up or editing colors, ColorScheme, ThemeData, ThemeExtension, TextTheme, fonts, gradients, shadows; or any task that produces or modifies Flutter visual code. Apply it even when the user just says "build a screen", "make a button", "style this", or "add a component", not only when they mention theming explicitly. It enforces a strict palette/theme architecture, prefers native Material widgets over hand-built ones, and prevents unnecessary widget nesting. |
Flutter UI & Theming Guidelines
These rules keep the UI layer consistent, themeable, and free of the two most common failure modes: hardcoded values scattered across widgets, and reinventing interaction logic that Material already provides. Follow them for every Flutter UI task.
When something is genuinely ambiguous (which Material widget fits, whether a color is a role or a specific tone, whether a component needs a variant or a separate component), ask before assuming. A wrong assumption costs more to unwind than a quick question.
1. Color & the palette
There is a single source of truth for raw colors and a strict separation between the raw palette, semantic roles, and component styles.
The three layers
- Palette — raw hex values. A class of
static const Color only. No logic.
- ColorScheme — Material's semantic roles (
primary, onPrimary, surface, onSurface, outline, error, etc.). Fed from the Palette when the theme is built.
- ThemeExtensions — component- or domain-specific styles (e.g.
AppButtonStyle, PillStyle, BottomNavStyle) that hold tones, paddings, radii, shadows, and text styles that don't map cleanly to a ColorScheme role.
Hard rules
- Hex literals (
Color(0xFF...)) live ONLY in the Palette class. Never in widgets, never in screens, never inline. The one exception is the theme-construction layer assigning palette tones — but even there, reference Palette.xxx, don't paste raw hex.
- Widgets read only from the theme. Inside a widget's
build, colors come from Theme.of(context).colorScheme, Theme.of(context).extension<T>(), or Theme.of(context).textTheme. A widget must never reference Palette directly.
- The theme-construction layer (ThemeData setup + the
fromColorScheme/fromTheme factories of extensions) MAY reference Palette directly — that's its job: translating raw tones into roles and component styles.
Roles vs. specific tones
A token's name in the palette (e.g. primary700, background400) is just a label. It does not dictate which ColorScheme role it fills. The mapping is a deliberate decision made when building the theme.
Decide where each color goes with this test: "Would this color look correct in every place Material uses that role?"
- Yes → put it in the ColorScheme. It's a genuine role. It also adapts to light/dark for free.
- No, it only looks right in one specific component → put it in that component's ThemeExtension. Forcing a decorative or component-specific tone into a ColorScheme slot (e.g.
tertiary, secondary) risks Material widgets picking it up and rendering strangely.
Prefer cs.<role> over Palette.<tone> whenever the color genuinely has a role — it stays consistent if the role changes and adapts to light/dark automatically. Use Palette.<tone> for tones that have no role.
Opacity
- A fixed semi-transparent color → bake the alpha into the hex in the Palette (
0xAARRGGBB, where AA is alpha; e.g. 33 = 20%).
- A color that needs to vary in opacity by state → expose an opacity field on the component's style with a default, and apply with
.withValues(alpha: ...) (not the deprecated .withOpacity).
2. ThemeExtensions for component styles
When a component has styling that isn't covered by ColorScheme roles, give it a ThemeExtension.
What goes in a component style
Everything visual and shared across instances: colors for each state (active/inactive), borderRadius, borderWidth, padding, TextStyle, icon sizes, gaps, shadows, dimensions.
What does NOT go in a component style
Per-instance data and behavior: the label/text, the icon choice, onTap/callbacks, the isActive logical state, and external margins (those are the parent's layout decision).
Construction pattern
The extension has a plain const constructor that receives already-resolved values. A separate factory (fromColorScheme(ColorScheme cs), or fromTheme(ColorScheme cs, TextTheme text) when typography is needed) is just a convenience that pulls values from the theme/palette and calls that constructor.
- If a factory receives
ColorScheme but uses none of it, drop the parameter — use a plain const value or a no-arg factory instead. Receiving an unused parameter is a smell.
- Pull text styles from the
TextTheme rather than redefining TextStyles, so typography has one source of truth.
copyWith and lerp are required by ThemeExtension. Implement copyWith properly. lerp can return this (or other ?? this) while the app is light-only; implement it fully only when animated light/dark transitions matter.
Variants
For a fixed, known set of variants (e.g. button primary/secondary/tertiary/outline), prefer an enum plus a resolver in the style, OR named factory variants the widget receives as a parameter. Group per-variant colors in a small value class (e.g. ButtonColors { background, foreground }). Keep shared properties (radius, padding, text style) at the top level of the style, not duplicated per variant.
For arbitrary/data-driven colors (e.g. a category color from a backend), accept an optional override parameter on the widget that falls back to the theme default. Don't expose a dozen loose color/size parameters "for flexibility" — that turns a themed component back into a raw Container.
3. Prefer native Material widgets
Material already solves ripple/press/focus/disabled states, accessibility, keyboard handling, and animations. Reinventing these is wasted effort and usually wrong.
- Before building any non-trivial UI element, state which Material widget it's based on — or, if none fits, why. If the answer is "built from scratch," justify it in a code comment.
- Build custom ONLY when the design genuinely cannot be achieved with the Material widget. A different look that the Material widget can be themed/composed into is not a reason to rebuild.
- Compose (wrap), don't inherit. A custom
TopBar returns/wraps an AppBar; it does not extend AppBar. For a PreferredSizeWidget slot (like Scaffold.appBar), implement PreferredSizeWidget and expose preferredSize.
Interaction & feedback
- Don't hand-roll tap feedback. Buttons (
FilledButton, ElevatedButton, OutlinedButton, TextButton) bring ripple + states. For a custom tappable surface, wrap an InkWell inside a Material.
- For a tappable surface that needs a gradient background (Material buttons only take solid colors), use
Material → Ink (with BoxDecoration.gradient) → InkWell. This keeps the ripple.
- Inputs →
TextField/TextFormField. Dropdowns → DropdownMenu. Don't rebuild focus/keyboard handling.
Red flags of reinvention
If you find yourself writing an AnimationController or a GestureDetector that manages swipes/positions for something Material provides (drawer, bottom sheet, dialog, navigation bar), stop — use the native widget instead.
4. Widget tree hygiene
- Prefer composition over deep nesting. If a
build method nests beyond ~3–4 levels, or a chunk repeats, extract it into its own named StatelessWidget/StatefulWidget.
- Avoid redundant wrappers (e.g.
Container inside Container inside Padding) — collapse them.
- Each extracted widget should have a clear, intention-revealing name so the tree stays readable even to someone not reading every line.
5. Naming
Name UI things by role/hierarchy or intent, not by color or appearance — a name like grayButton lies the moment the color changes.
- Buttons by importance:
primary, secondary, tertiary, outline/ghost (the shadcn convention). Map the design's colored buttons onto these.
- Text/content roles when ColorScheme isn't enough:
textPrimary, textSecondary, textTertiary, textDisabled, textInverse.
- Avoid reusing words that are already Material roles (
background, surface, primary) as raw-palette token names when it causes confusion; prefer color-family or neutral names (canvas, tint, a brand color name) and document the mapping.
6. The on prefix
In ColorScheme, onX is the foreground (text, icons, small decorative elements) designed to sit on top of X with adequate contrast. Always pair them: text colored onPrimary belongs on a primary background, onSurface on surface, etc. Material applies these foregrounds automatically when the scheme is filled in, which is why filling every relevant pair matters — leaving slots at Material defaults can leak unexpected default colors into widgets.
7. Light/dark readiness (even when shipping light-only)
If the app is light-only today, build only the light theme and set themeMode: ThemeMode.light. But keep the door open cheaply:
- Never hardcode hex outside the Palette. This alone makes adding dark a change localized to the theme layer, not a hunt across widgets.
- For colors that clearly have a role, route them through the ColorScheme now (same cost), so they adapt to dark automatically later.
- Accept that component-specific tones living in ThemeExtensions will need an explicit dark variant when dark arrives — this is the correct trade-off for keeping the ColorScheme clean and Material widgets looking right.
When dark is requested later, the work is: add dark tones to the Palette, build a ColorScheme.dark, build dark variants of the extensions (grep Palette. in the theme layer to find what needs a dark value), and pass darkTheme. No widget changes.
8. System UI (status bar)
The status bar (clock/battery/signal) is the OS's, not a widget — but its appearance is controllable per screen:
- Screen with an
AppBar → set systemOverlayStyle on the AppBar.
- Screen without one → wrap in
AnnotatedRegion<SystemUiOverlayStyle>.
- For content rendered behind the status bar →
Scaffold(extendBodyBehindAppBar: true) plus SafeArea where content must not be obscured.
- Always set the icon brightness to match the background (dark icons on light backgrounds, light icons on dark), or the system icons become invisible.
9. Verifying the result — a component gallery
Don't discover theming/layout problems by accident in real screens. Maintain a single debug screen (a "gallery") that renders the core Material widgets plus the project's custom components with the real theme applied: the button variants, TextField, Switch, Checkbox, Slider, Chip/FilterChip, Card, ListTile, both progress indicators, a FloatingActionButton, and the custom components.
After any theme change, view the gallery to confirm nothing renders strangely — this is how you catch a tone forced into the wrong ColorScheme slot. When dark mode exists, add a light/dark toggle to validate both at a glance.