Build Hybrid UI Components
WebF’s Hybrid UI system lets you expose Flutter widgets as custom HTML elements in WebF apps. This enables native performance, platform widgets, and seamless integration between Flutter and web technologies.
Overview of the Hybrid UI Approach
WebF’s Hybrid UI system enables:
- Native performance: UI-critical widgets rendered by Flutter
- Platform widgets: Use iOS/Android native components (Cupertino/Material)
- Existing Flutter packages: Leverage Flutter’s ecosystem
- Seamless integration: WebF apps treat them like normal HTML elements
Architecture
JavaScript/HTML → Custom Element → WidgetElement (Dart) → Flutter WidgetExample Flow
React code:
import React from 'react';
import { FlutterButton } from 'my-widgets';
function MyComponent() {
const handlePress = () => {
console.log('Button pressed!');
};
return (
<FlutterButton label="Click Me" onPress={handlePress} />
);
}Flutter-side implementation:
class FlutterButton extends WidgetElement {
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () => dispatchEvent(Event('press')),
child: Text(getAttribute('label') ?? 'Button'),
);
}
}
// Register the custom element
WebF.defineCustomElement('flutter-button', (context) => FlutterButton(context));Workflow with webf codegen
This is the recommended approach for production apps. The CLI generates all necessary code automatically.
Step 1: Install WebF CLI
npm install -g @openwebf/webfStep 2: Create TypeScript Definitions
Create a .d.ts file describing your widget’s interface:
// my-widgets.d.ts
/**
* Properties for <flutter-cupertino-button>
*/
interface FlutterCupertinoButtonProperties {
/**
* Visual variant of the button.
* - 'plain': Standard CupertinoButton
* - 'filled': CupertinoButton.filled
* - 'tinted': CupertinoButton.tinted
* Default: 'plain'
*/
variant?: string;
/**
* Size style used to derive default padding and min height.
* - 'small': minSize ~32, compact padding
* - 'large': minSize ~44, comfortable padding
* Default: 'small'
*/
size?: string;
/**
* Disable interactions.
*/
disabled?: boolean;
/**
* Opacity applied while pressed (0.0–1.0). Default: 0.4
*/
'pressed-opacity'?: string;
/**
* Hex color used when disabled. Accepts '#RRGGBB' or '#AARRGGBB'.
*/
'disabled-color'?: string;
}
/**
* Events emitted by <flutter-cupertino-button>
*/
interface FlutterCupertinoButtonEvents {
/** Fired when the button is pressed (not emitted when disabled). */
click: Event;
}
/**
* Properties for <flutter-cupertino-slider>
*/
interface FlutterCupertinoSliderProperties {
/** Current value of the slider. Default: 0.0. */
val?: double;
/** Minimum value of the slider range. Default: 0.0. */
min?: double;
/** Maximum value of the slider range. Default: 100.0. */
max?: double;
/** Number of discrete divisions between min and max. */
step?: int;
/** Whether the slider is disabled. Default: false. */
disabled?: boolean;
}
interface FlutterCupertinoSliderMethods {
/** Get the current value. */
getValue(): double;
/** Set the current value (clamped between min and max). */
setValue(val: double): void;
}
interface FlutterCupertinoSliderEvents {
/** Fired whenever the slider value changes. detail = value. */
change: CustomEvent<double>;
/** Fired when the user starts interacting with the slider. */
changestart: CustomEvent<double>;
/** Fired when the user stops interacting with the slider. */
changeend: CustomEvent<double>;
}Step 3: Generate Bindings
webf codegen my-widgets.d.ts \
--flutter-package-src=../my_flutter_package \
--publish-to-npm \
--npm-registry=https://registry.npmjs.org/This generates:
- Dart binding classes (abstract base classes) in
my_flutter_package/lib/src/- Example:
button_bindings_generated.dartwithFlutterCupertinoButtonBindings
- Example:
- NPM package with TypeScript definitions and React components
- Registration code structure
Important: webf codegen generates binding classes only. You still need to implement the actual widget logic.
Step 4: Implement Your Widget (Dart)
Extend the generated binding class and implement the widget:
// lib/src/button.dart
import 'package:flutter/cupertino.dart';
import 'package:webf/webf.dart';
import 'button_bindings_generated.dart';
class FlutterCupertinoButton extends FlutterCupertinoButtonBindings {
FlutterCupertinoButton(super.context);
String _variant = 'plain';
String _sizeStyle = 'small';
bool _disabled = false;
double _pressedOpacity = 0.4;
String? _disabledColor;
// Implement property getters/setters from bindings
@override
String get variant => _variant;
@override
set variant(value) {
_variant = value;
}
@override
String get size => _sizeStyle;
@override
set size(value) {
_sizeStyle = value;
}
@override
bool get disabled => _disabled;
@override
set disabled(value) {
_disabled = value;
}
@override
String get pressedOpacity => _pressedOpacity.toString();
@override
set pressedOpacity(value) {
_pressedOpacity = double.tryParse(value) ?? 0.4;
}
@override
String? get disabledColor => _disabledColor;
@override
set disabledColor(value) {
_disabledColor = value;
}
@override
WebFWidgetElementState createState() {
return FlutterCupertinoButtonState(this);
}
}
class FlutterCupertinoButtonState extends WebFWidgetElementState {
FlutterCupertinoButtonState(super.widgetElement);
@override
FlutterCupertinoButton get widgetElement => super.widgetElement as FlutterCupertinoButton;
@override
Widget build(BuildContext context) {
// Handling children: Convert child nodes to Flutter widgets
// Use WebFWidgetElementChild for proper lifecycle management
Widget buttonChild = WebFWidgetElementChild(
child: widgetElement.childNodes.isEmpty
? const SizedBox()
: widgetElement.childNodes.first.toWidget()
);
switch (widgetElement.variant) {
case 'filled':
return CupertinoButton.filled(
onPressed: widgetElement.disabled ? null : () {
widgetElement.dispatchEvent(Event('click'));
},
pressedOpacity: widgetElement._pressedOpacity,
child: buttonChild,
);
case 'tinted':
return CupertinoButton.tinted(
onPressed: widgetElement.disabled ? null : () {
widgetElement.dispatchEvent(Event('click'));
},
pressedOpacity: widgetElement._pressedOpacity,
child: buttonChild,
);
default:
return CupertinoButton(
onPressed: widgetElement.disabled ? null : () {
widgetElement.dispatchEvent(Event('click'));
},
pressedOpacity: widgetElement._pressedOpacity,
child: buttonChild,
);
}
}
}Step 5: Register Your Widget
import 'package:webf/webf.dart';
import 'src/button.dart';
void installMyWidgets() {
WebF.defineCustomElement(
'flutter-cupertino-button',
(context) => FlutterCupertinoButton(context)
);
}
// In your main.dart
void main() {
installMyWidgets();
WebFControllerManager.instance.initialize(...);
runApp(MyApp());
}Step 6: Use in Your WebF App
npm install my-widgets// React example
import React, { useState } from 'react';
import { FlutterCupertinoSlider, FlutterCupertinoButton } from 'my-widgets';
function App() {
const [value, setValue] = useState(50);
return (
<div>
<FlutterCupertinoSlider
val={value}
min={0}
max={100}
step={10}
onChange={(e) => setValue(e.detail)}
onChangeStart={(e) => console.log('Started:', e.detail)}
onChangeEnd={(e) => console.log('Ended:', e.detail)}
/>
<FlutterCupertinoButton
variant="filled"
size="large"
disabled={false}
pressed-opacity="0.6"
onClick={() => console.log('Submitted!', value)}
>
Submit
</FlutterCupertinoButton>
</div>
);
}Handling Children in Custom Elements
When your custom element needs to render child content from HTML/React, you need to convert child nodes to Flutter widgets.
Single Child Pattern
// For single child (like Button)
Widget buttonChild = WebFWidgetElementChild(
child: widgetElement.childNodes.isEmpty
? const SizedBox()
: widgetElement.childNodes.first.toWidget()
);Multiple Children Pattern
// For multiple children (like Card, Container)
class FlutterCardState extends WebFWidgetElementState {
@override
Widget build(BuildContext context) {
return Card(
child: Column(
children: [
// Convert all child nodes to widgets
...widgetElement.childNodes.map((node) =>
WebFWidgetElementChild(child: node.toWidget())
),
],
),
);
}
}Slotted Content Pattern
For complex layouts with slotted content, create dedicated slot element classes:
// Create dedicated slot element classes
class FlutterCupertinoListTileLeading extends WidgetElement {
FlutterCupertinoListTileLeading(super.context);
@override
WebFWidgetElementState createState() => FlutterCupertinoListTileLeadingState(this);
}
class FlutterCupertinoListTileLeadingState extends WebFWidgetElementState {
@override
Widget build(BuildContext context) {
return WebFWidgetElementChild(
child: widgetElement.childNodes.firstOrNull?.toWidget(),
);
}
}
// Similar classes for Trailing, Subtitle, etc.
// Parent element filters children by type instead of querySelector
class FlutterListTileState extends WebFWidgetElementState {
Widget? _buildSlotChild<T>() {
for (final node in widgetElement.childNodes) {
if (node is T) {
if (node is dom.Node) {
final widget = node.toWidget();
if (widget != null) {
return WebFWidgetElementChild(child: widget);
}
}
}
}
return null;
}
Widget? _buildTitle() {
final List<Widget> children = <Widget>[];
for (final node in widgetElement.childNodes) {
// Skip slot elements
if (node is FlutterCupertinoListTileLeading ||
node is FlutterCupertinoListTileTrailing) {
continue;
}
final widget = node.toWidget();
if (widget != null) {
children.add(WebFWidgetElementChild(child: widget));
}
}
if (children.isEmpty) return null;
if (children.length == 1) return children.first;
return Row(
mainAxisSize: MainAxisSize.min,
children: children,
);
}
@override
Widget build(BuildContext context) {
// Use type-based filtering (best practice)
final Widget? leading = _buildSlotChild<FlutterCupertinoListTileLeading>();
final Widget? trailing = _buildSlotChild<FlutterCupertinoListTileTrailing>();
final Widget title = _buildTitle() ?? const SizedBox.shrink();
return ListTile(
leading: leading,
title: title,
trailing: trailing,
);
}
}DOM Context Preservation
For dialogs, modals, or when children need to maintain their DOM context:
class FlutterModalPopupState extends WebFWidgetElementState {
Widget _buildPopupContent(BuildContext dialogContext) {
Widget child;
if (widgetElement.childNodes.isEmpty) {
child = const SizedBox.shrink();
} else {
// Wrap children in WebFHTMLElement to maintain DOM structure
child = WebFWidgetElementChild(
child: WebFHTMLElement(
tagName: 'DIV',
controller: widgetElement.ownerDocument.controller,
parentElement: widgetElement,
children: widgetElement.childNodes.toWidgetList(),
),
);
}
return Container(
padding: EdgeInsets.all(16),
child: child,
);
}
@override
Widget build(BuildContext context) {
// Modal popup doesn't render in the normal widget tree
return const SizedBox.shrink();
}
}Key Points:
- Always use
WebFWidgetElementChildto wrap child widgets for proper lifecycle management - For single child: Use
childNodes.first.toWidget() - For multiple children: Use
childNodes.map((node) => node.toWidget()) - For maintaining DOM context: Wrap children in
WebFHTMLElementwithchildNodes.toWidgetList() - For slotted content: Create dedicated slot element classes and use type-based filtering with
if (node is T)
Handling Infinite Layout Constraints
When creating custom widgets that contain scrollable content (like ListView, ScrollView, context menus, or modals), you need to override constraint properties to allow infinite dimensions.
Key Properties
class FlutterCupertinoContextMenu extends FlutterCupertinoContextMenuBindings {
FlutterCupertinoContextMenu(super.context);
@override
bool get allowsInfiniteHeight => true;
@override
bool get allowsInfiniteWidth => true;
// ... rest of implementation
}Property Descriptions
-
allowsInfiniteHeight: When set totrue, allows theRenderWidgetto pass infinite height constraints to its child if it receives infinite height constraints itself. This is essential for widgets that wrap scrollable containers (ListView, ScrollView, dialogs, modals, etc.) in the vertical direction. -
allowsInfiniteWidth: When set totrue, allows theRenderWidgetto pass infinite width constraints to its child if it receives infinite width constraints itself. This is essential for widgets that wrap horizontally scrollable containers or need to expand horizontally without bounds.
When to Use These Properties
| Scenario | allowsInfiniteHeight | allowsInfiniteWidth | Example |
|---|---|---|---|
| Vertical scrollable content | ✓ | - | ListView, ScrollView |
| Horizontal scrollable content | - | ✓ | Horizontal ListView |
| Both directions scrollable | ✓ | ✓ | 2D ScrollView |
| Context menus and modals | ✓ | ✓ | CupertinoContextMenu, Dialogs |
| List tiles and items | ✓ | - | CupertinoListTile |
| Fixed-size widgets | - | - | Button, Slider |
Real-World Examples
// Example 1: Context Menu - needs both infinite dimensions
class FlutterCupertinoContextMenu extends FlutterCupertinoContextMenuBindings {
@override
bool get allowsInfiniteHeight => true;
@override
bool get allowsInfiniteWidth => true;
}
// Example 2: List Tile - only needs infinite height
class FlutterCupertinoListTile extends FlutterCupertinoListTileBindings {
@override
bool get allowsInfiniteHeight => true;
// allowsInfiniteWidth defaults to false
}
// Example 3: ListView with horizontal scrolling
class WebFListViewElement extends WebFListViewBindings {
Axis _axis = Axis.vertical;
@override
bool get allowsInfiniteWidth =>
// Only loosen horizontal constraints when the list scrolls horizontally;
// vertical lists need a bounded cross-axis width to satisfy Flutter's
// shrink-wrapping viewport requirements.
axis == Axis.horizontal;
}Default Behavior
By default, both properties return false in the WidgetElement base class. This means:
RenderWidgetwill clamp child constraints to the viewport size- Most fixed-size widgets (buttons, sliders, text fields) work fine with defaults
- Only override when your widget explicitly needs to handle infinite constraints
Advanced Patterns
Popups, Dialogs, and Modals
Popup-related widgets have special requirements since they don’t render in the normal widget tree. Here’s the complete pattern:
// 1. Element class with imperative methods
class FlutterCupertinoModalPopup extends FlutterCupertinoModalPopupBindings {
FlutterCupertinoModalPopup(super.context);
bool _visible = false;
double? _height;
bool _maskClosable = true;
double _backgroundOpacity = 0.4;
@override
bool get visible => _visible;
@override
set visible(value) {
final bool next = value == true;
if (next == _visible) return;
_visible = next;
if (state == null) return;
if (next) {
state!.showPopup();
} else {
state!.hidePopup();
dispatchEvent(CustomEvent('close'));
}
}
// Imperative JavaScript methods exposed via StaticDefinedSyncBindingObjectMethod
void _showSync(List<dynamic> args) {
visible = true;
}
void _hideSync(List<dynamic> args) {
visible = false;
}
static StaticDefinedSyncBindingObjectMethodMap modalPopupMethods = {
'show': StaticDefinedSyncBindingObjectMethod(
call: (element, args) {
final popup = castToType<FlutterCupertinoModalPopup>(element);
popup._showSync(args);
return null;
},
),
'hide': StaticDefinedSyncBindingObjectMethod(
call: (element, args) {
final popup = castToType<FlutterCupertinoModalPopup>(element);
popup._hideSync(args);
return null;
},
),
};
@override
List<StaticDefinedSyncBindingObjectMethodMap> get methods => [
...super.methods,
modalPopupMethods,
];
@override
WebFWidgetElementState createState() => FlutterCupertinoModalPopupState(this);
}
// 2. State class that handles Flutter's modal APIs
class FlutterCupertinoModalPopupState extends WebFWidgetElementState {
FlutterCupertinoModalPopupState(super.widgetElement);
@override
FlutterCupertinoModalPopup get widgetElement =>
super.widgetElement as FlutterCupertinoModalPopup;
bool _isShowing = false;
Future<void> showPopup() async {
if (_isShowing) return;
final BuildContext? buildContext = context;
if (buildContext == null || !buildContext.mounted) {
return;
}
_isShowing = true;
try {
await showCupertinoModalPopup<void>(
context: buildContext,
useRootNavigator: true,
barrierDismissible: widgetElement._maskClosable,
barrierColor: CupertinoColors.black.withOpacity(
widgetElement._backgroundOpacity.clamp(0.0, 1.0),
),
builder: (BuildContext dialogContext) {
return _buildPopupContent(dialogContext);
},
);
} finally {
_isShowing = false;
widgetElement._visible = false;
widgetElement.dispatchEvent(CustomEvent('close'));
}
}
void hidePopup() {
if (!_isShowing) return;
final BuildContext? buildContext = context;
if (buildContext == null) return;
Navigator.of(buildContext, rootNavigator: true).pop();
}
Widget _buildPopupContent(BuildContext dialogContext) {
Widget child;
if (widgetElement.childNodes.isEmpty) {
child = const SizedBox.shrink();
} else {
// Critical: Wrap children in WebFHTMLElement to maintain DOM structure
child = WebFWidgetElementChild(
child: WebFHTMLElement(
tagName: 'DIV',
controller: widgetElement.ownerDocument.controller,
parentElement: widgetElement,
children: widgetElement.childNodes.toWidgetList(),
),
);
}
final double? fixedHeight = widgetElement._height;
Widget content = child;
if (fixedHeight != null && fixedHeight > 0) {
content = SizedBox(
height: fixedHeight,
width: double.infinity,
child: child,
);
}
// Wrap with Cupertino styling
content = CupertinoPopupSurface(
isSurfacePainted: true,
child: content,
);
return Align(
alignment: Alignment.bottomCenter,
child: content,
);
}
@override
Widget build(BuildContext context) {
// Popup host element doesn't render anything in the normal tree
return const SizedBox.shrink();
}
}Usage in React:
import React, { useRef } from 'react';
import { FlutterCupertinoModalPopup } from 'my-widgets';
function MyComponent() {
const popupRef = useRef<HTMLElement>(null);
const openPopup = () => {
popupRef.current?.show();
};
return (
<>
<button onClick={openPopup}>Open Popup</button>
<FlutterCupertinoModalPopup
ref={popupRef}
height={300}
mask-closable={true}
background-opacity={0.5}
onClose={() => console.log('Popup closed')}
>
<div style={{ padding: '20px' }}>
<h2>Popup Content</h2>
<p>This renders in a Flutter modal overlay!</p>
<button onClick={() => popupRef.current?.hide()}>Close</button>
</div>
</FlutterCupertinoModalPopup>
</>
);
}Key Points for Popups:
- Return
SizedBox.shrink()in build() - The element doesn’t render in normal tree - Use
WebFHTMLElementto wrap children - Maintains DOM context for popup content - Use
useRootNavigator: true- Ensures popup shows above all other content - Expose imperative methods - Use
StaticDefinedSyncBindingObjectMethodforshow()/hide() - Track showing state - Prevent multiple simultaneous shows with
_isShowingflag - Handle cleanup - Always dispatch
closeevent and reset state infinallyblock - Check context validity - Verify
buildContext.mountedbefore showing popup
Next Steps
- Build Native Plugins - Expose Flutter plugins to JavaScript
- Advanced Topics - Performance, theming, and more