Date Strip Picker
A horizontal strip of days that keeps the selected day centered as you scroll.

Description
Section titled “Description”A horizontal strip of days that keeps the selected day centered as you scroll.
With Reduce Motion on, tapping a day jumps to it without scrolling.
struct DateStripPickerDemo: View { @State private var selection = Date.now
var body: some View { ZStack { LinearGradient(colors: [.indigo.opacity(0.3), .teal.opacity(0.2)], startPoint: .top, endPoint: .bottom) .ignoresSafeArea()
DateStripPicker(selection: $selection)
VStack { Text("Your agenda") .font(.largeTitle.bold()) Spacer() Text(selection, format: .dateTime.weekday(.wide).day().month(.wide)) .font(.title3.weight(.semibold)) .multilineTextAlignment(.center) } .padding(32) } }}API reference
Section titled “API reference”init(selection:center:daysBefore:daysAfter:calendar:tint:)
| Parameter | Type | Default |
|---|---|---|
selection | Binding<Date> | Required |
center | Date | .now |
daysBefore | Int | 30 |
daysAfter | Int | 30 |
calendar | Calendar | .current |
tint | Color | .indigo |
Copy this code into your project, or download the file.
// SPDX-License-Identifier: MIT// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
/// A horizontal strip of days that keeps the selected day centered as you scroll.////// With Reduce Motion on, tapping a day jumps to it without scrolling.struct DateStripPicker: View { @Binding var selection: Date var tint: Color
private let days: [Date] private let calendar: Calendar
@State private var centeredDay: Date? @ScaledMetric(relativeTo: .title2) private var itemWidth = 58.0 @ScaledMetric(relativeTo: .title2) private var itemHeight = 84.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion
private let spacing = 10.0
init( selection: Binding<Date>, center: Date = .now, daysBefore: Int = 30, daysAfter: Int = 30, calendar: Calendar = .current, tint: Color = .indigo ) { _selection = selection self.tint = tint self.calendar = calendar days = DateStripPickerLogic.days(around: center, before: daysBefore, after: daysAfter, calendar: calendar) }
var body: some View { GeometryReader { proxy in ScrollView(.horizontal) { LazyHStack(spacing: spacing) { ForEach(days, id: \.self) { dayCell($0) } } .scrollTargetLayout() } .contentMargins(.horizontal, max(0, (proxy.size.width - itemWidth) / 2), for: .scrollContent) .scrollIndicators(.hidden) .scrollTargetBehavior(.viewAligned) .scrollPosition(id: $centeredDay, anchor: .center) } .frame(height: itemHeight) .onAppear { centeredDay = closestDay(to: selection) } .onChange(of: centeredDay) { _, day in guard let day, day != calendar.startOfDay(for: selection) else { return } selection = day } .onChange(of: selection) { _, newValue in let day = closestDay(to: newValue) guard day != centeredDay else { return } withAnimation(reduceMotion ? nil : .snappy) { centeredDay = day } } .sensoryFeedback(.selection, trigger: centeredDay) { old, new in old != nil && new != nil } }
private func dayCell(_ day: Date) -> some View { let isSelected = day == calendar.startOfDay(for: selection) let isToday = calendar.isDateInToday(day)
return Button { withAnimation(reduceMotion ? nil : .snappy) { centeredDay = day } } label: { VStack(spacing: 4) { Text(format(day, Date.FormatStyle().weekday(.abbreviated))) .font(.caption.weight(.medium)) Text(format(day, Date.FormatStyle().day())) .font(.title2.weight(.bold)) Circle() .fill(isToday ? (isSelected ? Color.white : tint) : Color.clear) .frame(width: 5, height: 5) } .lineLimit(1) .minimumScaleFactor(0.7) .foregroundStyle(isSelected ? Color.white : Color.primary) .frame(width: itemWidth, height: itemHeight) .background(isSelected ? AnyShapeStyle(tint.gradient) : AnyShapeStyle(.thinMaterial), in: .rect(cornerRadius: 18)) .scaleEffect(isSelected || reduceMotion ? 1 : 0.92) .animation(reduceMotion ? nil : .snappy, value: isSelected) } .buttonStyle(.plain) .accessibilityLabel(format(day, Date.FormatStyle().weekday(.wide).day().month(.wide))) .accessibilityValue(isToday ? "Today" : "") .accessibilityAddTraits(isSelected ? .isSelected : []) }
private func closestDay(to date: Date) -> Date? { DateStripPickerLogic.closestDay(to: date, in: days, calendar: calendar) }
private func format(_ date: Date, _ base: Date.FormatStyle) -> String { var style = base style.calendar = calendar style.timeZone = calendar.timeZone return date.formatted(style) }}
/// The date math of the strip, apart from the view so it can be tested.enum DateStripPickerLogic { /// Keeps a very wide request from building an enormous array. static let maximumDaysPerSide = 3_650
static func days(around center: Date, before: Int, after: Int, calendar: Calendar) -> [Date] { let before = min(max(before, 0), maximumDaysPerSide) let after = min(max(after, 0), maximumDaysPerSide) let start = calendar.startOfDay(for: center) return (-before...after).compactMap { offset in calendar.date(byAdding: .day, value: offset, to: start).map { calendar.startOfDay(for: $0) } } }
/// The same day when it is in the strip, otherwise the nearest end of the strip. static func closestDay(to date: Date, in days: [Date], calendar: Calendar) -> Date? { let day = calendar.startOfDay(for: date) return days.first { $0 >= day } ?? days.last }}
// MARK: - Demo
struct DateStripPickerDemo: View { @State private var selection = Date.now
var body: some View { ZStack { LinearGradient(colors: [.indigo.opacity(0.3), .teal.opacity(0.2)], startPoint: .top, endPoint: .bottom) .ignoresSafeArea()
DateStripPicker(selection: $selection)
VStack { Text("Your agenda") .font(.largeTitle.bold()) Spacer() Text(selection, format: .dateTime.weekday(.wide).day().month(.wide)) .font(.title3.weight(.semibold)) .multilineTextAlignment(.center) } .padding(32) } }}
#Preview { DateStripPickerDemo()}