Before TipKit, in-app education was a maintenance nightmare. I've personally lost count of the times I've rebuilt the same five components on different projects: a UserDefaults flag for "user has seen X", a custom popover, an analytics event, and yes/no logic for showing it after the third launch but only on Tuesdays. (You know the drill.) TipKit centralizes all of that. The framework gives you:
- Persistence across launches — tip state, dismissal, and display counts are stored automatically.
- Frequency control — built-in rules like "no more than one tip per day" prevent fatigue.
- Eligibility rules — declarative predicates determine when a tip qualifies.
- iCloud sync — dismissal state can sync across a user's devices via CloudKit.
- Localization — titles and messages flow through the standard String Catalog pipeline.
- A/B testing hooks — integrate with App Store Connect experiments or your own bucketing.
If your app currently shows banners using @AppStorage("hasSeenFooTip") flags, you can usually delete that code entirely after migrating. It's deeply satisfying.
Project Setup
TipKit ships with iOS 17+, iPadOS 17+, macOS 14+, watchOS 10+, tvOS 17+, and visionOS 1+. For the iOS 26 features I'll cover below, you'll want to target the iOS 26 SDK in Xcode 26 or later.
Configure the framework once, early in your app lifecycle — typically in the App struct's initializer:
import SwiftUI
import TipKit
@main
struct CraftedApp: App {
init() {
try? Tips.configure([
.displayFrequency(.immediate),
.datastoreLocation(.applicationDefault)
])
}
var body: some Scene {
WindowGroup {
RootView()
}
}
}
displayFrequency controls how often any tip can appear. .immediate shows tips as soon as they're eligible — great for development, terrible for shipping. In production, prefer .daily or .hourly to avoid overwhelming users. datastoreLocation is where TipKit persists state; .applicationDefault uses the app's container, while .groupContainer(identifier:) lets you share state with extensions.
Defining Your First Tip
A tip is just a struct conforming to the Tip protocol. The minimum requirement is a title:
import TipKit
struct FavoriteRecipeTip: Tip {
var title: Text {
Text("Save Your Favorites")
}
var message: Text? {
Text("Tap the heart to keep recipes one tap away on the home screen.")
}
var image: Image? {
Image(systemName: "heart.fill")
}
}
That's really it. Every tip you build follows this pattern. The title, message, and image properties are Text and Image values, so they participate fully in SwiftUI's localization, dynamic type, and dark-mode systems — no extra work.
Displaying Tips: Inline, Popover, and Custom Styles
TipKit gives you two built-in renderers, plus full control to build your own.
Inline TipView
The TipView SwiftUI view renders a tip inline in your layout — perfect for empty states or above a list:
struct RecipeListView: View {
private let favoriteTip = FavoriteRecipeTip()
var body: some View {
List {
TipView(favoriteTip, arrowEdge: .bottom)
ForEach(recipes) { recipe in
RecipeRow(recipe: recipe)
}
}
}
}
The view automatically hides itself when the tip is invalidated (dismissed, reaches max display count, or fails an eligibility rule). You don't manage visibility — TipKit does. This alone is reason enough to migrate.
Popover Tips
For tips that point at a specific control, attach them with the popoverTip modifier:
Button {
toggleFavorite()
} label: {
Image(systemName: isFavorite ? "heart.fill" : "heart")
}
.popoverTip(favoriteTip)
iOS 26 redesigned the popover to use a Liquid Glass material with a subtle directional arrow, and it now respects safe areas and Dynamic Island geometry automatically (which, finally — this used to be a constant pain). On visionOS the popover floats in 3D space and casts a soft shadow.
Custom Tip Views
If neither built-in style fits, render the tip yourself using the protocol's properties:
struct CustomTipBanner: View {
let tip: any Tip
var body: some View {
HStack(spacing: 12) {
tip.image
.font(.title2)
.foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 4) {
tip.title.font(.headline)
if let message = tip.message {
message.font(.subheadline)
.foregroundStyle(.secondary)
}
}
Spacer()
Button("Got it") { tip.invalidate(reason: .tipClosed) }
.buttonStyle(.borderless)
}
.padding()
.background(.regularMaterial, in: .rect(cornerRadius: 16))
}
}
Calling tip.invalidate(reason:) tells TipKit the tip has been handled. The reason — .tipClosed, .actionPerformed, or a custom case — is recorded for analytics, which is useful when you're trying to figure out why users dismiss things.
Eligibility Rules: When Tips Should Appear
Eligibility is what really makes TipKit shine. Add a rules array to a tip and the framework only displays it when every rule evaluates to true.
Parameter-Based Rules
Use @Parameter for state you set imperatively from anywhere in your app:
struct AdvancedSearchTip: Tip {
@Parameter
static var searchCount: Int = 0
var title: Text { Text("Try Advanced Search") }
var message: Text? { Text("Filter by ingredient, cook time, or cuisine.") }
var rules: [Rule] {
#Rule(Self.$searchCount) { $0 >= 3 }
}
}
// Elsewhere — increment as the user searches:
AdvancedSearchTip.searchCount += 1
The #Rule macro takes the parameter and a closure returning Bool. Parameters persist across launches automatically. The tip becomes eligible the moment the predicate flips to true — no polling, no manual refresh.
Event-Based Rules
For things you can model as discrete events, use Event:
struct ShareRecipeTip: Tip {
static let didCookEvent = Event(id: "didCookRecipe")
var title: Text { Text("Share What You Cook") }
var message: Text? { Text("Send your favorite recipes to friends with one tap.") }
var rules: [Rule] {
#Rule(Self.didCookEvent) { event in
event.donations.count >= 2
}
}
}
// When the user marks a recipe as cooked:
await ShareRecipeTip.didCookEvent.donate()
Events are persisted as a stream of donations; rules can inspect their count, dates, or even the parameters you donated alongside them. This is way more flexible than parameters when you actually need history.
Combining Multiple Rules
All rules in the array must pass — they combine with logical AND:
var rules: [Rule] {
#Rule(Self.$searchCount) { $0 >= 3 }
#Rule(Self.didCookEvent) { $0.donations.count >= 1 }
#Rule(Self.$isProUser) { $0 == true }
}
For OR logic, model it as a single rule with a compound predicate. (A bit awkward, sure, but it works.)
Display Frequency and Lifecycle Control
Each tip has options that control its lifecycle. Override options to customize:
struct OnboardingTip: Tip {
var title: Text { Text("Welcome") }
var message: Text? { Text("Tap anywhere to begin.") }
var options: [Option] {
MaxDisplayCount(3)
IgnoresDisplayFrequency(true)
}
}
MaxDisplayCount(n) — the tip becomes invalid after appearing n times.
IgnoresDisplayFrequency(true) — bypass the global frequency cap; useful for critical onboarding.
You can also invalidate a tip programmatically from anywhere:
FavoriteRecipeTip().invalidate(reason: .actionPerformed)
Calling this when the user actually uses the feature the tip is teaching is a key best practice — it prevents redundant display. Honestly, this is the single biggest mistake I see teams make: they teach a feature, the user uses it, and the tip keeps reappearing because nobody invalidated it.
TipGroup: Ordering Multiple Tips
iOS 26 introduced TipGroup to coordinate display order when several tips compete for attention. Without it, TipKit shows whichever tip was first eligible — which is rarely what you want for a guided tour.
@TipGroup(.ordered)
private var onboardingTips = [
WelcomeTip(),
FavoriteRecipeTip(),
AdvancedSearchTip(),
ShareRecipeTip()
]
var body: some View {
NavigationStack {
ContentView()
.popoverTip(onboardingTips.currentTip)
}
}
.ordered shows tips strictly in array order — the next one only appears after the previous is invalidated. Use .firstAvailable for "show whichever is eligible right now". The currentTip projected property updates reactively as tips are dismissed or invalidated.
iCloud Sync for Tip State
If a user dismisses a tip on their iPhone, they probably don't want to see it again on their iPad. (Seems obvious, right? But for years this was hilariously hard to ship.) Enable sync at configuration time:
try? Tips.configure([
.cloudKitContainer(.named("iCloud.com.swiftcrafted.recipes")),
.displayFrequency(.daily)
])
You'll need a CloudKit container with the appropriate entitlement. TipKit handles the schema and conflict resolution; your code stays the same.
A/B Testing and Experiment Integration
TipKit doesn't run experiments itself, but it makes integration trivial. Just gate eligibility on a parameter you set from your experiment system:
struct PromotedFeatureTip: Tip {
@Parameter static var experimentBucket: String = "control"
var rules: [Rule] {
#Rule(Self.$experimentBucket) { $0 == "treatment" }
}
// ...
}
// On launch, after fetching experiment assignment:
PromotedFeatureTip.experimentBucket = await experiments.bucket(for: "promoted_v2")
Pair this with App Store Connect's built-in product page experiments, or any third-party experiment SDK. It's a small amount of glue for a lot of leverage.
Analytics and Telemetry
Track tip lifecycle events by observing the tip's status stream:
Task {
for await status in FavoriteRecipeTip().statusUpdates {
switch status {
case .available:
analytics.log("tip_available", ["id": "favorite"])
case .invalidated(let reason):
analytics.log("tip_dismissed", [
"id": "favorite",
"reason": String(describing: reason)
])
case .pending:
break
@unknown default:
break
}
}
}
This async sequence emits whenever the tip transitions between states, which makes it ideal for funnels and dismissal-reason analysis.
Testing Tips During Development
Two utilities make iteration painless:
#if DEBUG
Tips.showAllTipsForTesting() // ignore eligibility, force-show every tip
Tips.resetDatastore() // wipe display counts, parameters, events
#endif
Wire these to a developer menu or LaunchArguments so QA can replay tips without reinstalling the app. In Xcode previews, call Tips.showAllTipsForTesting() in the preview's init to verify layout. (Trust me — you'll thank yourself the first time a designer asks for a screenshot.)
Common Pitfalls and How to Avoid Them
- Forgetting
Tips.configure — tips silently never appear. Always call it before any view that uses TipKit renders.
- Showing tips for features the user already mastered — invalidate with
.actionPerformed the first time the feature is used.
- Stacking too many popovers — use
TipGroup(.ordered) instead of attaching many popoverTip modifiers and hoping for the best.
- Hard-coded English strings — use String Catalog–backed
Text from day one; retrofitting localization is painful.
- Testing only in preview — display frequency rules behave differently in the simulator's persisted store; always test with
resetDatastore() before each run.
- Missing iPad multitasking — popovers in Slide Over windows can clip; use
arrowEdge to constrain or fall back to inline TipView on compact size classes.
Migrating From Custom Onboarding
If you currently track onboarding state with @AppStorage, the migration is pretty straightforward:
- For each flag like
@AppStorage("hasSeenFooTip") var hasSeenFoo = false, define a Tip struct.
- Replace the conditional rendering with a
TipView or popoverTip modifier.
- Move "user has done X" gating into
Parameter or Event rules.
- Delete the
@AppStorage declarations after one release cycle.
You'll typically remove 30–50 lines of state management per tip and gain frequency control, iCloud sync, and analytics for free. That's a great trade.
Performance Considerations
TipKit's runtime cost is minimal: rule evaluation happens on a background queue, and the datastore is just a small SQLite file. Two practical guidelines:
- Don't put expensive work inside
#Rule closures — they may run frequently. Predicates should be pure functions of parameters and event donations.
- Donate events with
await Event.donate() off the main thread when possible. The framework handles thread-hopping internally, but launching a Task from the main actor for trivial donations is wasteful.
Putting It All Together: A Complete Example
Here's a fully wired feature combining most of what we've covered — a recipe app that introduces favorites after the user has searched a few times:
import SwiftUI
import TipKit
struct FavoriteRecipeTip: Tip {
@Parameter static var searchCount: Int = 0
static let didFavoriteEvent = Event(id: "didFavorite")
var title: Text { Text("Save Your Favorites") }
var message: Text? {
Text("Tap the heart on any recipe to keep it on your home screen.")
}
var image: Image? { Image(systemName: "heart.fill") }
var rules: [Rule] {
#Rule(Self.$searchCount) { $0 >= 3 }
#Rule(Self.didFavoriteEvent) { $0.donations.isEmpty }
}
var options: [Option] {
MaxDisplayCount(2)
}
}
struct RecipeListView: View {
private let favoriteTip = FavoriteRecipeTip()
@State private var query = ""
var body: some View {
List {
TipView(favoriteTip)
ForEach(filteredRecipes) { recipe in
RecipeRow(recipe: recipe) {
Task {
await FavoriteRecipeTip.didFavoriteEvent.donate()
favoriteTip.invalidate(reason: .actionPerformed)
}
}
}
}
.searchable(text: $query)
.onChange(of: query) { _, new in
if !new.isEmpty { FavoriteRecipeTip.searchCount += 1 }
}
}
}
This single screen demonstrates parameter rules, event donations, max display count, and lifecycle invalidation working together — all without a single UserDefaults key. Pretty clean.
Frequently Asked Questions
What's the difference between TipKit and a regular SwiftUI alert or popover?
Alerts and popovers are dumb renderers — you decide when to show them. TipKit, by contrast, is a stateful coordination engine: it tracks display counts across launches, enforces frequency caps, evaluates eligibility rules, and coordinates ordering between competing tips. You describe what a tip is; TipKit decides when to show it.
Does TipKit work on macOS, watchOS, and visionOS?
Yes. TipKit is part of the cross-platform SDK starting with iOS 17 / iPadOS 17 / macOS 14 / watchOS 10 / tvOS 17 / visionOS 1. The TipView and popoverTip renderers adapt automatically to each platform's idioms — popovers float spatially in visionOS and use sheet-style presentation on watchOS.
How do I reset all tips for testing without reinstalling the app?
Call try? Tips.resetDatastore() early in your app launch. This clears every tip's display count, all parameters, and all event donations. Wire it to a developer menu or a launch argument like -resetTipKit to make QA loops fast.
Can I localize TipKit content?
Absolutely. Because title and message are Text values, they automatically use String Catalog localization. Just use Text("Save Your Favorites") and add the key to your Localizable.xcstrings — translations flow through normally.
How does TipKit handle App Clips and extensions?
App Clips can use TipKit, but state isn't shared with the full app unless you configure a shared .groupContainer(identifier:) datastore. Widget and other extensions typically don't display tips, but they can donate events using Event.donate(), which then influences tip eligibility in the main app.
Does TipKit support A/B testing tip copy or design?
Indirectly. TipKit doesn't have built-in experimentation, but you can branch on a @Parameter set from your experiment SDK to gate eligibility, vary copy, or swap between tip variants. App Store Connect product page experiments are also a good fit for testing first-launch tip flows.