Design tokens
Never hardcode values
Colour, spacing, radius, elevation, and typography values must always reference a design token. Hardcoded values break theming (light/dark, contrast modes) and will be rejected in review.
- Zeta Flutter
- Zeta Web
// ✅ Good — all values from tokens
Container(
padding: EdgeInsets.all(Zeta.of(context).spacing.medium),
decoration: BoxDecoration(
color: colors.surfaceDefault,
border: Border.all(color: colors.borderDefault),
borderRadius: context.rounded
? BorderRadius.circular(ZetaSpacing.minimum)
: BorderRadius.zero,
),
child: Text(label, style: ZetaTextStyles.bodyMedium),
)
// ❌ Bad — hardcoded values break theming
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFFFFFFF),
border: Border.all(color: const Color(0xFFCCCCCC)),
borderRadius: BorderRadius.circular(4),
),
child: Text(label, style: const TextStyle(fontSize: 16)),
)
/* ✅ Good */
border: var(--border-size-small) solid var(--border-default);
border-radius: var(--radius-minimal);
color: var(--main-default);
padding: var(--spacing-large);
/* ❌ Bad */
border: 1px solid #ccc;
border-radius: 4px;
color: #333;
padding: 16px;
Token namespaces
- Zeta Flutter
- Zeta Web
Tokens are accessed via Zeta.of(context) and related classes. Always use semantic tokens — raw values are for token definitions only.
| Class / accessor | Purpose | Examples |
|---|---|---|
colors.surface* | Background fills | surfaceDefault, surfacePrimary, surfaceNegative |
colors.main* | Foreground / text / icon | mainDefault, mainSubtle, mainDisabled, mainInverse |
colors.border* | Border colors | borderDefault, borderPrimary, borderNegative |
colors.state* | Interactive state fills | stateDefaultEnabled, stateDefaultHover, stateDefaultSelected |
ZetaSpacing.* | Padding, gap, margin | ZetaSpacing.minimum, ZetaSpacing.small, ZetaSpacing.large |
ZetaTextStyles.* | Typography | ZetaTextStyles.bodyMedium, ZetaTextStyles.labelLarge |
ZetaTextStyles not raw TextStyleText styles include the correct font weight, size, and line-height together. Using TextStyle(fontSize: ...) alone loses weight and line-height:
// ✅ Good
Text(label, style: ZetaTextStyles.bodyMedium)
// ❌ Bad — loses weight and line-height
Text(label, style: const TextStyle(fontSize: 14))
Tokens are generated from Figma into src/generated/tokens/. Always use semantic tokens in component styles — primitive tokens are for token definitions only.
| Namespace | Purpose | Examples |
|---|---|---|
--surface-* | Background fills | --surface-default, --surface-primary, --surface-negative |
--main-* | Foreground / text / icon | --main-default, --main-subtle, --main-disabled, --main-inverse |
--border-* | Border colours | --border-default, --border-primary, --border-subtle, --border-disabled |
--state-* | Interactive state fills | --state-default-enabled, --state-default-hover, --state-default-selected |
--spacing-* | Padding, gap, margin | --spacing-minimum, --spacing-small, --spacing-medium, --spacing-large |
--radius-* | Border radius | --radius-none, --radius-minimal, --radius-rounded, --radius-full |
--avatar-* | Avatar background colours | --avatar-blue, --avatar-green, --avatar-teal, --avatar-purple |
--chart-* | Data visualisation | --chart-1-primary, --chart-1-secondary, --chart-2-primary |
font not font-sizeFont styles include the correct font weight, size, and line-height together. Using font-size alone loses weight and line-height
Stylesheet / theme setup
- Zeta Flutter
- Zeta Web
All colors come from Zeta.of(context).colors. This automatically resolves to the correct value for the current theme mode (light, dark, high-contrast light, high-contrast dark):
final colors = Zeta.of(context).colors;
Always derive border radius from the rounded property — never hardcode:
BorderRadius get _borderRadius => context.rounded
? BorderRadius.circular(ZetaSpacing.minimum)
: BorderRadius.zero;
For interaction states, use InkWell with ZetaColors.state* tokens:
InkWell(
onTap: onTap,
overlayColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.pressed)) return colors.stateDefaultSelected;
if (states.contains(WidgetState.hovered)) return colors.stateDefaultHover;
return null;
}),
borderRadius: _borderRadius,
child: ...,
)
Size variants should use a single switch expression — not scattered if/else blocks:
// ✅ Good
double get _height => switch (size) {
ZetaWidgetSize.small => ZetaSpacing.xl_6,
ZetaWidgetSize.medium => ZetaSpacing.xl_9,
ZetaWidgetSize.large => ZetaSpacing.xl_11,
};
Styles live in a .styles.js file using Lit's css tag:
import { css } from "lit";
export default css`
:host {
display: inline-block;
}
.container {
background-color: var(--foo-bg);
}
`;
Expose themeable values as CSS custom properties on :host. Use a private --_prop alias so the component always has a fallback:
:host {
--_bg: var(--foo-bg, var(--surface-default));
--_border: var(--foo-border-color, var(--border-default));
}
.container {
background-color: var(--_bg);
border-color: var(--_border);
}
Document every exposed property with @cssproperty in JSDoc.
Wrap hover styles in a media query to prevent stuck states on touch devices:
:host {
-webkit-tap-highlight-color: transparent;
}
@media (hover: hover), (hover: none) and (pointer: fine) {
.container:hover {
background-color: var(--state-default-hover);
}
}
Drive visual variants from reflected attributes, not toggled class names:
/* ✅ Good */
:host([flavor="outline"]) .container {
border: 1px solid var(--border-primary);
}
/* ❌ Avoid — requires JS to manage classes on :host */
:host(.outline) .container { ... }