SwiftUI Tooltip Component
A pointed bubble anchored to whatever it is explaining. For onboarding coach-marks and "what does this do?" hints.
Tap to refresh the feed
Variants: Below, Above
Install the free SwiftUI tooltip
- Add SwiftKitUI.swift to your app once. It holds the tokens and motion curves.
- Copy SKTooltip.swift below into your project. It needs nothing else.
Usage
Usage.swift
Button("?") { show.toggle() }
.skTooltip(isPresented: $show, edge: .top) { Text("Tap to refresh") }Full source · 193 lines · MIT
SKTooltip.swift
//
// SKTooltip.swift
// Swift Kit UI
//
// Requires: SwiftKitUI.swift
//
// A pointed bubble anchored to whatever it is explaining. For onboarding
// coach-marks and "what does this do?" hints.
//
// The arrow is part of the bubble's `Shape`, not a rotated square behind it.
// A rotated square shows a seam wherever the bubble is translucent and gets
// clipped wrong at the corners; one path has neither problem.
//
// Usage:
// Button("?") { show.toggle() }
// .skTooltip(isPresented: $show, edge: .top) { Text("Tap to refresh") }
//
import SwiftUI
public struct SKTooltipShape: Shape {
public var arrowEdge: Edge
public var cornerRadius: CGFloat = SK.Radius.md
public var arrowWidth: CGFloat = 16
public var arrowHeight: CGFloat = 8
public init(arrowEdge: Edge) { self.arrowEdge = arrowEdge }
public func path(in rect: CGRect) -> Path {
var body = rect
switch arrowEdge {
case .top: body.origin.y += arrowHeight; body.size.height -= arrowHeight
case .bottom: body.size.height -= arrowHeight
case .leading: body.origin.x += arrowHeight; body.size.width -= arrowHeight
case .trailing: body.size.width -= arrowHeight
}
var path = Path(roundedRect: body, cornerRadius: cornerRadius, style: .continuous)
var arrow = Path()
switch arrowEdge {
case .top:
let x = body.midX
arrow.move(to: CGPoint(x: x - arrowWidth / 2, y: body.minY))
arrow.addLine(to: CGPoint(x: x, y: rect.minY))
arrow.addLine(to: CGPoint(x: x + arrowWidth / 2, y: body.minY))
case .bottom:
let x = body.midX
arrow.move(to: CGPoint(x: x - arrowWidth / 2, y: body.maxY))
arrow.addLine(to: CGPoint(x: x, y: rect.maxY))
arrow.addLine(to: CGPoint(x: x + arrowWidth / 2, y: body.maxY))
case .leading:
let y = body.midY
arrow.move(to: CGPoint(x: body.minX, y: y - arrowWidth / 2))
arrow.addLine(to: CGPoint(x: rect.minX, y: y))
arrow.addLine(to: CGPoint(x: body.minX, y: y + arrowWidth / 2))
case .trailing:
let y = body.midY
arrow.move(to: CGPoint(x: body.maxX, y: y - arrowWidth / 2))
arrow.addLine(to: CGPoint(x: rect.maxX, y: y))
arrow.addLine(to: CGPoint(x: body.maxX, y: y + arrowWidth / 2))
}
arrow.closeSubpath()
path.addPath(arrow)
return path
}
}
/// Positioning maths, kept out of the generic modifier.
///
/// An `alignmentGuide` closure inside a type generic over the tip's View would
/// capture `Tip.Type`, which Swift 6 flags as a non-Sendable capture in an
/// isolated closure. Pulling the logic into a non-generic enum means the
/// closure captures only an `Edge`.
private enum SKTooltipGeometry {
static func overlayAlignment(for edge: Edge) -> Alignment {
switch edge {
case .top: .top // arrow on top -> bubble sits ABOVE the anchor
case .bottom: .bottom
case .leading: .leading
case .trailing: .trailing
}
}
/// Pushes the bubble fully outside the anchor instead of overlapping it.
static func verticalGuide(for edge: Edge, _ d: ViewDimensions) -> CGFloat {
switch edge {
case .top: d[.bottom]
case .bottom: d[.top]
default: d[VerticalAlignment.center]
}
}
static func offset(for edge: Edge) -> CGSize {
switch edge {
case .top: CGSize(width: 0, height: -6)
case .bottom: CGSize(width: 0, height: 6)
case .leading: CGSize(width: -6, height: 0)
case .trailing: CGSize(width: 6, height: 0)
}
}
static func arrowPadding(for edge: Edge) -> Edge.Set {
switch edge {
case .top: .top
case .bottom: .bottom
case .leading: .leading
case .trailing: .trailing
}
}
}
private struct SKTooltipModifier<Tip: View>: ViewModifier {
@Binding var isPresented: Bool
let edge: Edge
let tip: Tip
func body(content: Content) -> some View {
let anchorEdge = edge
let alignment = SKTooltipGeometry.overlayAlignment(for: anchorEdge)
let shift = SKTooltipGeometry.offset(for: anchorEdge)
return content
.overlay(alignment: alignment) {
if isPresented {
bubble
.fixedSize()
.alignmentGuide(alignment.vertical) { d in
SKTooltipGeometry.verticalGuide(for: anchorEdge, d)
}
.offset(x: shift.width, y: shift.height)
.transition(.opacity.combined(with: .scale(scale: 0.88)))
.zIndex(1)
}
}
.skAnimation(.settle, value: isPresented)
}
private var bubble: some View {
tip
.font(SK.Type_.caption)
.foregroundStyle(SK.Palette.surface)
.multilineTextAlignment(.leading)
.padding(.horizontal, SK.Space.md)
.padding(.vertical, SK.Space.sm)
.padding(SKTooltipGeometry.arrowPadding(for: edge), 8)
.background {
SKTooltipShape(arrowEdge: edge)
.fill(SK.Palette.label)
.shadow(color: .black.opacity(0.22), radius: 10, y: 4)
}
.frame(maxWidth: 240)
.onTapGesture { isPresented = false }
.accessibilityAddTraits(.isStaticText)
}
}
extension View {
/// Shows a pointed tooltip attached to this view.
public func skTooltip<Tip: View>(
isPresented: Binding<Bool>,
edge: Edge = .bottom,
@ViewBuilder tip: () -> Tip
) -> some View {
modifier(SKTooltipModifier(isPresented: isPresented, edge: edge, tip: tip()))
}
}
#Preview("SKTooltip") {
@Previewable @State var a = true
@Previewable @State var b = false
VStack(spacing: 90) {
Image(systemName: "questionmark.circle.fill")
.font(SK.Icon.symbol(SK.Icon.xl))
.foregroundStyle(SK.Palette.accent)
.onTapGesture { a.toggle() }
.skTooltip(isPresented: $a, edge: .bottom) {
Text("Arrow points down at the thing it explains.")
}
Image(systemName: "bell.fill")
.font(SK.Icon.symbol(SK.Icon.xl))
.foregroundStyle(SK.Palette.accent)
.onTapGesture { b.toggle() }
.skTooltip(isPresented: $b, edge: .top) {
Text("And up, when there is no room below.")
}
}
.padding(SK.Space.xxxl)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(SK.Palette.surfaceSunken)
}
Questions
- Is this SwiftUI tooltip 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?
- SKTooltip.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.