designengineer.ing100% left
designengineer.ing

Designing a Glassy Arrow Date Selector in SwiftUI

designengineer.ing

We build the main screen of ArrowDateSelector: a glassy date selector that steps one day at a time with steel-style arrow buttons, smart labels like “Today” and “Tomorrow”, and smooth motion.

What we’re building

  • A date selector that steps forward/backward one day at a time
  • Steel/glass-style circular arrow buttons with a reflection and metallic rim
  • Smart labels: Today, Tomorrow, Yesterday, or a localized date like “March 15”
  • Haptics and accessibility so it feels right on device
  • Two Swift files — ArrowButton.swift for the control, ContentView.swift for the screen

Requirements: Xcode 15 or later, iOS 17 or later, and basic SwiftUI experience.

1. Design tokens: metrics and colors

Start in ArrowButton.swift by centralising button metrics in a private ArrowButtonMetrics enum — the same way Chapter 1 locked sizes in Figma before touching effects. Button size, reflection diameter, blur amount, stroke width, and shadow values all live here.

ArrowDateSelector›ArrowButton.swift
1private enum ArrowButtonMetrics {
2 static let buttonSize: CGFloat = 125
3 static let reflectionDiameter: CGFloat = 90
4 static let reflectionBlur: CGFloat = 8
5 static let reflectionYOffset: CGFloat = 14
6 static let strokeLineWidth: CGFloat = 3
7 static let arrowShadowOpacity: CGFloat = 0.1
8 static let arrowShadowRadius: CGFloat = 1
9 static let arrowShadowYOffset: CGFloat = 1
10}
SwiftLine 1, Col 1

The steel background palette lives in ContentView.swift. buttonFill — the face of the circle — stays in ArrowButton.swift next to the control that uses it.

ArrowDateSelector›ContentView.swift
1extension Color {
2 static let steelBackground1 = Color(red: 67/255, green: 80/255, blue: 89/255)
3 static let steelBackground2 = Color(red: 93/255, green: 106/255, blue: 114/255)
4 static let steelBackground3 = Color(red: 120/255, green: 133/255, blue: 141/255)
5 static let steelBackground4 = Color(red: 116/255, green: 123/255, blue: 129/255)
6}
SwiftLine 1, Col 1
ArrowDateSelector›ArrowButton.swift
1private extension Color {
2 static let buttonFill = Color(red: 120/255, green: 133/255, blue: 141/255)
3}
SwiftLine 1, Col 1

2. Gradients for background and reflection

ContentView.swift owns LinearGradient.appBackground — the diagonal steel backdrop from Chapter 1’s base fill. ArrowButton.swift owns reflectionFill — the vertical grey gradient behind the frosted core.

ArrowDateSelector›ContentView.swift
1private extension LinearGradient {
2 static var appBackground: LinearGradient {
3 LinearGradient(
4 gradient: Gradient(stops: [
5 .init(color: .steelBackground1, location: 0.0),
6 .init(color: .steelBackground2, location: 0.30),
7 .init(color: .steelBackground3, location: 0.59),
8 .init(color: .steelBackground4, location: 1.0)
9 ]),
10 startPoint: .topLeading,
11 endPoint: .bottomTrailing
12 )
13 }
14}
SwiftLine 1, Col 1
ArrowDateSelector›ArrowButton.swift
1private extension LinearGradient {
2 static var reflectionFill: LinearGradient {
3 LinearGradient(
4 stops: [
5 Gradient.Stop(color: Color(red: 0.48, green: 0.52, blue: 0.55), location: 0.00),
6 Gradient.Stop(color: Color(red: 0.64, green: 0.67, blue: 0.70), location: 1.00),
7 ],
8 startPoint: .top,
9 endPoint: .bottom
10 )
11 }
12}
SwiftLine 1, Col 1

3. Building the steel ring as a ViewModifier

The metallic ring from Chapter 1’s four stroke layers becomes CircularSteelStrokeOverlay in ArrowButton.swift. Four stroked circles with gradients and blend modes — two darken the edges, two add highlights. visibleStrokeCount lets the in-app tutorial reveal strokes one at a time, matching the build progression carousel above.

ArrowDateSelector›ArrowButton.swift
1private struct CircularSteelStrokeOverlay: ViewModifier {
2 var visibleStrokeCount: Int
3 
4 func body(content: Content) -> some View {
5 content.overlay(strokeLayer)
6 }
7 
8 private var strokeLayer: some View {
9 ZStack {
10 if visibleStrokeCount >= 1 {
11 Circle()
12 .stroke(
13 LinearGradient(
14 gradient: Gradient(stops: [
15 .init(color: .black.opacity(0.4), location: 0.0),
16 .init(color: .black.opacity(0.0), location: 0.1)
17 ]),
18 startPoint: .leading,
19 endPoint: .topTrailing
20 ),
21 lineWidth: ArrowButtonMetrics.strokeLineWidth
22 )
23 .blendMode(.darken)
24 }
25 
26 if visibleStrokeCount >= 2 {
27 Circle()
28 .stroke(
29 LinearGradient(
30 gradient: Gradient(stops: [
31 .init(color: .black.opacity(0.4), location: 0.0),
32 .init(color: .black.opacity(0.0), location: 0.1)
33 ]),
34 startPoint: .trailing,
35 endPoint: .topLeading
36 ),
37 lineWidth: ArrowButtonMetrics.strokeLineWidth
38 )
39 .blendMode(.darken)
40 }
41 
42 if visibleStrokeCount >= 3 {
43 Circle()
44 .stroke(
45 LinearGradient(
46 gradient: Gradient(stops: [
47 .init(color: .white.opacity(0.8), location: 0.1),
48 .init(color: .white.opacity(0.0), location: 0.4)
49 ]),
50 startPoint: .bottom,
51 endPoint: .top
52 ),
53 lineWidth: ArrowButtonMetrics.strokeLineWidth
54 )
55 .blendMode(.overlay)
56 }
57 
58 if visibleStrokeCount >= 4 {
59 Circle()
60 .stroke(
61 LinearGradient(
62 gradient: Gradient(stops: [
63 .init(color: .white.opacity(0.8), location: 0.0),
64 .init(color: .white.opacity(0.0), location: 0.3)
65 ]),
66 startPoint: .top,
67 endPoint: .bottom
68 ),
69 lineWidth: ArrowButtonMetrics.strokeLineWidth
70 )
71 .blendMode(.normal)
72 }
73 }
74 .compositingGroup()
75 }
76}
77 
78private extension View {
79 func circularSteelStrokeOverlay(visibleStrokeCount: Int = 4) -> some View {
80 modifier(CircularSteelStrokeOverlay(visibleStrokeCount: visibleStrokeCount))
81 }
82}
SwiftLine 1, Col 1

4. Composing the ArrowButton

ArrowButton stacks the fill, frosted reflection, steel ring, and arrow icon. The reflection is a blurred circle offset downward — closer to Chapter 1’s frosted core than a flat rectangle. The date selector passes step: .complete so all four strokes, blur, and the arrow render at once.

ArrowDateSelector›ArrowButton.swift
1struct ArrowButton: View {
2 var rotation: Angle = .zero
3 var step: ArrowButtonStep = .complete
4 
5 var body: some View {
6 ZStack {
7 buttonFace
8 
9 if step.showsArrow {
10 Image("arrow")
11 .rotationEffect(rotation)
12 .shadow(
13 color: .black.opacity(ArrowButtonMetrics.arrowShadowOpacity),
14 radius: ArrowButtonMetrics.arrowShadowRadius,
15 y: ArrowButtonMetrics.arrowShadowYOffset
16 )
17 .accessibilityHidden(true)
18 }
19 }
20 .contentShape(Circle())
21 }
22 
23 private var buttonFace: some View {
24 ZStack {
25 Circle()
26 .fill(Color.buttonFill)
27 
28 if step.showsReflection {
29 Circle()
30 .fill(LinearGradient.reflectionFill)
31 .frame(
32 width: ArrowButtonMetrics.reflectionDiameter,
33 height: ArrowButtonMetrics.reflectionDiameter
34 )
35 .offset(y: ArrowButtonMetrics.reflectionYOffset)
36 .blur(radius: step.reflectionBlurRadius)
37 }
38 }
39 .frame(width: ArrowButtonMetrics.buttonSize, height: ArrowButtonMetrics.buttonSize)
40 .clipShape(Circle())
41 .circularSteelStrokeOverlay(visibleStrokeCount: step.visibleStrokeCount)
42 }
43}
SwiftLine 1, Col 1

5. Managing date state and label logic

ArrowDateSelectorView in ContentView.swift owns a single @State currentDate. formattedLabel picks Today, Tomorrow, Yesterday, or a localized date. showsDetailedDate controls the second line with weekday + full date.

ArrowDateSelector›ContentView.swift
1struct ArrowDateSelectorView: View {
2 @State private var currentDate = Date()
3 private let calendar = Calendar.current
4 private let monthDayFormatter: DateFormatter = {
5 let f = DateFormatter()
6 f.setLocalizedDateFormatFromTemplate("MMMMd")
7 return f
8 }()
9 
10 private var formattedLabel: String {
11 if calendar.isDateInToday(currentDate) { return "Today" }
12 if calendar.isDateInTomorrow(currentDate) { return "Tomorrow" }
13 if calendar.isDateInYesterday(currentDate) { return "Yesterday" }
14 return monthDayFormatter.string(from: currentDate)
15 }
16 
17 private var showsDetailedDate: Bool {
18 calendar.isDateInToday(currentDate)
19 || calendar.isDateInTomorrow(currentDate)
20 || calendar.isDateInYesterday(currentDate)
21 }
22}
SwiftLine 1, Col 1

6. Laying out the selector

The body is a ZStack with appBackground edge to edge, then an HStack: previous arrow, date labels, next arrow. Negative spacing pulls the arrows toward the centre; scaleEffect(0.4) keeps them control-sized rather than hero-sized.

ArrowDateSelector›ContentView.swift
1var body: some View {
2 ZStack {
3 LinearGradient.appBackground
4 .ignoresSafeArea()
5 
6 HStack(spacing: -30) {
7 Button {
8 currentDate = calendar.date(byAdding: .day, value: -1, to: currentDate) ?? currentDate
9 HapticsHelper.impactMedium()
10 } label: {
11 ArrowButton(rotation: .degrees(180))
12 .scaleEffect(0.4)
13 }
14 .accessibilityLabel("Previous day")
15 
16 VStack(spacing: 4) {
17 Text(formattedLabel)
18 .font(.system(size: 24, weight: .regular))
19 .foregroundStyle(
20 LinearGradient(
21 colors: [Color.white.opacity(0.8), Color.white.opacity(0.2)],
22 startPoint: .topLeading,
23 endPoint: .bottomTrailing
24 )
25 )
26 .contentTransition(.numericText())
27 .animation(.spring(response: 0.25, dampingFraction: 0.5), value: formattedLabel)
28 
29 if showsDetailedDate {
30 Text(currentDate.formatted(.dateTime.weekday().month().day()))
31 .font(.system(size: 14, weight: .regular))
32 .foregroundStyle(.white.opacity(0.6))
33 .contentTransition(.numericText())
34 .animation(.spring(response: 0.25, dampingFraction: 0.5), value: formattedLabel)
35 .accessibilityHidden(true)
36 }
37 }
38 .frame(width: 120)
39 .accessibilityElement(children: .combine)
40 .accessibilityLabel(formattedLabel)
41 
42 Button {
43 currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate) ?? currentDate
44 HapticsHelper.impactMedium()
45 } label: {
46 ArrowButton(rotation: .degrees(0))
47 .scaleEffect(0.4)
48 }
49 .accessibilityLabel("Next day")
50 }
51 .buttonStyle(.plain)
52 .padding()
53 }
54}
SwiftLine 1, Col 1

7. Motion, haptics, and accessibility

Labels animate with .contentTransition(.numericText()) and a spring keyed off formattedLabel. HapticsHelper wraps UIImpactFeedbackGenerator and bails out in previews so the canvas stays stable.

ArrowDateSelector›ContentView.swift
1enum HapticsHelper {
2 static func impactMedium() {
3 #if canImport(UIKit)
4 if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" { return }
5 UIImpactFeedbackGenerator(style: .medium).impactOccurred()
6 #endif
7 }
8}
SwiftLine 1, Col 1

Arrows get explicit accessibility labels, the decorative arrow image is hidden from VoiceOver, and the label stack combines into one element. The whole view hides from accessibility in previews to avoid canvas crashes on complex trees.

8. Previewing the design

ContentView.swift ends with a #Preview that renders ArrowDateSelectorView in dark mode — the same setup you use while iterating on spacing and animation in the canvas.

ArrowDateSelector›ContentView.swift
1#Preview("Date selector") {
2 ArrowDateSelectorView()
3 .preferredColorScheme(.dark)
4}
SwiftLine 1, Col 1

Takeaways

  • Treat metrics and colours as design tokens so you can iterate without rewriting views.
  • Wrap visual effects like the steel ring in ViewModifiers to make them portable.
  • Layer simple shapes—gradients, blurs, strokes—to get a complex glass/metal look.
  • Keep view bodies clean with computed properties for labels and visibility flags.
  • Guard haptics in previews so you can lean on the SwiftUI canvas while designing.
  • Think about accessibility from the start: labels, combined elements, and hidden decoration.

You can explore the full source — ArrowButton.swift, ContentView.swift, and assets — in the swiftuibuttons GitHub repository.

View ArrowDateSelector on GitHub

0 from readers

A shout out is a small burst of encouragement. When you tap 📢, you let us know this tutorial helped — and that keeps us happy and motivated to write more like it.

we are also on Follow

design engineer.ing

© 2026 design engineering

·About