Spotlight Overlay
A dark overlay with a soft hole that follows the finger.

- Effects
- iOS 18.0+
- beginner
- Origin
- Original work
- Found by
- spotlight · overlay · dark · reveal · flashlight · drag · mask · foco · superposición · oscuro · revelar · linterna · arrastrar · máscara
Description
Section titled “Description”Darkens this view except for a soft hole at center, which follows the finger.
struct SpotlightOverlayDemo: View { @State private var center = UnitPoint(x: 0.5, y: 0.45) @State private var isAutoPlaying = true @Environment(\.accessibilityReduceMotion) private var reduceMotion
private let symbols = [ "star.fill", "heart.fill", "bolt.fill", "leaf.fill", "flame.fill", "drop.fill", "moon.fill", "sun.max.fill", "cloud.fill", "snowflake", "key.fill", "crown.fill", "gift.fill", "bell.fill", "camera.fill", "music.note", "paperplane.fill", "globe.americas.fill", "pawprint.fill", "fish.fill", "tortoise.fill", "hare.fill", "ladybug.fill", "ant.fill", "sparkles", "diamond.fill", "gamecontroller.fill", "airplane", ] private let palette: [Color] = [.pink, .orange, .yellow, .mint, .teal, .blue, .purple]
/// Lets a finger take over from the automatic movement. private var touchCenter: Binding<UnitPoint> { Binding( get: { center }, set: { newValue in isAutoPlaying = false center = newValue } ) }
var body: some View { ZStack { Color(red: 0.08, green: 0.08, blue: 0.16)
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 4), spacing: 12) { ForEach(symbols.indices, id: \.self) { index in RoundedRectangle(cornerRadius: 18) .fill(palette[index % palette.count].gradient) .aspectRatio(1, contentMode: .fit) .overlay { Image(systemName: symbols[index]) .font(.title) .foregroundStyle(.white) } .accessibilityHidden(true) } } .padding(.horizontal, 16) } .spotlightOverlay(center: touchCenter) .overlay(alignment: .bottom) { Text("Drag to explore") .font(.headline) .foregroundStyle(.white) .padding(.bottom, 44) .allowsHitTesting(false) } .ignoresSafeArea() .task(id: reduceMotion) { await autoPlay() } }
/// Only the demo moves the hole by itself, until the first touch. private func autoPlay() async { guard !reduceMotion else { return }
let start = Date.now while isAutoPlaying, !Task.isCancelled { let time = Date.now.timeIntervalSince(start) center = UnitPoint(x: 0.5 + 0.3 * sin(time * 1.7), y: 0.45 + 0.27 * sin(time * 1.1 + 0.6)) try? await Task.sleep(for: .milliseconds(16)) } }}API reference
Section titled “API reference”spotlightOverlay(center:radius:softness:dimming:)
| Parameter | Type | Default |
|---|---|---|
center | Binding<UnitPoint> | Required |
radius | Double | 130 |
softness | Double | 0.55 |
dimming | Color | .black.opacity(0.88) |
Copy this code into your project, or download the file.
// SPDX-License-Identifier: MIT// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
/// A dark overlay with a soft hole that follows the finger.////// The hole only moves with the finger, so it needs no change for Reduce Motion.struct SpotlightOverlay: ViewModifier { /// The center of the hole, as a fraction of the view. @Binding var center: UnitPoint var radius: Double = 130 /// How much of the radius is the soft edge, from 0 to 1. var softness: Double = 0.55 var dimming: Color = .black.opacity(0.88)
@Environment(\.layoutDirection) private var layoutDirection
func body(content: Content) -> some View { content.overlay { GeometryReader { proxy in // Past its end radius the gradient keeps its last color, so one fill is both the hole and the dark. Rectangle() .fill(RadialGradient( stops: [ .init(color: dimming.opacity(0), location: 1 - softness), .init(color: dimming, location: 1), ], center: center, startRadius: 0, endRadius: radius )) .contentShape(.rect) .gesture(DragGesture(minimumDistance: 0).onChanged { value in // Gestures report screen points, but a unit point counts from the leading edge. let x = value.location.x / max(proxy.size.width, 1) center = UnitPoint( x: layoutDirection == .rightToLeft ? 1 - x : x, y: value.location.y / max(proxy.size.height, 1) ) }) } .accessibilityHidden(true) } }}
extension View { /// Darkens this view except for a soft hole at `center`, which follows the finger. func spotlightOverlay( center: Binding<UnitPoint>, radius: Double = 130, softness: Double = 0.55, dimming: Color = .black.opacity(0.88) ) -> some View { modifier(SpotlightOverlay(center: center, radius: radius, softness: softness, dimming: dimming)) }}
// MARK: - Demo
struct SpotlightOverlayDemo: View { @State private var center = UnitPoint(x: 0.5, y: 0.45) @State private var isAutoPlaying = true @Environment(\.accessibilityReduceMotion) private var reduceMotion
private let symbols = [ "star.fill", "heart.fill", "bolt.fill", "leaf.fill", "flame.fill", "drop.fill", "moon.fill", "sun.max.fill", "cloud.fill", "snowflake", "key.fill", "crown.fill", "gift.fill", "bell.fill", "camera.fill", "music.note", "paperplane.fill", "globe.americas.fill", "pawprint.fill", "fish.fill", "tortoise.fill", "hare.fill", "ladybug.fill", "ant.fill", "sparkles", "diamond.fill", "gamecontroller.fill", "airplane", ] private let palette: [Color] = [.pink, .orange, .yellow, .mint, .teal, .blue, .purple]
/// Lets a finger take over from the automatic movement. private var touchCenter: Binding<UnitPoint> { Binding( get: { center }, set: { newValue in isAutoPlaying = false center = newValue } ) }
var body: some View { ZStack { Color(red: 0.08, green: 0.08, blue: 0.16)
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 4), spacing: 12) { ForEach(symbols.indices, id: \.self) { index in RoundedRectangle(cornerRadius: 18) .fill(palette[index % palette.count].gradient) .aspectRatio(1, contentMode: .fit) .overlay { Image(systemName: symbols[index]) .font(.title) .foregroundStyle(.white) } .accessibilityHidden(true) } } .padding(.horizontal, 16) } .spotlightOverlay(center: touchCenter) .overlay(alignment: .bottom) { Text("Drag to explore") .font(.headline) .foregroundStyle(.white) .padding(.bottom, 44) .allowsHitTesting(false) } .ignoresSafeArea() .task(id: reduceMotion) { await autoPlay() } }
/// Only the demo moves the hole by itself, until the first touch. private func autoPlay() async { guard !reduceMotion else { return }
let start = Date.now while isAutoPlaying, !Task.isCancelled { let time = Date.now.timeIntervalSince(start) center = UnitPoint(x: 0.5 + 0.3 * sin(time * 1.7), y: 0.45 + 0.27 * sin(time * 1.1 + 0.6)) try? await Task.sleep(for: .milliseconds(16)) } }}
#Preview { SpotlightOverlayDemo()}