Storybook Design System
Flutter package to build a storybook for your Design System: an attribute panel, a live preview of your component, and an auto-generated code snippet.
Live demo: showroom-ds.web.app
Reference implementation: see example/lib/ — in particular:
example/lib/main.dart— boots the app.example/lib/custom_card_storybook.dart— theCustomCardstory.example/lib/custom_card.dart— the widget itself (with@StorybookModelfor the nested config object).example/lib/sample_themes.dart— sample Material 3 themes.example/lib/storybook_example_attributes.dart— a reusablecolorPickerfactory.
Since version 1.2.5 the package no longer uses
reflectable(reflection does not run on Flutter AOT). Since 1.3.0, all internal references toAttributeDtohave been renamed toAttributeModel.AttributeDtoremains as a deprecated type alias for backwards compatibility. You build theAttributeModellist by hand (this guide shows how) or use the optionalstorybook_ds_builder(withbuild_runner) only to generatemerge*helpers for nested objects.
1. Install
In an existing Flutter app:
flutter pub add storybook_ds
In pubspec.yaml:
dependencies:
flutter:
sdk: flutter
storybook_ds: ^1.3.0
Single import you'll use everywhere:
import 'package:storybook_ds/storybook_ds.dart';
To use the merge* generator (nested objects), add to dev_dependencies:
dev_dependencies:
build_runner: 2.4.13
storybook_ds_builder: ^1.0.0
If
pub getcomplains about a conflict betweenbuild_runnerand the builder, pinbuild_runnerto2.4.13as inexample/pubspec.yaml.
2. What the package gives you
The abstract base is the Storybook<T> class. You extend it in a State and the package draws, around your widget:
- A preview (with
DevicePreviewand theme switching). - A left-side attribute panel with the knobs you declared.
- A code snippet that shows how to instantiate the widget with the current values.
To make that work, your State has to implement:
| Member | Required? | Purpose |
|---|---|---|
String get title |
yes | Story title |
String get description |
yes | Short description |
String get nameObjectInDisplay |
yes | Class name in the snippet (e.g. CustomCard) |
List<AttributeModel> get attributes |
yes | The panel controls |
Widget buildComponentWidget(BuildContext) |
yes | Your widget being rendered |
MultipleThemeSettings? multipleThemeSettings |
optional | Switch themes in the preview |
OnBuildExtraAttributesConfigCustom? get extraAttributesConfigCustom |
optional | Extra UI in the panel |
StorybookTheme? get storybookTheme |
optional | Colors of the storybook chrome |
There's no magic: you describe a list of knobs and the panel renders the editors automatically (String, bool, int, double range, enum, Color, Function?, nested object).
3. Step-by-step: build a story screen from scratch
I'll use the CustomCard from the example as a reference. By the end of these steps you'll have a story like example/lib/custom_card_storybook.dart and your widget (CustomCard here) as a regular StatefulWidget.
Step 1 — Host the story inside a MaterialApp
The story must live inside a MaterialApp. If you want the global theme to change when the user picks a theme in the panel, keep ThemeData in state:
// example/lib/main.dart
import 'package:flutter/material.dart';
import 'custom_card_storybook.dart';
void main() {
runApp(const StorybookDemoApp());
}
class StorybookDemoApp extends StatefulWidget {
const StorybookDemoApp({super.key});
@override
State<StorybookDemoApp> createState() => _StorybookDemoAppState();
}
class _StorybookDemoAppState extends State<StorybookDemoApp> {
ThemeData _appTheme = ThemeData.light();
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Storybook DS — example',
theme: _appTheme,
debugShowCheckedModeBanner: false,
home: CustomCardStorybook(
onThemeChanged: (ThemeData next) {
setState(() => _appTheme = next);
},
),
);
}
}
Important detail: the
CustomCardStorybookreceives aValueChanged<ThemeData>in its constructor. That callback is invoked by theStorybookbase every time the user picks another theme in the panel.
Step 2 — The story StatefulWidget
class CustomCardStorybook extends StatefulWidget {
const CustomCardStorybook({
super.key,
required this.onThemeChanged,
});
final ValueChanged<ThemeData> onThemeChanged;
@override
Storybook<CustomCardStorybook> createState() => _CustomCardStorybookState();
}
Notes:
- The class is a regular
StatefulWidget. createStatereturns aState<...>that extendsStorybook<...>(the package's base).onThemeChangedis what wires the panel's selected theme to theMaterialApp.
Step 3 — The State extends Storybook<T>
class _CustomCardStorybookState extends Storybook<CustomCardStorybook> {
@override
String get description =>
'Magna et nonumy dolor duo sanctus sed est stet voluptua...';
@override
String get nameObjectInDisplay => 'CustomCard';
@override
String get title => 'Custom Card';
@override
Widget buildComponentWidget(BuildContext context) {
return const Center(child: Text('work in progress'));
}
}
Four required getters for now. In step 6 we'll fill buildComponentWidget properly and in step 4 we'll declare attributes.
Step 4 — Declare attributes
Each AttributeModel is one knob in the panel. name is the key you use later in getWhereAttribut('name').
The most common types (all are factories of AttributeModel):
| Factory | Control rendered in the panel |
|---|---|
AttributeModel(name:..., type:'String', ...) |
Free-form text |
StorybookAttributeFactories.factoryAttributeDtoString(...) |
Long text with presets at 5, 10, 20, 40, 80, 120 words (great for description) |
AttributeModel(name:..., type:'bool', ...) |
On/off switch |
AttributeModel.enumType(values: ...) |
Enum dropdown |
AttributeModel.rangeIntInterval(begin, end, ...) |
Integer slider |
AttributeModel.rangeDoubleInterval(begin, end, ...) |
Double slider |
AttributeModel.color(...) |
Color picker |
AttributeModel.function(...) |
Callback (button) |
AttributeModel.objectInObject(...) |
Sub-panel for a nested object (needs merge) |
Real example (snippet from example/lib/custom_card_storybook.dart):
late final List<AttributeModel> attributes = [
// Required string with long-text presets
StorybookAttributeFactories.factoryAttributeDtoString(
name: 'title',
selectedValue: 'Lorem justo clita tempor labore',
required: true,
builders: _cardBuildersAll, // ['', 'inline', 'outline']
),
// Boolean
AttributeModel(
name: 'hidden',
type: 'bool',
selectedValue: VariableOption(value: false),
builders: _cardBuildersAll,
),
// Enum
AttributeModel.enumType(
name: 'imageDisplay',
selectedValue: CardImageDisplay.image,
values: CardImageDisplay.values,
builders: _cardImageBuildersAll,
),
// Double range
AttributeModel.rangeDoubleInterval(
name: 'width',
begin: 220,
end: 400,
canBeNull: true,
selectedValue: null,
builders: _cardBuildersAll,
),
// Callback (action button)
AttributeModel(
name: 'onPositive',
type: 'Function()?',
selectedValue: VariableOption(value: '(){}'),
builders: _cardBuildersAll,
),
];
Two valuable tips:
builders: const [''](empty string) is the default (unnamed) constructor. Use''for default, nevernull.- To reuse combinations, declare file-level constants:
const _cardBuildersAll = <String>['', 'inline', 'outline'];
const _cardBuilderDefaultOnly = <String>[''];
Step 5 — The builders field in detail
builders is a List<String> saying which constructors / factories that attribute appears under in the panel. You compare the value with selectedConstructor in buildComponentWidget.
Value in builders |
Meaning |
|---|---|
'' (empty string) |
Default (unnamed) constructor |
'inline', 'outline', ... |
Named factory — use the same label you test in selectedConstructor == 'inline' |
Mental example for CustomCard:
- The widget has the default constructor
CustomCard(...)and two factories:CustomCard.inline(...)andCustomCard.outline(...). - The
variantparameter only exists in the default constructor (the factories fix the variant internally). So:builders: _cardBuilderDefaultOnly(i.e.['']). - The
imageDisplayparameter exists in all constructors. So:builders: _cardImageBuildersAll(i.e.['', 'inline', 'outline']). - Everything else (
title,description,width,height,hidden,enabled,borderEnabled,settings,onPositive,onNegative,textPositive,textNegative) also exists in all →builders: _cardBuildersAll.
Step 6 — Implement buildComponentWidget
Here you read the knobs with getWhereAttribut('name') and decide which constructor to use with selectedConstructor.
Recommended pattern (a helper for readability):
T? _knob<T>(String name) => getWhereAttribut(name) as T?;
Three builders (one per constructor), each assembling the widget:
CustomCard _cardDefaultConstructor() {
return CustomCard(
title: _knob<String>('title') ?? '',
variant: _knob<CustomCardVariant>('variant') ?? CustomCardVariant.inline,
description: _knob<String>('description'),
settings: _knob<CustomCardSettings>('settings'),
onNegative: _knob<dynamic>('onNegative') != null ? () {} : null,
textNegative: _knob<String>('textNegative'),
onPositive: _knob<dynamic>('onPositive') != null ? () {} : null,
textPositive: _knob<String>('textPositive'),
height: _knob<double>('height'),
width: _knob<double>('width'),
hidden: _knob<bool>('hidden') ?? false,
enabled: _knob<bool>('enabled') ?? true,
imageDisplay: _knob<CardImageDisplay>('imageDisplay') ?? CardImageDisplay.image,
imageUrl: _kDefaultImageUrl,
borderEnabled: _knob<bool>('borderEnabled') ?? false,
);
}
CustomCard _cardInlineFactory() { /* CustomCard.inline(...) */ }
CustomCard _cardOutlineFactory() { /* CustomCard.outline(...) */ }
And buildComponentWidget branching on selectedConstructor:
@override
Widget buildComponentWidget(BuildContext context) {
final canvasColor = Theme.of(context).scaffoldBackgroundColor;
return Scaffold(
backgroundColor: canvasColor,
body: Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (selectedConstructor.isEmpty) _cardDefaultConstructor(),
if (selectedConstructor == 'inline') _cardInlineFactory(),
if (selectedConstructor == 'outline') _cardOutlineFactory(),
],
),
),
),
);
}
Key points:
selectedConstructor.isEmpty= default constructor (same as''inbuilders).selectedConstructor == 'inline'= theCustomCard.inline(...)factory.- For
Function?attributes (onPositive,onNegative), the panel only sends a value when the user enables the knob. Check_knob<dynamic>('onPositive') != nullbefore passing a real() {}, avoiding accidental null callbacks.
Step 7 — Optional themes (MultipleThemeSettings)
If you want to switch themes inside the preview, assign the multipleThemeSettings field in initState (the Storybook base already declares that field):
@override
void initState() {
super.initState();
multipleThemeSettings = MultipleThemeSettings(
selectableThemes: [
ThemeSettings(
title: 'System (default Material)',
light: ThemeData.light(useMaterial3: true),
dark: ThemeData.dark(useMaterial3: true),
),
ThemeSettings(
title: 'M3 — Sage (light / dark)',
light: buildSampleM3Theme(seedColor: sampleSeedSage, brightness: Brightness.light),
dark: buildSampleM3Theme(seedColor: sampleSeedSage, brightness: Brightness.dark),
),
// ...other themes
],
);
}
And forward the chosen theme to the MaterialApp in onUpdateTheme:
@override
void onUpdateTheme(MultipleThemeSettings settings) {
widget.onThemeChanged(settings.selectedThemes.currentTheme());
}
currentTheme() returns the right ThemeData (light or dark) of the theme the user picked in the panel.
4. Nested objects in the panel (AttributeModel.objectInObject)
When your widget takes a config object (e.g. CustomCardSettings), you want the panel to let users edit each field of that object separately. To make that work:
- Annotate the config class with
@StorybookModel(). - Add
part 'my_class.storybook.g.dart';in that file. - Run the generator (it produces a
mergeClassNamefunction). - Use
AttributeModel.objectInObject(...)withmerge: mergeClassName.
4.1. Annotate the model
// example/lib/custom_card.dart
import 'package:flutter/material.dart';
import 'package:storybook_ds/annotations.dart';
part 'custom_card.storybook.g.dart';
@StorybookModel()
class CustomCardSettings {
final Color? backgroundColor;
final Color? textColor;
final Color? borderColor;
const CustomCardSettings({
this.textColor,
this.backgroundColor,
this.borderColor,
});
}
Requirements for the class to be valid:
- An unnamed constructor with all fields as named parameters.
- All fields public (no leading
_).
4.2. Configure build_runner
In build.yaml (at the root of your app / example), limit the generator to annotated files so it doesn't scan the whole project:
# example/build.yaml
targets:
$default:
builders:
storybook_ds_builder|storybookModelBuilder:
generate_for:
- lib/custom_card.dart
In pubspec.yaml, ensure the dev_dependency:
dev_dependencies:
build_runner: 2.4.13
storybook_ds_builder: ^1.0.0
Run:
dart run build_runner build --delete-conflicting-outputs
This produces custom_card.storybook.g.dart with the function mergeCustomCardSettings(current, fieldName, newValue).
4.3. Use it in the panel
AttributeModel.objectInObject(
name: 'settings',
type: 'CustomCardSettings?',
merge: (instance, fieldName, newValue) => mergeCustomCardSettings(
instance as CustomCardSettings,
fieldName,
newValue,
),
children: [
StorybookExampleAttributes.colorPicker(
name: 'backgroundColor',
canBeNull: true,
builders: _cardBuildersAll,
),
StorybookExampleAttributes.colorPicker(
name: 'textColor',
canBeNull: true,
builders: _cardBuildersAll,
),
StorybookExampleAttributes.colorPicker(
name: 'borderColor',
canBeNull: true,
builders: _cardBuildersAll,
),
],
builders: _cardBuildersAll,
selectedValue: VariableOption(
value: const CustomCardSettings(
backgroundColor: null,
textColor: null,
borderColor: null,
),
),
),
In buildComponentWidget, just forward the object as settings: _knob<CustomCardSettings>('settings'). The generator ensures that on every sub-field edit, the object is rebuilt with a manual copyWith.
4.4. Without the generator (optional, manual)
If you don't want to use build_runner, write merge by hand:
CustomCardSettings mergeCustomCardSettings(
CustomCardSettings current,
String fieldName,
Object? newValue,
) {
switch (fieldName) {
case 'backgroundColor':
return CustomCardSettings(
backgroundColor: newValue as Color?,
textColor: current.textColor,
borderColor: current.borderColor,
);
case 'textColor':
return CustomCardSettings(
backgroundColor: current.backgroundColor,
textColor: newValue as Color?,
borderColor: current.borderColor,
);
case 'borderColor':
return CustomCardSettings(
backgroundColor: current.backgroundColor,
textColor: current.textColor,
borderColor: newValue as Color?,
);
default:
return current;
}
}
And use it the exact same way in AttributeModel.objectInObject(merge: ...).
5. Quick recipes
5.1. String attribute with handy presets (e.g. description)
StorybookAttributeFactories.factoryAttributeDtoString(
name: 'description',
selectedValue: 'Lorem ipsum dolor sit amet...',
canBeNull: true,
builders: _cardBuildersAll,
)
5.2. enum attribute
AttributeModel.enumType(
name: 'variant',
selectedValue: CustomCardVariant.outline,
values: CustomCardVariant.values,
builders: const [''], // default constructor only
)
5.3. bool attribute
AttributeModel(
name: 'borderEnabled',
type: 'bool',
selectedValue: VariableOption(value: false),
builders: _cardBuildersAll,
)
5.4. double slider (with null allowed)
AttributeModel.rangeDoubleInterval(
name: 'height',
begin: 160,
end: 600,
canBeNull: true,
selectedValue: null,
builders: _cardBuildersAll,
)
5.5. Reusable Color attribute (your own factory)
Instead of repeating the same AttributeModel with a color list, create a helper:
// example/lib/storybook_example_attributes.dart
class StorybookExampleAttributes {
StorybookExampleAttributes._();
static AttributeModel colorPicker({
required String name,
bool canBeNull = false,
VariableOption? selectedValue,
List<String> builders = const [''],
}) {
return AttributeModel(
type: 'Color${canBeNull ? '?' : ''}',
name: name,
builders: builders,
selectedValue: selectedValue ?? VariableOption(value: null),
variableOptions: [
VariableOption(value: Colors.lime, textInDisplay: 'Colors.lime', textInSelectedOptions: 'lime'),
VariableOption(value: Colors.black, textInDisplay: 'Colors.black', textInSelectedOptions: 'black'),
VariableOption(value: Colors.red, textInDisplay: 'Colors.red', textInSelectedOptions: 'red'),
VariableOption(value: Colors.white, textInDisplay: 'Colors.white', textInSelectedOptions: 'white'),
],
);
}
}
Usage:
StorybookExampleAttributes.colorPicker(
name: 'textColor',
canBeNull: true,
builders: _cardBuildersAll,
)
5.6. Function? attribute (callback / button)
AttributeModel(
name: 'onPositive',
type: 'Function()?',
selectedValue: VariableOption(value: '(){}'),
builders: _cardBuildersAll,
)
In the builder:
onPositive: _knob<dynamic>('onPositive') != null ? () {} : null,
6. Cheat sheet
StatefulWidget→State extends Storybook<T>.- Implement:
title,description,nameObjectInDisplay,attributes,buildComponentWidget. builders:''= default constructor; other strings = labels compared inselectedConstructor.getWhereAttribut('name')to read a knob's value.selectedConstructor.isEmpty→ default;selectedConstructor == 'inline'→inlinefactory.- Nested objects:
@StorybookModel()+part '*.storybook.g.dart';+dart run build_runner build. - Themes:
initState(assignmultipleThemeSettings) +onUpdateTheme(forward to the app). - No
reflectable: theAttributeModellist is explicit (you write it), no runtime magic.
7. Troubleshooting
- The attribute doesn't show up in the panel? Check
builders. If you created a knob withbuilders: const [''], it only appears for the default constructor. - The nested object doesn't update? You forgot the
mergeinAttributeModel.objectInObject. Without it, aStateErroris thrown when editing a sub-field. pub getcomplaining aboutbuild_runner? Pin to2.4.13as inexample/pubspec.yaml.- The code snippet shows parameters that don't exist on the current constructor? Verify the attribute
builderslists match the activeselectedConstructor.