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.12
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++);
},
),
],
);
}
}
Theme mode is state too, so you can place a switch beside or after a counter:
class ThemeModeToggle extends FlintComponent {
@override
FlintNode build() {
return StateSignalListener(flintTheme.mode, (mode) {
final isDark = mode == FlintThemeMode.dark;
return Container(
dartStyle: DartStyle(
padding: const EdgeInsets.all(16),
radius: ThemeToken.radius('md'),
background: ThemeToken.color('surface'),
color: ThemeToken.color('text'),
light: DartStyle(border: Border.all(color: Colors.slate200)),
dark: DartStyle(shadow: ThemeToken.shadow('card')),
),
children: [
Text.span(isDark ? 'Dark mode' : 'Light mode'),
Button(
child: isDark ? 'Use light' : 'Use dark',
onPressed: (_) => flintTheme.toggle(),
),
],
);
});
}
}
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.
Configure app-wide theme tokens once at startup:
createFlintApp(
'#app',
registry: componentRegistry,
themeMode: FlintThemeMode.dark,
);
Then use semantic tokens anywhere:
Container(
dartStyle: DartStyle(
background: ThemeToken.color('surface'),
color: ThemeToken.color('text'),
radius: ThemeToken.radius('md'),
light: DartStyle(
border: Border.all(color: ThemeToken.color('surfaceBorder')),
),
dark: DartStyle(
shadow: ThemeToken.shadow('card'),
),
),
)
Use ThemeProvider only when a subtree needs to override the global mode:
ThemeProvider(
mode: FlintThemeMode.light,
child: SettingsPreview(),
)
Switch the global theme from UI state with flintTheme:
StateSignalListener(flintTheme.mode, (mode) {
return Button(
child: mode == FlintThemeMode.dark ? 'Light mode' : 'Dark mode',
onPressed: (_) => flintTheme.toggle(),
);
})
The selected mode is stored in local storage by default. On page load Flint uses the saved mode first, then the system color scheme, then the app default.
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
Video
Audio
MediaPreview
Canvas
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
SwitchRow
TextEditingController
FormController
Layout
AppShell
Grid
Section
Panel
PageHeader
Spacer
Stack
StatCard
Topbar
Wrap
Divider
EmptyState
Data
Avatar
DataTable
DescriptionList
ProgressBar
Table
Timeline
UsageMeter
Feedback
Alert
Spinner
StatusBadge
Navigation
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',
)
Media And Device APIs
Media and device controllers are server-safe through conditional exports. In a
browser build they call native Web APIs; in server rendering they return typed
unsupported results without touching window, camera, microphone, or location.
final devices = MediaDevicesController();
final preview = MediaElementController();
final camera = await devices.camera();
MediaPreview(
controller: preview,
result: camera,
)
Control native media elements with the same controller:
final player = MediaElementController();
Column(children: [
Video(controller: player, src: '/media/demo.mp4'),
Row(children: [
Button(child: 'Play', onPressed: (_) => player.play()),
Button(child: 'Pause', onPressed: (_) => player.pause()),
]),
])
Location lookup follows the same SSR-safe pattern:
const location = GeoLocationController();
final position = await location.currentPosition();
if (position.granted) {
print('${position.latitude}, ${position.longitude}');
}
Canvas Editor
CanvasController manages retained objects that can be rendered in the browser
and serialized on the server. It supports selection, dragging, resize/rotate
handles, keyboard controls, grouping, history, snapping, rulers, guides, and
JSON import/export.
final canvas = CanvasController(
showGrid: true,
showRulers: true,
snapToGrid: true,
gridSize: 12,
constraints: const CanvasObjectConstraints(
minWidth: 24,
minHeight: 24,
keepAspectRatio: true,
preventOutsideCanvas: true,
),
);
canvas.addRect(
const CanvasRect(
id: 'card',
name: 'Card background',
x: 24,
y: 24,
width: 160,
height: 96,
borderRadius: 12,
paint: CanvasPaint(fill: '#ffffff', stroke: '#0f172a'),
),
);
Canvas(controller: canvas, width: 720, height: 420)
Common editor actions stay on the controller:
canvas.select('card');
canvas.moveSelectedBy(12, 0);
canvas.resizeSelectedFromHandle(CanvasSelectionHandle.resizeSouthEast, 20, 10);
canvas.rotateSelectedBy(15);
final scene = canvas.toJson();
final selectedOnly = canvas.selectedToJson();
canvas.undo();
canvas.redo();
Navigation
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
Build app navigation from primitives so each application owns its route tree and layout:
Container(
props: {'aria-label': 'Main navigation'},
dartStyle: const DartStyle(display: Display.grid, gap: 8),
children: [
Link(href: '/dashboard', child: 'Overview'),
Link(href: '/dashboard/settings', child: 'Settings'),
],
)
Routing And Page Patterns
Keep routes owned by the application. Flint UI should provide page shells, browser navigation helpers, query helpers, and API client helpers, while each app decides its route names, middleware, permissions, and sidebar structure.
Recommended pattern:
- Server route loads the first page payload.
- Page component receives props and renders the shell.
clientRouterhandles follow-up actions without hardcoding the backend host.querykeeps list filters, pagination, and tabs in the URL.- App-owned navigation is composed from primitives such as
Container,Link,Row,Column,Tabs, andButton.
Server-rendered page entry:
Future<void> subscriptionsPage(Context ctx) async {
final page = int.tryParse(ctx.req.query['page'] ?? '') ?? 1;
final result = await subscriptions.list(page: page, perPage: 25);
return ctx.res.page(
'Subscriptions',
CustomerSubscriptionsPage(
subscriptions: result.items,
page: result.page,
perPage: result.perPage,
total: result.total,
),
);
}
Client actions stay grouped by API area:
final billingApi = clientRouter.group('/api/customer/billing');
Future<void> cancelSubscription(String id, String reason) async {
final response = await billingApi.post(
'/subscriptions/$id/cancel',
body: {'reason': reason},
);
if (response.ok) {
navigation.reload();
}
}
List pages should keep pagination and filters shareable:
void goToPage(int page) {
query.update({
'page': page,
'perPage': 25,
});
}
Pagination(
page: currentPage,
pageSize: 25,
total: total,
onChanged: goToPage,
)
Detail/manage pages should keep the primary object visible and move uncommon actions into menus, drawers, or modals owned by the app. Settings pages should use form primitives such as TextField(readonly: true), SwitchRow, Select, and explicit save buttons. Checkout pages should be application-owned because pricing, currency, invoice rules, and payment callbacks are business logic.
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:
- Render the first screen from FlintDart with
res.page(..., props: {...}). - Refresh and mutate browser data with
ResourceControllerandFlintModelApi.
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.
- flint_ui_server
- Server-side Flint UI APIs.