Skip to content

Notched Tab Bar

A tab bar whose background path carves a smooth notch under the selected tab that slides between items.

Notched Tab Bar, playing
  • Tabs
  • iOS 18.0+
  • advanced
Download .swift
Origin
Original work
Found by
tab bar · notch · curved tab bar · custom shape · floating button · navigation · barra de pestañas · muesca · barra curva · forma personalizada · botón flotante · navegación

A tab bar whose background carves a smooth notch under the selected tab.

With Reduce Motion on, the notch moves to the new tab without sliding.

struct NotchedTabBarDemo: View {
private let items = [
NotchedTabBarItem(id: "home", title: "Home", systemImage: "house.fill"),
NotchedTabBarItem(id: "search", title: "Search", systemImage: "magnifyingglass"),
NotchedTabBarItem(id: "saved", title: "Saved", systemImage: "bookmark.fill"),
NotchedTabBarItem(id: "profile", title: "Profile", systemImage: "person.fill"),
]
@State private var selection = "home"
private var heading: LocalizedStringResource {
items.first { $0.id == selection }.map { "\($0.title) page" } ?? ""
}
var body: some View {
ZStack(alignment: .bottom) {
LinearGradient(colors: [.indigo.opacity(0.35), .pink.opacity(0.25)], startPoint: .top, endPoint: .bottom)
.ignoresSafeArea()
Text(heading)
.font(.largeTitle.bold())
.frame(maxWidth: .infinity, maxHeight: .infinity)
NotchedTabBar(items: items, selection: $selection)
.padding(.horizontal, 16)
.padding(.bottom, 8)
}
}
}
ParameterTypeDefault
items[NotchedTabBarItem]Required
selectionBinding<String>Required
tintColor.indigo

Copy this code into your project, or download the file.

NotchedTabBar.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
struct NotchedTabBarItem: Identifiable {
let id: String
let title: LocalizedStringResource
let systemImage: String
}
nonisolated enum NotchedTabBarMath {
/// The middle of a tab, with the tabs spread evenly inside the horizontal inset.
static func centerX(index: Int, count: Int, width: CGFloat, inset: CGFloat) -> CGFloat {
let usable = max(width - 2 * max(inset, 0), 0)
guard count > 0, usable > 0 else { return max(width, 0) / 2 }
let clamped = min(max(index, 0), count - 1)
return max(inset, 0) + usable * (CGFloat(clamped) + 0.5) / CGFloat(count)
}
/// Half the width of the notch, narrowed so it never runs into a rounded corner.
static func halfSpan(centerX: CGFloat, width: CGFloat, corner: CGFloat, desired: CGFloat) -> CGFloat {
max(min(desired, centerX - corner, width - centerX - corner), 0)
}
}
/// A rounded bar with a smooth notch that can slide along its top edge.
nonisolated struct NotchedTabBarShape: Shape {
var centerX: CGFloat
var desiredHalfSpan: CGFloat
var depth: CGFloat
var corner: CGFloat
var animatableData: CGFloat {
get { centerX }
set { centerX = newValue }
}
func path(in rect: CGRect) -> Path {
guard rect.width > 0, rect.height > 0 else { return Path() }
let radius = min(corner, rect.width / 2, rect.height / 2)
let span = NotchedTabBarMath.halfSpan(centerX: centerX, width: rect.width, corner: radius, desired: desiredHalfSpan)
let dip = min(depth, rect.height)
let (w, h) = (rect.width, rect.height)
var path = Path()
path.move(to: CGPoint(x: 0, y: radius))
path.addArc(tangent1End: .zero, tangent2End: CGPoint(x: radius, y: 0), radius: radius)
path.addLine(to: CGPoint(x: centerX - span, y: 0))
// Each side is two curves that share a tangent, so the notch has no visible seam.
path.addQuadCurve(to: CGPoint(x: centerX - span * 0.5, y: dip * 0.5), control: CGPoint(x: centerX - span * 0.62, y: 0))
path.addQuadCurve(to: CGPoint(x: centerX, y: dip), control: CGPoint(x: centerX - span * 0.38, y: dip))
path.addQuadCurve(to: CGPoint(x: centerX + span * 0.5, y: dip * 0.5), control: CGPoint(x: centerX + span * 0.38, y: dip))
path.addQuadCurve(to: CGPoint(x: centerX + span, y: 0), control: CGPoint(x: centerX + span * 0.62, y: 0))
path.addLine(to: CGPoint(x: w - radius, y: 0))
path.addArc(tangent1End: CGPoint(x: w, y: 0), tangent2End: CGPoint(x: w, y: radius), radius: radius)
path.addLine(to: CGPoint(x: w, y: h - radius))
path.addArc(tangent1End: CGPoint(x: w, y: h), tangent2End: CGPoint(x: w - radius, y: h), radius: radius)
path.addLine(to: CGPoint(x: radius, y: h))
path.addArc(tangent1End: CGPoint(x: 0, y: h), tangent2End: CGPoint(x: 0, y: h - radius), radius: radius)
path.closeSubpath()
return path
}
}
/// A tab bar whose background carves a smooth notch under the selected tab.
///
/// With Reduce Motion on, the notch moves to the new tab without sliding.
struct NotchedTabBar: View {
let items: [NotchedTabBarItem]
@Binding var selection: String
var tint: Color = .indigo
var body: some View {
NotchedTabBarStage(items: items, selection: $selection, tint: tint)
// The bar only holds short labels. The stage reads its sizes under this cap, which keeps every tab on screen.
.dynamicTypeSize(...DynamicTypeSize.xxxLarge)
}
}
private struct NotchedTabBarStage: View {
let items: [NotchedTabBarItem]
@Binding var selection: String
let tint: Color
@ScaledMetric(relativeTo: .caption) private var barHeight = 72.0
@ScaledMetric(relativeTo: .title3) private var bubbleSize = 52.0
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private let inset: CGFloat = 14
private var selectedIndex: Int {
items.firstIndex { $0.id == selection } ?? 0
}
var body: some View {
let depth = bubbleSize * 0.7
let bubbleY = depth - bubbleSize / 2 - 6
GeometryReader { proxy in
let center = NotchedTabBarMath.centerX(index: selectedIndex, count: items.count, width: proxy.size.width, inset: inset)
ZStack(alignment: .topLeading) {
NotchedTabBarShape(centerX: center, desiredHalfSpan: bubbleSize * 0.95, depth: depth, corner: 26)
.fill(.background)
.shadow(color: .black.opacity(0.15), radius: 14, y: 6)
if !items.isEmpty {
Circle()
.fill(tint.gradient)
.frame(width: bubbleSize, height: bubbleSize)
.position(x: center, y: bubbleY)
.accessibilityHidden(true)
}
HStack(spacing: 0) {
ForEach(items) { item in
tabButton(item, iconLift: barHeight / 2 - 10 - bubbleY)
}
}
.padding(.horizontal, inset)
}
}
.frame(height: barHeight)
.padding(.top, bubbleSize / 2)
.animation(reduceMotion ? nil : .snappy(duration: 0.4), value: selection)
}
private func tabButton(_ item: NotchedTabBarItem, iconLift: CGFloat) -> some View {
let isSelected = item.id == selection
return Button {
selection = item.id
} label: {
VStack(spacing: 4) {
Image(systemName: item.systemImage)
.font(.title3.weight(.semibold))
.foregroundStyle(isSelected ? .white : .secondary)
.offset(y: isSelected ? -iconLift : 0)
Text(item.title)
.font(.caption)
.fontWeight(isSelected ? .semibold : .regular)
.foregroundStyle(isSelected ? tint : .secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityLabel(item.title)
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
}
// MARK: - Demo
struct NotchedTabBarDemo: View {
private let items = [
NotchedTabBarItem(id: "home", title: "Home", systemImage: "house.fill"),
NotchedTabBarItem(id: "search", title: "Search", systemImage: "magnifyingglass"),
NotchedTabBarItem(id: "saved", title: "Saved", systemImage: "bookmark.fill"),
NotchedTabBarItem(id: "profile", title: "Profile", systemImage: "person.fill"),
]
@State private var selection = "home"
private var heading: LocalizedStringResource {
items.first { $0.id == selection }.map { "\($0.title) page" } ?? ""
}
var body: some View {
ZStack(alignment: .bottom) {
LinearGradient(colors: [.indigo.opacity(0.35), .pink.opacity(0.25)], startPoint: .top, endPoint: .bottom)
.ignoresSafeArea()
Text(heading)
.font(.largeTitle.bold())
.frame(maxWidth: .infinity, maxHeight: .infinity)
NotchedTabBar(items: items, selection: $selection)
.padding(.horizontal, 16)
.padding(.bottom, 8)
}
}
}
#Preview {
NotchedTabBarDemo()
}