How to Build a Tooltip in SwiftUI
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
-
.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. -
.onLongPressGesture(minimumDuration: 0.5)— familiar trigger. Half a second is long enough to avoid accidental fires during scrolling but short enough to feel snappy. SettingisPresented = trueinside the closure drives the popover. -
.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. -
.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. -
TooltipModifierowns its own@State. Each modified view gets its own isolatedisPresentedflag — 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
-
iOS 16.3 and earlier ignore
.presentationCompactAdaptation. On those versions, the popover silently becomes a sheet. If you must support iOS 16.0–16.3, check#available(iOS 16.4, *)and fall back to a sheet with a fixed.presentationDetents([.fraction(0.2)])instead. -
Popovers on views inside a
Listcan misalign. SwiftUI may attach the arrow to the wrong edge when the source view is in a scrolling list. Workaround: wrap the list row content in aGroupand apply.popoverto theGroup, or use.anchorPreferenceto capture the correct frame. -
Don't skip
.help()when you add a visual tooltip. VoiceOver users won't long-press to discover hidden information. The.help()modifier is what makes the tooltip accessible — treat it as required, not optional.
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.