Skip to content
Perspective Tilt preview
An animated render of the SwiftUI source on this page.

Perspective Tilt

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

Interactive

A card that tilts under your finger with layered depth and a specular highlight that tracks it.

Cards · SwiftUI · Flutter · iOS 17 · Flutter 3.27

  • card
  • 3d
  • tilt
  • perspective
  • parallax
  • gloss
  • drag
  • depth

The actual source

DepthTilt.swift
// Perspective Tilt · syxUI · https://syxui.dev/components/depth-tilt
// Free in any project. Keep this line and credit syxUI where a person can read it.

import SwiftUI

/// A card that tilts under your finger, its layers sliding against the tilt to
/// read as a diorama instead of a flat plane rotating in place.
///
/// One normalised drag vector (`nx, ny`, each −1…1) drives everything: the two
/// rotation axes, three layers offset at different rates, a specular highlight
/// that slides to the far side of the tilt, and a shadow that grows and slides
/// opposite it. No layout ever changes size, so the whole thing is offsets and
/// a rotation matrix.
struct DepthTilt: View {
    var surface: Color = Color(red: 0.078, green: 0.086, blue: 0.11)
    var accent: Color = Color(red: 0.486, green: 0.361, blue: 1.0)
    var gloss: Color = Color(white: 1.0)
    /// Degrees of rotation at full deflection, either axis.
    var maxTilt: Double = 12
    /// How far the foreground layer slides against the tilt, in points. The
    /// title and background art move at 0.55× and 0.15× of this.
    var depth: CGFloat = 18
    var glossOpacity: Double = 0.35
    /// Preview override. When set, the component renders a Lissajous sweep of
    /// the tilt space instead of waiting for a finger or a cursor.
    var phase: Double? = nil

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var interactiveX: Double = 0
    @State private var interactiveY: Double = 0

    private let cardWidth: CGFloat = 300
    private let cardHeight: CGFloat = 190
    private let corner: CGFloat = 24

    /// The figure-8 idle path: `sin(2πt)` and `sin(4πt)` sweep every corner of
    /// the tilt space in one loop, so a still frame never looks arbitrary.
    private var nx: Double {
        guard !reduceMotion else { return 0 }
        if let phase { return sin(2 * .pi * phase) }
        return interactiveX
    }
    private var ny: Double {
        guard !reduceMotion else { return 0 }
        if let phase { return sin(4 * .pi * phase) }
        return interactiveY
    }

    private var rotY: Double { nx * maxTilt }
    private var rotX: Double { -ny * maxTilt }
    private var magnitude: Double { min(1, (nx * nx + ny * ny).squareRoot()) }

    private var shape: RoundedRectangle {
        RoundedRectangle(cornerRadius: corner, style: .continuous)
    }

    var body: some View {
        cardBody
            // Y first, then X — chaining two 3D rotations (rather than one
            // compound axis) is what keeps top and side edges reading as
            // straight lines instead of skewing into a rhombus.
            .rotation3DEffect(.degrees(rotY), axis: (x: 0, y: 1, z: 0), anchor: .center, anchorZ: 0, perspective: 0.7)
            .rotation3DEffect(.degrees(rotX), axis: (x: 1, y: 0, z: 0), anchor: .center, anchorZ: 0, perspective: 0.7)
            .shadow(color: .black.opacity(0.42), radius: 20 + 14 * magnitude, x: -nx * 12, y: 8 - ny * 14)
            .contentShape(shape)
            .gesture(dragGesture)
            .onContinuousHover(coordinateSpace: .local, perform: handleHover)
            .accessibilityElement(children: .combine)
            .accessibilityLabel("Nightshade all-access pass, tilts toward the cursor or your finger")
    }

    private var cardBody: some View {
        ZStack {
            shape.fill(surface)
            // Interior lift so the surface reads as lit from above, not flat ink.
            shape.fill(
                LinearGradient(colors: [.white.opacity(0.05), .clear], startPoint: .top, endPoint: .center)
            )

            backgroundArt
                .offset(layerOffset(0.15))

            VStack(alignment: .leading, spacing: 4) {
                Text("Nightshade")
                    .font(.system(size: 19, weight: .semibold))
                    .foregroundStyle(.white)
                Text("All-access pass")
                    .font(.system(size: 12.5))
                    .foregroundStyle(.white.opacity(0.5))
            }
            .frame(width: cardWidth, height: cardHeight, alignment: .bottomLeading)
            .padding(.leading, 22)
            .padding(.bottom, 20)
            .offset(layerOffset(0.55))

            badge
                .frame(width: cardWidth, height: cardHeight, alignment: .topTrailing)
                .padding(16)
                .offset(layerOffset(1.0))
        }
        .frame(width: cardWidth, height: cardHeight)
        .clipShape(shape)
        .overlay {
            // The highlight sits at the inverse of the tilt vector: as the
            // card leans one way, the glint slides to the opposite corner,
            // the way a real reflective surface answers a change of angle.
            RadialGradient(
                colors: [gloss.opacity(glossOpacity), .clear],
                center: UnitPoint(x: 0.5 - nx * 0.4, y: 0.5 - ny * 0.4),
                startRadius: 0,
                endRadius: 220
            )
            .blendMode(.plusLighter)
        }
        .compositingGroup()
    }

    private var backgroundArt: some View {
        ZStack {
            Circle()
                .fill(accent.opacity(0.35))
                .frame(width: 160, height: 160)
                .blur(radius: 40)
                .offset(x: cardWidth * 0.32, y: -cardHeight * 0.32)
            Circle()
                .fill(gloss.opacity(0.1))
                .frame(width: 120, height: 120)
                .blur(radius: 30)
                .offset(x: -cardWidth * 0.38, y: cardHeight * 0.4)
        }
        .frame(width: cardWidth, height: cardHeight)
        .clipShape(shape)
    }

    private var badge: some View {
        Text("PRO")
            .font(.system(size: 10, weight: .bold))
            .tracking(1)
            .foregroundStyle(surface)
            .padding(.horizontal, 9)
            .padding(.vertical, 5)
            .background { Capsule().fill(accent) }
    }

    private func layerOffset(_ factor: Double) -> CGSize {
        CGSize(width: nx * factor * depth, height: ny * factor * depth)
    }

    // MARK: Input

    private var dragGesture: some Gesture {
        DragGesture(minimumDistance: 0)
            .onChanged { value in
                guard phase == nil, !reduceMotion else { return }
                track(
                    x: value.translation.width / (cardWidth / 2),
                    y: value.translation.height / (cardHeight / 2)
                )
            }
            .onEnded { _ in settle() }
    }

    /// Mouse and trackpad have no drag gesture until a button is held, so a
    /// Mac cursor would never move this card without this — hover writes the
    /// same two properties the finger does, with the same spring underneath.
    private func handleHover(_ hoverPhase: HoverPhase) {
        guard phase == nil, !reduceMotion else { return }
        switch hoverPhase {
        case .active(let location):
            track(
                x: (location.x - cardWidth / 2) / (cardWidth / 2),
                y: (location.y - cardHeight / 2) / (cardHeight / 2)
            )
        case .ended:
            settle()
        }
    }

    private func track(x: Double, y: Double) {
        // A short, springy response rather than a snap — "attached to the
        // finger" needs one frame of give, or it reads as rigid.
        withAnimation(.interactiveSpring(response: 0.15, dampingFraction: 0.9)) {
            interactiveX = clampUnit(x)
            interactiveY = clampUnit(y)
        }
    }

    private func settle() {
        withAnimation(.spring(response: 0.5, dampingFraction: 0.62)) {
            interactiveX = 0
            interactiveY = 0
        }
    }

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

#Preview {
    DepthTilt()
        .frame(width: 380, height: 240)
        .background(Color(white: 0.03))
}
iOS 17 · No dependencies

SwiftUI note. Drag-driven, so with no touch it would render dead flat in its own catalog card — the phase override instead drives a Lissajous figure-8 that sweeps every corner of the tilt space, and onContinuousHover wires the same state so a Mac cursor tilts it too, no click required.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27