The kit has no hard-coded colors or sizes. Everything components draw comes
from a Token, which is derived from a handful of seed values.
SeedToken → Token.derive() → Token → components
(inputs) (algorithm) (values)
SeedToken is what you set. Every field has a default, so override only
what you care about.
| Field | Default | Purpose |
|---|---|---|
colorPrimary |
#1677FF |
Accent for primary actions and links |
colorSuccess |
#52C41A |
Success status |
colorWarning |
#FAAD14 |
Warning status |
colorError |
#FF4D4F |
Error status and danger actions |
colorInfo |
#1677FF |
Informational status |
colorTextBase |
#000000 |
Base the text, border, divider and fill ramps |
colorBgBase |
#FFFFFF |
Base the surfaces are built from (see below) |
fontFamily |
null |
Font for all kit text; null uses the platform default |
fontSize |
14 |
Base font size; other sizes are relative to it |
borderRadius |
6 |
Base corner radius |
sizeUnit |
4 |
Base spacing unit |
controlHeight |
32 |
Height of a medium control |
lineWidth |
1 |
Border thickness |
By default the kit paints neutral surfaces — white panels on #f5f5f5,
#141414 panels on black — whatever the accent colours are. Name a
colorBgBase of your own and the surfaces follow it instead: panels take that
colour, the page sits a touch deeper, floating layers a touch lighter, and the
tinted accent shades are blended into it so a bg fill matches the page it
lands on.
// A warm, paper-coloured light theme.
ThemeData(
token: const SeedToken(
colorBgBase: Color(0xFFFFFCF6),
colorTextBase: Color(0xFF2A1D18),
),
)A complete worked preset — palette, per-component tokens and bundled type —
lives in the example app at example/lib/theme/new_year_theme.dart.
colorTextBase does more than colour text: every divider, border, hover and
fill is that colour at some alpha. A warm brown ink is what makes a whole theme
read as warm without naming a single divider.
Two rules keep this from surprising anyone:
- The background is only honoured when it is on the same side as the scheme. A
light
colorBgBasewithdark: truewould paint a dark app white, so the classic dark surfaces stand in instead. - A dark theme normally builds its text ramp from white. A
colorTextBasethat is already light is kept, so an off-white ink carries its warmth through.
ConfigProvider(
theme: ThemeData(
token: const SeedToken(
colorPrimary: Color(0xFFEB2F96),
borderRadius: 2,
controlHeight: 36,
),
),
child: const MyApp(),
)Token is what components read. It is produced by Token.derive()
and never constructed by hand.
Two hooks sit either side of the deriving, and they are a pair:
| Hook | When | What it is handed |
|---|---|---|
refineSeed |
before | the seed the palette is about to be generated from |
refineTokens |
after | the tokens that came out of it |
Some values a design states rather than derives — the ink a disabled label is
written in, most often. refineTokens has the last word, after the deriving:
ThemeData(
token: const SeedToken(colorPrimary: brand),
refineTokens: (t) => t.copyWith(colorTextQuaternary: disabledInk),
)It belongs there and not among the seeds, and the reason is worth a moment.
A seed is what a theme is derived from. The disabled ink is derived: a
quarter of the page's own ink, alphaOn(colorTextBase, 0.25) — black at a
quarter in the light, white at a quarter in the dark. One fixed grey cannot
be both, so a theme that named it in the seeds would look right in the light
and, in the dark, print its disabled labels at the same weight as its live
ones.
Which is why refineTokens is handed the derived tokens rather than a bare list of
values: it can read t.isDark and name both in one line.
refineTokens: (t) => t.copyWith(
colorTextQuaternary: t.isDark ? const Color(0xFF4F4F4F) : const Color(0xFFBFBFBF),
),A refinement survives inheritance: a nested provider that flips the brightness re-derives from the seed above it and the refinement is applied again, to the new tokens. A nested one of its own wins where both speak.
Token.copyWith is the same thing at arm's length, for a token set you are
holding — ThemeData.raw(Token.derive(seed).copyWith(...)) builds a theme that
takes its tokens as final and never re-derives.
Each semantic color expands into a ColorGroup of ten shades, so a status
color stays recognisable whether it is a fill, an outline or a label:
| Member | Typical use |
|---|---|
bg, bgHover |
Tinted backgrounds |
border, borderHover |
Outlines |
hover, base, active |
Interactive fills, keyed to pointer state |
text, textHover, textActive |
Colored labels |
onBase |
Ink for words drawn on base |
final token = context.softToken;
Container(color: token.error.bg, child: Text('!', style: TextStyle(color: token.error.text)));onBase is what a solid button's label, a solid tag's words and the tick in a
ticked box are written in. It is worked out from base — black or white,
whichever reads on it — so a yellow or a lime brand gets legible words without
being asked:
ConfigProvider(
theme: ThemeData(token: const SeedToken(colorPrimary: Color(0xFFFFD500))),
child: ..., // solid buttons write themselves in black
)The rule is Flutter's own, the threshold behind
ThemeData.estimateBrightnessForColor, rather than a bare contrast ratio:
contrast alone puts black on the default blue, which is legible arithmetic and
not what anyone draws.
A colour named on the spot gets the same treatment, which is why the rule sits
on the colour group rather than beside colorPrimary — there is no theme slot
a ButtonColor(Color(0xFFFFD500)) could look an ink up in:
Button(
color: const ButtonColor(Color(0xFFFFD500)),
variant: ButtonVariant.solid,
onPressed: _pay,
child: const Text('Pay'), // black, unasked
)Where a brand guide says otherwise, name the ink and the arithmetic stands aside:
ThemeData(
token: const SeedToken(colorPrimary: Color(0xFFFFD500)),
refineTokens: (t) => t.copyWith(primary: t.primary.withInk(const Color(0xFFFFFFFF))),
)inkOn(fill) is exported for a widget of your own that paints on a brand
colour.
Text, borders, surfaces and fills each form a ramp from most to least prominent:
- Text —
colorText,colorTextSecondary,colorTextTertiary,colorTextQuaternary - Borders —
colorBorder,colorBorderSecondary,colorSplit - Surfaces —
colorBgContainer(cards),colorBgElevated(floating layers),colorBgLayout(page),colorBgSpotlight,colorBgMask(scrims) - Fills —
colorFill,colorFillSecondary,colorFillTertiary,colorFillQuaternary
Spacing steps are multiples of sizeUnit:
| Token | Default |
|---|---|
sizeXXS |
4 |
sizeXS |
8 |
sizeSM |
12 |
size |
16 |
sizeMD |
20 |
sizeLG |
24 |
sizeXL |
32 |
Control heights: controlHeightSM (24), controlHeight (32),
controlHeightLG (40).
Radii: borderRadiusXS (2), borderRadiusSM (4), borderRadius (6),
borderRadiusLG (8).
fontSizeSM (12), fontSize (14), fontSizeLG (16), fontSizeXL (20),
plus lineHeight and fontFamily.
Fonts. Both fontFamily and fontFamilyFallback default to null, and on
every platform that is the right choice: the OS UI font — San Francisco on
Apple, Roboto on Android, Segoe UI on Windows — is exactly what a CSS
-apple-system / BlinkMacSystemFont stack resolves to, and it already
covers ordinary text and its own emoji.
Do not port a full CSS font stack here. It relies on -apple-system
winning first, which has no Flutter equivalent: with a null primary Flutter
picks the first named fallback that happens to exist — Helvetica Neue on
Apple — instead of the real system font, shifting every label's metrics. Even
an emoji-only fallback can make the shaper render spaces from the wrong font,
widening the gaps between words. So the default carries no fallback at all.
Set your own font only when you have bundled one:
SeedToken(fontFamily: 'Inter')
SeedToken(fontFamily: 'Inter', fontFamilyFallback: ['Noto Sans CJK'])A display face — a script, a hand-lettered holiday font — is a different job: charming in one heading, unreadable in a form. Keep it out of the seed and apply it by hand where it belongs, naming the body face as its fallback so a script the display font lacks still renders:
// The seed carries the readable face…
SeedToken(fontFamily: 'Nunito')
// …and a heading opts into the festive one.
TextStyle(
fontFamily: 'MountainsOfChristmas',
fontFamilyFallback: const ['Nunito'],
fontSize: 34,
)That fallback is not decoration: most Latin display fonts carry no Cyrillic or CJK, and without it those headings come out as empty boxes.
Better still, pick a display face that covers the scripts you actually ship. A
fallback rescues a heading from blank boxes, but it still changes typeface
mid-sentence — obvious in a bilingual line. The example's preset bundles Marck
Script and Ruslan Display for that reason, and its test/fonts_test.dart reads
the cmap table of each bundled file to prove the coverage rather than trusting
the font's description.
Note on
lineHeight. Applying it to short single-line labels shrink-wraps the line box around the font's metrics and visibly offsets glyphs against adjacent icons. Use it for paragraphs, not for button labels or toast text.
The neutral fills — colorFill, colorFillSecondary, colorFillTertiary,
colorFillQuaternary — are a few per cent of the text colour, so they tint
whatever is behind them. That is right for a static surface and wrong for an
animated one: lerping one of them to an opaque colour runs the midpoint through
a half-transparent dark grey, which reads as a flash.
Composite before animating, and both ends of the transition stay light:
Color.alphaBlend(token.colorFillTertiary, token.colorBgContainer)Button does this for its disabled fill and Steps for its markers and panels,
which is why neither flashes when a step advances or a button wakes up.
Durations motionDurationFast (100ms), motionDurationMid (200ms),
motionDurationSlow (300ms) and curves motionEaseInOut, motionEaseOut,
motionEaseOutCirc.
final token = context.softToken; // extension on BuildContext
final theme = ConfigProvider.of(context);context.softToken establishes a dependency, so widgets rebuild when the
theme changes.
ThemeData(dark: true)
// or
ThemeData.darkDark mode is not a color inversion: the palette generator blends each shade into the dark surface so accents stay legible, and the text ramp is rebuilt from a light base. Every component reads its colours from the token, so nothing is hard-coded to light — switching the theme restyles the whole kit, overlays (message, notification, Modal, Drawer, Tooltip) included.
There is no global "theme mode" — the kit follows Flutter's own model, where
you hold the choice in state and rebuild the provider, exactly as you would
swap a ThemeData. Put the provider above MaterialApp so the navigator's
overlay inherits it too:
class App extends StatefulWidget {
const App({super.key});
@override
State<App> createState() => _AppState();
}
class _AppState extends State<App> {
bool _dark = false;
@override
Widget build(BuildContext context) {
return ConfigProvider(
theme: ThemeData(dark: _dark),
child: MaterialApp(
navigatorKey: UiKit.navigatorKey,
home: HomePage(onToggle: () => setState(() => _dark = !_dark)),
),
);
}
}Because components depend on the provider via context.softToken, the flip
rebuilds them automatically — no manual invalidation. The example app has a
working light/dark toggle wired up this way.
The kit's theme is not Material's. Anything Material still draws for you — page
transitions above all — keeps using MaterialApp.theme, and left unset that is
Material's light default. A page transition paints its backdrop with
colorScheme.surface, so under a dark kit theme every navigation flashes white
before the page arrives.
You do not have to write that theme out. Every kit ThemeData can hand you the
matching Material one:
final kit = ThemeData(dark: _dark);
ConfigProvider(
theme: kit,
child: MaterialApp(
theme: kit.materialTheme,
home: const HomePage(),
),
)No Builder in between: materialTheme is reached from the theme itself, so
it can be named beside the very provider it belongs to. From inside the tree
the same value is context.softToken.materialTheme.
It carries the brightness, the primary and error colours, the divider, and — the one that stops the flash — the surface, which is the colour the kit paints a page in. It is a bridge for Material's own chrome, not a port of the kit's design language: components draw themselves from the tokens and pay it no attention.
A theme that leaves its brightness to inherit does not know it yet, so read this from the theme you hand to the top-level provider.
Builder(
builder: (context) => ConfigProvider(
theme: ThemeData(
dark: MediaQuery.platformBrightnessOf(context) == Brightness.dark,
),
child: const MyApp(),
),
)Combine both: seed the state from the platform brightness and still let the user override it with a toggle.
ConfigProvider states a status-bar style matching its theme, so a dark theme
gets light icons instead of the platform's dark ones — which on a dark bar
cannot be seen at all. It is declared through an AnnotatedRegion, not pushed
through SystemChrome, so the style belongs to the provider's subtree rather
than to global state.
Only the icon brightness is claimed. The bar's own colour is left alone, so a translucent or coloured status bar the app set survives.
If the app drives the system chrome itself — through AppBar.systemOverlayStyle
or its own AnnotatedRegion — turn it off:
ConfigProvider(
theme: ThemeData(dark: true),
systemOverlayStyle: false,
child: const MyApp(),
)generate(color) returns the ten shades behind a ColorGroup, with index
5 being the input color. It is exported for building custom palettes:
final shades = generate(const Color(0xFF722ED1));
final darkShades = generate(const Color(0xFF722ED1), dark: true);A pale or washed-out seed runs out of headroom at the light end of the ramp and
its first shades collapse into one another — the default error red #ff4d4f
loses two, a light brown such as #eed9c4 three. ColorGroup guards against
that for bgHover: when the shade next to bg is indistinguishable, it steps
from bg towards the darkest shade instead, far enough to actually read. So a
filled surface always has a visible hover, whatever colour it was seeded
with.
In seed_ui, styling is governed by a global Design Token system. Instead of hardcoding colors, borders, and paddings inside components, all components derive their appearance from Token. This ensures consistency across your app and makes theming a breeze.
You can easily access the current design tokens from anywhere in the widget tree using context.softToken. This is highly recommended when building custom components so they automatically adapt to your theme (e.g., light vs dark mode).
class MyCustomWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
// 1. Get the current token set
final token = context.softToken;
return Container(
// 2. Use tokens for colors, sizing, fonts, etc.
color: token.colorBgContainer,
padding: EdgeInsets.all(token.sizeLG),
child: Text(
'Hello World',
style: TextStyle(
color: token.colorText,
fontSize: token.fontSizeLG,
fontFamily: token.fontFamily,
),
),
);
}
}If you need to change the style of a specific component type globally (or locally), you can pass component-specific tokens. Every component has its own token class (e.g., ButtonToken, InputToken, AvatarToken).
You can override them globally via ThemeData using ComponentsConfig:
ConfigProvider(
theme: ThemeData(
components: const ComponentsConfig(
avatar: AvatarToken(colorTextPlaceholder: Colors.white),
button: ButtonToken(controlHeight: 40),
),
),
child: MyApp(),
)Or you can override them on a single instance:
Button(
token: const ButtonToken(
colorPrimary: Colors.red, // Overrides the global theme just for this button
),
child: const Text('Delete'),
)Providers nest, and a nested one inherits. It changes what it names and leaves everything else as the provider above it had it — the palette, the brightness, the other components' tokens, the language, the empty state.
So a screen that wants rounder buttons says only that:
ConfigProvider(
theme: ThemeData(
components: ComponentsConfig(button: ButtonToken(borderRadius: 16)),
),
child: ..., // keeps the app's colours, language and everything else
)What a nested theme inherits depends on what it states:
| The nested theme states | What it takes from above |
|---|---|
only components |
the whole token set |
only refineSeed: |
the seed, changed by what it names, and the brightness |
only a seed (token:) |
the brightness |
only dark: |
the palette the brightness is flipped on |
| both | nothing — it is fully specified |
ThemeData.raw(...) |
nothing; the token is taken as final |
Component tokens merge field by field, the nearer provider winning where the
two name the same field. emptyBuilder and locale are inherited the same
way: the nearest provider that states one wins, and a provider silent about it
passes down whatever it inherited.
token: replaces the seed outright — every field of it, including the ones
a nested theme never meant to touch. A SeedToken is one object, and a fresh
one is all defaults, so this drops the font, the radii and the sizes the app
had set:
// One screen, a different brand colour — and no font, no radii.
ThemeData(token: const SeedToken(colorPrimary: brand))refineSeed says the same thing without the loss. It is handed whatever seed
is in force, so nothing has to reach for a BuildContext:
ConfigProvider(
theme: ThemeData(
refineSeed: (seed) => seed.copyWith(colorPrimary: brand),
),
child: ..., // this brand, and everything else as the app had it
)It is refineTokens's counterpart on the other side of the
deriving: refineSeed changes what the palette is generated from, refineTokens
changes what came out. Both survive inheritance — a subtree asked to be yellow
stays yellow through the providers inside it, and the nearer one wins where
both speak.
This is why ThemeData.dark nested inside a themed provider turns the lights
out without discarding your colours:
ConfigProvider(
theme: ThemeData(token: SeedToken(colorPrimary: Color(0xFFEB2F96))),
child: ConfigProvider(
theme: ThemeData.dark, // pink, in the dark
child: ...,
),
)size is a ControlSize on some components and a SoftSize on others, and
the line between them is what the preset actually feeds:
| Components | Why | |
|---|---|---|
ControlSize |
Avatar, Spin, Steps, Progress, Input, InputNumber, Select, TimePicker, DatePicker |
The size feeds the box alone — a diameter, or a height and a type size that can stay put |
SoftSize |
Button |
The preset feeds four or five things at once: height, type size, padding, radius. A bare number would supply one and leave the rest guessing |
Where ControlSize is taken, all four forms work:
size: SoftSize.large // a preset, walking the theme's scale
size: ControlSize.height(36) // a height; the component keeps its width
size: ControlSize.width(180) // a width; the height keeps the preset
size: ControlSize.box(180, 36) // bothA circle has one measurement, so height and width mean the same thing to
an Avatar, a Spin or a Steps marker: ControlSize.width(56) is the
56-wide circle that ControlSize.height(56) is. The two names part company
only where a control has two dimensions to name.
Two seeds carry weight, and every component reads one of them:
ConfigProvider(
theme: ThemeData(
token: const SeedToken(
fontWeight: FontWeight.w300, // ordinary text
fontWeightStrong: FontWeight.w700, // titles and the chosen row
),
),
child: ...,
)fontWeight is labels, body copy, a button's own words. fontWeightStrong is
what a component draws when something should stand out from the copy around
it: a card or modal title, a section header, the selected option in a list.
Said once, they reach the whole kit. Before this existed, every weight was written into the widget that drew it and could only be changed by wrapping each one.
A component whose weight is its own affair carries a token for it, so it can differ without moving the rest:
| Token | Default |
|---|---|
ButtonToken.fontWeight |
the theme's fontWeight |
ResultToken.fontWeight |
w500 |
TabsToken.fontWeightActive |
w500 |
CountdownToken.fontWeight |
the theme's fontWeight |
Two settings on ConfigProvider are defaults for the widgets under it rather
than colours: componentSize and componentDisabled.
ConfigProvider(
componentSize: SoftSize.small, // a dense screen
componentDisabled: saving, // the form is read-only while it saves
child: ...,
)componentSize is the size every component takes when it does not name its
own — buttons, inputs, selects, tabs, avatars, the lot. componentDisabled is
the same idea for controls: one flag instead of a disabled: threaded through
every field.
For one component only, say it in that component's defaults instead — see Tokens, and defaults below:
ConfigProvider(
componentSize: SoftSize.large, // the screen
defaults: const ComponentDefaults(
button: ButtonDefaults(size: SoftSize.small), // but the buttons
),
child: ...,
)What a widget states for itself always wins, so a control can stay live in a disabled subtree:
Button(disabled: false, onPressed: _cancel, child: const Text('Cancel'))Both are inherited like everything else on the provider: a nested provider silent about them passes down whatever it received, and one that names them overrides for its own subtree.
Two boundaries worth knowing.
A nearer container outranks the screen. An Avatar inside an
AvatarGroup takes the group's size, not the provider's — the group is the
more specific word about it.
A per-item flag is not the screen speaking. componentDisabled reaches the
controls a person operates. The disabled on one SelectOption, TreeNode,
TabItem or CheckboxOption is about that item and is left alone, as is
Popconfirm.disabled, which means "do not ask" rather than "cannot be used".
Reading them yourself, for a widget of your own that should follow along:
final size = ConfigProvider.componentSizeOf(context) ?? SoftSize.middle;
final off = ConfigProvider.componentDisabledOf(context) ?? false;Two different things can be set for a component, and they are not the same knob.
| Where | What it carries | |
|---|---|---|
| Tokens | ThemeData(components: ComponentsConfig(...)) |
The numbers and colours a component draws with — ButtonToken(borderRadius: 16) |
| Defaults | ConfigProvider(defaults: ComponentDefaults(...)) |
The component's own props, where a widget did not name one — ButtonDefaults(shape: ButtonShape.round) |
A token says how a button is drawn. A default says what a button is, unless it says otherwise. Some things can only be said the second way: a shape, a variant, whether a tag closes, which illustration an empty state uses.
ConfigProvider(
theme: ThemeData(
components: ComponentsConfig(button: ButtonToken(borderRadius: 16)),
),
defaults: const ComponentDefaults(
button: ButtonDefaults(shape: ButtonShape.round, variant: ButtonVariant.solid),
tag: TagDefaults(closable: true),
),
child: ...,
)A widget's own prop always wins, and defaults are inherited and merged field by field: a nested provider changes what it names and keeps everything it is silent about — the other components, and the other fields of the component it did name.
// At the root of the app.
ConfigProvider(
defaults: const ComponentDefaults(
button: ButtonDefaults(
size: ControlSize.height(52),
variant: ButtonVariant.solid,
color: ButtonColor.primary,
),
),
child: const MyApp(),
)
// On one screen inside it, where the buttons are round.
ConfigProvider(
defaults: const ComponentDefaults(
button: ButtonDefaults(shape: ButtonShape.circle),
),
child: ..., // round, and still 52 tall, solid and primary
)size and disabled can be said in three places. They resolve nearest
first — the closer the word is to the widget, the stronger it is:
widget.size // 1. this widget said so
?? defaults.button?.size // 2. said about buttons
?? ConfigProvider.componentSizeOf(context) // 3. said about the screen
?? SoftSize.middle // 4. the kit's own defaultcomponentSize keeps working exactly as before; it is simply no longer the
only way to say it. The same ladder governs disabled.
Every component whose size or disabled is nullable carries the matching
field in its defaults — the table below says which.
What can be set so far — the list grew to cover every prop that is a house-style decision rather than the state of one instance:
| Component | Defaults |
|---|---|
Alert |
showIcon, closable |
Avatar |
shape, size |
Badge |
size, overflowCount, showZero |
Button |
variant, color, shape, size, disabled |
Card |
hoverable, variant, type, size |
CheckableTagGroup |
multiple, disabled |
Checkbox |
disabled |
CheckboxGroup |
direction, disabled |
Collapse |
accordion, bordered, ghost, expandIconPosition, collapsible, size |
Countdown |
type |
DatePicker |
variant, allowClear, showToday, size, disabled |
DateRangePicker |
variant, allowClear, size, disabled |
Dropdown |
placement, arrow, closeOnSelect, trigger, disabled |
Empty |
image |
FloatButton |
shape, color, size, layout, direction, trigger, labelPlacement, disabled, dismissible, closeOnSelect |
Form |
layout, maxWidth, labelWidth, labelAlign, colon, requiredMark, trigger, disabled |
Input |
allowClear, size, disabled |
InputNumber |
controls, keyboard, mode, size, disabled |
Listy |
sticky, padding, physics |
MultiDatePicker |
variant, allowClear, size, disabled, maxTagCount, maxTagCountResponsive |
MultiRangeSlider |
draggableTrack, disabled |
Pagination |
showSizeChanger, showQuickJumper, hideOnSinglePage, showLessItems, align, size, disabled |
Popconfirm |
placement, arrow, showCancel |
Popover |
placement, trigger, arrow, animation, dismissOnOutsideTap |
Progress |
showInfo, gapPlacement, size |
Radio |
disabled |
RadioGroup |
direction, optionType, buttonStyle, size, disabled |
Ribbon |
placement |
Segmented |
direction, scrollButtons, size, disabled |
Select |
variant, allowClear, showSearch, size, disabled |
Slider |
dots, included, disabled |
SortableList |
direction, showHandle |
Spin |
size, delay, position |
Steps |
orientation, type, variant, responsive, overflow, size |
Switch |
size, disabled |
Table |
size, bordered, showHeader, rowHoverable |
Tabs |
type, tabPosition, hideAdd, animated, scrollAlign, snap, contentPosition, size |
Tag |
variant, closable |
TimePicker |
variant, allowClear, showNow, needConfirm, size, disabled |
Timeline |
mode, orientation, variant |
Tooltip |
placement, arrow |
Tour |
placement, arrow, closable |
Tree |
showLine, showLeafIcon, showIcon, blockNode, disabled |
Upload |
variant, showRemove, showRetry, showSize, disabled |
A prop belongs here when it is a decision about the house rather than the
occupant. Alert.showIcon is house style; Alert.type — success or error — is
about that one message, and stays where it is. So do the props that describe
what a widget is doing: loading, dragging, indeterminate.
For a widget of your own that should follow along:
final shape = ConfigProvider.defaultsOf<ButtonDefaults>(context)?.shape;