desen_ui 0.3.0
desen_ui: ^0.3.0 copied to clipboard
Web-grade UI components for Flutter, built on the widgets layer only. No Material or Cupertino dependency.
Desen UI #
Web-grade UI components for Flutter, built on the widgets layer only. No Material, no Cupertino.
Docs and live examples: https://derlioapp.github.io/desen_ui/
Status:
0.3.0, before 1.0: a minor release may still change the API, and every such change is listed in the changelog with how to update (VERSIONING.md). Requires Dart 3.13 and Flutter 3.47 or later.Foundation, overlay engine, localization (13 languages, plus Portuguese (Portugal) and Traditional Chinese) and 51 components plus form fields: button, badge, count, status dot, avatar, image, card, divider, alert, progress, spinner, skeleton, link, breadcrumb, checkbox, radio, radio card, switch, segmented control, slider, range slider, chip, choice chips, tabs, accordion, stepper, list, sidebar, top navigation, bottom navigation, pane header, empty state, pagination, toolbar, popover, tooltip, menu and context menu, dialog, panel and sheet, toast, select, form field, text field (single and multi-line), search field, autocomplete, multi-select, number field, table, scrollbar, calendar, date and date range picker, time picker, file upload, submenu, text magnifier;
Formintegration (DsFormFieldand typed form fields,DsValidators).
Principles #
- Widgets layer only.
lib/never importsmaterial.dart,cupertino.dart,material_uiorcupertino_ui(enforced by a test). - No app wrapper required.
DsAppis optional;DsScopeworks under any root, includingMaterialApp. Without either, components fall back to a default theme. Layers (menus, selects, comboboxes, popovers, pickers, dialogs, toasts) need anOverlay, and dialogs and panels aNavigator: anyWidgetsApp,MaterialApporDsAppprovides both. A tooltip without anOverlaysimply shows its control. - One seed, every role. Colors and shadows are generated in OKLCH from a single brand color, with a clash rule that keeps selection distinct from danger and success.
- End-user settings built in: contrast (soft / standard), corner style (sharp / standard / soft / pill), density (compact / touch), light / dark / system.
- Phones: density follows the platform unless you pin it: iOS and Android get
DsDensity.touch(44px tap areas while the visuals stay small, taller rows, body text 16/22), desktop and desktop browsersDsDensity.compact(24px targets per WCAG 2.5.8, body 14). - Keyboard focus shows only for keyboard users (
:focus-visible), as one line: fields turn their edge into a 2px focus edge (text fields on every focus, as browsers do for inputs), bordered controls swap their border for a 2px ring, filled controls get a ring with a small gap.
Quick start #
dependencies:
desen_ui: ^0.3.0
A complete first screen:
import 'package:desen_ui/desen_ui.dart';
import 'package:flutter/widgets.dart';
void main() => runApp(
DsApp(
theme: DsThemeData(seed: DsSeed.navy),
home: const HomePage(),
),
);
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final _email = TextEditingController();
@override
void dispose() {
_email.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final t = DsTheme.of(context);
return ColoredBox(
color: t.colors.canvas,
child: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 16,
children: [
Text('Sign up', style: t.typography.title.copyWith(color: t.colors.text)),
DsField(
label: const Text('Email'),
child: DsTextField(
controller: _email,
keyboardType: TextInputType.emailAddress,
),
),
DsButton(
onPressed: () => showDsToast(
context: context,
title: 'Welcome, ${_email.text}',
),
child: const Text('Continue'),
),
],
),
),
),
),
),
);
}
}
Inside an existing app:
DsScope(
theme: DsThemeData(seed: DsSeed.color(const Color(0xFF2D4D8B))),
child: child,
)
Buttons:
DsButton(onPressed: save, child: const Text('Save'))
DsButton(variant: .secondary, size: .sm, leading: const DsIcon(DsIcons.plus), onPressed: add, child: const Text('New task'))
DsButton.icon(icon: const DsIcon(DsIcons.ellipsis), semanticLabel: 'More', onPressed: openMenu)
// Customize one instance, a subtree, or build a new look on the same behavior:
DsButton(style: DsButtonStyle(borderColor: brand), …)
DsButtonTheme(data: const DsButtonThemeData(size: .sm), child: …)
DsPressable(onPressed: …, builder: (context, states, _) => …)
Customizing generated tokens (survives dark mode, contrast, corner and density switches):
DsThemeData(
seed: DsSeed.color(brand),
adjustColors: (k, brightness) => k.copyWith(link: …),
adjustRadii: (r, style) => r.copyWith(card: r.card + 4),
)
Component defaults for the whole app or one section (every component, one mechanism):
DsComponentThemes(
themes: const [
DsButtonThemeData(size: .sm),
DsChipThemeData(style: DsChipStyle(height: 28)),
],
child: app,
)
Floating layers are opaque. To make them frosted glass, give them a translucent fill and a backdrop filter. Keep the fill dense enough that text on it still reads over any content; the contrast budget assumes opaque layers.
import 'dart:ui' show ImageFilter;
DsApp(
builder: (context, child) {
final colors = DsTheme.colorsOf(context);
final glass = ImageFilter.blur(sigmaX: 24, sigmaY: 24);
final fill = colors.overlay.withValues(alpha: .8);
return DsComponentThemes(
themes: [
DsMenuThemeData(style: DsMenuStyle(background: fill, backdropFilter: glass)),
DsPopoverThemeData(style: DsPopoverStyle(background: fill, backdropFilter: glass)),
DsBottomNavThemeData(style: DsBottomNavStyle(background: fill, backdropFilter: glass)),
// also DsDialog, DsPanel, DsToast, DsToolbar, DsTooltip, DsTextSelectionToolbar
],
child: child!,
);
},
home: const HomePage(),
)
DsSurface draws the same kind of box for your own floating layers.
Reading tokens:
final colors = DsTheme.colorsOf(context); // rebuilds only when colors change
final t = DsTheme.of(context); // everything
Platforms, size and languages #
- Platforms: iOS, Android, web, macOS, Windows and Linux; widgets only, no platform plugins. Density, the text font and haptics follow the platform.
- Size: the bundled fonts add about 600 KB to every build (iOS and macOS included, even though they set text in the system font by default). Icons an app does not use are tree-shaken away.
- Languages: strings for 13 languages, plus Portuguese (Portugal) and Traditional Chinese, with right-to-left layout for Arabic.
DsAppresolves to itslocale, or English; to follow the device, pass the languages your app is translated into (orDsLocalizations.supportedLocales) assupportedLocales. Add or override strings withDsLocalizationScope. See Localization.
Fonts and icons #
Desen bundles Schibsted Grotesk for text and Geist Mono for code, so they need no setup (SIL Open Font License 1.1, see License). iOS and macOS apps set text in the system font (San Francisco) by default; pass a family to DsTypography to use another face there too. DsIcons is a fork of the free Hugeicons set: 5388 stroke icons from Hugeicons 4.3.5 (MIT), a few of them put together by Desen from Hugeicons parts where the free set has a gap (a plain clipboard, star-plus, eject), kept in this repository and updated only after review, drawn from SVG paths with no font asset; an icon an app does not use adds nothing to its size. Desen names the icons itself, after what they show and with Lucide's naming rules, in camel case (circle-check is DsIcons.circleCheck); where Lucide calls the same shape something else, that name works too (DsIcons.home is house). Components take icons as widgets, so any other icon set works too.
Testing your app #
Keyboard focus visibility is global state, like the browser's :focus-visible. Desen resets it when the test binding resets between tests; to pin the starting value (for example, to test focus rings as a desktop user sees them first), call:
setUp(() => DsFocusVisibility.debugReset(keyboard: true));
Known limitations #
- Screen readers aren't told which menu item opens a submenu (needs a role Flutter doesn't expose yet).
- Screen readers announce the select as a button with its expanded state, and the number field as an adjustable text field: Flutter doesn't support the combobox and spin button roles yet.
- Plain
FormState.validate()announces errors through Flutter's own unnamed announcement.formKey.currentState!.validateAndFocus()(fromDsFormValidation) focuses the first invalid field and announces it once, with its name. - VoiceOver users who turned hints off don't hear a field's description or error, which are attached as hints.
- Visual references (golden images) are rendered on macOS and checked by hand on the web and macOS; iOS and Android have not yet been checked on physical devices.
Layout #
| Path | Contents |
|---|---|
lib/src/foundation/ |
OKLCH conversion, contrast helpers, SVG path parser |
lib/src/icons/ |
DsIcon, DsIcons |
lib/src/painting/ |
DsShadow (with inset), DsBoxDecoration, DsSurface |
lib/src/theme/ |
Palette engine, color roles, shadows, radii, sizes, motion, typography, DsTheme, DsScope |
lib/src/app/ |
DsApp, DsPageRoute, DsScrollBehavior |
lib/src/behavior/ |
Headless behavior (DsPressable, DsMinTapTarget, DsFocusVisibility) |
lib/src/overlay/ |
Anchored layers (DsAnchoredOverlay, placement), modal route |
lib/src/l10n/ |
DsLocalizations (13 languages and 2 regional variants, generated) |
lib/src/components/ |
Styled components |
fonts/ |
Schibsted Grotesk and Geist Mono, with their licenses (SIL OFL 1.1) |
VERSIONING.md |
What counts as a breaking change, pre-1.0 rules, deprecation |
example/ |
The docs site (example/lib/site/, English, built with Desen): every component with live examples and code, foundations, guides and app examples |
Development #
Main stays green: flutter analyze, the format check and the full flutter test run before every commit (about 40 seconds). A new test must fail without its fix. Goldens are re-baselined only after reviewing the diff images, in a commit of their own.
flutter test # everything
flutter test --tags golden # visual regression only
flutter test --update-goldens --tags golden # after an intended visual change; review the PNGs
tool/native_snapshot.sh /tmp dark # native macOS (Impeller) render of the example
python3 tool/gen_colors.py lib/src/theme/colors.dart # regenerate DsColors
python3 tool/gen_icons.py # regenerate DsIcons from tool/icons/
python3 tool/import_icons.py --hugeicons <version> # take Hugeicons changes into tool/icons/ for review
python3 tool/gen_styles.py # regenerate component styles/themes
python3 tool/gen_l10n.py # regenerate DsLocalizations
License #
The code is under the MIT License (LICENSE). The font files in fonts/ are not: they are under the SIL Open Font License 1.1, which allows bundling and embedding them in any app, free or commercial. Its text is in fonts/ and in NOTICES, which Flutter adds to your app's license page (showLicensePage, LicenseRegistry) on its own.
- Schibsted Grotesk: Copyright 2023 The Schibsted-Grotesk Project Authors; license text in
fonts/OFL-SchibstedGrotesk.txt. - Geist Mono: Copyright 2024 The Geist Project Authors; license text in
fonts/OFL-GeistMono.txt.
The icon shapes are from the free Hugeicons set (MIT License, included in NOTICES).