Skip to content
Ticket Tear preview
An animated render of the SwiftUI source on this page.

Ticket Tear

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

Interactive

A perforated ticket whose stub tears away, the rip growing teeth as it separates.

Cards · SwiftUI · Flutter · iOS 17 · Flutter 3.27

  • card
  • ticket
  • receipt
  • tear
  • perforation
  • shape
  • morph
  • coupon

The actual source

MorphTicketTear.swift
// Ticket Tear · syxUI · https://syxui.dev/components/morph-ticket-tear
// Free in any project. Keep this line and credit syxUI where a person can read it.

import SwiftUI

/// A perforated ticket whose stub tears away, the rip growing teeth as it
/// separates.
///
/// The only thing that lives in the `Shape`'s `animatableData` is the tear
/// amplitude — that is what must be continuous in the parameter, not
/// crossfaded, or the fibrous edge would pop in rather than grow. The stub's
/// rotation, its drift offset, and the notch radius are ordinary values
/// derived straight from `progress`, since none of them need a `Shape` to
/// interpolate correctly.
struct MorphTicketTear: View {
    var paperColor: Color = Color(red: 1.0, green: 0.992, blue: 0.969)
    var inkColor: Color = Color(red: 0.078, green: 0.086, blue: 0.110)
    var accent: Color = Color(red: 0.878, green: 0.267, blue: 0.169)

    /// Tear amplitude at full separation. 0 keeps the perforation a flat line.
    var amplitude: Double = 7
    var teeth: Int = 22
    /// Notch radius while attached. Grows by 2pt as the ticket opens.
    var notchRadius: Double = 9

    /// Preview override. When set, this renders that point of one tear
    /// instead of waiting for a tap.
    var progress: Double? = nil

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

    private var p: Double { progress ?? interactive }

    private static let width: CGFloat = 240
    private static let mainHeight: CGFloat = 118
    private static let stubHeight: CGFloat = 54

    var body: some View {
        let currentAmplitude = CGFloat(amplitude * p)
        let currentRadius = CGFloat(notchRadius + 2 * p)

        VStack(spacing: 10) {
            piece(
                content: mainContent,
                width: Self.width,
                height: Self.mainHeight,
                amplitude: currentAmplitude,
                flipped: false,
                radius: currentRadius
            )
            .overlay(alignment: .bottom) { dashedGuide }

            piece(
                content: stubContent,
                width: Self.width,
                height: Self.stubHeight,
                amplitude: currentAmplitude,
                flipped: true,
                radius: currentRadius
            )
            .rotationEffect(.degrees(6 * p), anchor: .topLeading)
            .offset(x: 8 * p, y: 14 * p)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .contentShape(Rectangle())
        .onTapGesture(perform: toggle)
        .accessibilityElement()
        .accessibilityAddTraits(.isButton)
        .accessibilityLabel("Ticket stub")
        .accessibilityValue(p > 0.5 ? "Torn away" : "Attached")
        .accessibilityHint("Tears the stub away, or rejoins it")
    }

    /// One half of the ticket: its fill and the 1pt rip highlight share the
    /// same jagged outline, and the perforation notches are punched through
    /// both with `.destinationOut` rather than painted on top, so whatever
    /// sits behind the ticket actually shows through the holes.
    private func piece(
        content: some View,
        width: CGFloat,
        height: CGFloat,
        amplitude: CGFloat,
        flipped: Bool,
        radius: CGFloat
    ) -> some View {
        let edge = TicketTornEdge(amplitude: amplitude, teeth: teeth, flipped: flipped)
        return ZStack {
            content
                .frame(width: width, height: height)
                .clipShape(edge)
            edge
                .stroke(accent.opacity(p), lineWidth: 1)
            notches(radius: radius, width: width, height: height, atBottom: !flipped)
        }
        .compositingGroup()
        .frame(width: width, height: height)
    }

    private func notches(radius: CGFloat, width: CGFloat, height: CGFloat, atBottom: Bool) -> some View {
        let y = atBottom ? height : 0
        return ZStack {
            Circle().frame(width: radius * 2, height: radius * 2).position(x: 0, y: y)
            Circle().frame(width: radius * 2, height: radius * 2).position(x: width, y: y)
        }
        .blendMode(.destinationOut)
    }

    private var mainContent: some View {
        ZStack(alignment: .leading) {
            Rectangle().fill(paperColor)
            Rectangle().fill(accent).frame(width: 10)
            VStack(alignment: .leading, spacing: 8) {
                RoundedRectangle(cornerRadius: 3).fill(inkColor.opacity(0.85)).frame(width: 130, height: 10)
                RoundedRectangle(cornerRadius: 3).fill(inkColor.opacity(0.4)).frame(width: 90, height: 7)
            }
            .padding(.leading, 26)
            .padding(.top, 18)
        }
    }

    private var stubContent: some View {
        ZStack {
            Rectangle().fill(paperColor)
            HStack(spacing: 3) {
                ForEach(0..<12, id: \.self) { index in
                    Rectangle()
                        .fill(inkColor.opacity(index.isMultiple(of: 2) ? 0.8 : 0.35))
                        .frame(width: 2)
                }
            }
            .padding(.horizontal, 24)
        }
    }

    private var dashedGuide: some View {
        Path { path in
            path.move(to: CGPoint(x: 0, y: 0))
            path.addLine(to: CGPoint(x: Self.width, y: 0))
        }
        .stroke(inkColor.opacity(0.35 * (1 - p)), style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
        .frame(width: Self.width, height: 1)
    }

    private func toggle() {
        guard progress == nil else { return }
        let target = p > 0.5 ? 0.0 : 1.0
        if reduceMotion {
            interactive = target
        } else {
            withAnimation(.spring(response: 0.50, dampingFraction: 0.70)) {
                interactive = target
            }
        }
    }
}

/// The jagged perforation edge shared by both halves of the ticket.
///
/// Only `amplitude` interpolates — at 0 the edge is exactly a straight line
/// (a whole, unteared ticket), and it grows into a fibrous rip without ever
/// swapping to a different path. Teeth are smoothed through with
/// `addQuadCurve` so the edge reads as torn paper rather than a saw blade.
private struct TicketTornEdge: Shape {
    var amplitude: CGFloat
    var teeth: Int
    var flipped: Bool

    var animatableData: CGFloat {
        get { amplitude }
        set { amplitude = newValue }
    }

    func path(in rect: CGRect) -> Path {
        let count = max(teeth, 2)
        // The jagged edge sits at the ticket's own bottom for the main body,
        // or its own top for the stub — the two halves' perforations meet at
        // that shared line before they separate.
        let baseline: CGFloat = flipped ? rect.minY : rect.maxY

        var points: [CGPoint] = []
        points.reserveCapacity(count + 1)
        for i in 0...count {
            let x = rect.minX + rect.width * CGFloat(i) / CGFloat(count)
            let sign: CGFloat = i.isMultiple(of: 2) ? 1 : -1
            let jitter = 0.6 + 0.4 * Self.hash(i)
            let toothDepth = amplitude * jitter * sign
            // Teeth point away from the piece's own body on both halves, so
            // the two edges read as complementary fibres, not mirrors.
            let y = baseline + (flipped ? -toothDepth : toothDepth)
            points.append(CGPoint(x: x, y: y))
        }

        var path = Path()
        if flipped {
            path.move(to: CGPoint(x: rect.minX, y: rect.maxY))
            path.addLine(to: CGPoint(x: rect.minX, y: points[0].y))
            Self.addSmoothedEdge(&path, points: points)
            path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
        } else {
            path.move(to: CGPoint(x: rect.minX, y: rect.minY))
            path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
            path.addLine(to: CGPoint(x: rect.maxX, y: points[count].y))
            Self.addSmoothedEdge(&path, points: points.reversed())
        }
        path.closeSubpath()
        return path
    }

    /// Runs a quadratic curve through the midpoints of each consecutive
    /// pair, using the original vertex as the pull-toward control point —
    /// the standard way to round a zigzag into a fibrous curve without
    /// losing its overall shape.
    private static func addSmoothedEdge(_ path: inout Path, points: [CGPoint]) {
        guard points.count > 1 else { return }
        var previous = points[0]
        for index in 1..<points.count {
            let current = points[index]
            let mid = CGPoint(x: (previous.x + current.x) / 2, y: (previous.y + current.y) / 2)
            path.addQuadCurve(to: mid, control: previous)
            previous = current
        }
        path.addLine(to: points[points.count - 1])
    }

    /// Deterministic per-tooth pseudo-random in 0...1. `Double.random` would
    /// reshuffle the tear on every single frame instead of animating it.
    private static func hash(_ i: Int) -> Double {
        let x = sin(Double(i) * 12.9898) * 43758.5453
        return x - floor(x)
    }
}

#Preview {
    MorphTicketTear()
        .frame(width: 380, height: 240)
        .padding(24)
}
iOS 17 · No dependencies

SwiftUI note. The notches are punched with `.compositingGroup()` plus `.destinationOut`, so they show whatever sits behind the ticket rather than a painted circle. Keep something other than the default background behind it or the holes are invisible.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27