Horizontal Calendar Scroll in SwiftUI: The Date Strip
The strip answers which day nearby. Four hidden problems, each with a clean modern-SwiftUI answer.
TL;DR
A horizontal calendar strip in SwiftUI is a LazyHStack of day cells with scrollTargetBehavior(.viewAligned) for snapping and scrollPosition for scroll-to-today, fed by a windowed date range (a decade is only 3,652 cells) generated with Calendar arithmetic rather than 86,400-second math that breaks on daylight saving. Keep today (a ring) and the selection (a fill) visually distinct, drive the month label from the leading visible cell, make every cell a full-date VoiceOver button, and hand deep date navigation to a real picker. A free VP0 design shows the strip inside habit, booking, and tracker screens an agent generates from.
Why a strip instead of a grid?
Because most apps ask “which day this week?”, not “which day this year?”. The horizontally scrolling date strip answers the common question in one row of screen: habit trackers marking today’s check-in, booking flows picking a slot two days out, fitness apps flicking between yesterday’s and today’s workout. The full month grid is for planners and stays one tap deeper. Sparse-event apps feel the difference hardest, which is why the dividend income calendar leads with a strip: six paydays a month do not justify thirty-five grid cells of chrome.
The component looks trivial and hides four real problems: smooth snapping, a date range without an end, the month label that follows the scroll, and locale-correct date math. Each has a clean SwiftUI answer.
What is the core implementation?
A lazy horizontal stack with view-aligned snapping, on the modern ScrollView APIs:
struct DateStrip: View {
@Binding var selected: Date
let days: [Date]
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 8) {
ForEach(days, id: \.self) { day in
DayCell(date: day, isSelected: day == selected)
.onTapGesture { selected = day }
}
}
.scrollTargetLayout()
}
.scrollTargetBehavior(.viewAligned)
}
}
scrollTargetBehavior(.viewAligned) does the snapping that used to take a UIKit drop-down, and scrollPosition (bound to a date-identified item) gives you programmatic scroll-to-today and restore-on-appear. The day cell itself is a vertical pair, weekday letter over day number, with two visually distinct states that beginners merge and users notice: today is a ring, the selection is a fill. Today is a fact; selection is a choice; a day can be both.
Event indicators ride along as dots under the number (cap them at three plus overflow), which is all the strip should say; details belong to the screen below it.
How do you scroll without an end date?
Windowed generation, not infinity. A decade of days is only 3,652 cells, which a LazyHStack handles without complaint, so the pragmatic answer is generating a wide fixed window (a year back, a year forward) from Calendar and re-centering it in the rare session that reaches an edge. True infinite scroll in SwiftUI means fighting content-offset jumps when prepending; the windowed approach gives the same experience without the fight.
Generate days with Calendar arithmetic, never by adding 86,400-second intervals: daylight-saving days are 23 or 25 hours, and tick-based date math is how a strip ends up showing two Sundays in March. The same Calendar instance supplies locale truth, week starts on Monday in most of Europe and Sunday in the US, and the strip should follow calendar.firstWeekday rather than hardcoding either.
The month label that updates as you scroll is the last trick: read the leading visible day via onScrollGeometryChange (or a preference key carrying each cell’s frame) and render its month and year above the strip, with a crossfade on change. Skipping the label is the most common strip mistake, because forty day-cells with no month anchor turn into a guessing game by the second flick.
What do accessibility and scale demand?
Each cell is a Button whose label is the full formatted date (“Tuesday, June 9”), not “9”, so VoiceOver users hear dates rather than bare numbers; the selected state announces via accessibilityAddTraits(.isSelected). Dynamic Type needs the cells to grow: fix the layout’s shape but never the font sizes, and test at the largest accessibility size, where the weekday-plus-number pair will want roughly double the width. Apple’s picker guidance is the reference point for when the strip should give up and present a real date picker: deep navigation (a birthday years away) is picker territory; the strip owns the near present.
Haptics finish it: a light selection tick on snap, nothing on every cell passed. And if the strip drives content below (the usual case), bind the selection to a paged TabView so a horizontal flick on the content also advances the day, the pattern the daily check-in genre runs, with the shift-scheduling board showing the same strip stretched to team scale.
A free VP0 design covers the strip in context, habit homes, booking flows, tracker dashboards, so an agent generates the two-state day cells and month-label plumbing instead of rediscovering them.
Key takeaways: the SwiftUI date strip
- Strips answer “which day nearby?”; grids answer “which day this year?”; most apps need the strip first.
- viewAligned snapping plus scrollPosition cover the interaction; the day cell keeps today (ring) and selection (fill) visually distinct.
- Window, don’t infinitize: 3,652 cells covers a decade; re-center at the edges and let Calendar, never 86,400-second math, generate the days.
- The month label follows the leading visible cell; omitting it is the classic strip failure.
- Cells are full-date buttons for VoiceOver, layouts breathe for Dynamic Type, and deep dates belong to a real picker.
Frequently asked questions
How do I build a horizontal scrolling calendar in SwiftUI? A LazyHStack of day cells inside a horizontal ScrollView with scrollTargetLayout and scrollTargetBehavior(.viewAligned) for snapping, scrollPosition for scroll-to-today, a windowed date range generated from Calendar, and a month label driven by the leading visible cell. A free VP0 design shows the strip in real screens an agent can generate from.
How do I make the date strip infinite? Practically, you don’t: generate a wide window (a year each way is ~730 cells, a decade 3,652) and re-center when a session approaches an edge. True prepend-on-scroll fights SwiftUI’s content offset and buys nothing users notice.
How should today and the selected day look different? Today is a ring, selection is a fill, and a day can be both at once. Merging the two states is the most common visual bug; they answer different questions (what is versus what’s chosen) and need separate affordances.
Why does my strip show wrong days around daylight saving? Because the dates were generated by adding 86,400-second intervals. DST days run 23 or 25 hours, so always derive days with Calendar’s date arithmetic, which also supplies the locale’s correct first weekday.
When should I use a date picker instead of a strip? When the target date is far from now: birthdays, document dates, anything requiring year navigation. The strip owns the near present; deep navigation belongs to the system picker.
More questions from VP0 vibe coders
How do I build a horizontal scrolling calendar in SwiftUI?
A LazyHStack of day cells in a horizontal ScrollView with scrollTargetLayout plus scrollTargetBehavior(.viewAligned) for snapping, scrollPosition for programmatic scroll-to-today, a windowed date range from Calendar, and a month label that follows the leading visible cell. A free VP0 design supplies the strip in real screen context for an agent to generate from.
How do I make a SwiftUI date strip infinite?
Generate a wide fixed window instead: a year each way is about 730 cells and a decade only 3,652, comfortably lazy-loaded. Re-center the window in the rare session that nears an edge; true prepend-on-scroll fights content-offset jumps for no visible gain.
How should today differ from the selected day in a calendar strip?
Today wears a ring, the selection wears a fill, and one day can carry both. They answer different questions, a fact versus a choice, and merging them into one style is the strip's most common visual bug.
Why does my date strip break around daylight saving time?
The days were generated by adding 86,400-second intervals; DST days run 23 or 25 hours, so the strip drifts. Use Calendar date arithmetic for day generation, which also gives the locale's correct first weekday for free.
When is a date strip the wrong control?
Whenever the target date is far away: birthdays, document dates, year-level navigation. The strip serves the near present; deep navigation belongs to the system date picker, per Apple's own picker guidance.
Part of the Native Apple & SwiftUI: The iOS Ecosystem hub. Browse all VP0 topics →
Keep reading
Ready-Made SwiftUI Components: Free Options and How to Use Them
Want ready-made SwiftUI components (gotowe komponenty)? Here are the best free open-source libraries and the AI-builder path to generate your own from a design.
Free SwiftUI Components (Gratis SwiftUI Komponenter)
Want free SwiftUI components (gratis SwiftUI komponenter)? Here are the best open-source libraries and the AI-builder path to generate your own from a design.
Build a Stock Market Heat Map Grid UI in SwiftUI
A market heat map colors and sizes tiles by gain and market cap. Here is how to build the stock market heat map grid in SwiftUI, with an accessible color scale.
Build a Booking.com-Style Availability Calendar in SwiftUI
A Booking.com-style availability picker is more than a date picker. Here is how to build the availability calendar in SwiftUI, with real open and booked dates.
Build a Sideloading iOS App Install Animation in SwiftUI
In the EU, an alt-marketplace install is a real, system-gated flow. Here is how to build the sideloading install animation in SwiftUI, honestly.
Build a Smooth, Scrolling Social Media Feed in SwiftUI
A social media feed in SwiftUI is a scrolling list of post cards. Here is how to build it so it stays smooth with images, likes, and infinite scroll.