Creating Widgets
Add your own Flutter widgets to Fuse
Every Fuse widget has two parts: a JS wrapper component (what consumers import and write in JSX) and a Dart widget builder (how it renders). This page walks through creating a custom widget from scratch.
Before building from scratch, check pub.dev — the Dart side is just Flutter, so an existing package (charts, maps, video, pickers) can be wrapped instead of reinvented. The mechanics are identical to this page; see Using pub.dev packages.
The two-file pattern
Let's build a Badge widget that renders a Flutter Chip.
Step 1: Write the JS wrapper
import type { ColorInput, FlexInput } from "solid-fuse";
export interface BadgeProps {
label: string;
color?: ColorInput;
children?: any;
flex?: FlexInput;
}
export function Badge(props: BadgeProps) {
return <badge {...props} />;
}The lowercase <badge> is the wire format — the Solid universal renderer creates a node with type: "badge" that Dart will look up. Consumers never see it; they import Badge and write <Badge label="New" />.
You don't need to declare
<badge>in a.d.tsfile —solid-fusedefines the JSX namespace as an open[name: string]: anycatch-all so any lowercase tag works.
Step 2: Write the Dart widget
import 'package:flutter/material.dart';
import 'package:solid_fuse/solid_fuse.dart';
class FuseBadge extends StatelessWidget {
const FuseBadge(this.node);
final FuseNode node;
@override
Widget build(BuildContext context) {
final label = node.string('label') ?? '';
final color = node.color('color') ?? Colors.blue;
return Chip(
label: Text(label),
backgroundColor: color,
side: BorderSide.none,
);
}
}Step 3: Register
runtime.registerWidget('badge', FuseBadge.new);Now consumers can use it:
import { Badge } from "./badge";
<Badge label="New" color="green" />Why wrappers?
Wrapper components are the seam between user code and the renderer. Once you own that seam you can:
- Provide typed props so consumers get autocomplete and type errors
- Add default props, theme injection, dev-mode warnings, or perf marks later — in one place, without renaming every callsite
- Tree-shake unused widgets out of production bundles
Adding JS behavior to a widget no longer means switching from <widget> to <MyWidget> — the wrapper is already there.
FuseNode prop accessors
FuseNode extends FuseMap, which provides typed accessors for reading props. All return nullable — if the prop wasn't set in JSX, you get null.
| Accessor | Returns | What it parses |
|---|---|---|
string(key) | String? | String values |
double(key) | double? | Numbers |
int(key) | int? | Integers |
bool(key) | bool? | Booleans |
map(key) | FuseMap? | Nested objects (also has typed accessors) |
list<T>(key) | List<T>? | Arrays |
color(key) | Color? | Hex strings, named colors, RGB/HSL objects |
edgeInsets(key) | EdgeInsets? | Number or {top, left, ...} object |
borderRadius(key) | BorderRadius? | Number or {topLeft, ...} object |
boxDecoration(key) | BoxDecoration? | Full decoration with color, borders, shadows, gradient |
alignment(key) | Alignment? | Position names ("center", "topLeft", etc.) |
clipBehavior(key) | Clip | "hardEdge", "antiAlias", "none" (defaults to Clip.none) |
Nested objects return FuseMap, which has the same accessors — so you can do node.map('config')?.string('mode').
The flex + flexChildren mechanic
If you add
flex?: FlexInputto your wrapper props and usenode.flexChildrenin your Dart widget, layout just works. Users of your widget can passflex={{ direction: "horizontal", gap: 8 }}and children will arrange automatically — you don't write any layout code.
This is how every built-in element handles children. Here's a wrapper that supports it:
import type { FlexInput } from "solid-fuse";
export interface MyCardProps {
children?: any;
flex?: FlexInput;
}
export function MyCard(props: MyCardProps) {
return <myCard {...props} />;
}class FuseMyCard extends StatelessWidget {
const FuseMyCard(this.node);
final FuseNode node;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: node.flexChildren, // ← reads flex prop automatically
),
);
}
}Now consumers can do:
<MyCard flex={{ gap: 8 }}>
<Text>Title</Text>
<Text>Subtitle</Text>
</MyCard>How flexChildren works
- Reads the
flexprop from the node - If there's a
flexconfig: wraps children in a FlutterFlexwidget (Row/Column) with the specified gap, alignment, and justification - If there's only one child and no flex config: returns the child directly (no wrapper)
- This single-child optimization avoids unnecessary layout nodes
childWidgets vs flexChildren
node.flexChildren— reads theflexprop and applies layout. Use this for anything that should support user-configured layout. This is the recommended default.node.childWidgets— raw list of child widgets, no layout applied. Use this when you're passing children to a Flutter widget that manages its own layout (likeNavigatororStack).
Event callbacks
To support event callbacks from Dart to JS, use node.callback():
// Returns a callback if the prop was set in JSX, null if not
final onTap = node.callback('onTap');
// Safe to use directly — null means "no handler"
GestureDetector(
onTap: onTap,
child: node.flexChildren,
)Under the hood: when you write onTap={() => doSomething()} in JSX, the actual function is stored in a JS-side handler map. The Dart prop is just true — a marker that a handler exists. When Dart calls the callback, it sends a message back to JS via the bridge, which looks up and executes the real function.
To send data back with the callback:
node.callback('onChange')?.call(newValue);StatefulWidget
Use StatefulWidget when your widget needs persistent Dart-side state (animations, text editing, controllers). Flutter preserves State across rebuilds because each FuseNodeWidget uses ValueKey(node.id) — so your state survives prop changes and tree updates.
class FuseTextInput extends StatefulWidget {
const FuseTextInput(this.node);
final FuseNode node;
@override
State<FuseTextInput> createState() => _FuseTextInputState();
}
class _FuseTextInputState extends State<FuseTextInput> {
late final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _controller,
onChanged: (value) => widget.node.callback('onChange')?.call(value),
);
}
}Where should your widget live?
| Location | When to use | How to register |
|---|---|---|
| In your app | Domain-specific, uses app-level deps | runtime.registerWidget(...) after packages |
| In a Fuse package | Reusable across apps, has its own deps | Via register() function + fuse link |
| In solid-fuse | Core Flutter primitives any app needs | PR to the framework |
To wrap an existing Flutter package instead of building from scratch, see Using pub.dev packages. For creating a reusable package, see Packaging.