December 10, 2025
Want Next-Level UI In Flutter?
Most Developers Ignore — Custom GPU Shaders.

By Chirag Prajapati
8 min read
Make your app feel handcrafted, not by animating more widgets — by offloading pixels to the GPU and painting experiences that are smooth, expressive, and cheap on the CPU.
Shaders feel like one of those features we know exist… but rarely touch. Yet they're the secret ingredient behind the kinds of interfaces that feel alive — flowing backgrounds, glassy surfaces, pixel-perfect distortions, and animations that almost breathe. And to make this easier to apply, I've added a real Flutter screen example you can copy or adapt directly in your app, so you don't just read about shaders — you actually use them. Once you see how little code it takes to unlock GPU-powered visuals, you'll never look at "normal UI" the same way again.
Not a member of Medium? Then, click here to read the full story.
Quick map — what I'll cover
- What
FragmentProgramis and when to use it. - Authoring a small fragment shader (GLSL) and where to put it.
- Loading + using the shader in Dart with
FragmentProgramandFragmentShader. - Updating uniforms cheaply and reusing shader objects.
- Debugging, pitfalls, and CI/asset tips.
- Final example with screenshot: how to implement this?
- Checklist to ship safely.
Why use shaders (short answer)
Because they move pixel work onto the GPU, which:
- lets you run per-pixel effects (blur, distortion, lighting) cheaply,
- produces smooth 60/120fps visuals that are hard to achieve with widget-based painting, and
- centralizes visual logic in a single shader that the GPU can execute massively in parallel.
In practice, I've replaced several CPU-driven animations and expensive Canvas loops with small shaders and got smoother frames and less CPU contention — particularly important on mid-range devices.
The mental model: FragmentProgram → FragmentShader → Paint.shader
- FragmentProgram — compiled shader asset that you load at runtime. Think of it as the shader binary.
- FragmentShader — a configured instance of the program with its uniforms (the per-draw parameters). You can create many
FragmentShaderinstances from oneFragmentProgram. - Paint.shader — the
FragmentShaderis used viaPaint.shaderwhen drawing canvases (or viaShaderMask,CustomPainter, etc.).
Load a program once, reuse it, and update uniforms on the shader instance for each frame.
1) Author a tiny shader (GLSL) — shaders/wave.frag
Create a shader file. A minimal example (using Flutter's runtime helpers) looks like:
// shaders/wave.frag
// Include helper functions for coordinate mapping (optional)
#include "flutter/runtime_effect.glsl";
uniform float u_width;
uniform float u_height;
uniform float u_time; // seconds
half4 main(vec2 fragCoord) {
vec2 uv = fragCoord / vec2(u_width, u_height);
float wave = 0.5 + 0.5 * sin(uv.x * 12.0 + u_time * 2.0);
vec3 base = vec3(0.12, 0.6, 0.9);
vec3 color = mix(base * 0.8, base, wave);
return half4(color, 1.0);
}// shaders/wave.frag
// Include helper functions for coordinate mapping (optional)
#include "flutter/runtime_effect.glsl";
uniform float u_width;
uniform float u_height;
uniform float u_time; // seconds
half4 main(vec2 fragCoord) {
vec2 uv = fragCoord / vec2(u_width, u_height);
float wave = 0.5 + 0.5 * sin(uv.x * 12.0 + u_time * 2.0);
vec3 base = vec3(0.12, 0.6, 0.9);
vec3 color = mix(base * 0.8, base, wave);
return half4(color, 1.0);
}Notes:
- The shader receives uniforms (
u_width,u_height,u_time) you will set from Dart. - Depending on your Flutter toolchain,
#include "flutter/runtime_effect.glsl"helps with coordinate helpers; check docs/examples for exact include paths. (See official docs for more.)
Add the shader to your pubspec.yaml under shaders:, not assets::
shaders:
- shaders/wave.fragshaders:
- shaders/wave.fragPutting shader paths in shaders: ensures Flutter's build system compiles them into the form FragmentProgram expects. Missing this is the most common "it doesn't work" bug.
2) Load and use the shader in Dart
Here is a copy-paste-friendly CustomPainter example.
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
class WavePainter extends CustomPainter {
final ui.FragmentShader shader;
final double time;
WavePainter({ required this.shader, required this.time });
@override
void paint(Canvas canvas, Size size) {
// Set uniforms in the order they appear in the shader (ignore sampler uniforms)
shader.setFloat(0, size.width); // u_width
shader.setFloat(1, size.height); // u_height
shader.setFloat(2, time); // u_time
final paint = Paint()..shader = shader;
canvas.drawRect(Offset.zero & size, paint);
}
@override
bool shouldRepaint(covariant WavePainter old) => time != old.time;
}import 'dart:ui' as ui;
import 'package:flutter/material.dart';
class WavePainter extends CustomPainter {
final ui.FragmentShader shader;
final double time;
WavePainter({ required this.shader, required this.time });
@override
void paint(Canvas canvas, Size size) {
// Set uniforms in the order they appear in the shader (ignore sampler uniforms)
shader.setFloat(0, size.width); // u_width
shader.setFloat(1, size.height); // u_height
shader.setFloat(2, time); // u_time
final paint = Paint()..shader = shader;
canvas.drawRect(Offset.zero & size, paint);
}
@override
bool shouldRepaint(covariant WavePainter old) => time != old.time;
}And how to prepare the shader and hook it up:
// somewhere in your StatefulWidget
ui.FragmentProgram? _program;
ui.FragmentShader? _shader;
double _time = 0.0;
late final Ticker _ticker;
@override
void initState() {
super.initState();
_loadShader();
_ticker = Ticker((elapsed) {
setState(() => _time = elapsed.inMilliseconds / 1000.0);
})..start();
}
Future<void> _loadShader() async {
_program = await ui.FragmentProgram.fromAsset('shaders/wave.frag');
// Create a FragmentShader instance - reuse across frames (fast to update uniforms)
_shader = _program!.fragmentShader();
}
@override
void dispose() {
_ticker.dispose();
super.dispose();
}// somewhere in your StatefulWidget
ui.FragmentProgram? _program;
ui.FragmentShader? _shader;
double _time = 0.0;
late final Ticker _ticker;
@override
void initState() {
super.initState();
_loadShader();
_ticker = Ticker((elapsed) {
setState(() => _time = elapsed.inMilliseconds / 1000.0);
})..start();
}
Future<void> _loadShader() async {
_program = await ui.FragmentProgram.fromAsset('shaders/wave.frag');
// Create a FragmentShader instance - reuse across frames (fast to update uniforms)
_shader = _program!.fragmentShader();
}
@override
void dispose() {
_ticker.dispose();
super.dispose();
}Key points:
FragmentProgram.fromAssetis async; load once (e.g., at app start or when the screen mounts).- Use
fragmentShader()to create aFragmentShader. Reuse that instance and callsetFloateach frame to update uniforms rather than recreating new shader objects each frame.setFloat(index, value)sets the float uniform at the specified index (the index order follows the shader's uniform declaration, skipping samplers).
3) Uniforms, samplers, and textures
- Floats / vec2 / vec3 / vec4: set via repeated calls to
setFloat. For vector types, you set each component in order. - Image samplers: use
setImageSampler(onFragmentShader) to bind a texture to a sampler uniform — useful for effects that process a captured image or texture. Reuse textures where possible to avoid allocations.
4) Where to put shaders and how they're built
- Use
shaders:inpubspec.yamlso Flutter compiles them with theimpellerctoolchain into the runtime formatFragmentProgramexpects. If you mistakenly put them underassets:, the loader can fail with misleading errors. Test build locally and in CI. - In debug mode, shader edits often hot-reload (the toolchain recompiles) so your iteration loop is fast — but always sanity-check on profile/release builds.
5) Performance best practices (practical)
- Reuse the
FragmentProgramandFragmentShader. Creating them repeatedly is expensive; set uniforms each frame instead. - Minimize uniform updates — pack only what changes each frame (e.g., time, touch coords).
- Avoid big textures: large image samplers cost memory and texture upload time; downsample when possible.
- Use
shouldRepaintand state checks to avoid unnecessary repaints (classicCustomPainterhygiene). - Test on mid-range devices — descriptive benchmarks on high-end hardware can be misleading.
- Profile in profile mode (not debug) to see real GPU/CPU behaviour. The Flutter docs note differences across modes.
6) Debugging & common gotchas
- "Asset does not contain valid shader data" — often caused by the shader not being in
shaders:or the toolchain not compiling it; check build logs and pubspec. (This error is surprisingly common and confusing.) - Uniform ordering — the integer index for
setFloatis determined by the uniform order in the shader (samplers ignored). If values look wrong, check your index mapping. - Hot reload weirdness — shader recompiles in debug, but confirm behavior in profile/release.
- Platform differences — GPU drivers and OS versions can affect shader capability. Test on Android + iOS devices you support.
7) Accessibility & UX considerations
Shaders are visual — don't hide critical content inside an effect:
- Always provide textual equivalents for important information.
- Avoid using purely shader-driven color contrast to convey state.
- If a shader animates, give users a way to reduce motion (respect system "reduce motion" preferences).
8) Testing & CI tips
- Include the
shaders:paths in CI and run a build step that validatesFragmentProgram.fromAssetcan load each compiled shader (a small smoke test). Catch the "didn't compile" problem early. - Check size impact: shaders add tiny binary blobs; track app size in CI.
- For visual regression, snapshot key frames (e.g., using golden tests) to detect regressions.
Final Example (with screenshot)
A shader can generate waves, gradients, distortions, ripples, and organic motion mathematically in real time.
In the screenshot you generated, the top section — the colorful, wavy, fluid background behind Total Balance — is the part made using a fragment shader.
And The Middle & Bottom Sections Were Not Shader-Based — by Design
1. Add the shader file
Create: shaders/wave_header.frag
// shaders/wave_header.frag
#include <flutter/runtime_effect.glsl>
uniform float u_width;
uniform float u_height;
uniform float u_time;
half4 main(vec2 fragCoord) {
vec2 uv = fragCoord / vec2(u_width, u_height);
// base colors
vec3 c1 = vec3(0.09, 0.15, 0.36);
vec3 c2 = vec3(0.26, 0.20, 0.70);
vec3 c3 = vec3(0.05, 0.60, 0.85);
// layered waves
float w1 = sin(uv.x * 6.0 + u_time * 0.9);
float w2 = sin(uv.x * 10.0 - u_time * 1.3 + 2.0);
float waveMix = (w1 + w2) * 0.25 + uv.y;
vec3 color = mix(c2, c3, smoothstep(0.0, 1.0, waveMix));
color = mix(c1, color, 0.8);
return half4(color, 1.0);
}// shaders/wave_header.frag
#include <flutter/runtime_effect.glsl>
uniform float u_width;
uniform float u_height;
uniform float u_time;
half4 main(vec2 fragCoord) {
vec2 uv = fragCoord / vec2(u_width, u_height);
// base colors
vec3 c1 = vec3(0.09, 0.15, 0.36);
vec3 c2 = vec3(0.26, 0.20, 0.70);
vec3 c3 = vec3(0.05, 0.60, 0.85);
// layered waves
float w1 = sin(uv.x * 6.0 + u_time * 0.9);
float w2 = sin(uv.x * 10.0 - u_time * 1.3 + 2.0);
float waveMix = (w1 + w2) * 0.25 + uv.y;
vec3 color = mix(c2, c3, smoothstep(0.0, 1.0, waveMix));
color = mix(c1, color, 0.8);
return half4(color, 1.0);
}2. Register the shader in pubspec.yaml
flutter:
uses-material-design: true
shaders:
- shaders/wave_header.fragflutter:
uses-material-design: true
shaders:
- shaders/wave_header.fragNo assets: for this file — it must be under shaders:.
3. Flutter screen code (lib/main.dart)
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Fintech Shader Demo',
theme: ThemeData(useMaterial3: true),
home: const FintechHomeScreen(),
);
}
}
class FintechHomeScreen extends StatefulWidget {
const FintechHomeScreen({super.key});
@override
State<FintechHomeScreen> createState() => _FintechHomeScreenState();
}
class _FintechHomeScreenState extends State<FintechHomeScreen>
with SingleTickerProviderStateMixin {
ui.FragmentProgram? _program;
ui.FragmentShader? _shader;
late final AnimationController _controller;
@override
void initState() {
super.initState();
_loadShader();
_controller = AnimationController.unbounded(vsync: this)
..repeat(period: const Duration(seconds: 10));
}
Future<void> _loadShader() async {
final program =
await ui.FragmentProgram.fromAsset('shaders/wave_header.frag');
setState(() {
_program = program;
_shader = program.fragmentShader();
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
bottomNavigationBar: NavigationBar(
selectedIndex: 0,
destinations: const [
NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
NavigationDestination(icon: Icon(Icons.sync_alt), label: 'Transfer'),
NavigationDestination(icon: Icon(Icons.credit_card), label: 'Cards'),
NavigationDestination(icon: Icon(Icons.more_horiz), label: 'More'),
],
),
body: Column(
children: [
SizedBox(
height: 260,
child: (_program == null || _shader == null)
? const _HeaderFallback()
: AnimatedBuilder(
animation: _controller,
builder: (context, _) {
return CustomPaint(
painter: _HeaderShaderPainter(
shader: _shader!,
time: _controller.lastElapsedDuration?.inMilliseconds
.toDouble() ??
0.0,
),
child: const _HeaderContent(),
);
},
),
),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
children: const [
_AccountsCard(),
SizedBox(height: 16),
_QuickTransferCard(),
],
),
),
],
),
);
}
}
/// Fallback simple gradient while shader loads
class _HeaderFallback extends StatelessWidget {
const _HeaderFallback();
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFF141E30),
Color(0xFF243B55),
],
),
),
child: const _HeaderContent(),
);
}
}
/// Foreground UI on top of the shader
class _HeaderContent extends StatelessWidget {
const _HeaderContent();
@override
Widget build(BuildContext context) {
return SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Total Balance',
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(color: Colors.white70)),
const SizedBox(height: 8),
Text('\$6,280.50',
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w700,
)),
],
),
),
);
}
}
/// Custom painter that actually draws the shader
class _HeaderShaderPainter extends CustomPainter {
final ui.FragmentShader shader;
final double time;
_HeaderShaderPainter({required this.shader, required this.time});
@override
void paint(Canvas canvas, Size size) {
shader.setFloat(0, size.width); // u_width
shader.setFloat(1, size.height); // u_height
shader.setFloat(2, time / 1000.0); // u_time (seconds)
final paint = Paint()..shader = shader;
canvas.drawRect(Offset.zero & size, paint);
}
@override
bool shouldRepaint(covariant _HeaderShaderPainter oldDelegate) =>
oldDelegate.time != time;
}
// ===== foreground cards =====================================================
class _AccountsCard extends StatelessWidget {
const _AccountsCard();
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Accounts',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
_AccountRow(label: 'Checking', last4: '1234', amount: '\$2,150.75'),
const SizedBox(height: 8),
_AccountRow(label: 'Savings', last4: '5678', amount: '\$4,129.75'),
],
),
),
);
}
}
class _AccountRow extends StatelessWidget {
final String label;
final String last4;
final String amount;
const _AccountRow({
required this.label,
required this.last4,
required this.amount,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(fontWeight: FontWeight.w500)),
Text('•••• $last4',
style: Theme.of(context).textTheme.bodySmall),
],
),
const Spacer(),
Text(amount,
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(fontWeight: FontWeight.w600)),
],
);
}
}
class _QuickTransferCard extends StatelessWidget {
const _QuickTransferCard();
@override
Widget build(BuildContext context) {
return Card(
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Quick Transfer',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
Row(
children: [
_RoundAction(icon: Icons.north_east, label: 'Send'),
const SizedBox(width: 16),
_RoundAction(icon: Icons.south_west, label: 'Request'),
],
),
],
),
),
);
}
}
class _RoundAction extends StatelessWidget {
final IconData icon;
final String label;
const _RoundAction({required this.icon, required this.label});
@override
Widget build(BuildContext context) {
return Column(
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.08),
shape: BoxShape.circle,
),
child: Icon(icon,
size: 24, color: Theme.of(context).colorScheme.primary),
),
const SizedBox(height: 4),
Text(label, style: Theme.of(context).textTheme.bodySmall),
],
);
}
}import 'dart:ui' as ui;
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Fintech Shader Demo',
theme: ThemeData(useMaterial3: true),
home: const FintechHomeScreen(),
);
}
}
class FintechHomeScreen extends StatefulWidget {
const FintechHomeScreen({super.key});
@override
State<FintechHomeScreen> createState() => _FintechHomeScreenState();
}
class _FintechHomeScreenState extends State<FintechHomeScreen>
with SingleTickerProviderStateMixin {
ui.FragmentProgram? _program;
ui.FragmentShader? _shader;
late final AnimationController _controller;
@override
void initState() {
super.initState();
_loadShader();
_controller = AnimationController.unbounded(vsync: this)
..repeat(period: const Duration(seconds: 10));
}
Future<void> _loadShader() async {
final program =
await ui.FragmentProgram.fromAsset('shaders/wave_header.frag');
setState(() {
_program = program;
_shader = program.fragmentShader();
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
bottomNavigationBar: NavigationBar(
selectedIndex: 0,
destinations: const [
NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
NavigationDestination(icon: Icon(Icons.sync_alt), label: 'Transfer'),
NavigationDestination(icon: Icon(Icons.credit_card), label: 'Cards'),
NavigationDestination(icon: Icon(Icons.more_horiz), label: 'More'),
],
),
body: Column(
children: [
SizedBox(
height: 260,
child: (_program == null || _shader == null)
? const _HeaderFallback()
: AnimatedBuilder(
animation: _controller,
builder: (context, _) {
return CustomPaint(
painter: _HeaderShaderPainter(
shader: _shader!,
time: _controller.lastElapsedDuration?.inMilliseconds
.toDouble() ??
0.0,
),
child: const _HeaderContent(),
);
},
),
),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
children: const [
_AccountsCard(),
SizedBox(height: 16),
_QuickTransferCard(),
],
),
),
],
),
);
}
}
/// Fallback simple gradient while shader loads
class _HeaderFallback extends StatelessWidget {
const _HeaderFallback();
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFF141E30),
Color(0xFF243B55),
],
),
),
child: const _HeaderContent(),
);
}
}
/// Foreground UI on top of the shader
class _HeaderContent extends StatelessWidget {
const _HeaderContent();
@override
Widget build(BuildContext context) {
return SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Total Balance',
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(color: Colors.white70)),
const SizedBox(height: 8),
Text('\$6,280.50',
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w700,
)),
],
),
),
);
}
}
/// Custom painter that actually draws the shader
class _HeaderShaderPainter extends CustomPainter {
final ui.FragmentShader shader;
final double time;
_HeaderShaderPainter({required this.shader, required this.time});
@override
void paint(Canvas canvas, Size size) {
shader.setFloat(0, size.width); // u_width
shader.setFloat(1, size.height); // u_height
shader.setFloat(2, time / 1000.0); // u_time (seconds)
final paint = Paint()..shader = shader;
canvas.drawRect(Offset.zero & size, paint);
}
@override
bool shouldRepaint(covariant _HeaderShaderPainter oldDelegate) =>
oldDelegate.time != time;
}
// ===== foreground cards =====================================================
class _AccountsCard extends StatelessWidget {
const _AccountsCard();
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Accounts',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
_AccountRow(label: 'Checking', last4: '1234', amount: '\$2,150.75'),
const SizedBox(height: 8),
_AccountRow(label: 'Savings', last4: '5678', amount: '\$4,129.75'),
],
),
),
);
}
}
class _AccountRow extends StatelessWidget {
final String label;
final String last4;
final String amount;
const _AccountRow({
required this.label,
required this.last4,
required this.amount,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(fontWeight: FontWeight.w500)),
Text('•••• $last4',
style: Theme.of(context).textTheme.bodySmall),
],
),
const Spacer(),
Text(amount,
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(fontWeight: FontWeight.w600)),
],
);
}
}
class _QuickTransferCard extends StatelessWidget {
const _QuickTransferCard();
@override
Widget build(BuildContext context) {
return Card(
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Quick Transfer',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
Row(
children: [
_RoundAction(icon: Icons.north_east, label: 'Send'),
const SizedBox(width: 16),
_RoundAction(icon: Icons.south_west, label: 'Request'),
],
),
],
),
),
);
}
}
class _RoundAction extends StatelessWidget {
final IconData icon;
final String label;
const _RoundAction({required this.icon, required this.label});
@override
Widget build(BuildContext context) {
return Column(
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.08),
shape: BoxShape.circle,
),
child: Icon(icon,
size: 24, color: Theme.of(context).colorScheme.primary),
),
const SizedBox(height: 4),
Text(label, style: Theme.of(context).textTheme.bodySmall),
],
);
}
}What's happening here(super short):
- GLSL shader draws the animated wavy background.
FragmentProgram.fromAssetloads it,fragmentShader()creates a reusable shader._HeaderShaderPaintersets three uniforms: width, height, and time.- Foreground (balance text, cards, buttons, bottom nav) is normal Flutter UI.
Short checklist before you ship a shader
- Shader file declared under
shaders:inpubspec.yaml. - Build in profile/release and test on target devices.
- Reuse
FragmentProgramandFragmentShader; only update uniforms each frame. - Add fallback visuals or reduced-motion options if animation is critical.
- Add a CI smoke test that loads/instantiates each fragment program.
Do this
Write shaders as small GLSL fragment programs, register them under shaders: in pubspec.yaml, load them once with FragmentProgram.fromAsset, create FragmentShader instances, and then update uniforms with setFloat (and setImageSampler for textures) each frame. Reuse shader objects, profile in profile/release builds, and include CI checks so shader compile/load problems don't surprise you at runtime.
Design with intent, not tricks
Shaders are an artist's tool in an engineer's toolbox. They let you move visual complexity to the GPU and create crisp, unique UI touches — but with power comes responsibility: test, respect users (motion & accessibility), and keep iterations small. When done right, shaders are a tiny engineering investment that multiply UX polish across your whole app.
Read more stories like this:
Build Complex Flutter UI Without Images Ship Beautiful UI Without PNGs
Devices Don't Lie, But Emulators Can :) Run real-device tests that surface the bugs emulators hide.
Animate Less, Feel More in Flutter Flutter Animations Demystified