```html SwiftUI: How to Build a Tooltip (iOS 17+, 2026)

How to Build a Tooltip in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: popover / .help() Updated: May 11, 2026
TL;DR

SwiftUI has no built-in Tooltip view, but you can compose one using .popover(isPresented:arrowEdge:) triggered by a long-press gesture, combined with .help() for VoiceOver support. Add .presentationCompactAdaptation(.popover) inside the popover content so it stays a popover on iPhone instead of expanding to a sheet.

struct TipButton: View {
    @State private var showTip = false

    var body: some View {
        Button("ℹ️ Info") { showTip.toggle() }
            .help("Tap for more information")
            .popover(isPresented: $showTip, arrowEdge: .top) {
                Text("Saves your progress automatically.")
                    .padding()
                    .presentationCompactAdaptation(.popover)
            }
    }
}

Full implementation

The best pattern is a reusable TooltipModifier that encapsulates the @State toggle and the .popover binding, then exposed as a .tooltip() extension on View. The tooltip fires on a 0.5-second long-press — familiar to users from the iOS system and distinct from a regular tap. The .help() modifier mirrors the message for VoiceOver so the experience is accessible without any extra work.

import SwiftUI

// MARK: - Tooltip content view
private struct TooltipBubble: View {
    let message: String

    var body: some View {
        Text(message)
            .font(.subheadline)
            .foregroundStyle(.primary)
            .multilineTextAlignment(.leading)
            .padding(.horizontal, 14)
            .padding(.vertical, 10)
            .frame(maxWidth: 240, alignment: .leading)
    }
}

// MARK: - Tooltip view modifier
struct TooltipModifier: ViewModifier {
    let message: String
    var arrowEdge: Edge

    @State private var isPresented = false

    func body(content: Content) -> some View {
        content
            // Accessibility: VoiceOver reads this hint automatically
            .help(message)
            // Long-press to reveal the tooltip bubble
            .onLongPressGesture(minimumDuration: 0.5) {
                isPresented = true
            }
            .popover(isPresented: $isPresented, arrowEdge: arrowEdge) {
                TooltipBubble(message: message)
                    // Keep popover style on compact (iPhone) size classes
                    .presentationCompactAdaptation(.popover)
            }
    }
}

// MARK: - Convenience extension
extension View {
    /// Attaches a tooltip that appears on long-press and is readable by VoiceOver.
    func tooltip(_ message: String, arrowEdge: Edge = .top) -> some View {
        modifier(TooltipModifier(message: message, arrowEdge: arrowEdge))
    }
}

// MARK: - Demo screen
struct TooltipDemoView: View {
    var body: some View {
        NavigationStack {
            VStack(spacing: 36) {

                // Toolbar-style icon button
                Button {
                    // real action
                } label: {
                    Image(systemName: "square.and.arrow.up")
                        .font(.title2)
                        .padding(12)
                        .background(.tint.opacity(0.12), in: Circle())
                }
                .tooltip("Share this document with teammates.", arrowEdge: .bottom)

                // Labelled action button
                Button {
                    // real action
                } label: {
                    Label("Publish", systemImage: "paperplane.fill")
                        .padding(.horizontal, 24)
                        .padding(.vertical, 12)
                        .background(.tint, in: RoundedRectangle(cornerRadius: 12))
                        .foregroundStyle(.white)
                }
                .tooltip(
                    "Publishing makes your app visible on the App Store. This cannot be undone.",
                    arrowEdge: .top
                )

                // Inline info icon next to a form field
                HStack {
                    TextField("Bundle ID", text: .constant("com.example.app"))
                        .textFieldStyle(.roundedBorder)
                    Image(systemName: "questionmark.circle")
                        .foregroundStyle(.secondary)
                        .tooltip("Must match your provisioning profile exactly.", arrowEdge: .leading)
                }
                .padding(.horizontal)
            }
            .padding()
            .navigationTitle("Tooltip Demo")
        }
    }
}

#Preview {
    TooltipDemoView()
}

How it works

  1. .help(message) — free VoiceOver support. This modifier registers a hint string that VoiceOver reads after the accessibility label. It costs nothing at runtime and means users with assistive technology always get the tooltip text, even if they cannot long-press.
  2. .onLongPressGesture(minimumDuration: 0.5) — familiar trigger. Half a second is long enough to avoid accidental fires during scrolling but short enough to feel snappy. Setting isPresented = true inside the closure drives the popover.
  3. .popover(isPresented:arrowEdge:) — the bubble itself. SwiftUI positions the popover relative to the modified view and draws the arrow on the requested edge. The system handles safe-area avoidance and dismissal on tap-outside automatically.
  4. .presentationCompactAdaptation(.popover) — iPhone fix. By default, popovers adapt to a sheet on compact horizontal size classes (most iPhones). This modifier, introduced in iOS 16.4, tells SwiftUI to keep the popover style regardless of size class.
  5. TooltipModifier owns its own @State. Each modified view gets its own isolated isPresented flag — no need for the parent to manage tooltip state. Multiple tooltips on screen are independent.

Variants

Tap-to-reveal tooltip (no long-press)

If your UI already uses long-press for something else — like drag-and-drop reordering — switch the trigger to a plain tap. Override the modifier's gesture by replacing onLongPressGesture with simultaneousGesture(TapGesture()) so existing tap actions on the wrapped view still fire.

struct TapTooltipModifier: ViewModifier {
    let message: String
    @State private var isPresented = false

    func body(content: Content) -> some View {
        content
            .help(message)
            // simultaneousGesture lets the host view's own tap action fire too
            .simultaneousGesture(
                TapGesture().onEnded { isPresented = true }
            )
            .popover(isPresented: $isPresented, arrowEdge: .top) {
                Text(message)
                    .padding()
                    .frame(maxWidth: 220)
                    .presentationCompactAdaptation(.popover)
            }
    }
}

extension View {
    func tapTooltip(_ message: String) -> some View {
        modifier(TapTooltipModifier(message: message))
    }
}

Styled tooltip with custom background

Wrap TooltipBubble in a ZStack with a RoundedRectangle fill and set .presentationBackground(.clear) to remove the default popover chrome. This gives you a fully custom, branded bubble — just keep the .presentationCompactAdaptation(.popover) call in place. Note that removing the system chrome also removes the automatic arrow, so position your tooltip content accordingly or draw your own using a Canvas.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement a reusable tooltip in SwiftUI for iOS 17+.
Use .popover(isPresented:arrowEdge:) and .help() for accessibility.
Add .presentationCompactAdaptation(.popover) so it stays a popover on iPhone.
Trigger on long-press (0.5 s). Expose as a .tooltip(_ message:) View extension.
Make it accessible (VoiceOver labels via .help()).
Add a #Preview with at least three realistic use-cases: icon button, CTA button, and a form field.

In the Soarias Build phase, paste this prompt into a feature file for your interaction layer — Claude Code will scaffold TooltipModifier.swift alongside your existing button components and wire up the extension automatically.

Related

FAQ

Does this work on iOS 16?

Partially. The .popover modifier and .help() work on iOS 16.0+, but .presentationCompactAdaptation(.popover) requires iOS 16.4+. On iOS 16.0–16.3, the popover falls back to a full-screen sheet on iPhone. If you target iOS 16, add a version check and substitute a .sheet with .presentationDetents([.fraction(0.15)]) as a graceful fallback.

Can I show a tooltip programmatically without user interaction?

Yes. Because TooltipModifier exposes isPresented as internal state, you can hoist it out by accepting a Binding<Bool> parameter instead of the private @State. Pass that binding in from a parent view and set it to true on any trigger you like — a timer, a first-launch coach mark sequence, or a remote feature flag. This also makes unit-testing the popover presentation logic straightforward.

What is the UIKit equivalent?

UIPopoverPresentationController is the UIKit counterpart. You present a UIViewController with modalPresentationStyle = .popover, then configure its popoverPresentationController?.sourceView and permittedArrowDirections. You also implement UIPopoverPresentationControllerDelegate and return .none from adaptivePresentationStyle to keep it as a popover on iPhone — the conceptual equivalent of .presentationCompactAdaptation(.popover) in SwiftUI.

Last reviewed: 2026-05-11 by the Soarias team.

```