
Tipkit Generator
- 3 installs
- 591 repo stars
- Updated July 24, 2026
- rshankras/claude-code-apple-skills
Generates TipKit infrastructure with inline/popover tips, rules, display frequency, and testing utilities for contextual tips and feature discovery.
About
Generates a complete TipKit setup with tip definitions, rules, display frequency, inline and popover presentation, and testing utilities. A developer uses it when adding contextual tips or feature discovery to an iOS/macOS app.
- Inline and popover tips with display-frequency rules
- Includes testing utilities
Tipkit Generator by the numbers
- 3 all-time installs (skills.sh)
- +1 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #887 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rshankras/claude-code-apple-skills --skill tipkit-generatorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 591 |
| Last updated | July 24, 2026 |
| Repository | rshankras/claude-code-apple-skills ↗ |
What it does
Generates TipKit infrastructure with inline/popover tips, rules, display frequency, and testing utilities for contextual tips and feature discovery.
Files
TipKit Generator
Generate a complete TipKit setup for contextual tips and feature discovery, including tip definitions, rules, display frequency, inline and popover presentation, and testing utilities.
When This Skill Activates
Use this skill when the user:
- Asks to "add tips" or "add TipKit"
- Mentions "contextual tips" or "feature discovery"
- Wants "popover tips" or "inline tips"
- Asks about "coach marks" or "user education"
- Mentions "onboarding hints" or "tip prompts"
- Wants to "highlight new features" or "guide users"
Pre-Generation Checks
1. Project Context Detection
- [ ] Check deployment target (TipKit requires iOS 17+ / macOS 14+)
- [ ] Identify if SwiftUI or UIKit project
- [ ] Find App entry point location for Tips.configure()
- [ ] Check for existing TipKit implementations
2. Conflict Detection
Search for existing TipKit usage:
Glob: **/*Tip*.swift
Grep: "import TipKit" or "Tips.configure"If found, ask user:
- Extend existing tip infrastructure?
- Replace existing tips?
Configuration Questions
Ask user via AskUserQuestion:
1. What features need tips?
- List the features or UI elements that should have tips
- Example: "search bar, filter button, swipe-to-delete gesture"
2. Tip presentation style? (per tip or general preference)
- Inline (TipView embedded in layout)
- Popover (attached to a control)
- Both
3. Rule types needed?
- Parameter-based (show after user meets condition, e.g., has viewed a screen 3 times)
- Event-based (show after user performs an action N times)
- Both
4. Display frequency?
- Immediate (tips show as soon as eligible)
- Hourly
- Daily
- Weekly
- Monthly
5. Tip ordering?
- Independent (tips show whenever eligible)
- Ordered (use TipGroup to show tips in sequence)
Generation Process
Step 1: Read Templates
Read the templates file for code patterns:
Read("skills/generators/tipkit-generator/templates.md")Step 2: Create Core Files
Generate these files based on configuration: 1. Tips/ directory with one file per tip (e.g., SearchTip.swift, FilterTip.swift) 2. Tips/TipEvents.swift - Centralized event definitions 3. Tips/TipsConfiguration.swift - Tips.configure() setup and testing utilities
Step 3: Determine File Location
Check project structure:
- If
Sources/exists ->Sources/Tips/ - If
App/exists ->App/Tips/ - Otherwise ->
Tips/
Step 4: Integrate Tips
- Add
Tips.configure()call in App entry point - Add
TipViewor.popoverTip()at the appropriate view locations - Wire up event donation at action sites
- Wire up tip invalidation where appropriate
Output Format
After generation, provide:
Files Created
Sources/Tips/
├── SearchTip.swift # Tip with rules and options
├── FilterTip.swift # Another tip definition
├── TipEvents.swift # Centralized event definitions
└── TipsConfiguration.swift # Tips.configure() + testing helpersIntegration Steps
App Entry Point (Required):
import TipKit
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.task {
try? Tips.configure([
.displayFrequency(.daily),
.datastoreLocation(.applicationDefault)
])
}
}
}
}Inline Tip:
import TipKit
struct SearchView: View {
let searchTip = SearchTip()
var body: some View {
VStack {
TipView(searchTip)
SearchBar()
}
}
}Popover Tip:
import TipKit
struct ToolbarView: View {
let filterTip = FilterTip()
var body: some View {
Button("Filter", systemImage: "line.3.horizontal.decrease.circle") {
// action
}
.popoverTip(filterTip)
}
}Event Donation (at action site):
Button("Search") {
performSearch()
SearchTip.searchPerformed.donate()
}Tip Invalidation (when tip is no longer relevant):
func onFeatureUsed() {
// User discovered the feature, invalidate the tip
searchTip.invalidate(reason: .actionPerformed)
}Testing Instructions
1. Reset DataStore between runs:
// Add to a debug menu or call in preview
try? Tips.resetDatastore()2. Show all tips for testing:
// Ignores rules and frequency -- shows everything
Tips.showAllTipsForTesting()3. Show specific tips for testing:
Tips.showTipsForTesting([SearchTip.self])4. Test scenarios:
- Launch app fresh -- eligible tips should appear per display frequency
- Perform actions that donate events -- event-based tips should appear when thresholds met
- Tap tip close button -- tip should not reappear
- Invalidate tip programmatically -- tip should dismiss and not reappear
Common Gotchas
1. Forgetting Tips.configure() -- Tips will never appear if you do not call Tips.configure() before any tip is displayed. This must happen early, typically in the App body or .task.
2. Rules not evaluating -- Parameter-based rules require you to set the parameter value explicitly. If you define @Parameter static var hasSeenFeature = false but never set it to true, the rule never passes.
3. DataStore conflicts in tests -- If you run unit tests and the app simultaneously, they may share the same DataStore. Use .datastoreLocation(.url(...)) to isolate them.
4. Display frequency blocking tips -- If you set .displayFrequency(.daily) and a tip was already shown today, no new tips will appear until tomorrow. Use .immediate during development.
5. Tips not dismissing after invalidation -- You must hold a reference to the tip instance and call .invalidate(reason:) on that instance. Creating a new instance and invalidating it does nothing to the displayed tip.
6. TipGroup ordering ignored -- Tips in a TipGroup only show in order if their rules are all satisfied. If Tip B's rules pass but Tip A's do not, neither will show (Tip A blocks Tip B).
Patterns
Good Patterns
- One tip struct per file for clarity
- Centralize event definitions in a single file
- Use
.actionPerformedinvalidation reason when the user completes the action the tip describes - Use
TipGroupwhen tips should appear in a logical sequence - Provide a debug/testing menu that calls
Tips.resetDatastore() - Use meaningful tip IDs that describe the feature
Bad Patterns
- Defining all tips in a single massive file
- Forgetting to call
Tips.configure()in the App entry point - Using
.immediatedisplay frequency in production (overwhelming users) - Hardcoding tip text instead of using localized strings for shipped apps
- Creating a new tip instance to invalidate instead of using the displayed instance
- Placing
TipViewinside aScrollViewwithout considering layout impact
References
- templates.md - Code templates for tips, rules, configuration, and TipGroup
TipKit Code Templates
Tips.configure() Setup
Basic Configuration
import TipKit
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.task {
try? Tips.configure([
.displayFrequency(.daily),
.datastoreLocation(.applicationDefault)
])
}
}
}
}Configuration with Testing Support
import TipKit
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.task {
#if DEBUG
// Reset tips on every launch during development
try? Tips.resetDatastore()
#endif
try? Tips.configure([
.displayFrequency(.daily),
.datastoreLocation(.applicationDefault)
])
}
}
}
}Display Frequency Options
// Tips can show as soon as eligible
.displayFrequency(.immediate)
// At most one tip per hour
.displayFrequency(.hourly)
// At most one tip per day (recommended for most apps)
.displayFrequency(.daily)
// At most one tip per week
.displayFrequency(.weekly)
// At most one tip per month
.displayFrequency(.monthly)DataStore Location Options
// Default location managed by the system
.datastoreLocation(.applicationDefault)
// Custom URL (useful for isolating test data)
.datastoreLocation(.url(FileManager.default
.urls(for: .cachesDirectory, in: .userDomainMask)
.first!
.appending(path: "tips-store")))---
Tip Protocol Conformance
Minimal Tip (Title Only)
import TipKit
struct SearchTip: Tip {
var title: Text {
Text("Try searching")
}
}Tip with Title and Message
import TipKit
struct SearchTip: Tip {
var title: Text {
Text("Search your library")
}
var message: Text? {
Text("Quickly find any item by name, tag, or date.")
}
}Tip with Title, Message, and Image
import TipKit
struct SearchTip: Tip {
var title: Text {
Text("Search your library")
}
var message: Text? {
Text("Quickly find any item by name, tag, or date.")
}
var image: Image? {
Image(systemName: "magnifyingglass")
}
}Tip with Actions
import TipKit
struct UpgradeTip: Tip {
var title: Text {
Text("Unlock more features")
}
var message: Text? {
Text("Upgrade to Pro for unlimited access.")
}
var actions: [Action] {
Action(id: "learn-more", title: "Learn More")
Action(id: "dismiss", title: "Not Now")
}
}Handling actions in the view:
TipView(upgradeTip) { action in
if action.id == "learn-more" {
showUpgradeSheet = true
}
// Tip auto-dismisses after any action tap
}---
Parameter-Based Rules
Boolean Parameter
import TipKit
struct AdvancedSearchTip: Tip {
@Parameter
static var hasUsedBasicSearch: Bool = false
var title: Text {
Text("Try advanced search")
}
var message: Text? {
Text("Use filters to narrow your results.")
}
var rules: [Rule] {
#Rule(Self.$hasUsedBasicSearch) { $0 == true }
}
}Setting the parameter from elsewhere in the app:
func onBasicSearchPerformed() {
AdvancedSearchTip.hasUsedBasicSearch = true
}Numeric Parameter
import TipKit
struct PowerUserTip: Tip {
@Parameter
static var itemsCreated: Int = 0
var title: Text {
Text("You are on a roll")
}
var message: Text? {
Text("Try using templates to create items even faster.")
}
var rules: [Rule] {
#Rule(Self.$itemsCreated) { $0 >= 5 }
}
}Incrementing the parameter:
func onItemCreated() {
PowerUserTip.itemsCreated += 1
}---
Event-Based Rules
Basic Event Rule
import TipKit
struct FilterTip: Tip {
static let listViewed = Tips.Event(id: "listViewed")
var title: Text {
Text("Filter your results")
}
var message: Text? {
Text("Tap the filter icon to narrow down what you see.")
}
var rules: [Rule] {
#Rule(Self.listViewed) { $0.donations.count >= 3 }
}
}Donating the event:
struct ListView: View {
var body: some View {
List { /* ... */ }
.onAppear {
FilterTip.listViewed.donate()
}
}
}Event with Associated Value
import TipKit
struct ShareTip: Tip {
static let photoViewed = Tips.Event(id: "photoViewed")
var title: Text {
Text("Share this photo")
}
var message: Text? {
Text("Tap the share button to send this to friends.")
}
var rules: [Rule] {
#Rule(Self.photoViewed) {
$0.donations.count >= 2
}
}
}---
Combining Multiple Rules
Parameter + Event
import TipKit
struct ExportTip: Tip {
@Parameter
static var hasCreatedDocument: Bool = false
static let documentEdited = Tips.Event(id: "documentEdited")
var title: Text {
Text("Export your work")
}
var message: Text? {
Text("Tap Export to save as PDF or share with others.")
}
var image: Image? {
Image(systemName: "square.and.arrow.up")
}
var rules: [Rule] {
#Rule(Self.$hasCreatedDocument) { $0 == true }
#Rule(Self.documentEdited) { $0.donations.count >= 2 }
}
}All rules must be satisfied (logical AND) for the tip to become eligible.
---
Tip Options
MaxDisplayCount
import TipKit
struct SwipeToDeleteTip: Tip {
var title: Text {
Text("Swipe to delete")
}
var message: Text? {
Text("Swipe left on any item to delete it.")
}
// Tip will be shown at most 3 times across app sessions
var options: [TipOption] {
MaxDisplayCount(3)
}
}IgnoresDisplayFrequency
import TipKit
struct CriticalTip: Tip {
var title: Text {
Text("Important update")
}
var message: Text? {
Text("Your data has been migrated. Review your settings.")
}
// This tip ignores the global .displayFrequency setting
var options: [TipOption] {
IgnoresDisplayFrequency(true)
}
}Combining Options
var options: [TipOption] {
MaxDisplayCount(5)
IgnoresDisplayFrequency(true)
}---
Centralized Event Definitions
When multiple tips share events, centralize them:
// TipEvents.swift
import TipKit
enum TipEvents {
static let appLaunched = Tips.Event(id: "appLaunched")
static let listViewed = Tips.Event(id: "listViewed")
static let itemCreated = Tips.Event(id: "itemCreated")
static let searchPerformed = Tips.Event(id: "searchPerformed")
}Reference from tip structs:
struct FilterTip: Tip {
var title: Text {
Text("Filter your results")
}
var rules: [Rule] {
#Rule(TipEvents.listViewed) { $0.donations.count >= 3 }
}
}---
Inline Tips (TipView)
Basic Inline Tip
import SwiftUI
import TipKit
struct SearchView: View {
let searchTip = SearchTip()
var body: some View {
VStack {
TipView(searchTip)
SearchBar()
ResultsList()
}
}
}Inline Tip with Custom Arrow Direction
TipView(searchTip, arrowEdge: .bottom)Inline Tip with Action Handling
TipView(upgradeTip) { action in
switch action.id {
case "learn-more":
showUpgradeSheet = true
case "dismiss":
break // Tip auto-dismisses
default:
break
}
}---
Popover Tips
Basic Popover
import SwiftUI
import TipKit
struct ToolbarView: View {
let filterTip = FilterTip()
var body: some View {
HStack {
Spacer()
Button("Filter", systemImage: "line.3.horizontal.decrease.circle") {
showFilters.toggle()
}
.popoverTip(filterTip)
}
}
}Popover with Custom Arrow Direction
Button("Sort") {
// action
}
.popoverTip(sortTip, arrowEdge: .top)---
Tip Invalidation
Invalidate on Action Performed
struct SearchView: View {
let searchTip = SearchTip()
var body: some View {
VStack {
TipView(searchTip)
SearchBar(onSearch: { query in
performSearch(query)
searchTip.invalidate(reason: .actionPerformed)
})
}
}
}Invalidation Reasons
// User performed the action the tip describes
tip.invalidate(reason: .actionPerformed)
// Tip was shown enough times (reached MaxDisplayCount)
// This happens automatically -- you do not call this manually
// User closed the tip via the X button
// This happens automatically when the user taps dismissInvalidate from Elsewhere
Hold a reference to the same tip instance, or define a static method:
struct SearchTip: Tip {
var title: Text {
Text("Try searching")
}
// Call this when the feature is discovered
static func markAsDiscovered() {
// Create a temporary instance to invalidate all displays of this tip
SearchTip().invalidate(reason: .actionPerformed)
}
}---
TipGroup (Ordered Tips)
Basic TipGroup
import SwiftUI
import TipKit
struct OnboardingTipsView: View {
@State var tipGroup = TipGroup(.ordered) {
CreateItemTip()
EditItemTip()
ShareItemTip()
}
var body: some View {
VStack {
// Only the current tip in the group is shown
TipView(tipGroup.currentTip!)
}
}
}TipGroup with Priority Ordering
// .ordered: tips appear in the order listed, one at a time
// Each tip must be dismissed or invalidated before the next appears
@State var tipGroup = TipGroup(.ordered) {
WelcomeTip() // Shows first
SearchTip() // Shows second, after WelcomeTip is dismissed
FilterTip() // Shows third
}TipGroup in a View
struct ContentView: View {
@State var tipGroup = TipGroup(.ordered) {
WelcomeTip()
SearchTip()
FilterTip()
}
var body: some View {
VStack {
if let currentTip = tipGroup.currentTip {
TipView(currentTip)
}
MainContent()
}
}
}---
Testing Utilities
TipsConfiguration Helper
// TipsConfiguration.swift
import TipKit
enum TipsConfiguration {
/// Call once at app startup
static func configure(frequency: Tips.ConfigurationOption.DisplayFrequency = .daily) {
try? Tips.configure([
.displayFrequency(frequency),
.datastoreLocation(.applicationDefault)
])
}
/// Show all tips regardless of rules and frequency (debug only)
static func showAllForTesting() {
Tips.showAllTipsForTesting()
}
/// Show only specific tips for testing
static func showForTesting(_ tips: [any Tip.Type]) {
Tips.showTipsForTesting(tips)
}
/// Reset all tip state (debug only)
static func resetAll() {
try? Tips.resetDatastore()
}
}Debug Menu Integration
#if DEBUG
struct TipsDebugMenu: View {
var body: some View {
Section("Tips (Debug)") {
Button("Reset All Tips") {
TipsConfiguration.resetAll()
}
Button("Show All Tips") {
TipsConfiguration.showAllForTesting()
}
Button("Show Search Tip Only") {
TipsConfiguration.showForTesting([SearchTip.self])
}
}
}
}
#endifSwiftUI Preview Support
#Preview {
SearchView()
.task {
try? Tips.resetDatastore()
Tips.showAllTipsForTesting()
try? Tips.configure()
}
}---
Complete Example: Feature Discovery Flow
This example shows a complete tip setup for an app with search, filter, and export features.
Tip Definitions
// SearchTip.swift
import TipKit
struct SearchTip: Tip {
static let appOpened = Tips.Event(id: "appOpened")
var title: Text {
Text("Search your items")
}
var message: Text? {
Text("Use the search bar to quickly find what you need.")
}
var image: Image? {
Image(systemName: "magnifyingglass")
}
var rules: [Rule] {
#Rule(Self.appOpened) { $0.donations.count >= 2 }
}
}// FilterTip.swift
import TipKit
struct FilterTip: Tip {
@Parameter
static var hasSearched: Bool = false
var title: Text {
Text("Narrow your results")
}
var message: Text? {
Text("Tap the filter icon to show only what matters.")
}
var image: Image? {
Image(systemName: "line.3.horizontal.decrease.circle")
}
var rules: [Rule] {
#Rule(Self.$hasSearched) { $0 == true }
}
var options: [TipOption] {
MaxDisplayCount(3)
}
}// ExportTip.swift
import TipKit
struct ExportTip: Tip {
@Parameter
static var hasFilteredResults: Bool = false
static let itemViewed = Tips.Event(id: "itemViewed")
var title: Text {
Text("Export your results")
}
var message: Text? {
Text("Share or save your filtered results as a PDF.")
}
var image: Image? {
Image(systemName: "square.and.arrow.up")
}
var rules: [Rule] {
#Rule(Self.$hasFilteredResults) { $0 == true }
#Rule(Self.itemViewed) { $0.donations.count >= 3 }
}
}View Integration
// ContentView.swift
import SwiftUI
import TipKit
struct ContentView: View {
let searchTip = SearchTip()
let filterTip = FilterTip()
let exportTip = ExportTip()
@State private var searchText = ""
@State private var showFilters = false
var body: some View {
NavigationStack {
VStack {
// Inline tip above search bar
TipView(searchTip)
SearchBar(text: $searchText, onCommit: {
searchTip.invalidate(reason: .actionPerformed)
FilterTip.hasSearched = true
})
ResultsList(searchText: searchText)
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Filter", systemImage: "line.3.horizontal.decrease.circle") {
showFilters.toggle()
filterTip.invalidate(reason: .actionPerformed)
ExportTip.hasFilteredResults = true
}
.popoverTip(filterTip)
}
ToolbarItem(placement: .secondaryAction) {
Button("Export", systemImage: "square.and.arrow.up") {
exportResults()
exportTip.invalidate(reason: .actionPerformed)
}
.popoverTip(exportTip)
}
}
.onAppear {
SearchTip.appOpened.donate()
}
}
}
private func exportResults() {
// Export logic
}
}App Entry Point
// MyApp.swift
import SwiftUI
import TipKit
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.task {
#if DEBUG
try? Tips.resetDatastore()
#endif
try? Tips.configure([
.displayFrequency(.daily),
.datastoreLocation(.applicationDefault)
])
}
}
}
}---
Patterns: Good vs Bad
Tip Definition
// ✅ Good: One tip per file, clear naming, meaningful image
struct SearchTip: Tip {
var title: Text { Text("Search your library") }
var message: Text? { Text("Find items by name, tag, or date.") }
var image: Image? { Image(systemName: "magnifyingglass") }
}// ❌ Bad: Multiple tips crammed into one file with vague names
struct Tips {
struct Tip1: Tip { /* ... */ }
struct Tip2: Tip { /* ... */ }
struct Tip3: Tip { /* ... */ }
}Tips.configure() Placement
// ✅ Good: Configure early in the app lifecycle
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.task {
try? Tips.configure([
.displayFrequency(.daily),
.datastoreLocation(.applicationDefault)
])
}
}
}
}// ❌ Bad: Configure inside a deeply nested view (may run too late or multiple times)
struct SomeNestedView: View {
var body: some View {
Text("Hello")
.task {
try? Tips.configure() // Too late, may miss early tips
}
}
}Display Frequency
// ✅ Good: Use .daily or .weekly for production to avoid overwhelming users
.displayFrequency(.daily)// ❌ Bad: Using .immediate in production -- users see every tip at once
.displayFrequency(.immediate)Tip Invalidation
// ✅ Good: Invalidate the tip instance that is currently displayed
struct SearchView: View {
let searchTip = SearchTip()
var body: some View {
VStack {
TipView(searchTip)
Button("Search") {
performSearch()
searchTip.invalidate(reason: .actionPerformed)
}
}
}
}// ❌ Bad: Creating a new instance to invalidate (does not affect the displayed tip)
Button("Search") {
performSearch()
SearchTip().invalidate(reason: .actionPerformed) // New instance -- no effect on displayed tip
}Parameter Updates
// ✅ Good: Update parameter at the point where the condition becomes true
func onSearchPerformed() {
FilterTip.hasSearched = true
}// ❌ Bad: Setting the parameter in the tip's own init (defeats the purpose of rules)
struct FilterTip: Tip {
@Parameter static var hasSearched: Bool = false
init() { Self.hasSearched = true } // Always true -- rule is pointless
}Event Donation
// ✅ Good: Donate events at natural user interaction points
List(items) { item in
ItemRow(item: item)
}
.onAppear {
FilterTip.listViewed.donate()
}// ❌ Bad: Donating events in a timer or background task (inflates counts artificially)
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
FilterTip.listViewed.donate() // Fires every second -- meaningless
}TipGroup Usage
// ✅ Good: Use TipGroup when tips should appear in a logical learning sequence
@State var onboardingTips = TipGroup(.ordered) {
WelcomeTip()
CreateItemTip()
ShareItemTip()
}
var body: some View {
VStack {
if let tip = onboardingTips.currentTip {
TipView(tip)
}
MainContent()
}
}// ❌ Bad: Manually tracking tip ordering with state variables
@State private var currentTipIndex = 0
// Then manually showing/hiding tips based on index -- fragile and error-prone