Skip to content

Vertical Timeline

A vertical timeline with a connector line and done, current and upcoming steps.

Vertical Timeline, playing
Download .swift
Origin
Original work
Found by
timeline · steps · progress · order tracking · stepper · history · status · línea de tiempo · pasos · progreso · seguimiento de pedido · historial · estado

A vertical list of steps joined by a line, each done, in progress or still to come.

With Reduce Motion on, the current step does not pulse and changes are not animated.

struct VerticalTimelineDemo: View {
private let steps = [
VerticalTimelineStep(id: "placed", title: "Order placed", detail: "Monday, 09:24"),
VerticalTimelineStep(id: "packed", title: "Packed", detail: "Warehouse in Valencia"),
VerticalTimelineStep(id: "shipped", title: "Shipped", detail: "On its way with your courier"),
VerticalTimelineStep(id: "out", title: "Out for delivery", detail: "Arriving before 14:00"),
VerticalTimelineStep(id: "delivered", title: "Delivered", detail: "Leave at the front door"),
]
@State private var currentIndex = 2
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
VStack(alignment: .leading, spacing: 4) {
Text("Track order")
.font(.system(.largeTitle, design: .rounded, weight: .bold))
Text("Order number 2048")
.font(.subheadline)
.foregroundStyle(.secondary)
}
VerticalTimeline(steps: steps, currentIndex: currentIndex)
.padding(22)
.background(.background.secondary, in: .rect(cornerRadius: 24))
}
.padding(20)
}
.scrollBounceBehavior(.basedOnSize)
.task(id: reduceMotion) {
guard !reduceMotion else { return }
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(1))
guard !Task.isCancelled else { return }
currentIndex = currentIndex >= steps.count ? 0 : currentIndex + 1
}
}
}
}
ParameterTypeDefault
steps[VerticalTimelineStep]Required
currentIndexIntRequired
tintColor.indigo

Copy this code into your project, or download the file.

VerticalTimeline.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
struct VerticalTimelineStep: Identifiable {
let id: String
let title: LocalizedStringResource
let detail: LocalizedStringResource
}
enum VerticalTimelineStatus: Equatable {
case done, current, upcoming
/// A `currentIndex` past the end means everything is done, and a negative one that nothing has started.
static func of(index: Int, currentIndex: Int) -> VerticalTimelineStatus {
if index < currentIndex { return .done }
return index == currentIndex ? .current : .upcoming
}
var spokenName: String {
switch self {
case .done: "Done"
case .current: "In progress"
case .upcoming: "Upcoming"
}
}
}
/// A vertical list of steps joined by a line, each done, in progress or still to come.
///
/// With Reduce Motion on, the current step does not pulse and changes are not animated.
struct VerticalTimeline: View {
let steps: [VerticalTimelineStep]
let currentIndex: Int
var tint: Color = .indigo
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(steps.enumerated()), id: \.element.id) { index, step in
row(
step,
status: .of(index: index, currentIndex: currentIndex),
isLast: index == steps.count - 1
)
}
}
.animation(reduceMotion ? nil : .snappy(duration: 0.4), value: currentIndex)
}
private func row(_ step: VerticalTimelineStep, status: VerticalTimelineStatus, isLast: Bool) -> some View {
HStack(alignment: .top, spacing: 14) {
VStack(spacing: 0) {
VerticalTimelineMarker(status: status, tint: tint)
if !isLast {
Rectangle()
.fill(status == .done ? AnyShapeStyle(tint) : AnyShapeStyle(.quaternary))
.frame(width: 2)
.frame(maxHeight: .infinity)
}
}
VStack(alignment: .leading, spacing: 3) {
Text(step.title)
.font(.headline)
.foregroundStyle(status == .upcoming ? .secondary : .primary)
Text(step.detail)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.bottom, isLast ? 0 : 26)
Spacer(minLength: 0)
}
.fixedSize(horizontal: false, vertical: true)
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(step.title). \(step.detail)")
.accessibilityValue(status.spokenName)
}
}
private struct VerticalTimelineMarker: View {
let status: VerticalTimelineStatus
let tint: Color
@ScaledMetric(relativeTo: .headline) private var size = 26.0
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
ZStack {
switch status {
case .done:
Circle().fill(tint)
Image(systemName: "checkmark")
.font(.caption.weight(.heavy))
.foregroundStyle(VerticalTimelineContrastingForeground(background: tint))
case .current:
halo
Circle().fill(tint.opacity(0.18))
Circle().strokeBorder(tint, lineWidth: 2)
Circle().fill(tint).frame(width: size * 0.38, height: size * 0.38)
case .upcoming:
Circle().strokeBorder(.tertiary, lineWidth: 2)
}
}
.frame(width: size, height: size)
.accessibilityHidden(true)
}
@ViewBuilder
private var halo: some View {
if !reduceMotion {
Circle()
.fill(tint.opacity(0.35))
.phaseAnimator([false, true]) { circle, isExpanded in
circle
.scaleEffect(isExpanded ? 1.7 : 1)
.opacity(isExpanded ? 0 : 1)
} animation: { isExpanded in
isExpanded ? .easeOut(duration: 1.1) : nil
}
}
}
}
/// White or black, whichever reads better on `background`. Black only on a
/// light color, so the system's own colors keep white.
nonisolated struct VerticalTimelineContrastingForeground: ShapeStyle {
let background: Color
func resolve(in environment: EnvironmentValues) -> Color {
let color = background.resolve(in: environment)
let luminance = 0.2126 * color.linearRed + 0.7152 * color.linearGreen + 0.0722 * color.linearBlue
return luminance > 0.5 ? .black : .white
}
}
// MARK: - Demo
struct VerticalTimelineDemo: View {
private let steps = [
VerticalTimelineStep(id: "placed", title: "Order placed", detail: "Monday, 09:24"),
VerticalTimelineStep(id: "packed", title: "Packed", detail: "Warehouse in Valencia"),
VerticalTimelineStep(id: "shipped", title: "Shipped", detail: "On its way with your courier"),
VerticalTimelineStep(id: "out", title: "Out for delivery", detail: "Arriving before 14:00"),
VerticalTimelineStep(id: "delivered", title: "Delivered", detail: "Leave at the front door"),
]
@State private var currentIndex = 2
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
VStack(alignment: .leading, spacing: 4) {
Text("Track order")
.font(.system(.largeTitle, design: .rounded, weight: .bold))
Text("Order number 2048")
.font(.subheadline)
.foregroundStyle(.secondary)
}
VerticalTimeline(steps: steps, currentIndex: currentIndex)
.padding(22)
.background(.background.secondary, in: .rect(cornerRadius: 24))
}
.padding(20)
}
.scrollBounceBehavior(.basedOnSize)
.task(id: reduceMotion) {
guard !reduceMotion else { return }
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(1))
guard !Task.isCancelled else { return }
currentIndex = currentIndex >= steps.count ? 0 : currentIndex + 1
}
}
}
}
#Preview {
VerticalTimelineDemo()
}