Building a Prominent Tab in SwiftUI: iOS 27’s New Tab Role, Step by Step

  • Aug 22

Building a Prominent Tab in SwiftUI: iOS 27’s New Tab Role, Step by Step

iOS 27 introduces a new role for TabView: .prominent. Apply it to a tab, and the system gives that tab extra visual emphasis in the tab bar — larger, more eye-catching, easier to find at a glance than the tabs around it.

Apps have always had a destination that matters more than the rest. A shopping app leans on the cart. A travel app leans on trips. A banking app leans on payments. Before iOS 27, giving that one destination extra weight meant building a custom tab bar from scratch. Now it’s a single role modifier on a standard Tab.

In this tutorial, you’ll build a small shopping app called QuickCart and give its Cart tab the .prominent treatment, complete with a live item-count badge. By the end, you'll know exactly how the role behaves, how to wire it into an @Observable MVVM structure, and how to carry the same pattern into any app where one destination deserves the spotlight.

What You’ll Build

QuickCart — a four-tab shopping app:

  • Home, Search, Cart, and Profile tabs in a standard TabView

  • The Cart tab styled with the new .prominent role

  • A live badge on the Cart tab showing the current item count

  • A fully working CartView backed by an @Observable view model, with quantity steppers and a running subtotal

The finished app runs on the iOS 27 simulator with zero third-party dependencies — everything here is stock SwiftUI.

Prerequisites

  • Xcode 27 or later

  • iOS 27 SDK (simulator or device)

  • Swift 6.2+

  • Familiarity with SwiftUI, NavigationStack, and the @Observable macro

  • No Apple Developer account needed — this runs entirely in the simulator

Project Setup

Create a new project in Xcode: File → New → Project → iOS → App, interface set to SwiftUI, name it QuickCart.

Set up the following folder structure inside the project navigator:

QuickCart/
├── App/
│   └── QuickCartApp.swift
├── Core/
│   └── Models/
│       └── CartItem.swift
├── Features/
│   ├── Cart/
│   │   ├── CartViewModel.swift
│   │   └── CartView.swift
│   ├── Home/
│   │   └── HomeView.swift
│   ├── Search/
│   │   └── SearchView.swift
│   └── Profile/
│       └── ProfileView.swift
└── UI/
    └── QuickCartTabView.swift

Nothing here is .prominent-specific yet — it's just a clean shell to build into.

Step-by-Step Implementation

Step 1: Define the Cart Model

Start with the data. A cart is a list of items, each with a quantity.

// Core/Models/CartItem.swift
import Foundation
struct CartItem: Identifiable, Hashable {
    let id: UUID
    let name: String
    let price: Decimal
    var quantity: Int
}

What just happened? A plain value type — no logic, no dependencies. Keeping the model dumb makes it trivial to test and safe to pass around by value.

Step 2: Build the Cart View Model

The view model owns cart state and exposes the two numbers the UI cares about: how many items, and what they cost.

// Features/Cart/CartViewModel.swift
import Foundation
@Observable
final class CartViewModel {
    private(set) var items: [CartItem] = [
        CartItem(id: UUID(), name: "Trail Running Shoes", price: 128.00, quantity: 1),
        CartItem(id: UUID(), name: "Insulated Water Bottle", price: 24.50, quantity: 2)
    ]
    var itemCount: Int {
        items.reduce(0) { $0 + $1.quantity }
    }
    var subtotal: Decimal {
        items.reduce(Decimal.zero) { $0 + ($1.price * Decimal($1.quantity)) }
    }
    func updateQuantity(for item: CartItem, to newQuantity: Int) {
        guard let index = items.firstIndex(where: { $0.id == item.id }) else { return }
        items[index].quantity = max(1, newQuantity)
    }
    func remove(_ item: CartItem) {
        items.removeAll { $0.id == item.id }
    }
}

What just happened? @Observable replaces ObservableObject here — no @Published needed on the stored properties. itemCount is a computed property, so it recalculates automatically any time items changes, which matters in a couple of steps when this number ends up on the tab badge.

Step 3: Build the Cart Destination View

This is the screen the Cart tab navigates to.

// Features/Cart/CartView.swift
import SwiftUI
struct CartView: View {
    @State private var viewModel = CartViewModel()
    var body: some View {
        NavigationStack {
            List {
                ForEach(viewModel.items) { item in
                    HStack {
                        VStack(alignment: .leading, spacing: 4) {
                            Text(item.name)
                                .font(.body.weight(.medium))
                            Text(item.price, format: .currency(code: "USD"))
                                .font(.subheadline)
                                .foregroundStyle(.secondary)
                        }
                        Spacer()
                        Stepper(
                            "\(item.quantity)",
                            value: Binding(
                                get: { item.quantity },
                                set: { viewModel.updateQuantity(for: item, to: $0) }
                            ),
                            in: 1...10
                        )
                        .labelsHidden()
                        .fixedSize()
                    }
                }
                .onDelete { indexSet in
                    indexSet.map { viewModel.items[$0] }.forEach(viewModel.remove)
                }
                Section {
                    HStack {
                        Text("Subtotal")
                            .font(.headline)
                        Spacer()
                        Text(viewModel.subtotal, format: .currency(code: "USD"))
                            .font(.headline)
                    }
                }
            }
            .navigationTitle("Cart")
            .toolbar {
                ToolbarItem(placement: .confirmationAction) {
                    Button("Checkout") {
                        // Hook up your checkout flow here
                    }
                    .disabled(viewModel.items.isEmpty)
                }
            }
        }
    }
}

What just happened? @State private var viewModel = CartViewModel() is the correct way to hold an @Observable instance in SwiftUI — not @StateObject, since @Observable types don't conform to ObservableObject. Everything else is a standard List with a Stepper bound through a computed Binding, which lets the row read and write quantity without owning any state itself.

Step 4: Build the Remaining Destinations

The other three tabs just need something to show. Keep them simple for now.

// Features/Home/HomeView.swift
import SwiftUI
struct HomeView: View {
    var body: some View {
        NavigationStack {
            Text("Featured products")
                .navigationTitle("Home")
        }
    }
}

// Features/Search/SearchView.swift
import SwiftUI
struct SearchView: View {
    var body: some View {
        NavigationStack {
            Text("Search products")
                .navigationTitle("Search")
        }
    }
}

// Features/Profile/ProfileView.swift
import SwiftUI
struct ProfileView: View {
    var body: some View {
        NavigationStack {
            Text("Account details")
                .navigationTitle("Profile")
        }
    }
}

What just happened? Nothing exciting yet — that’s the point. These exist so the tab bar has four real destinations to compare against once Cart gets its .prominent styling.

Step 5: Assemble the Standard Tab Structure

Before touching .prominent, wire up a normal TabView with all four destinations.

// UI/QuickCartTabView.swift
import SwiftUI
struct QuickCartTabView: View {
    var body: some View {
        TabView {
            Tab("Home", systemImage: "house") {
                HomeView()
            }
            Tab("Search", systemImage: "magnifyingglass") {
                SearchView()
            }
            Tab("Cart", systemImage: "cart.fill") {
                CartView()
            }
            Tab("Profile", systemImage: "person") {
                ProfileView()
            }
        }
    }
}

What just happened? This is the iOS 27 Tab builder syntax — each Tab takes a title, an SF Symbol, and a trailing closure for its destination. Build and run now, and all four tabs look identical in weight. That's what .prominent changes next.

Step 6: Apply the .prominent Role

Add the role to the Tab initializer for Cart, and switch to the label-closure form so you can control the icon and title directly.

Tab(role: .prominent) {
    CartView()
} label: {
    Label("Cart", systemImage: "cart.fill")
}

Drop that in place of the plain Cart tab:

struct QuickCartTabView: View {
    var body: some View {
        TabView {
            Tab("Home", systemImage: "house") {
                HomeView()
            }
            Tab("Search", systemImage: "magnifyingglass") {
                SearchView()
            }
            Tab(role: .prominent) {
                CartView()
            } label: {
                Label("Cart", systemImage: "cart.fill")
            }
            Tab("Profile", systemImage: "person") {
                ProfileView()
            }
        }
    }
}

What just happened? role: .prominent tells the system to render this tab with extra visual weight — a larger icon treatment relative to its neighbors in the bar. The destination and navigation behavior are unchanged; tapping it still pushes CartView() exactly like any other tab. Build and run, and Cart now visually stands out from Home, Search, and Profile.

Step 7: Add a Live Badge to the Prominent Tab

A prominent tab pairs naturally with a badge, since both are ways of surfacing “this matters right now.” Bring in the view model at the tab level and attach .badge().

struct QuickCartTabView: View {
    @State private var cartViewModel = CartViewModel()
    var body: some View {
        TabView {
            Tab("Home", systemImage: "house") {
                HomeView()
            }
            Tab("Search", systemImage: "magnifyingglass") {
                SearchView()
            }
            Tab(role: .prominent) {
                CartView()
            } label: {
                Label("Cart", systemImage: "cart.fill")
            }
            .badge(cartViewModel.itemCount)
            Tab("Profile", systemImage: "person") {
                ProfileView()
            }
        }
    }
}

What just happened? .badge() accepts any Int, and since itemCount is a computed property on an @Observable view model, the badge updates automatically the moment items changes — no manual refresh logic required. Right now cartViewModel here and the one inside CartView are separate instances; in a real app you'd inject a single shared instance so both stay in sync, which the next section covers.

Step 8: Wire It Into the App Entry Point

Finish by pointing the app’s entry point at QuickCartTabView.

// App/QuickCartApp.swift
import SwiftUI
@main
struct QuickCartApp: App {
    var body: some Scene {
        WindowGroup {
            QuickCartTabView()
        }
    }
}

Sharing State Across the Badge and the Cart

To keep the badge and the cart contents in sync, share one view model instance instead of creating two.

struct QuickCartTabView: View {
    @State private var cartViewModel = CartViewModel()
    var body: some View {
            TabView {
                Tab("Home", systemImage: "house") {
                    HomeView()
                }
                Tab("Search", systemImage: "magnifyingglass") {
                    SearchView()
                }
                Tab(role: .prominent) {
                    CartView(viewModel: cartViewModel)
                } label: {
                    Label("Cart", systemImage: "cart.fill")
                }
                .badge(cartViewModel.itemCount)
                Tab("Profile", systemImage: "person") {
                    ProfileView()
                }
            }
        }
}

Update CartView to accept the shared instance instead of creating its own:

struct CartView: View {
    var viewModel: CartViewModel
    var body: some View {
        // body unchanged from Step 3
    }
}

What just happened? CartView now takes viewModel as a plain property instead of owning it with @State. Because CartViewModel is a reference type marked @Observable, SwiftUI still tracks changes to it correctly — updating a quantity inside CartView immediately reflects in the tab badge, since both views are watching the same object.

Running the App

Build and run on an iOS 27 simulator. You’ll see:

  • Home, Search, and Profile rendered as standard tabs

  • Cart rendered with the .prominent styling — visually heavier than its neighbors

  • A badge on the Cart tab showing 3 — 1 pair of Trail Running Shoes plus 2 Water Bottles

  • Adjusting a quantity in CartView updates the badge in real time, since both views share the same CartViewModel

Tapping the Cart tab behaves exactly like tapping any other tab — it navigates to CartView(). The only difference .prominent introduces is visual weight in the bar itself.

Key Takeaways

  • Tab(role: .prominent) { destination } label: { ... } is the full syntax — one role, one destination closure, one label

  • .prominent changes visual weight in the tab bar only; navigation behavior is identical to a standard tab

  • Pair it with .badge() for a live count that draws even more attention to the destination

  • Back it with an @Observable view model shared across the tab and its destination view so badges and content stay in sync automatically

  • Use @State — not @StateObject — to hold @Observable instances in SwiftUI

Where to Take This Next

  • Swap CartItem for a SwiftData @Model and persist the cart across launches

  • Explore TabViewCustomization in iOS 27 to let users reorder the non-prominent tabs while Cart stays pinned in place

  • Apply the same pattern to other destinations — a Tickets tab in a ticketing app, a Trips tab in a travel app, or a Pay tab in a banking app all follow the identical Tab(role: .prominent) { ... } label: { ... } structure shown here