SwiftUI Custom Tab Bar Component

A custom tab bar where the selection indicator travels between tabs rather than cutting. That is matchedGeometryEffect doing the work: one pill exists at a time and SwiftUI interpolates its frame, which is both cheaper and smoother than animating an offset you calculate yourself.

Home

Variants: Floating, Liquid Glass

Install the free SwiftUI custom tab bar

  1. Add SwiftKitUI.swift to your app once. It holds the tokens and motion curves.
  2. Copy SKTabBar.swift below into your project. It needs nothing else.

Usage

Usage.swift
SKTabBar(selection: $tab, tabs: [
.init(tab: .home,    title: "Home",    systemImage: "house.fill"),
.init(tab: .search,  title: "Search",  systemImage: "magnifyingglass"),
.init(tab: .profile, title: "Profile", systemImage: "person.fill"),
])

Full source · 171 lines · MIT

SKTabBar.swift
//
//  SKTabBar.swift
//  Swift Kit UI
//
//  Requires: SwiftKitUI.swift
//
//  A custom tab bar where the selection indicator *travels* between tabs rather
//  than cutting. That is `matchedGeometryEffect` doing the work: one pill exists
//  at a time and SwiftUI interpolates its frame, which is both cheaper and
//  smoother than animating an offset you calculate yourself.
//
//  Usage:
//      SKTabBar(selection: $tab, tabs: [
//          .init(tab: .home,    title: "Home",    systemImage: "house.fill"),
//          .init(tab: .search,  title: "Search",  systemImage: "magnifyingglass"),
//          .init(tab: .profile, title: "Profile", systemImage: "person.fill"),
//      ])
//

import SwiftUI

public struct SKTabItem<Tab: Hashable>: Identifiable {
    public let tab: Tab
    public let title: String
    public let systemImage: String

    public var id: Tab { tab }

    public init(tab: Tab, title: String, systemImage: String) {
        self.tab = tab
        self.title = title
        self.systemImage = systemImage
    }
}

public struct SKTabBar<Tab: Hashable>: View {
    public enum Style: Sendable {
        case floating
        case liquidGlass
    }

    @Binding private var selection: Tab
    private let tabs: [SKTabItem<Tab>]
    private let style: Style
    private let showsLabels: Bool

    @Namespace private var pill
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    public init(
        selection: Binding<Tab>,
        tabs: [SKTabItem<Tab>],
        style: Style = .floating,
        showsLabels: Bool = true
    ) {
        self._selection = selection
        self.tabs = tabs
        self.style = style
        self.showsLabels = showsLabels
    }

    public var body: some View {
        HStack(spacing: SK.Space.xxs) {
            ForEach(tabs) { item in
                tabButton(item)
            }
        }
        .padding(SK.Space.xs)
        .background { barBackground }
        .clipShape(Capsule(style: .continuous))
        .overlay {
            if style == .floating {
                Capsule(style: .continuous)
                    .strokeBorder(SK.Palette.separator, lineWidth: SK.Stroke.hairline)
            }
        }
        .shadow(color: .black.opacity(style == .floating ? 0.12 : 0), radius: 18, x: 0, y: 8)
        .accessibilityElement(children: .contain)
    }

    private func tabButton(_ item: SKTabItem<Tab>) -> some View {
        let isSelected = item.tab == selection

        return Button {
            guard !isSelected else { return }
            withAnimation(SKMotion.snap.resolved(reduceMotion: reduceMotion)) {
                selection = item.tab
            }
            SK.Haptic.play(.selection)
        } label: {
            HStack(spacing: SK.Space.xs) {
                Image(systemName: item.systemImage)
                    .font(SK.Icon.symbol(SK.Icon.md))
                    // Bounces only the newly selected icon. `value:` is the
                    // selection itself so deselection stays silent.
                    .symbolEffect(.bounce, value: isSelected ? selection : nil)

                if showsLabels && isSelected {
                    Text(item.title)
                        .font(SK.Type_.caption)
                        .fixedSize()
                        // Asymmetric on purpose: the label should appear with
                        // the pill but leave instantly, otherwise two labels
                        // are briefly visible mid-transition.
                        .transition(AnyTransition.asymmetric(
                            insertion: AnyTransition.opacity
                                .combined(with: .scale(scale: 0.82, anchor: .leading)),
                            removal: AnyTransition.opacity
                        ))
                }
            }
            .foregroundStyle(isSelected ? Color.white : SK.Palette.labelSecondary)
            .padding(.vertical, SK.Space.sm + 2)
            .padding(.horizontal, isSelected ? SK.Space.lg : SK.Space.md)
            .background {
                if isSelected {
                    Capsule(style: .continuous)
                        .fill(SK.Palette.accent)
                        // One pill, moved by SwiftUI. Do not add a second.
                        .matchedGeometryEffect(id: "sk.tabbar.pill", in: pill)
                }
            }
            .contentShape(.rect)
        }
        .buttonStyle(.plain)
        .accessibilityLabel(item.title)
        .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton)
    }

    @ViewBuilder
    private var barBackground: some View {
        switch style {
        case .floating:
            SK.Palette.surfaceElevated
        case .liquidGlass:
            Color.clear.skSurface(.liquidGlass, radius: SK.Radius.pill)
        }
    }
}

private enum PreviewTab: Hashable { case home, search, saved, profile }

#Preview("SKTabBar") {
    @Previewable @State var selection: PreviewTab = .home

    VStack {
        Spacer()
        SKTabBar(selection: $selection, tabs: [
            .init(tab: .home, title: "Home", systemImage: "house.fill"),
            .init(tab: .search, title: "Search", systemImage: "magnifyingglass"),
            .init(tab: .saved, title: "Saved", systemImage: "bookmark.fill"),
            .init(tab: .profile, title: "Profile", systemImage: "person.fill"),
        ])
        Spacer().frame(height: SK.Space.xl)
        SKTabBar(selection: $selection, tabs: [
            .init(tab: .home, title: "Home", systemImage: "house.fill"),
            .init(tab: .search, title: "Search", systemImage: "magnifyingglass"),
            .init(tab: .saved, title: "Saved", systemImage: "bookmark.fill"),
            .init(tab: .profile, title: "Profile", systemImage: "person.fill"),
        ], style: .liquidGlass)
        Spacer()
    }
    .frame(maxWidth: .infinity, maxHeight: .infinity)
    .background(
        LinearGradient(
            colors: [SK.Palette.accentSoft, SK.Palette.surfaceSunken],
            startPoint: .top, endPoint: .bottom
        )
    )
}

Questions

Is this SwiftUI custom tab bar free for commercial apps?
Yes. Swift Kit UI is MIT licensed, so you can use it in personal and commercial apps with no payment or sign-up. Keep the licence notice with the code.
What does it depend on?
SKTabBar.swift depends only on SwiftKitUI.swift, a single foundation file with the design tokens and motion curves. There are no third-party packages.
Which iOS versions does it support?
iOS 17 and later, plus macOS 14.

Related components

Building a whole app? The Swift Kit adds auth, payments and onboarding, ready to ship.