A bottom bar with a floating action button sitting in a curved cut-out is a common app layout, and Flutter supports it out of the box: BottomAppBar accepts a NotchedShape, and the Scaffold tells it where the FAB is. Under Material 3 a few defaults changed, so code from older tutorials often produces no notch, a square button in a round hole, or a bar with no shadow.

This recipe covers the built-in shapes first, then a custom NotchedShape with a smoother, wider curve. All the behaviour below was checked in Flutter 3.38 with Material 3, which ThemeData uses by default.

Three bottom app bars: Material 3 default with no notch, CircularNotchedRectangle with a round FAB, and AutomaticNotchedShape matching the rounded-square FAB


The built-in recipe

class NotchedBarScreen extends StatelessWidget {
  const NotchedBarScreen({
    super.key,
    this.shape = const CircularNotchedRectangle(),
    this.fabShape = const CircleBorder(),
  });

  final NotchedShape? shape;
  final ShapeBorder? fabShape;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      extendBody: true, // let the body show through the notch
      body: ListView.builder(
        itemCount: 20,
        itemBuilder: (context, i) => ListTile(title: Text('Item ${i + 1}')),
      ),
      floatingActionButton: FloatingActionButton(
        shape: fabShape, // the M3 default is a rounded square
        onPressed: () {},
        child: const Icon(Icons.add),
      ),
      floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
      bottomNavigationBar: BottomAppBar(
        shape: shape,
        notchMargin: 6,
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceAround,
          children: [
            IconButton(icon: const Icon(Icons.home_outlined), onPressed: () {}),
            IconButton(icon: const Icon(Icons.search), onPressed: () {}),
            const SizedBox(width: 48), // keep the middle free for the FAB
            IconButton(icon: const Icon(Icons.favorite_border), onPressed: () {}),
            IconButton(icon: const Icon(Icons.person_outline), onPressed: () {}),
          ],
        ),
      ),
    );
  }
}

Four pieces work together:

  • FloatingActionButtonLocation.centerDocked puts the FAB's centre on the top edge of the bar. A widget test confirms the FAB centre's y equals the bar's top. endDocked, startDocked and the miniCenterDocked variants do the same at other positions.
  • BottomAppBar.shape is the NotchedShape that cuts the hole. The FAB has to be the Scaffold's floatingActionButton: the bar reads the button's rectangle from the scaffold's geometry, and with no FAB the bar is drawn whole (a test with CircularNotchedRectangle and no FAB finds no notch).
  • notchMargin is the gap around the button. In the SDK source the bar inflates the FAB's rectangle by this amount before passing it to the shape, so the shape never needs to know about the margin.
  • extendBody: true lets the body run behind the bar, so content scrolls through the notch. In an 800 × 600 test the ListView is 600 px tall with it and 520 px without (the bar is 80 px).

What Material 3 changed

These come from the BottomAppBar and FloatingActionButton defaults in the SDK source, and the first four are also checked in widget tests:

Material 3 default What to do
Bar shape AutomaticNotchedShape(RoundedRectangleBorder()) with no guest shape, so no notch Set shape: yourself
FAB shape RoundedRectangleBorder with 16 px corners Use CircleBorder() with a circular notch, or match the notch to the square
Bar height 80 Pass height: to change it
Shadow elevation: 3 but shadowColor: Colors.transparent, so no visible shadow Pass shadowColor: (and an elevation:) to get one back
Colour colorScheme.surfaceContainer Pass color:
Padding 12 vertical, 16 horizontal Pass padding:

The shape you pass is used as-is under Material 3; it takes priority over the theme and the defaults. The most common surprise is the second row: CircularNotchedRectangle with an untouched Material 3 FAB gives a round hole around a rounded square. Either make the button round, as in the recipe above, or cut a notch the same shape as the button.

A notch that matches the rounded-square FAB

AutomaticNotchedShape subtracts one ShapeBorder (the guest) from another (the host). Give it a guest that matches the FAB:

/// Matches the M3 FAB (radius 16) plus notchMargin (6).
const roundedFabNotch = AutomaticNotchedShape(
  RoundedRectangleBorder(),
  RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(22))),
);

The guest rectangle has already been grown by notchMargin, so for the notch's corners to run parallel to the button's, use the button's radius plus the margin: 16 + 6 = 22. That's the right-hand bar in the screenshot above. Under the hood, AutomaticNotchedShape calls Path.combine(PathOperation.difference, host, guest), the same technique the ticket card recipe uses for its side notches.


Why CircularNotchedRectangle looks tight

CircularNotchedRectangle builds its notch from three pieces: an arc that follows the button's circle, plus a short quadratic curve on each side that joins the arc to the bar's top edge. In the SDK source the size of those transitions is fixed (s1 = 15.0 and s2 = 1.0 pixels), whatever the size of the button. The result is a hole that hugs the FAB with small, fairly sharp shoulders.

Many designs want the opposite: a wide, soft dip that the button sinks into. For that you write your own NotchedShape.


A custom NotchedShape with a smooth curve

NotchedShape has one method. host is the bar's rectangle, and guest is the FAB's rectangle already inflated by notchMargin (or null when there is no FAB):

/// A notch that eases in and out of the bar with two cubic curves per side.
class SmoothNotchedShape extends NotchedShape {
  const SmoothNotchedShape();

  @override
  Path getOuterPath(Rect host, Rect? guest) {
    if (guest == null || !host.overlaps(guest)) {
      return Path()..addRect(host);
    }
    // guest is the FAB's rect already inflated by notchMargin.
    final r = guest.width / 2;
    final cx = guest.center.dx;
    final bottom = guest.center.dy + r;

    return Path()
      ..moveTo(host.left, host.top)
      ..lineTo(cx - 2 * r, host.top)
      ..cubicTo(cx - r, host.top, cx - r, bottom, cx, bottom)
      ..cubicTo(cx + r, bottom, cx + r, host.top, cx + 2 * r, host.top)
      ..lineTo(host.right, host.top)
      ..lineTo(host.right, host.bottom)
      ..lineTo(host.left, host.bottom)
      ..close();
  }
}

Use it by passing shape: const SmoothNotchedShape() to the BottomAppBar, for example NotchedBarScreen(shape: SmoothNotchedShape()).

CircularNotchedRectangle next to the custom SmoothNotchedShape

How the curve is built

With r the radius of the inflated button:

  • The dip starts 2r to the left of the button's centre and ends 2r to the right, so it is 4r wide: 136 px for a 56 px FAB with a 6 px margin.
  • Its lowest point is at guest.center.dy + r, the bottom of the inflated circle. For a docked FAB that is r below the bar's top.
  • Each side is one cubic Bézier. Its first control point sits level with the bar's top edge and its second level with the bottom of the dip, both at cx ± r.

Because a Bézier curve leaves its start point heading towards its first control point, and arrives at its end point from the direction of its last one, the curve is horizontal where it meets the flat top of the bar and horizontal again at the bottom of the dip. There are no corners anywhere in the outline. How Bézier curves work explains why.

Checking it clears the button

A curve that is wide and smooth can still clip into the button if the control points are wrong, so this layout was checked with a test rather than by eye. With a real Scaffold, a docked FAB and notchMargin: 6, the test reads the bar's actual clip path and checks 3,600 directions × 68 distances from the FAB's centre, every point closer than the FAB radius plus the margin. None of them is inside the bar. The test also confirms the bottom of the dip touches the margin circle and that the dip starts 2r from the centre. Moving the second control point inward (to cx ± 0.8r) makes the curve cut about 1.5% into the margin circle, which is why the control points sit at cx ± r.

One caveat: the shoulders are measured from host.top, so the shape assumes the button's centre is on the bar's top edge. That holds for every ...Docked location. If you move the FAB higher or lower, recheck the clearance.

Make room for the wider dip

The notch is wider than the one CircularNotchedRectangle cuts, so items near the middle of the bar end up on the sloping shoulders. BottomAppBar.clipBehavior defaults to Clip.none, so they aren't cut off, but they can look like they're floating over the dip. Widen the gap in the middle of the row, or use fewer items.


Getting the shadow back

The BottomAppBar paints its background with a PhysicalShape whose clipper is your notched path, so any shadow follows the notch. Under Material 3 the default shadow colour is transparent, so you need both properties:

Property Value to try
elevation 3 is the M3 default; 8 was the Material 2 default
shadowColor Colors.black or Colors.black54

If you build a curved bar without BottomAppBar (for example a plain Container with a custom painter), you lose this, and a ClipPath around a shadowed widget will clip the shadow off. Why ClipPath removes your shadow shows the fixes, and BoxShadow vs PhysicalModel vs elevation compares the shadow options.


Summary

Goal Use
Round FAB, standard notch CircularNotchedRectangle() + FloatingActionButton(shape: CircleBorder())
Keep the M3 rounded-square FAB AutomaticNotchedShape(RoundedRectangleBorder(), RoundedRectangleBorder(borderRadius: radius + notchMargin))
Wide, smooth dip A custom NotchedShape such as SmoothNotchedShape
Content visible through the notch Scaffold(extendBody: true)
Shadow under M3 BottomAppBar(shadowColor: ..., elevation: ...)

Related articles