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.

ASOasis
6 min read
Mastering Flutter CustomClipper: Build Custom Shapes and Animated Reveals

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 and feed it to ClipPath (or PhysicalShape for elevation).

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