SwiftUI ContentUnavailableView: The Complete Guide to Empty States, Errors, and No-Results Screens in iOS 26

A practical guide to ContentUnavailableView in SwiftUI: every initializer, the .searchable pairing, retry buttons, VoiceOver behaviour, iOS 26 Liquid Glass, and iOS 15/16 fallbacks.

SwiftUI ContentUnavailableView Guide 2026

Updated: July 25, 2026

ContentUnavailableView in SwiftUI is Apple's built-in view for rendering empty states, error screens, and "no results" placeholders with a consistent, accessible layout that matches Photos, Files, Mail, and Reminders. It was introduced in iOS 17 and picks up Liquid Glass styling automatically when you recompile with Xcode 26 for iOS 26. Honestly, before this shipped I'd hand-rolled the same VStack maybe a dozen times across different apps, so seeing Apple standardise it was a small relief. In this guide I'll walk through every initializer, the canonical .searchable pairing, retry buttons, VoiceOver behaviour, iOS 15/16 fallbacks, and the small footguns that trip people up in production apps.

  • ContentUnavailableView requires iOS 17+, macOS 14+, watchOS 10+, tvOS 17+, and visionOS 1+. Guard with if #available if you support older systems.
  • Three initializers matter: the string/systemImage convenience, the label:/description:/actions: builder, and the two static .search variants.
  • The correct place to render it is inside .overlay { } on the container, not inside a List cell or Section.
  • VoiceOver reads the label, description, then actions in order. Do not add a custom accessibilityLabel on the container, or you'll collapse the semantic group.
  • iOS 26 doesn't change the API, but action buttons inherit Liquid Glass styling when embedded in a glass toolbar or navigation context.
  • Always guard against showing an empty state during the initial load. Check !isLoading before deciding the collection is truly empty.

What is ContentUnavailableView in SwiftUI?

ContentUnavailableView is a first-party SwiftUI view for the class of screens that used to be a hand-rolled VStack containing an SF Symbol, a title, a description, and sometimes a button. Apple shipped it at WWDC 2023 for iOS 17 to standardise those layouts across the system apps and third-party apps alike. The visual result is that same "large centred symbol, big title, muted subtitle, optional action" arrangement Photos uses when an album is empty, that Mail uses when a mailbox has nothing in it, and that Files uses when a folder is empty.

Underneath, it's a leaf view: it doesn't manage state, it doesn't observe your model, and it doesn't know whether your data is genuinely empty or still loading. Your job is to decide when to show it. Its job is to draw it correctly in every layout SwiftUI hands it to (inside a NavigationSplitView detail column, inside a ScrollView, on watchOS where the image is hidden and the description scrolls, and on visionOS where it centres in the volume). Because it's a system view, it also participates in Dynamic Type, VoiceOver grouping, right-to-left mirroring, and, as of iOS 26, Liquid Glass surfaces without any extra work.

I default to it any time I have a screen that can legitimately show nothing. Empty inbox, filtered list with zero matches, offline error, search with no results, permission not granted. Rolling your own VStack may feel like ten fewer lines, but you'll drift from system behaviour the first time Apple ships a new adaptation and your handmade version doesn't follow along.

How do I show an empty state in SwiftUI?

To show an empty state you render ContentUnavailableView as an alternate branch when your data source is empty. The most concise form takes a title, an SF Symbol name, and an optional description:

import SwiftUI

struct NotesListView: View {
    @Query private var notes: [Note]

    var body: some View {
        NavigationStack {
            if notes.isEmpty {
                ContentUnavailableView(
                    "No Notes",
                    systemImage: "doc.text",
                    description: Text("Tap the plus button to write your first note.")
                )
            } else {
                List(notes) { note in
                    NoteRow(note: note)
                }
            }
        }
        .navigationTitle("Notes")
    }
}

The three-argument convenience is enough for maybe 70% of real empty states. When you need an action button or a fully custom label, reach for the ViewBuilder form:

ContentUnavailableView {
    Label("No Bookmarks", systemImage: "bookmark")
} description: {
    Text("Bookmarks you save from Safari will appear here.")
} actions: {
    Button("Open Safari") {
        openURL(URL(string: "https://apple.com")!)
    }
    .buttonStyle(.borderedProminent)
}

A few conventions I follow: pick an SF Symbol that looks like the content type (tray for inbox, magnifyingglass for search, wifi.slash for network), keep the title under five words, and let the description explain the next action rather than restate the title. If you can't think of a useful description, drop it. An unhelpful "There is no content" line just adds noise for both sighted users and VoiceOver.

The .search variant and searchable integration

ContentUnavailableView ships two static factories specifically for search: .search and .search(text:). The first renders a generic "No Results" screen with the standard magnifying-glass symbol; the second interpolates the user's query into the message, matching what the system apps do. The canonical pattern is to combine them with the SwiftUI searchable modifier and an .overlay:

struct ArticleSearchView: View {
    @State private var query = ""
    @State private var articles: [Article] = Article.samples

    var filtered: [Article] {
        guard !query.isEmpty else { return articles }
        return articles.filter { $0.title.localizedCaseInsensitiveContains(query) }
    }

    var body: some View {
        NavigationStack {
            List(filtered) { ArticleRow(article: $0) }
                .searchable(text: $query, prompt: "Search articles")
                .overlay {
                    if filtered.isEmpty && !query.isEmpty {
                        ContentUnavailableView.search(text: query)
                    } else if articles.isEmpty {
                        ContentUnavailableView(
                            "No Articles",
                            systemImage: "tray",
                            description: Text("Pull down to refresh.")
                        )
                    }
                }
                .refreshable {
                    articles = await ArticleStore.shared.fetch()
                }
        }
    }
}

Two subtle things are happening here. First, the overlay renders on top of the empty List, which means system separators and the search field stay in place instead of collapsing. Second, the two branches ("there is a query with no matches" and "there is no data at all") are semantically different, and they get different copy. The system reserves .search(text:) for the first case; don't use it for the empty-collection case, because it will read as if the user searched when they never did.

On iOS 26 the search field lives inside the Liquid Glass toolbar surface, and ContentUnavailableView.search(text:) in .overlay continues to adjust its safe-area insets automatically so the message stays vertically centred in the visible content area rather than being tucked under the toolbar.

Adding a retry button and handling network errors

The actions: slot is where you put recovery UI. Most commonly, a Retry button for a failed network request. Wire it to an async function on your view model and let the button do double duty as the visible affordance and the primary VoiceOver action:

enum LoadState<Value> {
    case loading
    case loaded(Value)
    case failed(Error)
}

struct FeedView: View {
    @State private var state: LoadState<[Post]> = .loading

    var body: some View {
        Group {
            switch state {
            case .loading:
                ProgressView("Loading feed")
            case .loaded(let posts) where posts.isEmpty:
                ContentUnavailableView(
                    "Nothing to Read",
                    systemImage: "newspaper",
                    description: Text("New posts will appear here as people you follow publish.")
                )
            case .loaded(let posts):
                List(posts) { PostRow(post: $0) }
            case .failed(let error):
                ContentUnavailableView {
                    Label("Can't Load Feed", systemImage: "wifi.slash")
                } description: {
                    Text(error.localizedDescription)
                } actions: {
                    Button("Retry") { Task { await load() } }
                        .buttonStyle(.borderedProminent)
                }
            }
        }
        .task { await load() }
    }

    func load() async {
        state = .loading
        do {
            let posts = try await FeedAPI.fetch()
            state = .loaded(posts)
        } catch {
            state = .failed(error)
        }
    }
}

A few notes from shipping this pattern in real apps. I hit this exact bug on a client project last winter: don't conflate "the request failed" with "the collection is empty". Those are separate branches with separate copy. Don't throw the raw Error into the description either; a localised message you author yourself will read better than a bridged NSError. And if a request is retryable but the failure is permanent (a 404 for content the user deleted, say), replace the Retry action with a navigation button back to a safer screen rather than promising a retry that will always fail.

Accessibility: VoiceOver, Dynamic Type, and Reduce Motion

This is the part most tutorials skip, and it's the whole reason to use the system view instead of a custom VStack. ContentUnavailableView forms a single VoiceOver accessibility group that reads the label, then the description, then focuses each action button in order. If you use the standard initializers you don't need to add a single accessibility modifier. Apple wired it correctly. If you feel the urge to slap an .accessibilityLabel on the container, resist: it collapses the group into one flat string and drops the description, which is exactly the opposite of what you want.

Dynamic Type scales the title, description, and SF Symbol together because the symbol resolves against the current text style. That means the empty state grows for a user with the largest accessibility text sizes without wrapping into the toolbar or clipping. If you wrap the view in a fixed .frame(height:), you break this, so leave the sizing to SwiftUI. For a deeper walkthrough of how VoiceOver, Dynamic Type, and the accessibility environment values fit together, see my complete guide to SwiftUI accessibility.

Reduce Motion is worth a mention because transitions between "loading", "empty", and "populated" states are one of the more common places to introduce a subtle animation. If you animate the branch swap with .animation(.smooth, value: state.isEmpty), wrap the modifier in a check on @Environment(\.accessibilityReduceMotion) and swap the animation to .none when the environment flag is on:

@Environment(\.accessibilityReduceMotion) private var reduceMotion

.animation(reduceMotion ? nil : .smooth, value: filtered.isEmpty)

iOS 26, Liquid Glass, and Xcode 26 behaviour

iOS 26 doesn't add any new initializers, deprecate anything, or change the layout algorithm for ContentUnavailableView. What it does is give action buttons the Liquid Glass surface treatment automatically when the empty state sits inside a navigation stack or toolbar context that has already opted into glass. If you recompile an iOS 17 app with Xcode 26 and target iOS 26, the Retry button in your error state suddenly renders on a translucent glass background with the correct vibrancy for the current appearance. No code changes required.

To go one step further and mark the Retry action as the primary CTA on iOS 26, use .buttonStyle(.glassProminent) guarded by an availability check so older systems still get .borderedProminent. If you're new to the whole Liquid Glass rollout, my Liquid Glass guide covers where the styles come from and when they compose.

One quirk to be aware of: several developers on Apple Developer Forums have reported layout jitter when placing ContentUnavailableView inside a ScrollView that also uses .contentMargins. The workaround, and the pattern I recommend anyway, is to render the empty state as an .overlay on the parent rather than as a scrollable child. For the authoritative spec, see the official ContentUnavailableView documentation and the WWDC 2023 "What's new in SwiftUI" session where the type was first introduced.

Supporting iOS 15 and iOS 16 with a fallback

If your deployment target is below iOS 17 you'll have to gate every use of ContentUnavailableView behind an availability check, or the app will crash at launch on older devices. The cleanest approach is a small wrapper that falls back to a hand-rolled VStack with the same shape:

struct EmptyStateView: View {
    let title: LocalizedStringKey
    let systemImage: String
    let description: LocalizedStringKey?

    var body: some View {
        if #available(iOS 17.0, macOS 14.0, watchOS 10.0, tvOS 17.0, *) {
            ContentUnavailableView(
                title,
                systemImage: systemImage,
                description: description.map(Text.init)
            )
        } else {
            VStack(spacing: 12) {
                Image(systemName: systemImage)
                    .font(.system(size: 48, weight: .light))
                    .foregroundStyle(.secondary)
                Text(title)
                    .font(.title2.weight(.semibold))
                if let description {
                    Text(description)
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                        .multilineTextAlignment(.center)
                }
            }
            .padding()
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .accessibilityElement(children: .combine)
        }
    }
}

Note the .accessibilityElement(children: .combine) on the fallback. That's the manual equivalent of the grouping the iOS 17 version does for free. Without it, VoiceOver focuses the image and each text view separately, which is exactly the sort of subtle regression a "just support iOS 16" branch introduces.

Common footguns and how to avoid them

Here's the list of mistakes I've caught in code review (and in my own projects), roughly in order of frequency:

  • Showing "No Items" during the initial load. If your view starts with an empty collection and populates asynchronously, a naive if items.isEmpty check will flash the empty state for a fraction of a second before the data lands. Model an explicit loading state (like the LoadState enum above) and only render the empty view once loading has definitively finished.
  • Rendering inside a List or Section. ContentUnavailableView is designed to fill its container. Putting it inside a List cell or a Form section wraps it in a row and destroys the layout. Render it as a sibling branch or via .overlay on the parent.
  • Hardcoded String values. The public initializers accept LocalizedStringKey by default; passing a String(...) bypasses the SwiftGen/String Catalog pipeline and stops localisation from working. Pass string literals directly or use a LocalizedStringKey variable.
  • Overriding VoiceOver on the container. Adding .accessibilityLabel("Empty") to a ContentUnavailableView collapses the semantic group and drops the description. Trust the built-in grouping.
  • Showing .search when the query is empty. If the user hasn't typed anything, the message "No Results" is confusing. Guard on !query.isEmpty before rendering the search variant.
  • Missing iOS 17 availability guard. If your deployment target is iOS 15 or 16 and you reference ContentUnavailableView unguarded, the app crashes on launch on older devices. Use the wrapper above.
  • Fixed frames. Wrapping the view in .frame(height: 200) forces truncation at large Dynamic Type sizes. Let it fill.
  • Using it as a full-page loading indicator. An empty state isn't a spinner. Show a ProgressView during loading and the empty view only when loading has finished.

Get past those and you'll have empty states that look like they were designed by Apple, respect every accessibility environment, and require exactly as much code as they deserve. Which is the point.

Frequently Asked Questions

Does ContentUnavailableView work on iOS 16 or iOS 15?

No. ContentUnavailableView requires iOS 17 or later. If your deployment target is iOS 16 or below, gate the API with if #available(iOS 17.0, *) and fall back to a manual VStack containing an SF Symbol, a title, and a description. Add .accessibilityElement(children: .combine) on the fallback so VoiceOver groups the elements the way the iOS 17 view does automatically.

What is the difference between ContentUnavailableView.search and .search(text:)?

.search shows a generic "No Results" screen with the standard magnifying-glass symbol. .search(text:) interpolates the user's query into the message so it reads "No Results for “query”", matching what Photos and Files do. Use .search(text:) whenever you have the current query in scope, since it gives users confirmation that their search ran against the string they actually typed.

How do I add a retry button to ContentUnavailableView?

Use the ViewBuilder form and put a Button in the actions: trailing closure. Kick off an async task inside the button's action and update your view model's state when the reload completes. Style the button with .buttonStyle(.borderedProminent) on iOS 17+ or .buttonStyle(.glassProminent) on iOS 26 for the correct Liquid Glass appearance.

Does ContentUnavailableView support Liquid Glass in iOS 26?

Yes, automatically. The view itself doesn't need any new modifiers, but action buttons inside the actions: slot render on Liquid Glass surfaces when the empty state sits inside a glass toolbar or navigation stack. Recompile with Xcode 26 targeting iOS 26 and the Retry button picks up the translucent appearance and correct vibrancy for the current interface style.

How do I show ContentUnavailableView only when a list is empty?

Attach an .overlay { } to the container view and render the ContentUnavailableView conditionally inside the closure when your collection is empty. This keeps the list's toolbar, search field, and pull-to-refresh gesture intact while showing the empty state on top of the empty content area. Do not put the empty view inside a List row.

Ava Thompson
About the Author Ava Thompson

SwiftUI engineer focused on declarative animations and accessibility. Will fight you about navigation stacks.