Greetings, traveler!
Onboarding often requires guiding users through a complex interface by highlighting specific elements and explaining their purpose. Many applications solve this with a dimmed background and a visible cutout around the focused control. While the visual effect looks simple, implementing it in a reusable and layout-safe way in SwiftUI requires careful coordination between view geometry, coordinate spaces, and animations.
In this article, we will build a reusable spotlight onboarding component that:
- Highlights any SwiftUI view using a rounded cutout.
- Displays an explanatory overlay card positioned relative to the highlighted element.
- Animates smoothly between multiple steps.
- Works across navigation stacks, scroll views, toolbars, safe areas, and sheets.
The solution is fully declarative and relies on SwiftUI’s PreferenceKey and anchor system rather than UIKit overlays or global windows.
The Problem We Are Solving
A spotlight onboarding flow must satisfy several constraints:
- The highlighted view can live deep inside the hierarchy.
- Its frame may change due to scrolling or layout updates.
- The onboarding overlay must be rendered above the entire screen.
- The explanatory card should adapt to available space and avoid clipping at screen edges.
Passing frames manually through view models or using global geometry readers quickly becomes brittle. The robust solution is to let each spotlight target report its bounds upward using anchors, and let a single container resolve and render everything in one place.
High-Level Architecture
The component consists of two main pieces:
- A source modifier that marks a view as spotlightable.
- A container modifier that collects all spotlight targets and renders the overlay.
The public API looks like this:
public extension View {
func tutorialSpotlight<ID: Hashable, Overlay: View>(
selection: Binding<ID?>,
orderedIDs: [ID] = [],
spotlightPadding: CGFloat = 8,
cornerRadius: CGFloat = 28,
dismissOnBackgroundTap: Bool = true,
@ViewBuilder overlay: @escaping (_ id: ID, _ actions: TutorialSpotlightActions) -> Overlay
) -> some View {
modifier(
TutorialSpotlightContainerModifier(
selection: selection,
orderedIDs: orderedIDs,
spotlightPadding: spotlightPadding,
cornerRadius: cornerRadius,
dismissOnBackgroundTap: dismissOnBackgroundTap,
overlay: overlay
)
)
}
func tutorialSpotlightSource<ID: Hashable>(id: ID) -> some View {
modifier(TutorialSpotlightSourceModifier(id: id))
}
}The container is attached once to a common ancestor, usually the root of a screen. Individual spotlight targets are marked with tutorialSpotlightSource(id:).
Marking Spotlight Targets with Anchors
Each spotlightable view reports its bounds using an anchor preference:
private struct TutorialSpotlightSourceModifier<ID: Hashable>: ViewModifier {
let id: ID
func body(content: Content) -> some View {
content.anchorPreference(
key: TutorialSpotlightPreferenceKey<ID>.self,
value: .bounds
) { anchor in
[id: anchor]
}
}
}This does not compute frames immediately. Instead, it stores a lightweight anchor. The actual frame is resolved later inside the container using a GeometryProxy, ensuring correctness even when layout changes.
The associated preference key merges anchors by ID:
private struct TutorialSpotlightPreferenceKey<ID: Hashable>: PreferenceKey {
static var defaultValue: [ID: Anchor<CGRect>] { [:] }
static func reduce(
value: inout [ID: Anchor<CGRect>],
nextValue: () -> [ID: Anchor<CGRect>]
) {
value.merge(nextValue(), uniquingKeysWith: { _, new in new })
}
}This allows multiple spotlight sources to coexist and be referenced by logical identifiers.
Rendering the Spotlight Overlay
The container modifier collects all anchors and renders a fullscreen overlay above the content:
.overlayPreferenceValue(TutorialSpotlightPreferenceKey<ID>.self) { preferences in
GeometryReader { proxy in
ZStack {
Color.clear
overlayContent(preferences: preferences, proxy: proxy)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}The selected spotlight ID is stored in a Binding<ID?>. When selection is non-nil, the container resolves the corresponding anchor:
if let selected = selection,
let anchor = preferences[selected] {
let targetFrame = proxy[anchor]
let focusFrame = targetFrame.insetBy(
dx: -spotlightPadding,
dy: -spotlightPadding
)
}At this stage, we have the exact frame of the highlighted element in container coordinates.
Creating the Cutout Effect
The dimmed background with a visible hole is implemented using even-odd fill rules:
private struct TutorialSpotlightCutoutShape: Shape {
let focusFrame: CGRect
let cornerRadius: CGFloat
func path(in rect: CGRect) -> Path {
var path = Path()
path.addRect(rect)
path.addPath(
RoundedRectangle(
cornerRadius: cornerRadius,
style: .continuous
)
.path(in: focusFrame)
)
return path
}
}This shape first draws the full screen rectangle, then adds a rounded rectangle where the spotlight should appear. When filled using FillStyle(eoFill: true), the inner path becomes a transparent cutout.
The overlay layer looks like this:
TutorialSpotlightCutoutShape(
focusFrame: overlayFocusFrame,
cornerRadius: cornerRadius
)
.fill(
.black.opacity(0.58),
style: FillStyle(eoFill: true)
)
.contentShape(Rectangle())
.onTapGesture {
if dismissOnBackgroundTap {
selection = nil
}
}Positioning the Explanatory Overlay Card
The onboarding card is provided by the caller through a @ViewBuilder closure. To place it correctly, we measure its rendered size using another preference key:
.overlay(selected, actions)
.frame(maxWidth: min(320, containerBounds.width - 32))
.background {
GeometryReader { overlayProxy in
Color.clear
.preference(
key: TutorialSpotlightOverlaySizePreferenceKey.self,
value: overlayProxy.size
)
}
}Once measured, we compute a position relative to the spotlight:
private func overlayPosition(
for focusFrame: CGRect,
overlaySize: CGSize,
in container: CGRect
) -> CGPoint {
let horizontalPadding: CGFloat = 16
let verticalSpacing: CGFloat = 24
let verticalPadding: CGFloat = 24
let maxOverlayWidth = min(320, container.width - (horizontalPadding * 2))
let measuredWidth = overlaySize.width > 0
? overlaySize.width
: maxOverlayWidth
let measuredHeight = overlaySize.height > 0
? overlaySize.height
: 180
let overlayWidth = min(measuredWidth, maxOverlayWidth)
let centeredX = min(
max(focusFrame.midX,
container.minX + horizontalPadding + overlayWidth / 2),
container.maxX - horizontalPadding - overlayWidth / 2
)
let preferredBelowY =
focusFrame.maxY + verticalSpacing + measuredHeight / 2
if preferredBelowY + measuredHeight / 2
<= container.maxY - verticalPadding {
return CGPoint(x: centeredX, y: preferredBelowY)
}
let preferredAboveY =
focusFrame.minY - verticalSpacing - measuredHeight / 2
let clampedY = min(
max(preferredAboveY,
container.minY + verticalPadding + measuredHeight / 2),
container.maxY - verticalPadding - measuredHeight / 2
)
return CGPoint(x: centeredX, y: clampedY)
}The card prefers to appear below the spotlight. If space is insufficient, it moves above. Horizontal clamping ensures it never touches screen edges.
Handling Step Navigation
To support multi-step onboarding flows, the container exposes actions:
public struct TutorialSpotlightActions {
public let dismiss: () -> Void
public let advance: () -> Void
}The advance implementation uses orderedIDs to move through steps:
let actions = TutorialSpotlightActions(
dismiss: {
withAnimation(.easeInOut(duration: 0.25)) {
selection = nil
}
},
advance: {
guard let currentSelection = selection,
let index = orderedIDs.firstIndex(of: currentSelection)
else {
selection = nil
return
}
let nextIndex = orderedIDs.index(after: index)
withAnimation(.easeInOut(duration: 0.25)) {
selection = nextIndex < orderedIDs.endIndex
? orderedIDs[nextIndex]
: nil
}
}
)This keeps navigation logic inside the container while the overlay content remains focused on presentation.
Using the Component in Practice
Here is a simplified example:
struct FeatureScreen: View {
enum Step: CaseIterable {
case profile
case filters
case checkout
}
@State private var selection: Step?
var body: some View {
VStack {
Button("Profile") {
selection = .profile
}
.tutorialSpotlightSource(id: Step.profile)
Button("Filters") {
selection = .filters
}
.tutorialSpotlightSource(id: Step.filters)
Button("Checkout") {
selection = .checkout
}
.tutorialSpotlightSource(id: Step.checkout)
}
.tutorialSpotlight(
selection: $selection,
orderedIDs: Step.allCases
) { id, actions in
VStack(alignment: .leading, spacing: 12) {
Text("Step: \(String(describing: id))")
.font(.headline)
Button("Next") {
actions.advance()
}
Button("Skip") {
actions.dismiss()
}
}
.padding()
.background(.white,
in: .rect(cornerRadius: 20))
.shadow(radius: 12)
}
}
}The spotlight container attaches once to the root. Any child view can register as a spotlight source without tight coupling or explicit geometry propagation.
Conclusion
This spotlight onboarding component demonstrates how SwiftUI’s anchor and preference system can coordinate complex overlay behavior in a fully declarative way. Spotlight targets report their bounds upward, a single container resolves frames in a unified coordinate space, and the cutout overlay is rendered using even-odd path filling. Measured overlay sizing allows adaptive positioning, and a small action interface enables multi-step flows.
The result is a reusable, layout-safe onboarding system that works across navigation stacks, scroll views, safe areas, and sheets while preserving a clean and composable API.
You can find the full code on my GitHub.
