Skip to content
Blog

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:

ValueBehavior
Clip.noneNo clipping; the default for most widgets. Pay nothing unless content overflows.
Clip.hardEdgeFastest clip, but curves and diagonals are jagged. Fine for axis-aligned rects and tiny corner radii.
Clip.antiAliasAnti-aliased edges; the right choice for diagonal or curved shapes. Slightly slower than hardEdge, and can show bleeding-edge artifacts.
Clip.antiAliasWithSaveLayerAnti-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/ClipOval first; only pay for Path when the geometry genuinely needs it.
  • Keep shouldReclip tight — a clipper that always returns true forces re-rasterization of the child on every rebuild.
  • Avoid Clip.antiAliasWithSaveLayer in lists; the offscreen buffer it allocates multiplies with every row.
  • Remember getApproximateClipRect exists 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 ClipRSuperellipse for 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.

Sources