
Ticket Tear
From syxUI — written for both platforms, not translated between them.
InteractiveA 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
- Flutter
- No external dependencies
Requires iOS 17
Requires Flutter 3.27


