Mastering Flutter CustomClipper: Build Custom Shapes and Animated Reveals
Master Flutter’s CustomClipper to build responsive shapes, wavy headers, notched bars, and animated reveals—with clean code and solid performance tips.
Image used for representation purposes only.
Overview
Flutter’s clipping system lets you cut widgets to any shape. With a custom clipper you can design diagonal cards, wavy headers, notched bars, circular reveals, and more—without exporting PNG masks. In this guide you’ll learn how CustomClipper works, how to build production-ready shapes, and how to animate them efficiently.
When and why to clip
Use clipping when you need to:
- Create non-rectangular layouts (tickets, waves, triangles, arcs)
- Reveal or hide content with shape-based transitions
- Punch out notches for floating buttons or avatars
- Constrain expensive paints (images, shaders) to a precise region
Clipping is purely visual. It does not add shadows or change layout; it only defines what gets painted.
The Flutter clipping toolbox at a glance
- ClipRect: rectangular clipping
- ClipRRect: rounded rectangles
- ClipOval: circles/ellipses
- ClipPath: free-form shapes using a Path (works with CustomClipper
) - PhysicalShape / Material with shape: like ClipPath but also renders elevation/shadow
For custom shapes, you’ll extend CustomClipper
Anatomy of a CustomClipper
A minimal custom clipper:
class DiagonalClipper extends CustomClipper<Path> {
@override
Path getClip(Size size) {
final p = Path()
..lineTo(0, size.height)
..lineTo(size.width, size.height - 40)
..lineTo(size.width, 0)
..close();
return p;
}
@override
bool shouldReclip(covariant DiagonalClipper oldClipper) => false;
}
- getClip receives the child’s Size and must return the clipping Path.
- shouldReclip lets Flutter skip recomputation if the shape hasn’t changed; return true only when relevant inputs change.
Usage:
ClipPath(
clipper: DiagonalClipper(),
child: Container(height: 200, color: Colors.indigo),
)
Path essentials in 60 seconds
- moveTo(x, y): set the starting point
- lineTo(x, y): straight line
- quadraticBezierTo(cpx, cpy, x, y): smooth 1-control-point curve
- cubicTo(c1x, c1y, c2x, c2y, x, y): 2-control-point curve
- arcTo / addOval / addRRect: arcs and shapes
- close(): connect last point to the first
- Path.combine: union, intersect, difference, xor
Always build in terms of size to keep shapes responsive.
Example 1: A diagonal card with responsive slant
class DiagonalCardClipper extends CustomClipper<Path> {
final double slope; // 0..1 fraction of height to cut
DiagonalCardClipper({this.slope = 0.2});
@override
Path getClip(Size size) {
final cut = size.height * slope;
return Path()
..lineTo(0, size.height)
..lineTo(size.width, size.height - cut)
..lineTo(size.width, 0)
..close();
}
@override
bool shouldReclip(covariant DiagonalCardClipper old) => old.slope != slope;
}
Use it:
ClipPath(
clipper: DiagonalCardClipper(slope: 0.25),
child: Container(
height: 180,
decoration: BoxDecoration(
gradient: LinearGradient(colors: [Colors.indigo, Colors.deepPurple]),
),
),
)
Example 2: A wavy header using quadratic beziers
class WaveHeaderClipper extends CustomClipper<Path> {
final double waveHeight; // logical pixels
WaveHeaderClipper({this.waveHeight = 48});
@override
Path getClip(Size size) {
final h = size.height;
final w = size.width;
final p = Path()..lineTo(0, h - waveHeight);
// First hump
p.quadraticBezierTo(w * 0.25, h - waveHeight - 20, w * 0.5, h - waveHeight);
// Second hump
p.quadraticBezierTo(w * 0.75, h - waveHeight + 20, w, h - waveHeight);
p
..lineTo(w, 0)
..close();
return p;
}
@override
bool shouldReclip(covariant WaveHeaderClipper old) => old.waveHeight != waveHeight;
}
Use it with elevation:
PhysicalShape(
clipper: WaveHeaderClipper(waveHeight: 56),
elevation: 6,
color: Colors.white,
child: SizedBox(height: 220, width: double.infinity),
)
Why PhysicalShape? It clips like ClipPath and also paints a shadow (elevation), great for headers and cards that float above content.
Example 3: A bottom bar with a concave notch
Create a full-rect path and subtract a circle. Perfect for a floating action button (FAB) notch.
class NotchedBottomClipper extends CustomClipper<Path> {
final double notchRadius;
NotchedBottomClipper({this.notchRadius = 28});
@override
Path getClip(Size size) {
final rect = Path()..addRect(Offset.zero & size);
final notch = Path()
..addOval(Rect.fromCircle(center: Offset(size.width / 2, size.height), radius: notchRadius));
return Path.combine(PathOperation.difference, rect, notch);
}
@override
bool shouldReclip(covariant NotchedBottomClipper old) => old.notchRadius != notchRadius;
}
Use it:
ClipPath(
clipper: NotchedBottomClipper(notchRadius: 32),
clipBehavior: Clip.antiAlias,
child: Container(height: 72, color: Colors.blueGrey.shade900),
)
Animating a clip (circular reveal)
Animating a clip path unlocks delightful transitions such as reveals and wipes. Drive the shape with a progress value and invalidate when it changes.
class RevealClipper extends CustomClipper<Path> {
final double progress; // 0..1
RevealClipper(this.progress);
@override
Path getClip(Size size) {
final r = size.longestSide * progress;
return Path()..addOval(Rect.fromCircle(center: size.center(Offset.zero), radius: r));
}
@override
bool shouldReclip(covariant RevealClipper old) => old.progress != progress;
}
Hook it to a tween:
TweenAnimationBuilder<double>(
duration: Duration(milliseconds: 900),
curve: Curves.easeOutCubic,
tween: Tween(begin: 0, end: 1),
builder: (context, value, child) => ClipPath(
clipper: RevealClipper(value),
clipBehavior: Clip.antiAlias,
child: child,
),
child: Image.asset('assets/hero.jpg', fit: BoxFit.cover),
)
Tips for smooth animations:
- Minimize work inside getClip; avoid allocations in hot paths
- Prefer hardEdge unless you notice jaggies; antiAlias adds work
- Cache expensive inputs outside the clipper and pass primitives
Making clippers truly responsive
Hard-coded pixels often break on tablets. Scale from size instead:
class TicketClipper extends CustomClipper<Path> {
@override
Path getClip(Size size) {
final r = size.shortestSide * 0.06; // 6% radius
final p = Path()..addRRect(RRect.fromRectAndRadius(Offset.zero & size, Radius.circular(r)));
// Punch holes on the sides
final holeR = r * 0.6;
final holes = Path()
..addOval(Rect.fromCircle(center: Offset(0, size.height * 0.3), radius: holeR))
..addOval(Rect.fromCircle(center: Offset(size.width, size.height * 0.7), radius: holeR));
return Path.combine(PathOperation.difference, p, holes);
}
@override
bool shouldReclip(_) => false;
}
Shadows, elevation, and touch behavior
- Shadows: ClipPath does not draw shadows. Use PhysicalShape or Material(shape: …) with elevation.
- Anti-aliasing: Curved edges look better with Clip.antiAlias (costs a bit more). Avoid antiAliasWithSaveLayer unless you need blending.
- Semantics: Clipping is visual; it doesn’t describe the shape to screen readers. Wrap with Semantics if the clip conveys meaning.
Performance checklist
- Avoid rebuilding clippers every frame unless animating; make them const or reuse instances
- Implement shouldReclip precisely; return false when inputs are unchanged
- Prefer simple paths; combine curves sparingly
- Use repaint boundaries around complex clipped children (RepaintBoundary)
- Benchmark on low-end devices; profile with Flutter DevTools (Raster stats and Shader compilation)
Testing your clips
Golden tests catch regressions in shape math.
// flutter_test example
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: ClipPath(
clipper: WaveHeaderClipper(waveHeight: 56),
child: Container(width: 320, height: 160, color: Colors.blue),
),
),
),
);
await expectLater(
find.byType(ClipPath),
matchesGoldenFile('goldens/wave_header.png'),
);
You can also write unit tests for shouldReclip and quick math checks on inputs.
Choosing between CustomClipper, CustomPainter, and ShapeBorder
- CustomClipper + ClipPath: you need to mask child content (images, gradients, videos)
- CustomPainter: you draw the shape yourself; no child masking
- ShapeBorder / Material: when you need consistent theming, borders, and elevation (cards, chips)
In practice you’ll often combine them: a Material with a custom ShapeBorder for elevation plus a ClipPath for inner content.
Wrap-up
CustomClipper unlocks expressive, performant layouts in Flutter. Define your shape in terms of Size, keep it responsive, return precise shouldReclip results, and leverage PhysicalShape when you need shadows. With a handful of path commands and an animation tween, you can deliver polished, brand-specific UI without bitmaps or heavy assets. Happy clipping!
Related Posts
Flutter + Rive: A Complete Guide to Interactive Animations
A practical guide to integrating Rive animations in Flutter: setup, state machines, inputs, performance, testing, and fixes.
Flutter Animated ListView Tutorial (AnimatedList): Smooth Insert, Remove, and Reorder
Learn Flutter AnimatedList and ListView animations: setup, insert/remove, custom transitions, slivers, reordering, performance tips, and complete code.
Flutter Custom Dialogs and Bottom Sheets: A Practical Guide
A practical guide to building custom dialogs and bottom sheets in Flutter, with patterns, animations, keyboard-safe layouts, theming, and testing tips.