Skip to Content
DocsAdd WebF To FlutterBuild Hybrid UI Components

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 Widget

Example 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/webf

Step 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.dart with FlutterCupertinoButtonBindings
  • 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:

  1. Always use WebFWidgetElementChild to wrap child widgets for proper lifecycle management
  2. For single child: Use childNodes.first.toWidget()
  3. For multiple children: Use childNodes.map((node) => node.toWidget())
  4. For maintaining DOM context: Wrap children in WebFHTMLElement with childNodes.toWidgetList()
  5. 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 to true, allows the RenderWidget to 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 to true, allows the RenderWidget to 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

ScenarioallowsInfiniteHeightallowsInfiniteWidthExample
Vertical scrollable content-ListView, ScrollView
Horizontal scrollable content-Horizontal ListView
Both directions scrollable2D ScrollView
Context menus and modalsCupertinoContextMenu, 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:

  • RenderWidget will 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:

  1. Return SizedBox.shrink() in build() - The element doesn’t render in normal tree
  2. Use WebFHTMLElement to wrap children - Maintains DOM context for popup content
  3. Use useRootNavigator: true - Ensures popup shows above all other content
  4. Expose imperative methods - Use StaticDefinedSyncBindingObjectMethod for show()/hide()
  5. Track showing state - Prevent multiple simultaneous shows with _isShowing flag
  6. Handle cleanup - Always dispatch close event and reset state in finally block
  7. Check context validity - Verify buildContext.mounted before showing popup

Next Steps