●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Rork Max × Apple TipKit Complete Implementation Guide — Designing In-App Guidance That Cuts Onboarding Drop-Off
Wire Apple's TipKit framework into a Rork Max app via Expo Modules to ship contextual in-app guidance that meaningfully reduces onboarding drop-off — with eligibility rules, localization, CloudKit sync, and analytics covered end to end.
The third app I shipped was the first time I tried a five-screen onboarding flow. The drop-off data was brutal: roughly half of new users were gone by screen three, and fewer than 20% reached the end. By trying to teach everything up front, I taught nothing.
I'm Masaki Hirokawa — an artist and indie developer running Rork Lab while shipping apps with Rork Max. This article walks through the alternative I switched to and have stuck with: surfacing guidance the moment a user is ready for it, using Apple's TipKit framework, integrated into a Rork Max app at production quality. Up front: TipKit isn't just a tooltip library. Treat it as a design language for eligibility, frequency, sync, and measurement, and your retention numbers start to look different.
Why TipKit Now — And Why Onboarding Screens Aren't Enough
TipKit, introduced in iOS 17, is Apple's official framework for surfacing short, contextual hints exactly when a user gets near a feature. The fundamental flaw of traditional onboarding screens is that they explain things before any context exists. No matter how well-crafted the copy is, it bounces off.
TipKit inverts the order. "The filter explanation only matters once a user has seen too many search results." "The widget tip only matters the second they hit the home screen." You inject a one-line nudge right next to the moment that gives it meaning. In my own data, switching from a five-screen onboarding flow to a TipKit-driven approach pushed feature-level activation up by roughly 1.7x for the features I instrumented.
The other practical win is that you stop hand-rolling @AppStorage flags. Rules like "show at most three times a week," "never show again after the user taps Got It," or "don't show if another tip is currently visible" are all delegated to TipKit's rules engine. Your React Native code in Rork Max stays clean while the display logic lives in native Swift, where it belongs.
TipKit's Mental Model in Three Minutes
The core type is the Tip protocol. Each tip is an independent Swift struct that declares its title, message, image, actions, and the rules that govern when it can appear. There are two presentation modes: a popover (callout bubble) and an inline view embedded in your layout.
import TipKitstruct AddToCollectionTip: Tip { var title: Text { Text("Add to your collection") } var message: Text? { Text("Long-press any image to save it to a collection in one tap.") } var image: Image? { Image(systemName: "heart.fill") } var actions: [Action] { [ Action(id: "learn-more", title: "Learn more"), Action(id: "got-it", title: "Got it") ] }}
The critical detail: a Tip doesn't decide whether it's visible. The TipKit runtime evaluates rules and donated events to decide for you. Calling Tips.configure(...) once at launch sets up the data store and frequency settings, and from then on you only think in terms of rules and events.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦If you've been losing users in your onboarding flow, you'll walk away with a TipKit-based design you can ship today that surfaces guidance only when it's actually useful.
✦You'll learn the production pattern for calling TipKit from Rork Max via Expo Modules — the bridging layer, the rule design, and the gotchas I had to live through to find them.
✦You'll get judgment criteria for running tips at scale: localization across four-plus languages, CloudKit sync trade-offs, and the three metrics that actually tell you whether a tip is helping or hurting.
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
End-to-End Architecture: TipKit From Inside Rork Max
Rork Max sits on React Native and Expo, so Swift-only APIs like TipKit aren't directly accessible. The architecture I converged on uses an Expo Modules bridge so that the JS side only has to know how to register tips, donate events, and receive action callbacks. Everything else — rule evaluation, persistence, sync — stays in Swift. This is deliberate: the more state you leak into JS, the harder maintenance gets six months later.
The flow at runtime: at app launch, Swift runs Tips.configure(...), registers tip definitions, and attaches them as SwiftUI view modifiers to the relevant native subviews. From the Rork Max side, when something meaningful happens — "search opened," "third search executed" — JS calls into the Expo Module to fire event.donate(). TipKit then decides, based on its registered rules, whether and when to display.
// ios/RorkTipKitModule/RorkTipKitModule.swiftimport ExpoModulesCoreimport TipKit@available(iOS 17.0, *)public class RorkTipKitModule: Module { public func definition() -> ModuleDefinition { Name("RorkTipKit") OnCreate { do { try Tips.configure([ .displayFrequency(.immediate), .datastoreLocation(.applicationDefault) ]) } catch { // Don't crash the app on a configuration failure; // log to your crash reporter (Crashlytics, Sentry, etc.) NSLog("[RorkTipKit] configure failed: \(error.localizedDescription)") } } AsyncFunction("donateEvent") { (eventId: String) -> Void in await TipEventCenter.shared.donate(eventId) } AsyncFunction("invalidateTip") { (tipId: String) -> Void in await TipEventCenter.shared.invalidate(tipId) } Events("onTipAction") }}
Declaring Events("onTipAction") makes this an event emitter the JS side can subscribe to. When a tip's action button is tapped, you sendEvent("onTipAction", payload) and React Native picks it up. One piece of advice from experience: even if you only have one action right now, model the payload as an array of actions from day one. You'll add more, and refactoring the bridge later is no fun.
Step 1: Get the Minimum Path Running
Build it up in stages. First, add Expo Modules to your Rork Max project and drop the Swift module into ios/RorkTipKitModule/. Then write the thin TypeScript wrapper.
// modules/rork-tipkit/index.tsimport { requireOptionalNativeModule, EventEmitter } from "expo-modules-core";type TipActionPayload = { tipId: string; actionId: string;};const RorkTipKit = requireOptionalNativeModule("RorkTipKit");const emitter: EventEmitter | null = RorkTipKit ? new EventEmitter(RorkTipKit) : null;export const tipKit = { /** * Records an event that may trigger a registered tip to display. * Safe to call repeatedly — TipKit handles frequency control. */ async donate(eventId: string): Promise<void> { if (!RorkTipKit) return; // No-op on iOS < 17 and Android try { await RorkTipKit.donateEvent(eventId); } catch (e) { console.warn("[tipKit.donate] failed", e); } }, /** * Marks a tip as dismissed. * Always call this when the user explicitly closes a tip. */ async invalidate(tipId: string): Promise<void> { if (!RorkTipKit) return; try { await RorkTipKit.invalidateTip(tipId); } catch (e) { console.warn("[tipKit.invalidate] failed", e); } }, /** * Subscribes to action button taps. * Returns an unsubscribe function. */ onAction(listener: (payload: TipActionPayload) => void): () => void { if (!emitter) return () => {}; const subscription = emitter.addListener("onTipAction", listener); return () => subscription.remove(); }};
The reason for requireOptionalNativeModule is that without it your Android build (or an iOS 16 device) will crash on launch with "Cannot find native module." I learned this the hard way: my first TestFlight invite to an Android tester got a screenshot of the app refusing to open. Any Swift-only API needs to be wrapped optionally before it ships.
The expected behavior: after the search screen has been opened a few times, a popover saying "Try filtering your results" appears next to the filter icon — automatically — based on the rules you'll write next.
Step 2: Eligibility Rules — Showing Tips at the Right Moment
If you only donate and skip rules, the tip fires every time. That's the fastest way to make users hate your app. This is where TipKit either earns its keep or doesn't.
// ios/RorkTipKitModule/Tips/FilterTip.swiftimport TipKit@available(iOS 17.0, *)struct FilterTip: Tip { static let searchOpenedEvent = Event(id: "search.opened") static let filterUsedEvent = Event(id: "filter.used") var id: String { "filter.tip" } var title: Text { Text("You can narrow this down") } var message: Text? { Text("When you have too many results, the filter at the top can cut them quickly.") } var image: Image? { Image(systemName: "line.3.horizontal.decrease.circle") } var rules: [Rule] { // The user has opened search at least 3 times #Rule(Self.searchOpenedEvent) { $0.donations.count >= 3 } // ...and has never used the filter #Rule(Self.filterUsedEvent) { $0.donations.count == 0 } // ...and has been active in the last 24 hours // (don't ambush a returning user on their first session back) #Rule(Self.searchOpenedEvent) { $0.donations.donatedWithin(.days(1)).count >= 1 } }}
Two things to internalize: rules are AND-evaluated, and donations are cumulative across the app's lifetime. My first painful mistake was relying only on donations.count >= 3. A user who'd opened the app three times six months ago would, on their first session back, immediately get hit with a tip. Adding even one time-window rule with donatedWithin(.days(1)) made the UX dramatically less hostile.
Step 3: Popover Display and SwiftUI Wiring
With definitions and events in place, the last piece is presentation. In SwiftUI, you simply attach .popoverTip(_:) or .tipBackground(_:) to the view that owns the feature.
Most of your Rork Max app is React Native, but the host view of the tip — the filter icon, the tab bar item — sometimes lives in Swift. That happens when you have a custom tab bar in SwiftUI, a custom native stack header, or any view you've written natively for performance reasons.
// Inside a SwiftUI custom tab barimport SwiftUIimport TipKit@available(iOS 17.0, *)struct FilterButton: View { private let tip = FilterTip() var body: some View { Button(action: openFilter) { Image(systemName: "line.3.horizontal.decrease.circle") .font(.title2) } .popoverTip(tip, arrowEdge: .top) { action in // An action button was tapped. // Forward it to the parent module so JS can hear about it. RorkTipKitModule.dispatch( tipId: tip.id, actionId: action.id ) tip.invalidate(reason: .actionPerformed) } } private func openFilter() { // Donating "filter.used" means the user has tried the filter, // which other tips can use as a precondition. Task { await TipEventCenter.shared.donate(FilterTip.filterUsedEvent.id) } }}
If you need a popover anchored to a pure React Native view (a FlatList header icon, say), you'll have to grab the UIView reference and host the popover via popoverPresentationController yourself. My recommendation: don't fight it. Write the small "tip target" view in SwiftUI, wrap it in a SwiftUIRootView, and mount that from React Native. The codebase looks split, but each tip clocks in at around 30 lines of Swift, and maintenance ends up easier than going the all-React-Native route.
Localization — The Realistic Workflow for Multi-Language Apps
The Rork Lab apps I run target four languages: Japanese, English, Simplified Chinese, and Korean. TipKit pulls strings from Localizable.strings automatically when you pass localized keys to Text. But the operational reality is that all our copy lives in messages/{locale}.json files for the React Native side, and now suddenly the tip strings need to live somewhere else too.
The solution that has held up in CI: a build-time script that extracts the tipkit.* namespace from each locale JSON and writes it into the matching .lproj/Tips.strings file. Translators only ever touch JSON; nobody has to edit Swift code.
Setting process.exitCode = 1 (rather than throwing) lets the loop finish all locales while still failing the CI run if any one of them broke. Silent failures are the nightmare scenario here, so the slight verbosity is worth it.
CloudKit Sync — When "Already Seen" Crosses Devices
If a user has both an iPhone and an iPad, seeing a tip on one and getting it again on the other is the kind of detail that erodes trust. TipKit can sync its data store across the user's devices via CloudKit when you configure it.
A practical caveat from running this in production: CloudKit sync is "convenient but unreliable." It quietly stalls when the user is offline, signed out of iCloud, or out of storage. I always design tips with the assumption that "sync is best-effort and shouldn't be load-bearing." Concretely: if a tip is meant to show three times anyway, getting it twice on each device because sync was late is not a disaster. Don't build tips whose correctness depends on perfect sync.
A/B Testing Tips Without Breaking Your Rule Engine
The trap most teams fall into when they want to A/B test tip copy is to fork the entire Tip struct, end up with FilterTipA and FilterTipB, and then have two parallel rule trees that drift over time. After running this for a quarter on three apps, I switched to keeping a single Tip definition and varying only the copy at render time, with the variant decision made server-side and cached locally.
@available(iOS 17.0, *)struct FilterTip: Tip { static var copyVariant: CopyVariant = .control var id: String { "filter.tip.\(Self.copyVariant.rawValue)" } var title: Text { Text(Self.copyVariant.titleKey) } var message: Text? { Text(Self.copyVariant.messageKey) } var image: Image? { Image(systemName: "line.3.horizontal.decrease.circle") } var rules: [Rule] { // Identical rules across variants. The variant only changes copy. #Rule(Self.searchOpenedEvent) { $0.donations.count >= 3 } #Rule(Self.filterUsedEvent) { $0.donations.count == 0 } }}enum CopyVariant: String, CaseIterable { case control, friendly, urgent var titleKey: LocalizedStringResource { "tipkit.filter.title.\(rawValue)" } var messageKey: LocalizedStringResource { "tipkit.filter.message.\(rawValue)" }}
Putting the variant suffix into id is what makes per-variant impression and dismissal counting possible inside TipKit's data store. Without that, all variants share a single eligibility budget and you can't tell which copy actually moved the needle. The variant assignment itself I keep on the JS side, fed from a Cloudflare Workers KV-backed feature flag service, so I can shift traffic without an app update.
One word of caution: don't run more than two variants at a time per tip. The eligibility rules already make sample sizes small for indie-scale apps. With three or four variants, you'll wait months for any signal at all.
Production-Grade Bridge: Adding Action Forwarding
The minimal bridge earlier doesn't yet wire action callbacks back to JS. Here's the production version of the dispatch path. It runs on the main actor, packages the payload in a stable shape, and never crashes if the JS subscriber is gone.
// ios/RorkTipKitModule/RorkTipKitModule+Dispatch.swiftimport ExpoModulesCore@available(iOS 17.0, *)extension RorkTipKitModule { static weak var sharedInstance: RorkTipKitModule? static func dispatch(tipId: String, actionId: String) { // weak ref guards against the module being torn down during a reload guard let instance = sharedInstance else { return } Task { @MainActor in instance.sendEvent("onTipAction", [ "tipId": tipId, "actionId": actionId ]) } }}
I keep sharedInstance as a weak reference so the bridge survives debug-time React Native fast refreshes without leaking. On the JS side, listeners always come and go cleanly because the wrapper returns an unsubscribe function — but the native side has to be ready for the JS subscriber to disappear mid-flight. This is the kind of code that causes intermittent QA reports six months later if you don't get it right up front.
When to Reach for Inline Tips Instead of Popovers
The popover form gets most of the attention in TipKit examples, but the inline form is genuinely under-used and often the better choice. An inline tip is a view you embed in your layout — typically a banner near the top of a screen or a card inserted into a list. It doesn't interrupt; it sits in the user's flow and waits.
I now reach for inline tips whenever the guidance is about the content a user is currently looking at, rather than a specific button. "These results look thin — try broadening your search terms" reads naturally as a banner above an empty results list. The same idea as a popover floating over the search box would feel intrusive.
Mechanically, inline tips are dead simple:
// In a SwiftUI listif let tip = ResultsHelpTip.current { TipView(tip) .padding(.horizontal)}
The trade-off is that you're committing the layout cost — the tip occupies space whether or not it's a runtime hit. So inline tips work best when the layout already has room for an occasional banner and the message is about the content, not a missed action. For features behind a button, the popover is still the right tool.
A pattern I use: each screen has a single dedicated "tip slot" near the top, and at most one inline tip can occupy it at a time. The slot is a SwiftUI Group that asks each tip in priority order whether it wants to display, and renders the first one that does. This keeps the layout stable, prevents tip pile-ups, and gives me a single place to instrument impressions.
Common Pitfalls and How to Get Out of Them
The traps you're going to hit, in roughly the order I hit them:
Tips.configure runs before the App Delegate is ready. Expo Modules' OnCreate runs early in the app lifecycle. If your DI container, analytics SDK, or another initializer hasn't finished, you can crash trying to write to the data store. Wrap the call in Task { ... } so it dispatches asynchronously on the main actor.
A tip never appears. Almost always, this means at least one rule never evaluates true. Open Xcode's Tips inspector (Window menu) — it shows you the current state of every registered tip's rules. I lost half a day before learning this existed.
Forgetting to call invalidate(reason:). When a user taps an action or hits the close button, you must explicitly invalidate the tip, or TipKit will think it was never dismissed. From the user's perspective: "I closed it, why is it back?" Make invalidation a non-negotiable part of the popover callback.
Tests are hard to write. TipKit's internal state lives in a data store that's awkward to control from XCTest. My approach is to extract the predicate inside #Rule { ... } into a plain function and unit-test that function. The TipKit machinery itself I test via UI tests on a clean simulator install.
Android explodes despite "no-op." If you forget requireOptionalNativeModule, the Android build will fail at runtime with "Cannot find native module." Any cross-platform-aware module wrapper has to be optional + early-return on every method.
Tips appearing on the wrong screen. A subtle one: if you donate search.opened from a screen that mounts behind a modal, the popover can attach to a screen the user can't currently see. The user then dismisses the modal and walks away wondering why the filter button is glowing. Donate from the screen that owns the affordance, not from any screen that happens to mount the data.
Race conditions during deep links. When a deep link launches the app directly into a feature, your OnCreate may not have finished Tips.configure before your screen tries to render its tip. Defend against this by gating the popover with if let _ = try? Tips.shared (or your own ready flag) before attaching the modifier. A missing tip is fine; a crash is not.
Measurement — Knowing Whether a Tip Actually Helped
A tip that fired but didn't change behavior is worse than no tip — you've added cognitive load for zero benefit. In my apps, the onAction listener emits a custom event to GA4 with the tip ID and action ID broken out as separate dimensions.
import * as Analytics from "expo-firebase-analytics";tipKit.onAction(({ tipId, actionId }) => { Analytics.logEvent("tip_action", { tip_id: tipId, action_id: actionId, // Separately, track "feature usage within N seconds of tip impression" });});
The three metrics worth watching: feature-usage rate within 24 hours of a tip impression, median time-to-first-use after the tip fires, and the close-rate of the app immediately after a tip appears. The third one is the canary — a tip with a high "user closed the app right after seeing it" rate is actively harming the experience and needs the copy or the timing fixed today, not next sprint.
For what it's worth, about a third of the tips I shipped initially turned out to be pure friction. The discipline TipKit forces is staring at "this tip I built actually made things worse" without flinching, and pulling it.
Designing Tips That Read as Helpful, Not Pushy
Copy quality is half the battle, and it's the half indie developers most often skip. Three rules I've internalized after dozens of iterations:
First, never describe the feature. The user already sees the button. Describe the outcome they'll get. "Find what you want faster" beats "Use the filter to narrow results" every time, because it speaks to their problem, not your UI.
Second, avoid the imperative voice. "Try the filter to see fewer results" sounds like a polite suggestion. "Tap the filter to narrow results" sounds like a manual. Tips compete for attention with everything else on screen; sounding like a friend rather than a manual buys you the half-second of curiosity that gets the tap.
Third, lead with the trigger condition in your own head. If you can't articulate "this user just did X, so they're now ready to hear Y," the tip probably shouldn't exist. Tips that exist for the developer's convenience, not the user's, are the ones that drive up close-rate.
I rewrite every tip three times before shipping it, and almost every revision is shorter than the previous one. The version that ships is usually 30-50% shorter than the first draft.
Ship-Day Checklist
Before you cut a build with a new tip, run through this list. It's the one I keep pinned in my notes app for every tip release.
The tip's id is unique across the app and follows your naming convention (feature.purpose).
All copy strings exist in every locale your app supports — no fall-throughs to English.
At least one rule constrains time (donatedWithin(.days(N))), not just total counts.
You've manually verified in the Tips inspector that the rules evaluate correctly for a fresh install and for a user who already met the conditions.
invalidate(reason:) is called on every action button and on the close button.
The action handler in JS has a fallback for unknown actionId values — variants you may add later.
You've decided whether the tip is local-only or CloudKit-synced, and documented why.
An analytics event fires on impression and on each action, with stable parameter names.
The tip's removal path is documented (when do you stop showing this and why).
Skipping any one of these is a tax you'll pay in support tickets within the month.
Related Reading
If you're building out the bridging layer more broadly, the foundations are in Rork Max Expo Modules Custom Native Guide. For the in-app feedback that pairs naturally with a tip-driven flow, see the Rork Haptic Feedback Complete Guide. And if you want a complementary surface that lives outside the app itself, the Rork Max WidgetKit Home Screen Widgets Guide covers how to keep features visible from the home screen.
Next Step
Pick one feature in your app — the single feature you most wish people would use — and ship one TipKit tip for it. A safe starting condition: "user has launched the app three times, has not used this feature, was active in the last 24 hours." Run it for a week and look at one number: did usage of that feature move? If yes, you've found a tool that works for your app. If no, the question isn't TipKit, it's the feature.
Tips work best when they're scarce. If it feels like you've added too few, you've probably added the right number.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.