Design-System Components
On this page
Introduction#
Magic Starter ships a complete atomic design-system component library under lib/src/ui/components/. Every component is driven by a WindRecipe that resolves colors through the MagicStarterTokens semantic alias layer, so a single brand configuration flows into all built-in screens and every component your host app composes directly.
All 46 components are exported from package:magic_starter/magic_starter.dart.
Semantic Token Layer#
MagicStarterTokens#
MagicStarterTokens is a single const class with one public member: defaultAliases. It is a Map<String, String> of 17 semantic roles to light+dark Wind className pairs following the bg-/text-/border-color- prefix convention:
final aliases = MagicStarterTokens.defaultAliases;
// {
// 'surface': 'bg-white dark:bg-gray-950',
// 'surface-container': 'bg-gray-50 dark:bg-gray-900',
// 'surface-container-high': 'bg-gray-100 dark:bg-gray-800',
// 'fg': 'text-gray-900 dark:text-gray-50',
// 'fg-muted': 'text-gray-500 dark:text-gray-400',
// 'fg-disabled': 'text-gray-300 dark:text-gray-600',
// 'primary': 'bg-primary-600 dark:bg-primary-500',
// 'on-primary': 'text-white dark:text-white',
// 'primary-container': 'bg-primary-50 dark:bg-primary-950',
// 'accent': 'bg-accent-600 dark:bg-accent-500',
// 'border': 'border-gray-200 dark:border-gray-800',
// 'border-subtle': 'border-gray-100 dark:border-gray-900',
// 'destructive': 'bg-red-600 dark:bg-red-500',
// 'on-destructive': 'text-white dark:text-white',
// 'destructive-container': 'bg-red-50 dark:bg-red-950',
// 'success': 'bg-green-600 dark:bg-green-500',
// 'warning': 'bg-amber-500 dark:bg-amber-400',
// }
Wiring into WindThemeData#
Pass MagicStarterTokens.defaultAliases as the aliases argument when constructing your WindThemeData:
WindApp(
theme: WindThemeData(
aliases: MagicStarterTokens.defaultAliases,
),
child: const MyApp(),
)
Components that reference a semantic role (e.g. bg-surface, text-fg, bg-primary) will resolve through this map at render time. Without the aliases wired in, the semantic class names are silently skipped by Wind, so your brand color and surface tokens will not apply.
Token Reference#
| Role | Semantic meaning | Maps to (default) |
|---|---|---|
surface |
Page/card background | bg-white dark:bg-gray-950 |
surface-container |
Subtle recessed area | bg-gray-50 dark:bg-gray-900 |
surface-container-high |
Elevated container | bg-gray-100 dark:bg-gray-800 |
fg |
Primary text | text-gray-900 dark:text-gray-50 |
fg-muted |
Secondary/supporting text | text-gray-500 dark:text-gray-400 |
fg-disabled |
Disabled state text | text-gray-300 dark:text-gray-600 |
primary |
Brand primary fill | bg-primary-600 dark:bg-primary-500 |
on-primary |
Text on primary fill | text-white dark:text-white |
primary-container |
Tinted primary area | bg-primary-50 dark:bg-primary-950 |
accent |
Secondary brand fill | bg-accent-600 dark:bg-accent-500 |
border |
Standard border | border-gray-200 dark:border-gray-800 |
border-subtle |
Faint separator | border-gray-100 dark:border-gray-900 |
destructive |
Danger / delete fill | bg-red-600 dark:bg-red-500 |
on-destructive |
Text on danger fill | text-white dark:text-white |
destructive-container |
Tinted danger area | bg-red-50 dark:bg-red-950 |
success |
Positive / confirmed | bg-green-600 dark:bg-green-500 |
warning |
Caution / review | bg-amber-500 dark:bg-amber-400 |
bg-ai-soft |
Soft container for AI-authored content (replies, summaries) | bg-violet-100 dark:bg-violet-900 |
text-ai-soft-foreground |
Text or icon on top of bg-ai-soft |
text-violet-700 dark:text-violet-200 |
border-ai |
Accent edge of AI-authored content | border-violet-500 dark:border-violet-400 |
bg-warning-soft |
Soft amber container for a quiet status pill | bg-amber-100 dark:bg-amber-900 |
text-warning-soft-foreground |
Text or icon on top of bg-warning-soft |
text-amber-800 dark:text-amber-200 |
bg-success-soft |
Soft green container for a quiet status pill | bg-green-100 dark:bg-green-900 |
text-success-soft-foreground |
Text or icon on top of bg-success-soft |
text-green-800 dark:text-green-200 |
These keys are written as the full class they replace (bg-ai-soft, border-ai), not as a bare role. The support screens use the AI pair and border-ai for the assistant's avatar, its bubble (a 2px left edge) and its chip, and the soft warning and success pairs for the "Team answering" and "Resolved" pills. A host theme that defines the same keys supplies its own map and wins.
To override individual roles, spread defaultAliases and replace specific keys:
WindThemeData(
aliases: {
...MagicStarterTokens.defaultAliases,
'primary': 'bg-indigo-600 dark:bg-indigo-500',
'on-primary': 'text-white dark:text-white',
},
)
Atomic Folder Shape#
Every component lives in a 4-file atomic folder:
lib/src/ui/components/<name>/
<name>.dart # The widget class (StatelessWidget or StatefulWidget)
<name>.recipe.dart # WindRecipe function: resolves className from theme tokens
<name>.preview.dart # Standalone preview widget for interactive inspection
index.dart # Barrel: re-exports the public API for this component
The .recipe.dart file contains a top-level function (e.g. buildButtonRecipe, buildInputRecipe, cardRecipe) that accepts variant/state arguments and returns a Wind className string. The widget delegates its className derivation to the recipe so the visual contract is inspectable and testable in isolation.
Component Families#
Form Controls#
| Component | Enums / Helpers | Notes |
|---|---|---|
MSButton |
ButtonIntent (primary/secondary/ghost/destructive), ButtonSize (sm/md/lg), buildButtonRecipe |
Use ButtonIntent.destructive for delete / danger actions; fullWidth: true fills the parent width (wraps in SizedBox, defaults false). Shape, the three sizes and the secondary border come from MagicStarterButtonTheme (MagicStarter.useButtonTheme). Below lg, every size keeps its painted size but takes a 44x44 hit area |
MSInput |
InputState (idle/focused/error/disabled), buildInputRecipe |
Pairs with MSFormField for label + error display; fullWidth: true fills the parent width (defaults false). A single-line field is 44px tall below lg and 40px from lg up (py-3 lg:py-2.5 over a leading-5 line); the padding and type of the text, placeholder and value come from MagicStarterFieldTheme.inputSizeClassName (MagicStarter.useFieldTheme) |
MSTextarea |
TextareaState, textareaRecipe |
Multi-line input, same state model as MSInput; fullWidth: true fills the parent width (defaults false). On iOS a focused field carries a Done toolbar above the keyboard, since a multiline field's Return key inserts a newline and would otherwise leave the keyboard with no way out. One consequence for consumer TESTS: while that toolbar is up it schedules a frame callback every frame, so pumpAndSettle under debugDefaultTargetPlatformOverride = TargetPlatform.iOS never settles; pump explicitly instead |
MSCheckbox |
Boolean toggle; Wind-only, no Material dependency | |
MSSwitch |
Toggle; replaces Flutter's MSSwitch in Wind layouts. An optional label renders beside the track and is part of the tap target; below lg the hit area is at least 44 px tall. Shows a focus ring on keyboard focus |
|
MSRadio |
Single-select option | |
MSSelect |
selectRecipe |
Dropdown single-select backed by an item list; the trigger is the same height as MSInput (44px below lg, 40px from lg up), and its padding, minimum height and type come from MagicStarterFieldTheme.selectSizeClassName |
MSCombobox |
comboboxRecipe |
Searchable single-select with filter input; the trigger is the same height as MSInput (44px below lg, 40px from lg up) and shares MagicStarterFieldTheme.selectSizeClassName with MSSelect. The search field's placeholder defaults to trans('common.search_placeholder') |
MSSwitchRow |
switchRowRecipe |
An MSSwitch with its label beside it; the label doubles as the switch's accessibility name |
MSKeyValueEditor |
MSKeyValueRow, keyValueEditorRecipe |
Controlled editor for an ordered list of key/value pairs (e.g. HTTP headers); every mutation emits a fresh list |
MSStringValueList |
StringValueListTone, stringValueListRecipe |
Controlled editor for a short list of distinct strings rendered as chips; commits on Enter/IME-done, trims and de-duplicates |
Display and Feedback#
| Component | Enums / Helpers | Notes |
|---|---|---|
MSBadge |
BadgeTone (neutral/primary/success/warning/destructive) |
Inline label chip |
MSTypography |
TypographyVariant (h1-h6/body/caption/overline) |
Semantic text wrapper |
MSSkeleton |
SkeletonShape (line/rect/circle) |
Placeholder loading block |
MSToast |
ToastVariant (info/success/warning/error) |
Transient feedback banner (the message is text-sm font-medium; messageClassName is appended after it, so text-[15px] leading-5 font-normal sets another body type, with or without an action); presents nothing itself, so the host shows it (through its own OverlayEntry) and decides how long it stays. Magic.toast(String) cannot carry an action and its overlay ignores pointers, so an action needs the host's own overlay. actionLabel and onAction (both or neither) add a trailing text action such as Undo: a cell at least 44 px tall and wide at every width (it grows with the label at a large text size), named by actionLabel for assistive technology, with a ring inside the toast on keyboard focus. A long message wraps beside it and the action keeps its size; a one line toast is as tall with an action as without |
MSTooltip |
Hover/long-press hint bubble | |
MSEmptyState |
Illustrated empty-list placeholder | |
MSErrorState |
Full-screen or inline error with retry | |
MSDataTable |
MSDataColumn, dataTable*ClassName |
Header plus rows, with the columns passed in. Two modes: the default renders rows eagerly (right for a short, complete list); MSDataTable.paginated hands the body to magic's MagicPaginatedListView inside a bounded box, so a long collection costs the viewport rather than the result and reaching the tail asks the paginator for its next page. The header stays outside the scrolling body. bodyHeight is what bounds it, since a ListView needs a bound and shrinkWrap: true would build every row anyway. Column labels and loadingLabel are ALREADY TRANSLATED strings, not keys: half the callers render a label that is not a key at all (a currency code, a region name) |
Selection and Navigation#
| Component | Enums / Helpers | Notes |
|---|---|---|
MSSegmentedControl |
SegmentedControlSize, segmentedControlRecipe |
Inline tab switcher; below lg each segment is at least 44 px tall to tap, the painted pill unchanged. The pill's padding and type per size and the root's padding come from MagicStarterFieldTheme (segmentedSmClassName, segmentedMdClassName, segmentedPaddingClassName); the hit cell is not themed. In a horizontal scroller (the overflow-x-auto root override, or your own scroll view that holds nothing but the control) it scrolls the selected segment into view on mount and when selectedIndex changes; a page view, a vertical list or a strip with padding or other content around the control is left alone, and the page's vertical scroll never moves |
MSTabs |
tabsRecipe |
Full tab bar with content panels |
MSAccordion |
MSAccordionItem, accordionRecipe |
Collapsible section list |
MSNavbar |
Horizontal top navigation bar | |
MSDropdownMenu |
MSDropdownMenuItem |
Contextual action menu |
Overlay#
| Component | Notes |
|---|---|
MSDialog |
Modal dialog shell; reads MagicStarterModalTheme tokens. scrollable: false hands a body that scrolls itself the bounded height, as on MSBottomSheet. With the modal theme's presentation: MagicStarterModalPresentation.sheet, MSDialog.show opens the same content as a bottom sheet, and the widget draws the sheet panel inside a bottom sheet route |
MSBottomSheet |
Slide-up sheet; reads MagicStarterModalTheme tokens. MSBottomSheet.show(..., dialogBreakpoint: 'lg') presents an MSDialog from that Wind breakpoint up, with the same title, description, body, footer and scrollable, so one call serves a phone and a desktop; the width is read when the call is made. Null (the default) always presents a sheet, and so does the sheet presentation, which ignores the breakpoint; the adaptive presentation ignores it too and swaps at MagicStarterModalTheme.dialogBreakpoint for every call. Built in a dialog route (an overlay route under adaptive), the widget draws the dialog shell. canDismiss (a ValueListenable<bool>, read at the gesture) holds a sheet with unsaved input open: while false a drag down is off (the handle stays drawn), and a scrim tap or its semantics dismiss, Back and Escape leave it open and call onDismissBlocked(sheetContext), where the app asks the person and, on a confirmed discard, closes it with Navigator.of(sheetContext).pop() (a pop skips the guard; the opener's context would pop the wrong navigator, since the sheet sits on the root one); the dialog from dialogBreakpoint is held the same way |
Composition and App Chrome#
| Component | Notes |
|---|---|
MSFormField |
Label + input + hint + error layout wrapper |
MSCard |
Surface/inset/elevated variants |
MSPageHeader |
Full-width responsive header (title, subtitle, leading, actions). Stacked below sm, a row above it; inlineActions (or MagicStarterPageHeaderTheme.inlineActions) makes it a row at every width AND lets a long title shrink instead of overflowing. size picks MSPageHeaderSize.regular, .large (the theme's largeTitleClassName, display size on a phone and one step smaller from lg) or .compact (a 44 pt bar below the top safe area: leading control, centred one-line title, actions, over a translucent fill and a backdrop blur that turn opaque under high contrast). Null follows MagicStarterPageHeaderTheme.largeTitle. className is appended to the container after the theme's container class, for a page under a bar of its own (pt-0 or lg:pt-0 takes the header's top padding off); it styles the regular and the large header, and the compact bar ignores it. Under MagicStarter.useHostPageChrome, where the predicate answers true, it draws only its actions and reports its title, subtitle and back control to MagicStarter.pageChrome (host-owned page chrome) |
MSSocialDivider |
"Or continue with" separator for auth forms |
MSUserProfileDropdown |
Avatar menu with profile links, theme toggle and logout |
MSTeamSelector |
Current-team switcher; requires a registered team resolver |
MSPageContainer |
Shared page geometry: width cap, edge margins, vertical rhythm, horizontal safe area. Reads MagicStarter.manager.pageContainerClassName |
MSPageScaffold |
Full page treatment: page surface + own scroll + MSPageContainer + MSPageHeader + gap-6 sections column. footer docks a widget below the scroll area, outside it, in the same width and edge margins; it clears the bottom safe area and rides above the on-screen keyboard (a chat composer is the use). A docked layout needs a bounded height, as the app shell provides. scrollController hands the scroll area to the page, to keep a thread on its newest message. containerClassName is appended to the page container after the host's geometry, for a page under a bar of its own (pt-4 replaces the host's top padding). headerClassName is handed to the regular and the large header the same way (lg:pt-0 takes the header's own top padding off from lg up); the compact bar is built without it. largeTitle: true (or MagicStarterPageHeaderTheme.largeTitle, which also reaches the starter's own pages) renders a large header that collapses into the compact bar pinned over the page once it has scrolled under it, fading in 200 ms (a swap under Reduce Motion). Under host-owned page chrome it builds neither the large title nor the bar |
MSMasterDetail |
A list and its selected item side by side from breakpoint (lg by default): master in a fixed w-96 column, detail (or placeholder while it is null) in the rest. Below the breakpoint it renders detail ?? master, which is push navigation when the selection lives in the route. Owns no page geometry or scroll, so it goes inside MSPageScaffold; MSMasterDetail.isSplit(context) gives a page the same answer for its header |
MSFormActions |
Cancel/submit footer row for a form; cancelLabel/onCancel are optional (omitting both renders no cancel button) |
MSHeaderAction |
Page-header action that collapses to an icon-only glyph below lg and a labelled MSButton at lg and up; label becomes the icon form's accessibility label |
Import Collisions#
Every component carries an MS prefix (MSButton, MSDialog, MSSwitch, ...),
so none of the barrel's component exports collide with
package:material_ui/material_ui.dart. You can import both without a hide clause:
import 'package:material_ui/material_ui.dart';
import 'package:magic_starter/magic_starter.dart';
// No ambiguity: MSSwitch is the component, Switch is Material's.
Earlier releases exported unprefixed names (
Switch,Dialog, ...) and required ahideclause. See the migration table in the package README to update from the pre-MSnames.
Or hide the specific colliding names:
import 'package:material_ui/material_ui.dart' hide MSSwitch, MSDialog, MSCheckbox;
import 'package:magic_starter/magic_starter.dart';
Widget tests that need both Material and magic_starter types must do the same.
design.md.stub#
assets/stubs/design.md.stub is a DESIGN.md template that covers all 17 semantic roles, typography on the 4px logical scale, rounded/spacing scales, and key component entries with {{ placeholder }} tokens.
Consumers copy it into their project root:
cp $(flutter pub cache list magic_starter)/assets/stubs/design.md.stub DESIGN.md
Then fill in brand hex values and fonts, and run the CLI commands:
# Validate your DESIGN.md against the schema
dart run magic:dowel design:lint
# Generate the Wind theme from your DESIGN.md
dart run magic:dowel design:sync
design:sync regenerates the WindThemeData(aliases: ...) map, replacing MagicStarterTokens.defaultAliases with your brand-specific values so every component reflects your brand automatically.