Flip Card
A two-sided card you flip by tap or by dragging sideways. The drag scrubs the rotation, release commits or snaps back by velocity, the card lifts toward you mid-turn with a layered shadow that slides off the raised edge, and each face shades as it turns edge-on.
- Type
- FlipCard
- Files
- FlipCard.swift
- Depends on
- Nothing (Apple frameworks only)
- Version
- 2.0.0
Card Details
A card screen with a card that catches the light and flips to its CVV, plus limits and controls.
Two-sided card with tap-to-flip and drag-to-flip.
Notes
- One
DragGesture(minimumDistance: 0)handles everything: touch-down tilts the card toward the finger and scales it to 0.98, an 8pt sideways move turns into a scrub where the full card width equals 180°, and a lift without movement is a tap. - Release commits when
predictedEndTranslationprojects past 90° in either direction, so a flick finishes the flip and a slow drag that stops short snaps back. Dragging past a full turn rubber-bands at 200°. - Each face darkens with
brightnessas it turns edge-on and hides past 90°; the back is pre-rotated by -180° so it lands unmirrored. The shadow slides opposite the lifted edge and widens mid-flip. - The angle accumulates (0, 180, 360...) so repeated flips keep rotating the same way;
isFlipped:stays in sync in both directions. - A soft impact fires as the visible face changes. Reduce Motion replaces the rotation with a crossfade that the drag still scrubs.
- v2: the card lifts 6% toward you at the edge-on point, sits on a layered shadow (a tight contact shadow plus a soft key shadow that slides off the raised edge), a soft-light band crosses each face as it turns, and the scrub rubber-bands past a half turn. Touch-down gives a light impact.
style:(FlipCard.Style) tunescornerRadius,shadow/shadowOpacity(tripled in dark mode),lift,pressScale,pressTilt, andshading.onFlip:reports each committed flip. A disabled card dims and ignores touches.- The example is a vocabulary flashcard: a butter front with a heavy word and a sky back with the meaning. The preview shows the card alone, with no surrounding UI.
- For a full card screen with number masking and controls, see the Pro Card Details screen (
payment-card).
Usage
FlipCardExample()
.preferredColorScheme(.light)Parameters
| Parameter | Description |
|---|---|
isFlipped | Optional binding to drive the flip externally. Leave nil to let the card manage its own state; taps and drags still update the binding when one is passed. |
style | Depth, lift, press, and shading tuning. .standard uses the house values; the faces bring their own color. |
onFlip | Called after a flip commits, with true when the back is now showing. |
front | Front face. Give it its own shape and surface; the card only rotates, lifts, shades, and shadows it. |
back | Back face, shown after the flip. It is pre-rotated so text is never mirrored. |
Source
import SwiftUI
/// Two-sided card with tap-to-flip and drag-to-flip.
///
/// - Parameters:
/// - isFlipped: Optional binding to drive the flip externally. Leave nil to let the card manage its own state; taps and drags still update the binding when one is passed.
/// - style: Depth, lift, press, and shading tuning. `.standard` uses the house values; the faces bring their own color.
/// - onFlip: Called after a flip commits, with `true` when the back is now showing.
/// - front: Front face. Give it its own shape and surface; the card only rotates, lifts, shades, and shadows it.
/// - back: Back face, shown after the flip. It is pre-rotated so text is never mirrored.
public struct FlipCard<Front: View, Back: View>: View {
/// Physical tuning for the card. Colors live on the faces you pass in; this controls how the card moves and sits on the page.
public struct Style: Sendable {
/// Corner radius of the faces, used to shape the shadow and the edge sheen. Match your faces' radius.
public var cornerRadius: CGFloat
/// Shadow ink.
public var shadow: Color
/// Shadow strength in light mode; dark mode uses about three times this so the card still separates from a dark ground.
public var shadowOpacity: Double
/// Extra scale at the edge-on point of a flip, so the card rises toward you as it turns. 0 keeps it flat.
public var lift: CGFloat
/// Scale while a finger is down without dragging.
public var pressScale: CGFloat
/// Touch-down tilt toward the finger, in degrees.
public var pressTilt: Double
/// How much a face darkens edge-on, 0...1.
public var shading: Double
public init(cornerRadius: CGFloat = 26, shadow: Color = .black, shadowOpacity: Double = 0.14, lift: CGFloat = 0.06, pressScale: CGFloat = 0.97, pressTilt: Double = 6, shading: Double = 0.16) {
self.cornerRadius = cornerRadius
self.shadow = shadow
self.shadowOpacity = shadowOpacity
self.lift = lift
self.pressScale = pressScale
self.pressTilt = pressTilt
self.shading = shading
}
/// House defaults: 26 pt corners, a 6% lift mid-turn, a 0.97 press.
public static var standard: Style { Style() }
}
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.isEnabled) private var isEnabled
@State private var angle: Double = 0
@State private var dragBase: Double?
@State private var press: CGPoint?
@State private var size = CGSize(width: 1, height: 1)
@State private var pressTick = 0
private let external: Binding<Bool>?
private let style: Style
private let onFlip: ((Bool) -> Void)?
private let front: Front
private let back: Back
public init(isFlipped: Binding<Bool>? = nil, style: Style = .standard, onFlip: ((Bool) -> Void)? = nil, @ViewBuilder front: () -> Front, @ViewBuilder back: () -> Back) {
self.external = isFlipped
self.style = style
self.onFlip = onFlip
self.front = front()
self.back = back()
}
private var isFlipped: Bool { Int((angle / 180).rounded()).magnitude % 2 == 1 }
private var showsBack: Bool { Self.showsBack(at: angle) }
private var isPressed: Bool { press != nil && dragBase == nil }
private var isDragging: Bool { dragBase != nil }
nonisolated fileprivate static func showsBack(at angle: Double) -> Bool {
let a = (angle.truncatingRemainder(dividingBy: 360) + 360).truncatingRemainder(dividingBy: 360)
return a > 90 && a < 270
}
public var body: some View {
// Touch-down tilts the card slightly toward the finger before any drag begins.
let tiltX = press.map { -(($0.y / size.height) - 0.5) * style.pressTilt } ?? 0
let tiltY = press.map { (($0.x / size.width) - 0.5) * style.pressTilt } ?? 0
ZStack {
front.modifier(Face(angle: angle, isBack: false, crossfade: reduceMotion, shading: style.shading, radius: style.cornerRadius))
back.modifier(Face(angle: angle, isBack: true, crossfade: reduceMotion, shading: style.shading, radius: style.cornerRadius))
}
.modifier(Depth(angle: angle, style: style, raised: isDragging, flat: reduceMotion))
.scaleEffect(isPressed && !reduceMotion ? style.pressScale : 1)
.rotation3DEffect(.degrees(reduceMotion ? 0 : tiltX), axis: (x: 1, y: 0, z: 0), perspective: 0.5)
.rotation3DEffect(.degrees(reduceMotion ? 0 : tiltY), axis: (x: 0, y: 1, z: 0), perspective: 0.5)
.opacity(isEnabled ? 1 : 0.5)
.contentShape(.rect(cornerRadius: style.cornerRadius, style: .continuous))
.gesture(drag, isEnabled: isEnabled)
.onGeometryChange(for: CGSize.self) { $0.size } action: { size = $0 }
.animation(.spring(duration: 0.3, bounce: 0.2), value: isPressed)
.sensoryFeedback(.impact(weight: .light, intensity: 0.6), trigger: pressTick)
.sensoryFeedback(.impact(flexibility: .soft), trigger: showsBack)
.onChange(of: external?.wrappedValue) { _, new in
if let new, new != isFlipped { flip() }
}
.accessibilityElement(children: .contain)
.accessibilityAddTraits(.isButton)
.accessibilityValue(showsBack ? "Back" : "Front")
.accessibilityHint("Flips the card")
.accessibilityAction { flip() }
}
private var drag: some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { drag in
if press == nil && dragBase == nil {
press = drag.startLocation
pressTick += 1
}
if dragBase == nil, abs(drag.translation.width) > 8 {
dragBase = angle
press = nil
}
if let dragBase {
let delta = Double(drag.translation.width / size.width) * 180
// Past a full half turn the scrub rubber-bands instead of stopping dead.
let limited = abs(delta) <= 180 ? delta : (delta > 0 ? 1 : -1) * (180 + (abs(delta) - 180) * 0.25)
angle = dragBase + min(max(limited, -210), 210)
}
}
.onEnded { drag in
if let base = dragBase {
// Commit when the projected end passes 90° in either direction, otherwise snap back.
let projected = Double(drag.predictedEndTranslation.width / size.width) * 180
let target = abs(projected) >= 90 ? base + (projected > 0 ? 180 : -180) : base
let committed = target != base
dragBase = nil
withAnimation(settle) { angle = target }
sync()
if committed { onFlip?(isFlipped) }
} else if press != nil {
flip()
}
press = nil
}
}
private var settle: Animation { reduceMotion ? .easeInOut(duration: 0.3) : .spring(duration: 0.55, bounce: 0.22) }
private func flip() {
withAnimation(reduceMotion ? .easeInOut(duration: 0.3) : .spring(duration: 0.7, bounce: 0.22)) { angle += 180 }
sync()
onFlip?(isFlipped)
}
private func sync() {
if let external, external.wrappedValue != isFlipped { external.wrappedValue = isFlipped }
}
/// Lift and shadow that follow the animated angle: the card rises mid-turn, a tight contact shadow shrinks, and a soft key shadow slides away from the raised edge.
private struct Depth: ViewModifier, Animatable {
var angle: Double
let style: Style
let raised: Bool
let flat: Bool
@Environment(\.colorScheme) private var colorScheme
nonisolated var animatableData: Double {
get { angle }
set { angle = newValue }
}
func body(content: Content) -> some View {
let strength = min(style.shadowOpacity * (colorScheme == .dark ? 3 : 1), 1)
let edge = flat ? 0 : sin(angle * .pi / 180)
let turn = abs(edge)
let up = max(turn, raised ? 0.35 : 0)
content
.scaleEffect(1 + style.lift * turn)
.shadow(color: style.shadow.opacity(strength * (1 - up * 0.6)), radius: 2, y: 1)
.shadow(color: style.shadow.opacity(strength), radius: 16 + up * 18, x: -edge * 18, y: 12 + up * 10)
}
}
/// Rotates one face, darkens it as it turns edge-on, and hides it past 90° so each face is only seen from its own side.
private struct Face: ViewModifier, Animatable {
var angle: Double
let isBack: Bool
let crossfade: Bool
let shading: Double
let radius: CGFloat
nonisolated var animatableData: Double {
get { angle }
set { angle = newValue }
}
func body(content: Content) -> some View {
let radians = angle * .pi / 180
let visible = isBack ? FlipCard.showsBack(at: angle) : !FlipCard.showsBack(at: angle)
if crossfade {
let backOpacity = 0.5 - 0.5 * cos(radians)
content
.opacity(isBack ? backOpacity : 1 - backOpacity)
.accessibilityHidden(!visible)
} else {
let turn = abs(sin(radians))
content
.brightness(-turn * shading)
// A light band crosses the face as it turns, so the rotation reads as a lit surface.
.overlay {
RoundedRectangle(cornerRadius: radius, style: .continuous)
.fill(.white.opacity(0.18 * turn * (sin(radians) > 0 ? 1 : 0.4)))
.blendMode(.softLight)
.allowsHitTesting(false)
}
.opacity(visible ? 1 : 0)
// The back starts at -180° so it lands unmirrored when the flip completes.
.rotation3DEffect(.degrees(isBack ? angle - 180 : angle), axis: (x: 0, y: 1, z: 0), perspective: 0.45)
.accessibilityHidden(!visible)
}
}
}
}
fileprivate extension Color {
/// An appearance-adaptive color, resolved per trait collection.
init(flipLight light: Color, dark: Color) {
self.init(uiColor: UIColor { $0.userInterfaceStyle == .dark ? UIColor(dark) : UIColor(light) })
}
}
// MARK: - Example
/// A vocabulary flashcard: a butter front with a heavy word, a sky back with the meaning.
private struct FlipCardExample: View {
@State private var flipped = false
private let ink = Color(red: 0.078, green: 0.078, blue: 0.078)
private let butter = Color(red: 1, green: 0.851, blue: 0.463)
private let sky = Color(red: 0.612, green: 0.761, blue: 1)
private let ground = Color(flipLight: Color(red: 0.953, green: 0.949, blue: 0.933), dark: Color(red: 0.071, green: 0.071, blue: 0.071))
var body: some View {
FlipCard(isFlipped: $flipped) {
front
} back: {
back
}
.frame(width: 320, height: 214)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(ground)
}
private var front: some View {
RoundedRectangle(cornerRadius: 26, style: .continuous)
.fill(butter)
.overlay(alignment: .topLeading) {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("PORTUGUESE")
.font(.caption2.weight(.bold))
.tracking(1)
Spacer()
Text("3 / 12")
.font(.system(.caption, design: .monospaced).weight(.semibold))
}
Spacer()
Text("Saudade")
.font(.system(size: 46, weight: .bold))
.tracking(-1.8)
.minimumScaleFactor(0.6)
.lineLimit(1)
Text("noun · sow-DAH-jee")
.font(.subheadline.weight(.medium))
.opacity(0.62)
}
.foregroundStyle(ink)
.padding(22)
}
}
private var back: some View {
RoundedRectangle(cornerRadius: 26, style: .continuous)
.fill(sky)
.overlay(alignment: .topLeading) {
VStack(alignment: .leading, spacing: 10) {
Text("MEANING")
.font(.caption2.weight(.bold))
.tracking(1)
Text("A deep longing for someone or something far away.")
.font(.system(size: 22, weight: .bold))
.tracking(-0.6)
.minimumScaleFactor(0.7)
Spacer(minLength: 0)
Text("Often heard in fado songs")
.font(.subheadline.weight(.medium))
.opacity(0.62)
}
.foregroundStyle(ink)
.padding(22)
}
}
}
#Preview("Light") {
FlipCardExample()
.preferredColorScheme(.light)
}
#Preview("Dark") {
FlipCardExample()
.preferredColorScheme(.dark)
}Install
Pick one. Run it from the folder that contains your .xcodeproj and the files land inside your app.
Terminal
Or ask your coding agent · Claude Code, Cursor or Xcode, once the MCP server is connected
Or copy the source above into your app.
Building a whole app? See Swift Pieces Pro →
Secure Entry
A password field on a soft 56pt block with a focus ring and a round reveal toggle that keeps focus, a strength bar that grows through four color blocks with a matching label chip, requirement chips that turn into solid blocks with drawn checks and collapse when complete, a success check, and a damped shake with an error chip.
Motion Card
A solid color card that tilts with the device's attitude through an exponential filter, with a fixed-light sheen and a layered shadow that slide as it turns. Press settles it flat and scales it down with a light impact, and Core Motion stops whenever the scene is not active.