Custom Clipper Patterns in Flutter: Diagonal Reveals, Notches, and Blobs
Build custom clips in Flutter: CustomClipper Path anatomy, getClip vs shouldReclip, clipBehavior choices, practical patterns, and clipping performance notes.
Published on • November 10, 2026
AI Assistant

Rounded rectangles cover most UI, but diagonal reveals, notched cards, and blob-shaped avatars need a real Path, and the widget that owns that path is ClipPath with a CustomClipper<Path>. This post covers the clipper contract, which Clip mode to pick, three patterns you can copy today, and how to keep clipping off your critical path.
Anatomy of CustomClipper
CustomClipper<T> lives in the rendering library and is used by ClipRect, ClipRRect, ClipOval, and ClipPath. It has exactly two responsibilities:
class DiagonalClipper extends CustomClipper<Path> {
DiagonalClipper({required this.progress});
/// 0.0 = fully hidden, 1.0 = fully revealed.
final double progress;
@override
Path getClip(Size size) {
final dx = size.width * progress;
return Path()
..moveTo(dx, 0)
..lineTo(size.width, 0)
..lineTo(size.width, size.height)
..lineTo(0, size.height)
..close();
}
@override
bool shouldReclip(DiagonalClipper oldClipper) =>
oldClipper.progress != progress;
}
getClip(Size size) returns the clip for the current layout size. shouldReclip(oldClipper) runs whenever the widget rebuilds with a new clipper instance: return true only when your parameters actually changed, otherwise the render object re-clips (and re-rasterizes) on every build for no reason. The docs also note the efficient way to drive a changing clip is the reclip constructor argument — a Listenable that updates the clip when it ticks, bypassing both build and layout.
Choosing clipBehavior
Clip (in dart:ui) documents four modes with very different costs:
| Value | Behavior |
|---|---|
Clip.none | No clipping; the default for most widgets. Pay nothing unless content overflows. |
Clip.hardEdge | Fastest clip, but curves and diagonals are jagged. Fine for axis-aligned rects and tiny corner radii. |
Clip.antiAlias | Anti-aliased edges; the right choice for diagonal or curved shapes. Slightly slower than hardEdge, and can show bleeding-edge artifacts. |
Clip.antiAliasWithSaveLayer | Anti-alias plus an offscreen buffer. Very slow and it changes compositing semantics — use only when you must, such as a bright image over a very different background. |
ClipPath defaults to Clip.antiAlias, which is what you want for shapes like the one above:
ClipPath(
clipper: DiagonalClipper(progress: reveal.value),
clipBehavior: Clip.antiAlias,
child: const HeroImage(),
)
Pattern 1: animated diagonal reveal
Feed a ValueListenableBuilder (or an AnimatedBuilder) straight into the clipper. Because shouldReclip compares progress, only the changed parameter triggers a re-clip; if you drive the clip from a long-lived Animation, prefer passing it as reclip: so the clip updates on every tick without waiting for a rebuild:
AnimatedBuilder(
animation: reveal, // Animation<double> from 0.0 to 1.0
builder: (context, child) => ClipPath(
clipper: DiagonalClipper(progress: reveal.value),
child: child,
),
child: const SizedBox(height: 220, child: ColoredTile()),
)
Pattern 2: notched card and blob avatar
A notch is the same clipper idea with an extra subpath. Path.combine with PathOperation.difference punches a circle out of a rectangle:
Path notchCard(Size size, double radius) {
final card = Path()..addRect(Offset.zero & size);
final notch = Path()
..addOval(Rect.fromCircle(center: Offset(size.width * 0.78, size.height), radius: radius));
return Path.combine(PathOperation.difference, card, notch);
}
A blob avatar is four cubic curves through points nudged off the circle. Start from a circle of radius r around the center and displace each control point by a small, asymmetric amount:
Path blob(Size size) {
final c = size.center(Offset.zero), r = size.shortestSide / 2;
final p = Path()..moveTo(c.dx + r, c.dy);
p.cubicTo(c.dx + r, c.dy + r * 0.6, c.dx + r * 0.6, c.dy + r, c.dx, c.dy + r);
p.cubicTo(c.dx - r * 0.7, c.dy + r, c.dx - r, c.dy + r * 0.5, c.dx - r, c.dy);
p.cubicTo(c.dx - r, c.dy - r * 0.7, c.dx - r * 0.5, c.dy - r, c.dx, c.dy - r);
p.cubicTo(c.dx + r * 0.65, c.dy - r, c.dx + r, c.dy - r * 0.6, c.dx + r, c.dy);
return p..close();
}
Clipping to a ShapeBorder
Before writing a custom clipper, check whether a ShapeBorder already describes your shape: ClipPath ships a static shape helper backed by the built-in ShapeBorderClipper, which is the bridge between the theming world (a Card’s shape) and the clipping world — any shape you can theme, you can clip to, including custom ShapeBorder subclasses.
ClipPath.shape(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(28)),
),
child: child,
)
Performance notes
The API docs are blunt: clipping to a path is expensive, and simpler widgets exist because ClipRect, ClipOval, and ClipRRect take fast paths that ClipPath cannot. Practical rules:
- Reach for
ClipRRect/ClipOvalfirst; only pay forPathwhen the geometry genuinely needs it. - Keep
shouldRecliptight — a clipper that always returnstrueforces re-rasterization of the child on every rebuild. - Avoid
Clip.antiAliasWithSaveLayerin lists; the offscreen buffer it allocates multiplies with every row. - Remember
getApproximateClipRectexists for the semantics layer: an imprecise approximation can wrongly exclude widgets from accessibility traversal, so keep your clipper’s bounds honest. - Newer platforms have purpose-built shapes — for example
ClipRSuperellipsefor the iOS-style squircle — which beat a hand-rolled path both in fidelity and cost.
Takeaway: Implement getClip and a strict shouldReclip, drive motion through the reclip listenable, pick Clip.antiAlias for diagonal or curved shapes and reserve antiAliasWithSaveLayer for the rare case that needs it, and before writing any path check whether ClipPath.shape or a cheaper ClipRRect already gives you the geometry.