Component structure
- Zeta Flutter
- Zeta Web
File structure
packages/zeta_flutter/lib/src/components/foo/
└── foo.dart
test/src/components/foo/
└── foo_test.dart
widgetbook/lib/src/components/
└── foo.widgetbook.dart
packages/zeta_flutter/lib/src/examples/components/
└── foo_example.dart
For components with sub-components, keep them in the same directory.
Skeleton
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../../../zeta_flutter.dart';
/// One-line description matching the Figma component name.
///
/// Optional Flutter-specific clarification.
///
/// Figma: https://www.figma.com/design/JesXQFLaPJLc1BdBM4sisI/...
/// Widgetbook: https://design.zebra.com/flutter/widgetbook/index.html#/?path=components/foo/zetafoo
class ZetaFoo extends ZetaStatelessWidget {
/// Creates a [ZetaFoo].
const ZetaFoo({
super.key,
super.rounded,
required this.label,
this.flavor = ZetaWidgetStatus.info,
this.size = ZetaWidgetSize.medium,
this.onTap,
});
/// The text label displayed by this component.
final String label;
/// The semantic color variant of this component.
final ZetaWidgetStatus flavor;
/// The size of this component.
final ZetaWidgetSize size;
/// Called when this component is tapped.
final VoidCallback? onTap;
Widget build(BuildContext context) {
final colors = Zeta.of(context).colors;
return GestureDetector(
onTap: onTap,
child: Text(label, style: ZetaTextStyles.bodyMedium),
);
}
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(StringProperty('label', label))
..add(EnumProperty<ZetaWidgetStatus>('flavor', flavor))
..add(EnumProperty<ZetaWidgetSize>('size', size))
..add(ObjectFlagProperty<VoidCallback?>.has('onTap', onTap));
}
}
Naming
| Thing | Convention | Example |
|---|---|---|
| Class name | PascalCase, Zeta prefix | ZetaFoo |
| File name | snake_case matching class | foo.dart |
| Directory name | snake_case matching filename | foo/ |
| Parameter name | camelCase | leadingIcon, onTap |
Required in every file
/// One-line descriptiondart doc comment on the class (matching the Figma component name)/// Figma:and/// Widgetbook:links in the class doc comment/// Descriptiondart doc on every public parameterdebugFillPropertiesoverride that adds every parameterconstconstructor wherever possible
Exports and registration
Add an export line to packages/zeta_flutter/lib/src/zeta_components.dart. Keep entries alphabetical:
// ...
export 'src/components/foo/foo.dart';
// ...
Only export the main component class. Internal helpers, private state classes, and sub-widgets that are not intended for direct consumer use must stay unexported.
Interactive elements
Never implement tap detection with raw GestureDetector when InkWell is appropriate, and never build focus handling from scratch. Flutter provides the correct primitives — use them.
| Interaction needed | Widget to use |
|---|---|
| Tap / long-press with ink splash | InkWell (inside a Material) |
| Tap without ink (icon buttons) | GestureDetector |
| Keyboard focus + navigation | Focus or FocusableActionDetector |
| Form field participation | FormField<T> with FormFieldState<T> |
// ✅ Good — InkWell gives ink splash, keyboard activation, and semantics
InkWell(
onTap: onTap,
borderRadius: _borderRadius,
child: Padding(
padding: EdgeInsets.all(ZetaSpacing.medium),
child: Text(label),
),
)
// ❌ Bad — GestureDetector has no ink, no keyboard activation
GestureDetector(
onTap: onTap,
child: Text(label),
)
Wrap InkWell in a Material widget when there is no Material ancestor (e.g. inside a Card content area that clips overflow).
File structure
src/components/foo/
├── foo.ts
└── foo.styles.js
src/test/foo/
└── foo.test.ts
src/stories/components/foo/
└── foo.stories.ts
example/public/components/
└── Foo.html
For components with sub-components, give each its own subdirectory. Re-export sub-components from the parent:
export * from "./foo-header/foo-header.js";
export * from "./foo-body/foo-body.js";
Skeleton
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import styles from "./foo.styles.js";
/**
* One-line description matching the Figma component name.
*
* @slot - Default slot content.
* @slot icon - Leading icon.
* @cssproperty --foo-bg - Background colour of the component.
* @part container - The root element.
* @event {CustomEvent} change - Fired when the value changes.
*
* @figma https://www.figma.com/file/JesXQFLaPJLc1BdBM4sisI/...
* @storybook https://design.zebra.com/web/storybook/?path=/docs/components-foo--docs
*/
@customElement("zeta-foo")
export class ZetaFoo extends LitElement {
static styles = [styles, super.styles ?? []];
@property({ type: String, reflect: true }) flavor: FooFlavor = "primary";
protected render() {
return html`<div part="container"><slot></slot></div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"zeta-foo": ZetaFoo;
}
}
Naming
| Thing | Convention | Example |
|---|---|---|
| Class name | PascalCase, Zeta prefix | ZetaFoo |
| Tag name | kebab-case, zeta- prefix | zeta-foo |
| File name | kebab-case matching tag | foo.ts |
Required in every file
@customElement("zeta-...")decorator- JSDoc block with every
@slot,@cssproperty,@part,@event,@figma,@storybook declare global { interface HTMLElementTagNameMap { ... } }at the bottom[styles, super.styles ?? []]style array
Exports and registration
Add a named import and export to src/index.ts. Do not use wildcard exports. Keep entries alphabetical:
import { ZetaFoo } from "./components/foo/foo.js";
export {
// ...
ZetaFoo,
// ...
};
Only export the public-facing class. Internal base classes, sub-widgets, and helper classes stay unexported unless they are genuinely reusable by consumers.
Interactive elements
A zeta-button must contain a real <button>. A zeta-checkbox must contain a real <input type="checkbox">. Using a <div> with ARIA roles instead breaks keyboard interaction, form association, browser built-ins, and assistive technology.
The native element inside the shadow root gives you keyboard behaviour, native form participation, browser affordances (autofill, password managers), and correct ARIA semantics — all for free.
Put the native element inside the shadow root and expose its semantics upward with delegatesFocus: true and the FormField / Interactive mixin. Override focus(), blur(), and click() to forward to the inner element:
export class BaseButton extends Interactive(LitElement) {
static formAssociated = true;
static override shadowRootOptions: ShadowRootInit = {
...LitElement.shadowRootOptions,
mode: "open",
delegatesFocus: true,
};
@query("button") private readonly buttonEl!: HTMLButtonElement;
override focus(): void {
this.buttonEl?.focus();
}
override blur(): void {
this.buttonEl?.blur();
}
override click(): void {
if (!this.disabled) this.buttonEl?.click();
}
protected render() {
return html`
<button ?disabled=${this.disabled} class="contourable-target">
<slot></slot>
</button>
`;
}
}
Native element by component type:
| Zeta component type | Native element |
|---|---|
| Button / icon button | <button> |
| Link / navigation item | <a> |
| Text input / search | <input type="text"> |
| Checkbox | <input type="checkbox"> |
| Radio | <input type="radio"> |
| Toggle / switch | <input type="checkbox"> |
| Select / dropdown | <select> (hidden) + custom UI |
| Textarea | <textarea> |
Set delegatesFocus: true whenever the shadow root contains a native focusable element. For form-participating components, use static formAssociated = true and this.attachInternals() (or the FormField mixin).
Mixins and composition
- Zeta Flutter
- Zeta Web
Use ZetaStatelessWidget for components with no internal state, and ZetaStatefulWidget for components that own mutable UI state. Prefer stateless — push state up to the parent when possible.
| Class | What it provides | When to use |
|---|---|---|
ZetaStatelessWidget | rounded prop, Zeta.of(context) convenience | No internal state |
ZetaStatefulWidget | Same as above plus createState() | Component owns mutable state |
ZetaWidgetStatus | info, positive, warning, negative enum | Semantic colour variants |
ZetaWidgetSize | small, medium, large enum | Multiple size variants |
When computing visual properties inside build(), always derive colors from Zeta.of(context).colors. Never set a color independently of the token system:
// ✅ Good
final colors = Zeta.of(context).colors;
final bgColor = disabled ? colors.surfaceDisabled : colors.surfacePrimary;
// ❌ Bad — hardcodes a color, breaks theming
final bgColor = disabled ? const Color(0xFFCCCCCC) : const Color(0xFF0000FF);
Always dispose controllers and focus nodes in dispose().
Prefer composition via mixins over deep inheritance.
export class ZetaFoo extends Interactive(Size(Contourable(LitElement))) {}
| Mixin | Property added | When to use |
|---|---|---|
Contourable | rounded: boolean | Two-state shape (sharp / rounded) |
ContourableThree | shape: "sharp" | "rounded" | "full" | Three-state shape |
Flavored | flavor: Flavor | Colour variants (primary, positive, negative, …) |
Size | size: "small" | "medium" | "large" | Multiple size variants |
Interactive | disabled: boolean, focus handling | Anything clickable or focusable |
Navigate | href | Anchor-like components |
Popup | Dialog-like open/close | Dropdowns, tooltips, dialogs |
Mixins target specific class names in your template:
| Class | Applied by | Effect |
|---|---|---|
.contourable-target | Contourable / ContourableThree | Applies border-radius |
.interactive-target | Interactive | Applies cursor, focus ring, disabled styles |
<button class="contourable-target interactive-target">...</button>
If neither class is present, the mixin falls back to :host > :first-child.
Mixins inject their own styles through the static styles array. If a subclass replaces the array instead of extending it, all mixin styles are silently discarded — producing broken disabled states, missing border-radius, etc.
// ✅ Good — merges parent and component styles
static styles = [styles, super.styles ?? []];
// ❌ Bad — discards all mixin styles
static styles = [styles];
This rule applies at every level of the chain:
// BaseButton already merges mixin styles; ZetaButton must continue the chain
static get styles() {
return [super.styles ?? [], styles];
}
All colours, spacing, and radii must come from CSS custom properties — never hardcode values in component styles:
/* ✅ Good */
.container {
background-color: var(--surface-default);
color: var(--main-default);
border-radius: var(--radius-minimal);
padding: var(--spacing-medium);
}
/* ❌ Bad — hardcoded values break theming */
.container {
background-color: #fff;
color: #333;
border-radius: 4px;
padding: 16px;
}