Flutter Widget Cookbook
A hands-on collection of production-ready UI patterns every Flutter developer should know.
Every Flutter project eventually hits the same wall: you need a search bar, a loading skeleton, a swipeable list, or a responsive sidebar — and the widget catalog alone doesn't hand you the finished recipe. This cookbook bridges that gap. Each section solves a concrete, real-world problem with a complete, copy-paste Dart example you can drop straight into your app.
Think of this page as your personal recipe book. Whether you're building a social feed, an e-commerce dashboard, or a settings screen, the patterns below will save you hours of googling and stack-overflowing. Every recipe includes the full widget tree, key technique explanations, and practical tips to help you adapt the code to your own design system.
1. Custom AppBar with Search
Problem: You need a visually distinctive top bar that integrates a search field and a notification bell — common in e-commerce, messaging, and content apps. The default AppBar widget is too plain for branded experiences.
class CustomSearchAppBar extends StatelessWidget {
const CustomSearchAppBar({super.key});
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Colors.indigo, Colors.purple],
),
),
child: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
title: TextField(
decoration: InputDecoration(
hintText: 'Search products...',
prefixIcon: const Icon(Icons.search, color: Colors.white),
filled: true,
fillColor: Colors.white.withOpacity(0.2),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(30),
borderSide: BorderSide.none,
),
hintStyle: const TextStyle(color: Colors.white70),
),
style: const TextStyle(color: Colors.white),
),
actions: [
Stack(children: [
IconButton(
icon: const Icon(Icons.notifications_outlined),
onPressed: () {},
),
Positioned(
right: 8, top: 8,
child: CircleAvatar(
radius: 8, backgroundColor: Colors.red,
child: const Text('3',
style: TextStyle(fontSize: 10, color: Colors.white)),
),
),
]),
],
),
);
}
}
AppBar in a Container with backgroundColor: Colors.transparent lets the gradient show through. The Stack + Positioned pattern for the badge is reusable for any icon that needs a count indicator.
2. Avatar & Profile Card
Problem: Social apps, team dashboards, and chat interfaces all need a polished profile card with an avatar, online status indicator, user info, and action buttons. This pattern combines multiple widgets into one reusable component.
class ProfileCard extends StatelessWidget {
const ProfileCard({super.key});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.all(16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(children: [
Stack(alignment: Alignment.bottomRight, children: [
const CircleAvatar(
radius: 48,
backgroundImage: NetworkImage('https://i.pravatar.cc/150'),
),
Container(
width: 16, height: 16,
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
),
]),
const SizedBox(height: 16),
const Text('Sarah Chen',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const Text('Flutter Developer & UI Designer',
style: TextStyle(color: Colors.grey)),
const SizedBox(height: 16),
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [
_StatItem(128, 'Projects'),
_StatItem(4500, 'Followers'),
_StatItem(320, 'Following'),
]),
const SizedBox(height: 16),
SizedBox(width: double.infinity,
child: ElevatedButton(
onPressed: () {},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
),
child: const Text('Follow',
style: TextStyle(color: Colors.white)),
),
),
]),
),
);
}
}
class _StatItem extends StatelessWidget {
final int count;
final String label;
const _StatItem(this.count, this.label);
@override
Widget build(BuildContext context) {
return Column(children: [
Text('$count',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
Text(label, style: TextStyle(color: Colors.grey[600])),
]);
}
}
Stack with Alignment.bottomRight to overlay the online status indicator on the avatar. Extract the stats into a private _StatItem widget for clean separation.
3. Animated List
Problem: You need items to slide in when added and slide out when removed — essential for to-do apps, chat lists, and any dynamic collection. A plain ListView doesn't animate item changes.
class AnimatedListScreen extends StatefulWidget {
const AnimatedListScreen({super.key});
@override
State<AnimatedListScreen> createState() => _AnimatedListScreenState();
}
class _AnimatedListScreenState extends State<AnimatedListScreen> {
final _listKey = GlobalKey<AnimatedListState>();
final List<String> _items = [];
void _addItem() {
final index = _items.length;
_items.add('Item ${index + 1}');
_listKey.currentState!.insertItem(index);
}
void _removeItem(int index) {
final removed = _items.removeAt(index);
_listKey.currentState!.removeItem(
index,
(context, animation) => SizeTransition(
sizeFactor: animation,
child: Card(child: ListTile(title: Text(removed))),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Animated List')),
floatingActionButton: FloatingActionButton(
onPressed: _addItem,
child: const Icon(Icons.add),
),
body: AnimatedList(
key: _listKey,
initialItemCount: 0,
itemBuilder: (context, index, animation) {
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(1, 0), end: Offset.zero,
).animate(animation),
child: Card(child: ListTile(
title: Text(_items[index]),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () => _removeItem(index),
),
)),
),
);
},
),
);
}
}
AnimatedList requires you to call insertItem and removeItem explicitly. Always provide an animation widget in the itemBuilder — typically a FadeTransition or SlideTransition — for smooth entry/exit effects.
5. Pull-to-Refresh
Problem: Users expect to pull down on a list to reload fresh data — it's the universal gesture for "get me the latest." The RefreshIndicator wraps any scrollable widget and adds this behavior out of the box.
class PullToRefreshScreen extends StatefulWidget {
const PullToRefreshScreen({super.key});
@override
State<PullToRefreshScreen> createState() => _PullToRefreshScreenState();
}
class _PullToRefreshScreenState extends State<PullToRefreshScreen> {
List<String> _items = [];
Future<void> _refresh() async {
await Future.delayed(const Duration(seconds: 2));
setState(() {
_items = List.generate(20, (i) => 'Fresh item ${i + 1}');
});
}
@override
void initState() {
super.initState();
_refresh();
}
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: _refresh,
child: ListView.builder(
itemCount: _items.length,
itemBuilder: (context, index) =>
ListTile(title: Text(_items[index])),
),
);
}
}
ListView, CustomScrollView, and SingleChildScrollView all work. Always return a Future from onRefresh so the spinner dismisses when data is ready.
6. Swipe to Delete
Problem: Email apps, task managers, and chat apps let users swipe items to delete them, with an undo option. The Dismissible widget handles this gesture and provides the standard swipe-to-reveal pattern.
ListView.builder(
itemCount: tasks.length,
itemBuilder: (context, index) {
final task = tasks[index];
return Dismissible(
key: Key(task.id),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
color: Colors.red,
child: const Icon(Icons.delete, color: Colors.white),
),
confirmDismiss: (direction) async {
return await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete task?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel')),
TextButton(onPressed: () => Navigator.pop(ctx, true),
child: const Text('Delete')),
],
),
);
},
onDismissed: (direction) {
setState(() => tasks.removeAt(index));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${task.title} deleted'),
action: SnackBarAction(
label: 'UNDO',
onPressed: () => setState(() => tasks.insert(index, task)),
),
),
);
},
child: ListTile(title: Text(task.title)),
);
},
)
confirmDismiss returns a Future<bool> — return true to actually remove the item. The SnackBar action lets users undo the deletion. Always give Dismissible a unique Key for proper list reconciliation.
7. Custom Text Field
Problem: Every form needs polished input fields with labels, icons, validation, and special features like password visibility toggles. Wrapping TextFormField in a reusable widget eliminates repetitive decoration code.
class CustomTextField extends StatefulWidget {
final String label;
final IconData icon;
final String? hint;
final bool isPassword;
final String? Function(String?)? validator;
const CustomTextField({
required this.label,
required this.icon,
this.hint,
this.isPassword = false,
this.validator,
super.key,
});
@override
State<CustomTextField> createState() => _CustomTextFieldState();
}
class _CustomTextFieldState extends State<CustomTextField> {
bool _obscure = true;
@override
Widget build(BuildContext context) {
return TextFormField(
obscureText: widget.isPassword ? _obscure : false,
validator: widget.validator,
decoration: InputDecoration(
labelText: widget.label,
hintText: widget.hint,
prefixIcon: Icon(widget.icon),
suffixIcon: widget.isPassword
? IconButton(
icon: Icon(_obscure
? Icons.visibility_off
: Icons.visibility),
onPressed: () => setState(() => _obscure = !_obscure),
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12)),
filled: true,
),
);
}
}
// Usage:
Form(
child: Column(children: [
const CustomTextField(
label: 'Email',
icon: Icons.email_outlined,
hint: '[email protected]',
validator: (v) => v!.contains('@') ? null : 'Invalid email',
),
const SizedBox(height: 16),
const CustomTextField(
label: 'Password',
icon: Icons.lock_outlined,
isPassword: true,
validator: (v) => v!.length >= 8 ? null : 'Min 8 characters',
),
]),
)
_obscure state to flip between Icons.visibility and Icons.visibility_off. The validator callback pattern lets callers define custom validation logic without subclassing.
8. Tag/Chip Filter
Problem: E-commerce, music, and news apps use horizontal filter chips that users tap to select categories. You need selection state, horizontal scrolling, and visual feedback — all in one clean pattern.
class ChipFilter extends StatefulWidget {
const ChipFilter({super.key});
@override
State<ChipFilter> createState() => _ChipFilterState();
}
class _ChipFilterState extends State<ChipFilter> {
final List<String> _tags = ['All', 'Flutter', 'Dart', 'Firebase', 'UI/UX', 'Testing'];
int _selectedIndex = 0;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 48,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: _tags.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final selected = index == _selectedIndex;
return FilterChip(
label: Text(_tags[index]),
selected: selected,
onSelected: (_) => setState(() => _selectedIndex = index),
selectedColor: Colors.indigo,
checkmarkColor: Colors.white,
labelStyle: TextStyle(
color: selected ? Colors.white : Colors.grey[700],
),
);
},
),
);
}
}
FilterChip provides built-in selection state. For multi-select, replace the single _selectedIndex with a Set<int> and toggle membership on each tap. The ListView.separated adds consistent spacing between chips.
9. Shimmer Loading
Problem: When content loads from an API, users see a blank screen or a spinner. Shimmer skeletons (like Facebook and LinkedIn) give the impression of content loading and reduce perceived wait time significantly.
class ShimmerLoading extends StatefulWidget {
const ShimmerLoading({super.key});
@override
State<ShimmerLoading> createState() => _ShimmerLoadingState();
}
class _ShimmerLoadingState extends State<ShimmerLoading>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
)..repeat();
}
@override
void dispose() { _controller.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, _) => Column(
children: List.generate(5, (_) => Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8),
child: Row(children: [
Container(
width: 48, height: 48,
decoration: BoxDecoration(
color: Colors.grey[300],
shape: BoxShape.circle,
),
),
const SizedBox(width: 16),
Expanded(child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(height: 14, width: 120,
color: Colors.grey[300]),
const SizedBox(height: 8),
Container(height: 12, width: 80,
color: Colors.grey[200]),
],
)),
]),
)),
),
);
}
}
shimmer package: wrap your skeleton in Shimmer.fromColors with baseColor and highlightColor. The manual animation above shows the core concept without external dependencies.
10. Gradient Cards
Problem: Flat grey cards look dated. Gradient cards with icons and subtle shadows give your UI a modern, premium feel — perfect for feature highlights, stats dashboards, and promotional sections.
class GradientCard extends StatelessWidget {
final String title;
final String subtitle;
final IconData icon;
final List<Color> colors;
const GradientCard({
required this.title,
required this.subtitle,
required this.icon,
required this.colors,
super.key,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: colors,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(
color: colors.first.withOpacity(0.3),
blurRadius: 12,
offset: const Offset(0, 6),
)],
),
child: Row(children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: Colors.white, size: 28),
),
const SizedBox(width: 16),
Expanded(child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
)),
Text(subtitle, style: TextStyle(
color: Colors.white.withOpacity(0.8),
fontSize: 13,
)),
],
)),
]),
);
}
}
// Usage:
Column(children: [
const GradientCard(
title: 'Revenue', subtitle: '\$12,450 this month',
icon: Icons.trending_up,
colors: [Colors.indigo, Colors.purple],
),
const SizedBox(height: 16),
const GradientCard(
title: 'Users', subtitle: '1,280 active today',
icon: Icons.people,
colors: [Colors.teal, Colors.green],
),
])
BoxShadow uses the first gradient color at 30% opacity to create a natural, colored shadow that matches the card. The frosted icon container uses Colors.white.withOpacity(0.2) for a glassmorphism effect.
12. Real-World: Complete Dashboard
Problem: You've learned individual patterns — now you need to combine them into a real, production-quality screen. This dashboard pulls together gradient cards, a profile header, filter chips, and a responsive layout into one cohesive screen.
class DashboardScreen extends StatefulWidget {
const DashboardScreen({super.key});
@override
State<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends State<DashboardScreen> {
int _tab = 0;
final _tabs = ['Overview', 'Analytics', 'Reports'];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Dashboard'),
actions: [
IconButton(icon: const Icon(Icons.notifications),
onPressed: () {}),
],
),
body: RefreshIndicator(
onRefresh: () async => await Future.delayed(
const Duration(seconds: 1)),
child: ListView(padding: const EdgeInsets.all(16), children: [
// Filter chips
SizedBox(height: 48, child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: _tabs.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (ctx, i) => ChoiceChip(
label: Text(_tabs[i]),
selected: i == _tab,
onSelected: (_) => setState(() => _tab = i),
),
)),
const SizedBox(height: 16),
// Stat cards
LayoutBuilder(builder: (ctx, c) {
var cols = c.maxWidth > 600 ? 3 : 2;
return GridView.count(
crossAxisCount: cols,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: 12,
crossAxisSpacing: 12,
children: [
GradientCard(title: 'Users', subtitle: '12.4k',
icon: Icons.people,
colors: [Colors.indigo, Colors.purple]),
GradientCard(title: 'Revenue', subtitle: '\$8.2k',
icon: Icons.attach_money,
colors: [Colors.teal, Colors.green]),
GradientCard(title: 'Orders', subtitle: '342',
icon: Icons.shopping_cart,
colors: [Colors.orange, Colors.red]),
],
);
}),
const SizedBox(height: 24),
// Recent activity
Text('Recent Activity',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
...List.generate(5, (i) =>
ListTile(
leading: CircleAvatar(
child: Text('${i + 1}')),
title: Text('Activity ${i + 1}'),
subtitle: Text('Updated ${i + 1} min ago'),
trailing: const Icon(Icons.chevron_right),
),
),
]),
),
);
}
}
RefreshIndicator + ListView + GridView with shrinkWrap: true lets a grid coexist with a list inside a single scrollable. The LayoutBuilder dynamically adjusts the column count based on screen width.
Summary
- Custom AppBar — gradient background with integrated search and badge indicators
- Profile Card — stacked avatars with status dots and stat rows
- Animated List —
insertItem/removeItemwith slide transitions - Bottom Nav — floating center button via
Stack+Positioned - Pull-to-Refresh —
RefreshIndicatorwrapping any scrollable - Swipe to Delete —
Dismissiblewith undoSnackBar - Custom Text Field — reusable with validation and password toggle
- Chip Filter —
FilterChipin a horizontalListView - Shimmer Loading — skeleton placeholders with animated gradients
- Gradient Cards — linear gradients with colored shadows and icon badges
- Responsive Sidebar —
NavigationRailon tablet,Draweron mobile - Complete Dashboard — combining all patterns into one production screen