Flint UI

flint_ui is a Dart-first web UI layer for Flint fullstack applications. It gives you a small component model, browser rendering, page mounting, typed style helpers, browser storage, navigation utilities, and a growing set of application components.

The goal is simple: build real FlintDart web interfaces in Dart without reaching for raw HTML, JavaScript, and CSS strings for every screen.

Status

Flint UI is pre-1.0 and actively evolving. The current package is suitable for building FlintDart app UIs and internal dashboards, but APIs may still change while the v1 surface is shaped.

Install

Add flint_ui to your app:

dependencies:
  flint_ui: ^0.1.3

For local Flint development inside this monorepo, use a path override:

dependency_overrides:
  flint_ui:
    path: ../flint_ui

Import the full UI package:

import 'package:flint_ui/flint_ui.dart';

For lower-level tests or package internals, you can import:

import 'package:flint_ui/flint_ui_core.dart';

Quick Start

Create a Flint UI entry file, usually lib/ui/main.dart:

import 'package:flint_ui/flint_ui.dart';

void main() {
  createFlintApp(
    '#app',
    pages: {
      'Home': (_) => HomePage(),
    },
  );
}

class HomePage extends FlintComponent {
  @override
  FlintNode build() {
    return Container(
      dartStyle: const DartStyle(
        padding: EdgeInsets.all(24),
        maxWidth: SizeValue.rem(48),
      ),
      children: [
        Text.h1('Hello Flint UI'),
        Text.p('Build browser UI with Dart components.'),
        Button(
          child: 'Continue',
          onPressed: (_) {
            navigation.assign('/dashboard');
          },
        ),
      ],
    );
  }
}

Render the page from FlintDart:

Response home(Request req, Response res) {
  return res.page(
    'Home',
    title: 'My Flint App',
    props: {
      'name': 'Flint',
    },
  );
}

Compile the UI:

dart compile js lib/ui/main.dart -o public/main.dart.js

When used through FlintDart hot reload, Flint can compile the UI bundle and refresh the browser for you.

Components

Every UI component is a FlintComponent that returns a FlintNode.

class Counter extends FlintComponent {
  int value = 0;

  @override
  FlintNode build() {
    return Row(
      dartStyle: const DartStyle(
        display: Display.flex,
        gap: 12,
        alignItems: AlignItems.center,
      ),
      children: [
        Text.span('Count: $value'),
        Button(
          child: 'Add',
          onPressed: (_) {
            setState(() => value++);
          },
        ),
      ],
    );
  }
}

Lifecycle hooks are available:

class Example extends FlintComponent {
  @override
  void didMount() {}

  @override
  void didUpdate() {}

  @override
  void willUnmount() {}

  @override
  View  build() => Text('Example');
}

Basic Nodes

You can use high-level components:

Column(
  children: [
    Text.h2('Account'),
    Text.p('Manage your profile.'),
  ],
)

Or create raw elements:

h(
  'section',
  props: {'class': 'panel'},
  children: [
    text('Raw element'),
  ],
)

Common helpers include:

div()
span()
button()
input()
text()
fragment()
component()
toFlintNode()

Pages And Registry

For multi-page apps, use FlintComponentRegistry:

final componentRegistry = FlintComponentRegistry({
  'Login': (props) => LoginPage(props),
  'Dashboard': (props) => DashboardPage(props),
});

void main() {
  createFlintApp(
    '#app',
    registry: componentRegistry,
  );
}

A page receives server props through data-flint-page:

class DashboardPage extends FlintComponent {
  DashboardPage(this.props);

  final Map<String, dynamic> props;

  @override
  FlintNode build() {
    final role = props['role']?.toString() ?? 'customer';
    return Text.h1('Dashboard: $role');
  }
}

Page Middleware

Client-side page middleware can stop rendering or redirect before a page mounts:

const session = AuthSessionManager(
  tokenKey: 'app.token',
  userKey: 'app.user',
);

void requireAuth(FlintPageContext context) {
  if (context.page.component != 'Dashboard') return;
  if (session.isLoggedIn) return;

  navigation.redirect('/login');
  context.stop();
}

void main() {
  createFlintApp(
    '#app',
    registry: componentRegistry,
    middlewares: [requireAuth],
  );
}

For real protection, also guard the server route in FlintDart.

Styling

Flint UI supports plain style maps and the typed DartStyle API.

Container(
  dartStyle: const DartStyle(
    padding: EdgeInsets.all(24),
    background: Colors.white,
    color: Colors.slate900,
    radius: 12,
    border: Border.all(color: Colors.slate200),
    shadow: Shadow(
      y: 16,
      blur: 40,
      color: Color.rgba(15, 23, 42, 0.12),
    ),
  ),
  child: Text('Styled panel'),
)

Size Values

Numbers become pixels by default:

DartStyle(width: 320) // width: 320px

Use SizeValue when you want another unit:

const DartStyle(
  width: SizeValue.full,
  maxWidth: SizeValue.rem(48),
  height: SizeValue.auto,
)

Available helpers:

SizeValue.px(12)
SizeValue.percent(50)
SizeValue.rem(4)
SizeValue.em(2)
SizeValue.auto
SizeValue.full

Colors

Use CSS strings, Color, or predefined Colors:

const DartStyle(color: '#0f172a')
const DartStyle(color: Color.rgb(15, 23, 42))
const DartStyle(color: Color.rgba(37, 99, 235, 0.9))
const DartStyle(color: Colors.blue600)

Current named colors include:

Colors.white
Colors.black
Colors.transparent

Colors.slate50 ... Colors.slate900
Colors.blue50 ... Colors.blue900
Colors.sky50 ... Colors.sky900
Colors.cyan50
Colors.cyan700
Colors.rose50
Colors.rose200
Colors.rose700

Gradients

Use predefined gradients:

const DartStyle(gradient: Gradients.ocean)
const DartStyle(gradient: Gradients.sky)
const DartStyle(gradient: Gradients.softPanel)

Or build custom gradients:

DartStyle(
  gradient: Gradient.linear(135, const [
    GradientStop(Colors.sky500, 0),
    GradientStop(Colors.blue600, 58),
    GradientStop(Colors.blue700, 100),
  ]),
)

For evenly distributed colors:

DartStyle(
  gradient: Gradient.linearColors(135, const [
    Colors.blue600,
    Colors.sky500,
  ]),
)

Flex

Use Flex helpers instead of raw CSS strings:

const DartStyle(
  display: Display.flex,
  flex: Flex.fill(),
)

Available helpers:

Flex.fill() // 1 1 auto
Flex.auto() // 1 1 auto
Flex.grow() // 1 1 0%
Flex.none() // 0 0 auto
Flex(2, 0, SizeValue.auto)

Individual properties are also available:

const DartStyle(
  flexGrow: 1,
  flexShrink: 0,
  flexBasis: SizeValue.auto,
)

Responsive Styles

DartStyle supports responsive breakpoint overrides:

const DartStyle(
  width: SizeValue.full,
  padding: EdgeInsets.all(16),
  md: DartStyle(
    width: 520,
    padding: EdgeInsets.all(32),
  ),
  lg: DartStyle(
    width: 640,
  ),
)

Breakpoints:

sm: 640px
md: 768px
lg: 1024px
xl: 1280px

Responsive styles are compiled into scoped CSS rules by mergeComponentProps.

Style Sheets

For reusable CSS classes, use StyleSheet and StyleRule:

const appStyles = StyleSheet(
  'app',
  {
    '.button': StyleRule({
      'background': Colors.blue600,
      'color': Colors.white,
      'border-radius': '8px',
    }, hover: {
      'background': Colors.blue700,
    }),
  },
);

Register stylesheets when the app starts:

createFlintApp(
  '#app',
  registry: componentRegistry,
  stylesheets: [appStyles],
);

Then use generated class names:

Button(
  className: appStyles.className('button'),
  child: 'Save',
)

Component Catalog

These are the main public component groups exported by package:flint_ui/flint_ui.dart.

Primitives

Container
Row
Column
Text
Link
Image
Figure

Actions

Button
ButtonGroup
IconButton

Icons

Icon(Icons.home)
Icon(Icons.search, label: 'Search')
Icon(Icons.server, size: 24, color: Colors.blue600)
Icons.all

Forms

Form
TextField
TextArea
Select
Checkbox
RadioGroup
Switch
FileInput
FieldGroup
TextEditingController
FormController

Layout

AppShell
Grid
Section
Panel
PageHeader
Sidebar
Spacer
Stack
StatCard
Topbar
Wrap
Divider
EmptyState

Data

Avatar
DataTable
DescriptionList
ProgressBar
Table
Timeline
UsageMeter

Feedback

Alert
Spinner
StatusBadge
Breadcrumbs
Pagination
SearchBox
Tabs

Overlays

ConfirmAction
Drawer
Modal
Popover
Skeleton
Toast
Tooltip

Images

Image(
  src: '/images/avatar.png',
  alt: 'User avatar',
  width: 80,
  height: 80,
  loading: ImageLoading.lazy,
  decoding: ImageDecoding.async,
)

With a caption:

Figure(
  image: Image(
    src: '/images/server.png',
    alt: 'Server rack',
  ),
  caption: 'Primary hosting node',
)

Use the navigation singleton:

navigation.assign('/dashboard');
navigation.redirect('/login');
navigation.replace('/settings');
navigation.back();
navigation.reload();

Read the current URL:

currentUrl
currentPath
currentQuery
currentHash
currentUri

Example active sidebar:

Sidebar(
  items: const [
    SidebarItem(label: 'Overview', href: '/dashboard'),
    SidebarItem(label: 'Settings', href: '/dashboard/settings'),
  ],
  activePath: currentUri.path,
)

Query Parameters

Use the query helper when you need to read or update browser query state without manually parsing window.location.search.

final tab = query.get('tab');

query.set('tab', 'billing');
query.update({
  'page': 2,
  'filter': 'active',
});
query.remove('filter');

Browser Storage

Flint UI provides a small browser storage abstraction:

localStorage.write('theme', 'dark');
final theme = localStorage.read('theme');
localStorage.remove('theme');

Session storage:

sessionStorage.write('draft', 'hello');

Cookies:

cookies.write(
  'auth.token',
  token,
  maxAge: const Duration(days: 7),
  sameSite: CookieSameSite.lax,
);

final token = cookies.read('auth.token');
cookies.remove('auth.token');

Auth Session

AuthSessionManager stores an auth token and user object in browser storage:

const authSession = AuthSessionManager(
  tokenKey: 'app.token',
  userKey: 'app.user',
);

authSession.save(
  token: token,
  user: {
    'id': 1,
    'email': 'admin@example.com',
    'role': 'admin',
  },
);

if (authSession.isLoggedIn) {
  navigation.assign('/dashboard');
}

authSession.clear();

By default it uses localStorage. You can provide another storage implementation:

const sessionAuth = AuthSessionManager(
  storage: sessionStorage,
);

HTTP Client

Use clientRouter to call FlintDart APIs from the browser:

final response = await clientRouter.group('/auth').post<Map<String, dynamic>>(
  '/login',
  body: {
    'email': email,
    'password': password,
  },
);

if (response.isError) {
  throw response.error!;
}

final data = response.data;

When running in the browser, ClientRouter defaults to the current browser origin if no base URL is provided.

Data Fetching With FlintDart

Flint UI is designed to pair with FlintDart in two phases:

  1. Render the first screen from FlintDart with res.page(..., props: {...}).
  2. Refresh and mutate browser data with ResourceController and FlintModelApi.

This gives you fast first paint, predictable state, and live UI updates without hand-writing fetch boilerplate in every component.

Server Props First

Use FlintDart models on the server, then pass plain JSON props to the browser:

Future<Response> dashboard(Request req, Response res) async {
  final plans = await Plan().all();

  return res.page(
    'Dashboard',
    title: 'Dashboard',
    script: '/main.dart.js',
    props: {
      'plans': plans.map((plan) => plan.toMap()).toList(),
    },
  );
}

Resource State In The Browser

Use a resource when data can load, refresh, fail, or be locally mutated:

class PlansPanel extends FlintComponent {
  PlansPanel(List<Map<String, dynamic>> initialPlans) {
    plans = ResourceController<List<FlintModelRecord>>(
      initialData: initialPlans.map(FlintModelRecord.new).toList(),
      loader: () => FlintModelApi<FlintModelRecord>
          .records('/plans')
          .list(),
      loadImmediately: true,
    );
  }

  late final ResourceController<List<FlintModelRecord>> plans;

  @override
  void willUnmount() {
    plans.dispose();
  }

  @override
  FlintNode build() {
    return ResourceView<List<FlintModelRecord>>(
      plans,
      (snapshot) {
        final rows = snapshot.data ?? const <FlintModelRecord>[];

        if (snapshot.isLoading && rows.isEmpty) {
          return DataTable(columns: columns, loading: true);
        }

        if (snapshot.isError && rows.isEmpty) {
          return EmptyState(
            title: 'Could not load plans',
            message: snapshot.error.toString(),
          );
        }

        return Column(children: [
          Button(
            child: 'Refresh',
            onPressed: (_) => plans.refresh(silent: true),
          ),
          DataTable(
            columns: columns,
            rows: [
              for (final plan in rows)
                TableRowData(cells: {
                  'name': plan.string('name') ?? 'Plan',
                  'price': plan.string('price') ?? '0',
                }),
            ],
          ),
        ]);
      },
    );
  }
}

ResourceController<T> stores:

status     idle | loading | success | error
data       the latest successful value
error      the latest error
updatedAt  when data/error last changed

Use refresh(silent: true) when you want to keep existing data visible while the next request runs.

Can Flint UI Use FlintDart Models Directly?

Not directly in browser code. Server-side FlintDart Model classes depend on database and server APIs, so they should stay on the server.

The recommended bridge is:

FlintDart Model -> toMap()/JSON -> Flint UI DTO/FlintModelRecord

For quick dashboards, use FlintModelRecord:

final api = FlintModelApi<FlintModelRecord>.records('/users');
final users = await api.list();
final email = users.first.string('email');

For larger apps, create typed client DTOs:

class PlanDto {
  PlanDto.fromJson(Map<String, dynamic> json)
      : id = json['id'].toString(),
        name = json['name'].toString(),
        price = double.tryParse(json['price']?.toString() ?? '') ?? 0;

  final String id;
  final String name;
  final double price;
}

final plansApi = FlintModelApi<PlanDto>(
  path: '/plans',
  fromJson: PlanDto.fromJson,
);

State Management

Flint UI has three practical state layers:

setState          component-local state
StateSignal<T>    shared reactive value
ResourceController<T> API data with loading/error/cache state

Component State

Use setState for state owned by one component:

class Counter extends FlintComponent {
  int count = 0;

  @override
  FlintNode build() {
    return Button(
      child: 'Count: $count',
      onPressed: (_) => setState(() => count++),
    );
  }
}

Shared State

Use StateSignal<T> when multiple components need the same value:

final sidebarOpen = StateSignal<bool>(true);

StateSignalListener<bool>(
  sidebarOpen,
  (open) => Text(open ? 'Open' : 'Closed'),
);

API State

Use ResourceController<T> for data fetched from FlintDart:

final users = ResourceController<List<FlintModelRecord>>(
  loader: () => FlintModelApi<FlintModelRecord>.records('/users').list(),
);

await users.load();

One-Way Or Two-Way?

Flint UI's recommended architecture is one-way data flow:

server props/API data -> state/resource -> build() -> DOM
user event -> setState/resource mutation -> build() -> DOM

Form controls can feel like two-way binding because TextEditingController and FormController keep input fields synchronized with form state. Under the hood, that is still controlled state: user input updates the controller, the controller notifies listeners, and the component rerenders from the new value.

For complex apps, prefer one-way state for pages and resources, and use controlled form fields for editing.

A complete source example lives at example/resource_dashboard.dart. It shows:

server props -> ResourceController initialData
GET /plans -> FlintModelApi refresh
FormController -> controlled input state
ResourceController.mutate -> local UI update
ResourceView -> loading/error/cached-data rendering

Environment Config

Use EnvironmentConfig for browser-side configuration:

final apiBase = env.get('API_BASE_URL', fallback: '/api');

This is useful when the server injects public configuration into the page.

FlintDart Integration

A typical FlintDart fullstack UI route looks like this:

class UiController {
  Response login(Request req, Response res) {
    return res.page(
      'Login',
      title: 'Login',
      script: '/main.dart.js',
      props: {
        'authBase': '/auth',
      },
    );
  }
}

And the browser entrypoint:

void main() {
  createFlintApp(
    '#app',
    registry: FlintComponentRegistry({
      'Login': (props) => LoginPage(props),
      'Dashboard': (props) => DashboardPage(props),
    }),
  );
}

Development

Install dependencies:

dart pub get

Run tests:

dart test -p chrome

Run focused tests:

dart test test/style_test.dart -p chrome
dart test test/widgets_test.dart -p chrome

Analyze:

dart analyze

Generate API documentation:

dart doc

The generated API reference is written to doc/api/index.html.

Format:

dart format lib test

Package Structure

lib/
  flint_ui.dart              # Full public export
  flint_ui_core.dart         # Core public export
  src/
    auth/                    # Auth session helpers
    client/                  # Browser API client
    config/                  # Browser environment config
    navigation/              # Browser navigation/query helpers
    storage/                 # localStorage/sessionStorage/cookies
    style.dart               # Style public entrypoint
    style/                   # Split style modules
    widgets/                 # Component library

Design Direction For V1

Flint UI v1 should make it possible to build production admin apps, dashboards, auth flows, forms, and operational tools with Dart-first components.

Priority areas:

Typed style system
Responsive layout primitives
Core form controls
Feedback and loading states
Tables and data views
App shells and navigation
Better examples and docs
Stable FlintDart fullstack workflow

License

MIT

Libraries

flint_ui
Browser entrypoint APIs for building Flint UI applications.
flint_ui_core
Core component, node, style, storage, navigation, and widget APIs.