Every custom shape in Flutter ends up as a Path. What changes is what you do with that path:

  • Draw it with a CustomPainter.
  • Cut a child widget to it with ClipPath and a CustomClipper.
  • Describe an outline with a ShapeBorder, which Container, Material, Card and InkWell already know how to fill, shadow and clip.

Most "why did my shadow disappear", "why can't I put an image in my shape" and "why do taps land outside my shape" questions come from picking the wrong one. This article builds the same curved header three ways so you can see exactly where each approach wins.

The same curved header built with CustomPainter, ClipPath and ShapeBorder


Quick answer

CustomPainter ClipPath + CustomClipper ShapeBorder
What it does Draws pixels on a canvas Masks a child widget Describes an outline other widgets use
Image / video / any widget inside the shape No (the child is painted on top, unclipped) Yes Yes (with clipBehavior)
Shadow that follows the shape Manual (drawShadow or a blurred path) No, the clip cuts it off Built in (ShapeDecoration.shadows, Material.elevation)
Taps outside the shape Counted, unless you mask them (see below) Ignored Same as the widget using it
Strokes, multiple paths, text on the canvas Best fit No Only a border stroke
Boilerplate Low Lowest Highest (5 overrides)

Rule of thumb: draw when the shape is the content, clip when the content is something else (a photo, a gradient widget, a video), and use a ShapeBorder when you want the shape to behave like a normal Material surface.


The shared path

All three examples below use this function, so the only thing that changes between them is how the path is used:

Path curvedHeaderPath(Size size) {
  final w = size.width;
  final h = size.height;
  return Path()
    ..moveTo(0, 0)
    ..lineTo(0, h * 0.75)
    ..quadraticBezierTo(w / 2, h * 1.05, w, h * 0.75)
    ..lineTo(w, 0)
    ..close();
}

The path is built from the Size it is given, never from fixed pixel values, so it scales with the widget. If quadratic and cubic curves are new to you, the Flutter Bezier curves guide covers quadraticBezierTo and cubicTo in detail.


Option 1: CustomPainter (draw it)

class HeaderPainter extends CustomPainter {
  HeaderPainter({required this.color});
  final Color color;

  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawPath(curvedHeaderPath(size), Paint()..color = color);
  }

  @override
  bool shouldRepaint(HeaderPainter oldDelegate) => oldDelegate.color != color;
}

// Usage
SizedBox(
  height: 220,
  width: double.infinity,
  child: CustomPaint(painter: HeaderPainter(color: Colors.indigo)),
)

When it is the right choice

  • The shape is the content: charts, gauges, progress rings, decorative backgrounds, signatures.
  • You need several paths, strokes, text or gradients in one place. A painter can call drawPath, drawCircle, drawParagraph and friends as many times as it likes.
  • You want the cheapest option. A single drawPath with a solid or gradient Paint costs less than clipping a child.

Gradients don't need a clip either. Give the Paint a shader:

class GradientHeaderPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..shader = const LinearGradient(
        colors: [Color(0xFFF59E0B), Color(0xFFEC4899)],
      ).createShader(Offset.zero & size);
    canvas.drawPath(curvedHeaderPath(size), paint);
  }

  @override
  bool shouldRepaint(GradientHeaderPainter oldDelegate) => false;
}

Gotchas

A CustomPaint with no child is zero-sized by default. Its size argument defaults to Size.zero, so without a parent that forces a size (like the SizedBox above) your painter runs with nothing to paint on.

Taps land on the whole rectangle. A background painter treats every point in its box as a hit unless you say otherwise. In the curved header, tapping the empty corner below the curve still fires onTap. The reliable fix is to wrap the CustomPaint in a ClipPath with clipBehavior: Clip.none. ClipPath always hit-tests against its path, even when it isn't clipping anything, so it works as a tap mask without changing what's painted:

GestureDetector(
  onTap: onTap,
  child: ClipPath(
    clipper: HeaderClipper(),
    clipBehavior: Clip.none, // only used for hit testing, nothing is clipped
    child: SizedBox(
      height: 220,
      width: double.infinity,
      child: CustomPaint(painter: HeaderPainter(color: Colors.indigo)),
    ),
  ),
)

HeaderClipper is the clipper from option 2 below.

You can also override the painter's hitTest, but it doesn't receive the size. The common workaround of saving the size in paint() breaks after a rebuild: Flutter swaps in a new painter, and if shouldRepaint returns false, paint() never runs on it. The saved size stays zero and every tap misses. Make taps respect your custom shape covers hit testing in depth.

child is not clipped. CustomPaint(painter: ..., child: ...) paints your shape behind the child, and the child keeps its rectangular bounds. If you need the child cut to the shape, you want option 2 or 3.

Repaints. shouldRepaint doesn't stop every repaint. If your painter runs every frame, read why your CustomPainter repaints every frame.


Option 2: ClipPath + CustomClipper (cut a child to it)

class HeaderClipper extends CustomClipper<Path> {
  @override
  Path getClip(Size size) => curvedHeaderPath(size);

  @override
  bool shouldReclip(HeaderClipper oldClipper) => false;
}

// Usage
ClipPath(
  clipper: HeaderClipper(),
  child: Image.asset(
    'assets/mountains.jpg',
    height: 220,
    width: double.infinity,
    fit: BoxFit.cover,
  ),
)

When it is the right choice

  • The content inside the shape is another widget: a photo, a Container with a gradient, a video player, a map, a whole subtree.
  • You want taps outside the shape ignored. ClipPath only hit-tests inside its path, so the empty corner under the curve doesn't respond. That's usually what you want for custom-shaped buttons.

Gotchas

It clips the shadow too. Anything painted outside the path disappears, and shadows are always painted outside the shape. A BoxShadow on the child vanishes completely. This is the single most common complaint about ClipPath. Flutter ClipPath removes your shadow walks through three fixes.

Clipping has a cost. ClipPath defaults to Clip.antiAlias, which gives smooth edges. Clip.hardEdge is cheaper but leaves jagged diagonals and curves. Avoid Clip.antiAliasWithSaveLayer unless you actually see edge artifacts, because it allocates an offscreen buffer. And if all you're clipping is a solid colour or gradient, draw it with option 1 instead.

Animating the clip. Pass a Listenable to the clipper's reclip argument (super(reclip: animation)) and the clip updates every tick without rebuilding the widget, the same idea as repaint: on a painter.

Shortcut for existing borders. If you already have a ShapeBorder, ClipPath.shape(shape: StadiumBorder(), child: ...) clips to it without writing a clipper.


Option 3: ShapeBorder (let widgets decorate it)

A ShapeBorder is the most boilerplate of the three, but it plugs into widgets that already know how to paint shadows, ink ripples and elevation:

class CurvedHeaderBorder extends ShapeBorder {
  const CurvedHeaderBorder();

  @override
  EdgeInsetsGeometry get dimensions => EdgeInsets.zero;

  @override
  Path getOuterPath(Rect rect, {TextDirection? textDirection}) =>
      curvedHeaderPath(rect.size).shift(rect.topLeft);

  @override
  Path getInnerPath(Rect rect, {TextDirection? textDirection}) =>
      getOuterPath(rect, textDirection: textDirection);

  @override
  void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) {}

  @override
  ShapeBorder scale(double t) => this;
}

Note the .shift(rect.topLeft). A clipper always gets a size starting at (0, 0), but a ShapeBorder gets a Rect that can sit anywhere, so the path has to be moved into place.

Now the same shape works with a shadow and clipped content:

Container(
  height: 220,
  clipBehavior: Clip.antiAlias,
  decoration: const ShapeDecoration(
    shape: CurvedHeaderBorder(),
    color: Colors.indigo,
    shadows: [
      BoxShadow(color: Colors.black26, blurRadius: 16, offset: Offset(0, 8)),
    ],
  ),
  child: const Center(
    child: Text('Welcome back', style: TextStyle(color: Colors.white, fontSize: 28)),
  ),
)

This works because Container applies its clip to the child only, inside the decoration. The shadow is painted by the decoration, outside the clip, so it survives.

The same border also drops into Material widgets:

Material(
  color: Colors.indigo,
  shape: const CurvedHeaderBorder(),
  child: InkWell(
    customBorder: const CurvedHeaderBorder(),
    onTap: () {},
    child: const SizedBox(height: 220, width: double.infinity),
  ),
)

Material(shape:) gives you elevation shadows, and InkWell(customBorder:) keeps the ripple inside the curve instead of splashing across the rectangle. Card(shape:) accepts it too.

Gotchas

  • Buttons need an OutlinedBorder, not a plain ShapeBorder. ButtonStyle.shape is typed as OutlinedBorder, so extend that class (and implement copyWith) if you want the shape on an ElevatedButton.
  • Animation. AnimatedContainer interpolates decorations by calling ShapeBorder.lerpFrom / lerpTo. If you don't override them, a custom border snaps from one shape to the other halfway through the animation instead of morphing.
  • spreadRadius works. ShapeDecoration builds each shadow by calling getOuterPath with a rect inflated by the spread. Because your path is built from rect.size, the shadow grows with the shape.

Which one should you choose?

Ask these in order:

  1. Is something other than a flat fill inside the shape (image, video, widgets)? If no, use a CustomPainter. It's the simplest and cheapest.
  2. Do you need a shadow, elevation or an ink ripple that follows the shape? If yes, use a ShapeBorder.
  3. Otherwise, use ClipPath + CustomClipper. It has the least code for "put this widget in that shape."

You can also mix them. A common pattern is a ShapeBorder for the surface and shadow, with a CustomPainter drawing decorations on top.


Skip the coordinate maths

Every approach above needs the same thing: a Path. If you'd rather draw the outline than work out control points by hand, Flutter Shape Builder lets you draw it visually and exports it as CustomPainter code, which is option 1 as-is. To reuse the outline in a clipper or ShapeBorder, copy the Path it builds and scale it from the design's size to the widget's size with Path.transform.


Related articles