Styling
Styling with DOU and CSS.
Styling
Styling in Pulsar is flexible and pragmatic. Use vanilla CSS, CSS frameworks like Tailwind or Materialize, or create Domain Styling Classes to encapsulate complex styling logic. The goal is clarity and maintainability.
Pulsar follows a principle we call Domain Oriented UI (DOU) — applying Domain-Driven Design principles to frontend development. Styling logic can be isolated into domain objects, making components cleaner and more testable.
CSS Co-location
CSS files live in the web/ directory and are referenced using the css() helper with paths relative to web/:
import 'package:pulsar_web/pulsar.dart';
final class Card extends Component {
final String title;
final String content;
Card(this.title, this.content);
@override
List<Stylesheet> get styles => [
css("styles/components/card/card.css"), // Relative to web/
];
@override
Morphic render() {
return Div().classes("card")([
H3().classes("card-title")([title]),
P().classes("card-content")([content]),
]);
}
}
The CSS file would be located at:
project/
├── lib/
│ └── components/
│ └── card/
│ └── card.dart
└── web/
└── styles/
└── components/
└── card/
└── card.css
The css() function accepts a path relative to the web/ directory, making it easy to organize styles alongside your project structure.
.card {
background: white;
border-radius: 8px;
padding: 1rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.card-title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.card-content {
color: #666;
line-height: 1.6;
}
Inline Classes
The most direct approach is to use CSS classes directly on elements:
final class Button extends Component {
final String label;
final void Function(Event) onPressed;
Button(this.label, {required this.onPressed});
@override
Morphic render() {
return Button()
.classes("btn btn-primary")
.onClick(onPressed)
([label]);
}
}
This works well for simple components with stable styling.
CSS Frameworks
Pulsar supports CSS frameworks through CDN imports. Two officially supported frameworks are Tailwind CSS and Materialize:
Tailwind CSS
final class TailwindCard extends Component {
@override
Morphic render() {
return Div().classes(
"bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow"
)([
H3().classes("text-xl font-semibold mb-2")(['Card Title']),
P().classes("text-gray-600")(['Card content goes here.']),
]);
}
}
Materialize
final class MaterialCard extends Component {
@override
Morphic render() {
return Div().classes("card")([
Div().classes("card-content")([
Span().classes("card-title")(['Card Title']),
P()(['Card content goes here.']),
]),
Div().classes("card-action")([
A().href('#')(['Link']),
]),
]);
}
}
Framework Flexibility: Pulsar is framework-agnostic. You can use Tailwind, Materialize, Bootstrap, or any other CSS framework via CDN. Choose what fits your project best.
Domain Styling Classes
When styling logic becomes complex or repetitive, extract it into Domain Styling Classes. These are domain objects that encapsulate styling decisions, keeping components clean and testable.
Domain Styling Classes allow you to:
- Precompute complex class logic outside of render()
- Switch between CSS frameworks without touching components
- Encapsulate conditional styling logic
- Test styling logic independently
- Maintain clarity in components
Basic Domain Styling Class
// Use enums instead of strings
enum ButtonVariant { primary, secondary, success, danger, warning }
enum ButtonSize { small, medium, large }
// Domain object encapsulating button styling
class ButtonStyles {
final ButtonVariant variant;
final ButtonSize size;
ButtonStyles({
this.variant = ButtonVariant.primary,
this.size = ButtonSize.medium,
});
String get classes {
final base = 'btn';
final variantClass = 'btn-${variant.name}';
final sizeClass = 'btn-${size.name}';
return '$base $variantClass $sizeClass';
}
// Static constants for common configurations
static const primary = ButtonStyles();
static const secondary = ButtonStyles(variant: ButtonVariant.secondary);
static const success = ButtonStyles(variant: ButtonVariant.success);
static const danger = ButtonStyles(variant: ButtonVariant.danger);
static const largePrimary = ButtonStyles(size: ButtonSize.large);
}
// Component using domain styling
final class Button extends Component {
final String label;
final ButtonStyles styles;
final void Function(Event) onPressed;
Button(
this.label, {
required this.onPressed,
this.styles = ButtonStyles.primary,
});
@override
Morphic render() {
return Button()
.classes(styles.classes)
.onClick(onPressed)
([label]);
}
}
// Usage with static constants:
Button('Submit', onPressed: handleSubmit, styles: ButtonStyles.success)
Button('Cancel', onPressed: handleCancel, styles: ButtonStyles.secondary)
// Or with custom configuration:
Button(
'Delete',
onPressed: handleDelete,
styles: ButtonStyles(variant: ButtonVariant.danger, size: ButtonSize.large),
)
Static Helper Methods for Conditional Styles
// Domain styling with static helper methods
class ButtonStyles {
final String classes;
const ButtonStyles._(this.classes);
// Static constants
static const primary = ButtonStyles._('btn btn-primary');
static const secondary = ButtonStyles._('btn btn-secondary');
static const success = ButtonStyles._('btn btn-success');
static const danger = ButtonStyles._('btn btn-danger');
// Static method for conditional styling
static String forState({
required bool active,
required bool disabled,
String variant = 'primary',
}) {
final base = 'btn btn-$variant';
final activeClass = active ? 'btn-active' : '';
final disabledClass = disabled ? 'btn-disabled' : '';
return [base, activeClass, disabledClass]
.where((c) => c.isNotEmpty)
.join(' ');
}
}
final class ToggleButton extends Component {
bool active = false;
void handleToggle(Event e) {
morph(() => active = !active);
}
@override
Morphic render() {
return Button()
.classes(ButtonStyles.forState(
active: active,
disabled: false,
))
.onClick(handleToggle)
(['Toggle']);
}
}
Framework-Agnostic Styling
Domain Styling Classes can abstract away framework details:
// Abstract styling interface
abstract class CardStyles {
String get containerClasses;
String get titleClasses;
String get contentClasses;
}
// Tailwind implementation
class TailwindCardStyles implements CardStyles {
@override
String get containerClasses =>
'bg-white rounded-lg shadow-md p-6';
@override
String get titleClasses =>
'text-xl font-semibold mb-2';
@override
String get contentClasses =>
'text-gray-600';
}
// Materialize implementation
class MaterializeCardStyles implements CardStyles {
@override
String get containerClasses => 'card';
@override
String get titleClasses => 'card-title';
@override
String get contentClasses => 'card-content';
}
// Vanilla CSS implementation
class VanillaCardStyles implements CardStyles {
@override
String get containerClasses => 'custom-card';
@override
String get titleClasses => 'custom-card-title';
@override
String get contentClasses => 'custom-card-content';
}
// Component is framework-agnostic
final class Card extends Component {
final String title;
final String content;
final CardStyles styles;
Card(this.title, this.content, {required this.styles});
@override
Morphic render() {
return Div().classes(styles.containerClasses)([
H3().classes(styles.titleClasses)([title]),
P().classes(styles.contentClasses)([content]),
]);
}
}
// Switch frameworks without touching Card component
Card('Title', 'Content', styles: TailwindCardStyles())
Card('Title', 'Content', styles: MaterializeCardStyles())
Card('Title', 'Content', styles: VanillaCardStyles())
Domain Oriented UI: This pattern follows the DOU principle — isolating styling decisions into domain objects. Components become framework-agnostic and purely describe structure, while domain objects handle presentation.
Conditional Styling with Domain Objects
// Use enum instead of strings
enum AlertType { success, error, warning, info }
// Domain object encapsulating complex conditional logic
class AlertStyles {
final AlertType type;
final bool dismissible;
final bool outlined;
const AlertStyles({
required this.type,
this.dismissible = false,
this.outlined = false,
});
String get classes {
final base = 'alert';
final typeClass = 'alert-${type.name}';
final outlinedClass = outlined ? 'alert-outlined' : '';
final dismissClass = dismissible ? 'alert-dismissible' : '';
return [base, typeClass, outlinedClass, dismissClass]
.where((c) => c.isNotEmpty)
.join(' ');
}
String get iconClasses {
return switch (type) {
AlertType.success => 'icon-check-circle',
AlertType.error => 'icon-error',
AlertType.warning => 'icon-warning',
AlertType.info => 'icon-info',
};
}
// Static constants for common cases
static const success = AlertStyles(type: AlertType.success);
static const error = AlertStyles(type: AlertType.error);
static const warning = AlertStyles(type: AlertType.warning);
static const successDismissible = AlertStyles(
type: AlertType.success,
dismissible: true,
);
}
final class Alert extends Component {
final String message;
final AlertStyles styles;
final void Function()? onDismiss;
Alert(this.message, {required this.styles, this.onDismiss});
void handleDismiss(Event e) {
if (onDismiss != null) {
onDismiss!();
}
}
@override
Morphic render() {
return Div().classes(styles.classes)([
I().classes(styles.iconClasses)([]),
Span()([message]),
if (styles.dismissible)
Button()
.classes('alert-close')
.onClick(handleDismiss)
(['×']),
]);
}
}
// Usage - clean and type-safe
Alert('Operation successful!', styles: AlertStyles.successDismissible)
Alert('Error occurred', styles: AlertStyles.error)
Alert(
'Custom warning',
styles: AlertStyles(
type: AlertType.warning,
dismissible: true,
outlined: true,
),
)
The conditional logic lives in the domain object. Enums provide type safety, and static constants make common configurations easy. The component's render() remains clean and easy to read.
Abstraction: Enums and Objects
A core principle in Pulsar is to abstract when necessary. Instead of using strings for variants or states, use enums. When dealing with related data, extract it into objects.
Replace Strings with Enums
// BAD - Strings are error-prone
Button().classes('btn btn-primary')([])
Button().classes('btn btn-primry')([]) // Typo!
// GOOD - Enums are type-safe
enum ButtonVariant { primary, secondary, success, danger }
class ButtonStyles {
static String variant(ButtonVariant v) => 'btn btn-${v.name}';
}
Button().classes(ButtonStyles.variant(ButtonVariant.primary))([])
Button().classes(ButtonStyles.variant(ButtonVariant.primry)) // Compile error!
Extract Related Data into Objects
// BAD - Multiple separate parameters
class TableStyles {
static String build({
required bool striped,
required bool hoverable,
required bool bordered,
required String size,
required String theme,
}) {
// Complex logic...
}
}
// GOOD - Related data in object
enum TableSize { small, medium, large }
enum TableTheme { light, dark }
class TableConfig {
final bool striped;
final bool hoverable;
final bool bordered;
final TableSize size;
final TableTheme theme;
const TableConfig({
this.striped = false,
this.hoverable = false,
this.bordered = false,
this.size = TableSize.medium,
this.theme = TableTheme.light,
});
// Static presets
static const standard = TableConfig();
static const compact = TableConfig(size: TableSize.small);
static const interactive = TableConfig(
striped: true,
hoverable: true,
);
}
class TableStyles {
final TableConfig config;
const TableStyles(this.config);
String get classes {
final base = 'table';
final stripeClass = config.striped ? 'table-striped' : '';
final hoverClass = config.hoverable ? 'table-hover' : '';
final borderClass = config.bordered ? 'table-bordered' : '';
final sizeClass = 'table-${config.size.name}';
final themeClass = 'table-${config.theme.name}';
return [base, stripeClass, hoverClass, borderClass, sizeClass, themeClass]
.where((c) => c.isNotEmpty)
.join(' ');
}
}
// Usage - clean and type-safe
Table().classes(TableStyles(TableConfig.interactive).classes)([...])
Static Methods for Component-Dependent Logic
// Domain styling that depends on component state
class NavLinkStyles {
// Static method accepts component state
static String forState({
required bool isActive,
required bool isDisabled,
}) {
final base = 'nav-link';
final activeClass = isActive ? 'active' : '';
final disabledClass = isDisabled ? 'disabled' : '';
return [base, activeClass, disabledClass]
.where((c) => c.isNotEmpty)
.join(' ');
}
}
final class NavLink extends Component {
final String href;
final String label;
final bool disabled;
NavLink(this.href, this.label, {this.disabled = false});
bool get isActive => window.location.pathname == href;
@override
Morphic render() {
// Logic lives in domain, component passes state
return A()
.href(href)
.classes(NavLinkStyles.forState(
isActive: isActive,
isDisabled: disabled,
))
([label]);
}
}
Abstraction Principle: Always prefer enums over strings for variants and states. Extract related data into objects. Use static methods when styling logic depends on component state. This provides type safety, better IDE support, and makes refactoring easier.
Use Inline Classes When:
- Styling is simple and stable
- Component has few CSS classes
- No conditional styling logic
- Framework-specific classes are acceptable
Use Domain Styling Classes When:
- Styling logic is complex or conditional
- Many CSS classes make render() hard to read
- You want framework-agnostic components
- Styling decisions need to be tested
- Styling logic is reused across components
// GOOD - Simple inline classes
Div().classes("container mx-auto p-4")([
H1().classes("text-2xl font-bold")(['Title']),
])
// GOOD - Complex logic in domain object
final styles = TableStyles(
striped: true,
hoverable: true,
bordered: false,
size: 'large',
theme: 'dark',
);
Table().classes(styles.classes)([...])
// AVOID - Complex inline logic
Div().classes(
'table ${striped ? "table-striped" : ""} '
'${hoverable ? "table-hover" : ""} '
'${bordered ? "table-bordered" : ""} '
'table-$size theme-$theme'
)([...])
Domain Oriented UI (DOU)
Domain Oriented UI is a methodology that applies Domain-Driven Design principles to frontend development. The core idea: isolate domain logic from presentation logic.
In Pulsar, this manifests through:
- Domain Styling Classes — Encapsulate styling decisions
- Domain Objects — Encapsulate business logic (State Management)
- Components — Pure description of UI structure
Benefits of DOU:
- Testability — Domain objects are isolated and testable
- Clarity — Components remain focused on structure
- Reusability — Domain logic is decoupled from UI
- Flexibility — Swap implementations without touching components
// Domain: Styling logic
class FormStyles {
final String size;
final bool inline;
FormStyles({this.size = 'medium', this.inline = false});
String get formClasses =>
inline ? 'form-inline' : 'form-stacked';
String get inputClasses => 'input input-$size';
String get labelClasses => 'label label-$size';
String get buttonClasses => 'btn btn-$size btn-primary';
}
// Domain: Business logic
class LoginForm {
String email = '';
String password = '';
bool isValid = false;
void updateEmail(String value) {
email = value;
_validate();
}
void updatePassword(String value) {
password = value;
_validate();
}
void _validate() {
isValid = email.isNotEmpty && password.length >= 8;
}
Map<String, String> toJson() => {
'email': email,
'password': password,
};
}
// Component: Pure UI structure
final class LoginFormComponent extends Component {
final LoginForm form = LoginForm();
final FormStyles styles = FormStyles(size: 'large');
void handleEmailInput(Event e) {
final target = e.target as HTMLInputElement;
form.updateEmail(target.value);
}
void handlePasswordInput(Event e) {
final target = e.target as HTMLInputElement;
form.updatePassword(target.value);
}
void handleSubmit(Event e) {
e.preventDefault();
if (form.isValid) {
submitLogin(form.toJson());
}
}
@override
Morphic render() {
return Form()
.classes(styles.formClasses)
.onSubmit(handleSubmit)
([
Label().classes(styles.labelClasses)(['Email']),
Input()
.classes(styles.inputClasses)
.type(InputType.email)
.value(form.email)
.onInput(handleEmailInput)
([]),
Label().classes(styles.labelClasses)(['Password']),
Input()
.classes(styles.inputClasses)
.type(InputType.password)
.value(form.password)
.onInput(handlePasswordInput)
([]),
Button()
.classes(styles.buttonClasses)
.type(ButtonType.submit)
.disabled(!form.isValid)
(['Login']),
]);
}
}
The component is clean and focused. Styling logic lives in FormStyles, business logic lives in LoginForm, and the component simply describes structure.
Icons (Experimental)
Pulsar supports icons from Bootstrap Icons and Material Design Icons, but the API is currently experimental and not yet standardized.
Material Design Icons
// Material Design Icons via Materialize
I().classes("material-icons")(['add'])
I().classes("material-icons")(['delete'])
I().classes("material-icons")(['search'])
Bootstrap Icons
// Bootstrap Icons
I().classes("bi bi-alarm")([])
I().classes("bi bi-heart-fill")([])
I().classes("bi bi-search")([])
Experimental Feature: Icon support is currently experimental. The API differs between icon libraries (Material Design uses icon name as text content, Bootstrap uses CSS classes). A unified, ergonomic API is being designed for future versions.
Best Practices
- Co-locate CSS with components — Keep styles close to where they're used.
- Use Domain Styling Classes for complex logic — Precompute classes, keep render() clean.
- Make components framework-agnostic when possible — Use domain objects to abstract framework details.
- Test styling logic independently — Domain objects are testable.
- Keep render() pure — No conditional class logic in render().
- Document domain styling classes — Make styling decisions explicit.
Anti-Patterns
- ❌ Complex conditional class logic inline in render()
- ❌ Tight coupling to specific CSS frameworks
- ❌ Repeating the same styling logic across components
- ❌ Building class strings with string interpolation in render()
- ❌ Mixing styling logic with business logic
Core principle: Styling in Pulsar is flexible but intentional. Use inline classes for simplicity, Domain Styling Classes for complexity. Follow Domain Oriented UI principles: isolate styling logic into domain objects, keep components pure and focused on structure. This makes your codebase testable, maintainable, and adaptable.