Skip to content
Circle Reveal preview
An animated render of the SwiftUI source on this page.

Circle Reveal

From syxUI — written for both platforms, not translated between them.

Interactive

A tap grows a circular mask from your finger, swapping the card's face underneath.

Cards · SwiftUI · Flutter · iOS 17 · Flutter 3.27

  • card
  • reveal
  • mask
  • circle
  • clip
  • tap
  • theme-switch
  • transition

The actual source

MorphRevealCircle.swift
// Circle Reveal · syxUI · https://syxui.dev/components/morph-reveal-circle
// Free in any project. Keep this line and credit syxUI where a person can read it.

import SwiftUI

/// A tap grows a circular mask from your finger, swapping the card's face
/// underneath — the classic light/dark theme-switch transition.
///
/// The mask radius is the only thing driven by `p`; the tap point is
/// captured once per tap, not animated, so tapping again contracts toward
/// the *new* point rather than the old one. A 1.5pt ring riding the mask
/// edge is what reads as "a circle is growing" once the fill underneath is
/// uniform and the motion alone would not say so.
struct MorphRevealCircle: View {
    var faceA: Color = Color(red: 0.184, green: 0.435, blue: 0.929)
    var faceB: Color = Color(red: 0.063, green: 0.075, blue: 0.09)
    var ringColor: Color = Color(white: 1.0)
    var textColor: Color = Color(white: 1.0)
    /// Seconds. How long a tap takes to grow or contract the mask.
    var duration: Double = 0.45
    var ringWidth: CGFloat = 1.5
    /// Preview override. 0 shows face A only, 1 face B fully revealed.
    var progress: Double? = nil

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var interactive: Double = 0
    @State private var revealed = false
    @State private var tapPoint: CGPoint?

    private var p: Double { progress ?? interactive }

    var body: some View {
        GeometryReader { geo in
            let size = geo.size
            // Centre reads as an iris closing rather than a wipe — the
            // brief for this effect specifically calls out a lower-right
            // anchor as the better-looking default.
            let anchor = tapPoint ?? CGPoint(x: size.width * 0.72, y: size.height * 0.78)
            let maxDistance = cornerDistance(from: anchor, in: size)
            let radius = maxDistance * p
            let ringAlpha = 1 - ramp(p, 0.7, 1.0)
            let contentScale = lerp(1.06, 1.0, p)

            ZStack {
                face(faceA, icon: "sun.max.fill", label: "Light", tint: .white)

                face(faceB, icon: "moon.stars.fill", label: "Dark", tint: textColor)
                    .scaleEffect(contentScale)
                    .mask {
                        Circle()
                            .frame(width: radius * 2, height: radius * 2)
                            .position(anchor)
                    }
            }
            .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
            // Required — without it the mask above leaks past the clip
            // shape instead of compositing against it.
            .compositingGroup()
            .overlay {
                Circle()
                    .strokeBorder(ringColor.opacity(ringAlpha), lineWidth: ringWidth)
                    .frame(width: radius * 2, height: radius * 2)
                    .position(anchor)
            }
            .contentShape(Rectangle())
            .gesture(tapGesture)
            .accessibilityElement()
            .accessibilityAddTraits(.isButton)
            .accessibilityLabel(revealed ? "Dark theme" : "Light theme")
        }
    }

    private func face(_ color: Color, icon: String, label: String, tint: Color) -> some View {
        ZStack {
            color
            VStack(spacing: 12) {
                Image(systemName: icon).font(.system(size: 42))
                Text(label).font(.system(size: 17, weight: .semibold))
            }
            .foregroundStyle(tint)
        }
        .accessibilityHidden(true)
    }

    private var tapGesture: some Gesture {
        SpatialTapGesture()
            .onEnded { value in
                guard progress == nil else { return }
                tapPoint = value.location
                revealed.toggle()
                withAnimation(reduceMotion ? .linear(duration: 0) : .easeOut(duration: duration)) {
                    interactive = revealed ? 1 : 0
                }
            }
    }

    private func cornerDistance(from point: CGPoint, in size: CGSize) -> CGFloat {
        let corners = [
            CGPoint(x: 0, y: 0), CGPoint(x: size.width, y: 0),
            CGPoint(x: 0, y: size.height), CGPoint(x: size.width, y: size.height),
        ]
        return corners.map { hypot($0.x - point.x, $0.y - point.y) }.max() ?? 0
    }
}

private func clamp(_ value: Double) -> Double { min(max(value, 0), 1) }

private func ramp(_ value: Double, _ from: Double, _ to: Double) -> Double {
    clamp((value - from) / (to - from))
}

private func lerp(_ a: CGFloat, _ b: CGFloat, _ t: Double) -> CGFloat {
    a + (b - a) * CGFloat(t)
}

#Preview {
    HStack(spacing: 30) {
        MorphRevealCircle()
        MorphRevealCircle(progress: 0.5)
        MorphRevealCircle(progress: 1.0)
    }
    .frame(height: 240)
    .padding(40)
}
iOS 17 · No dependencies

SwiftUI note. `.compositingGroup()` before the ring `.overlay` is required — without it the Circle mask on face B leaks past the outer clipShape instead of compositing against it. The tap point is captured once per tap and held in @State; it is never itself animated, only the radius is.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27