Skip to content

Spotlight Overlay

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

Spotlight Overlay, playing
Download .swift
Origin
Original work
Found by
spotlight · overlay · dark · reveal · flashlight · drag · mask · foco · superposición · oscuro · revelar · linterna · arrastrar · máscara

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))
}
}
}

spotlightOverlay(center:radius:softness:dimming:)

ParameterTypeDefault
centerBinding<UnitPoint>Required
radiusDouble130
softnessDouble0.55
dimmingColor.black.opacity(0.88)

Copy this code into your project, or download the file.

SpotlightOverlay.swift
// 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()
}