swiftcnearly preview
Component library

Calendar

Native calendar composition, package imports, and editable Swift source.

Preview

Calendar rendered by native SwiftUI.Calendar rendered by native SwiftUI. Dark appearance.
Captured from the native SwiftUI catalog on macOS.

Use it

Import SwiftCN to use CNCalendar directly. This complete example also runs in the native component gallery.

CalendarExample.swift
import SwiftUI
import SwiftCN

struct CalendarExample: View {
    @State private var date = Date(timeIntervalSince1970: 1_760_054_400)

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            CNCalendar("Delivery date", selection: $date)

        }
    }
}

Native behavior

A custom six-week SwiftUI grid uses styled day buttons and month navigation. Calendar arithmetic respects the environment calendar, locale, time zone, and daylight-saving changes. Selected, today, adjacent-month, and disabled days have separate recipes. Bounds apply to days and month navigation. Day buttons expose full date labels and selected state. External selection changes reveal that month. Override dayClasses or reuse CNCalendarDay in your own layout. Changes to the allowed date range reconcile the displayed month and clear an unavailable focused day.

Style and compose

Visual recipes use .tw classes. Append classes: to override a recipe locally, or change the recipe in TWGlobalRules.named for your subtree. Native layout and interaction modifiers remain SwiftUI APIs. For control colors, append cn-tint-[primary], another theme token, or an interpolated Color.

Motion recipes are editable too: control-motion uses a 150 ms smooth response, disclosure-motion uses a 250 ms spring, and feedback-motion uses a 200 ms smooth response. Set a recipe to animate-none in your global rules to turn it off. Presets respect Reduce Motion. Static components stay still; native scrolling, drag tracking, and platform presentation keep their own gestures.

Action groups and card, dialog, and questionnaire footers stack when their content does not fit. The native layout preserves the same child controls during this change.

View builders preserve native child content. Native Button roles, labels, keyboard shortcuts, submit actions, and presentation modifiers still work. A string recipe does not replace a native control API or a typed chart mark.

Own the source

Copy the complete file below into your app alongside import SwiftCN. The file contains related composable parts from the same family. Its dependencies use public package APIs, so copied and imported components can coexist.

To own the styling engine too, download the complete source bundle. It contains every component, the .tw engine, its recipes, and the MIT license. Keep those files in one module and remove import SwiftCN from the usage example.

Calendar.swift
import SwiftUI
import SwiftCN

/// A custom month grid. SwiftUI Buttons retain native activation and accessibility.
public struct CNCalendar: View {
    @Environment(\.calendar) private var environmentCalendar
    @Environment(\.timeZone) private var timeZone
    @Environment(\.locale) private var locale
    @Environment(\.layoutDirection) private var direction
    @Binding private var date: Date
    @State private var visibleMonth: Date?
    @State private var focusedDate: Date?
    private let title: LocalizedStringKey
    private let range: ClosedRange<Date>
    private let classes: TWClasses
    private let dayClasses: TWClasses
    public init(_ title: LocalizedStringKey = "Date", selection: Binding<Date>,
                in range: ClosedRange<Date> = Date.distantPast...Date.distantFuture,
                classes: TWClasses = "", dayClasses: TWClasses = "") {
        self.title = title; _date = selection; self.range = range
        self.classes = classes; self.dayClasses = dayClasses
    }
    private var calendar: Calendar {
        var value = environmentCalendar; value.timeZone = timeZone; value.locale = locale; return value
    }
    private var month: Date {
        CNCalendarGrid.month(containing: visibleMonth ?? date, in: range, calendar: calendar)
    }
    private var days: [Date] { CNCalendarGrid.days(in: month, calendar: calendar) }
    private var weekdaySymbols: [String] {
        let symbols = calendar.veryShortStandaloneWeekdaySymbols
        let offset = calendar.firstWeekday - 1
        return Array(symbols[offset...] + symbols[..<offset])
    }
    private var monthLabel: String {
        let formatter = DateFormatter(); formatter.locale = locale; formatter.calendar = calendar
        formatter.timeZone = timeZone; formatter.setLocalizedDateFormatFromTemplate("MMMM yyyy")
        return formatter.string(from: month)
    }
    public var body: some View {
        VStack(spacing: 12) {
            HStack(spacing: 8) {
                navigationButton(-1, title: "Previous month", image: "chevron.backward")
                Text(monthLabel).tw("calendar-heading w-full text-center").accessibilityAddTraits(.isHeader)
                navigationButton(1, title: "Next month", image: "chevron.forward")
            }
            LazyVGrid(columns: Array(repeating: GridItem(.flexible(minimum: 0), spacing: 4), count: 7), spacing: 4) {
                ForEach(weekdaySymbols.indices, id: \.self) { index in
                    Text(weekdaySymbols[index]).tw("calendar-weekday w-full text-center h-6")
                        .accessibilityHidden(true)
                }
                ForEach(days, id: \.self) { day in
                    CNCalendarDay(day, calendar: calendar, locale: locale,
                                  isSelected: calendar.isDate(day, inSameDayAs: date),
                                  isToday: calendar.isDateInToday(day),
                                  isOutsideMonth: !calendar.isDate(day, equalTo: month, toGranularity: .month),
                                  classes: dayClasses, focus: $focusedDate) {
                        if let selected = CNCalendarGrid.selection(for: day, in: range, calendar: calendar) { date = selected }
                    }
                    .disabled(CNCalendarGrid.selection(for: day, in: range, calendar: calendar) == nil)
                    .onKeyPress(keys: [.leftArrow, .rightArrow, .upArrow, .downArrow, .pageUp, .pageDown]) { key in
                        moveFocus(from: day, key: key.key); return .handled
                    }
                }
            }
            .id(month)
            .transition(.opacity)
        }
        .tw(cn("calendar", widthClasses, "disclosure-motion", classes), value: month, animationScope: .content)
        .accessibilityElement(children: .contain).accessibilityLabel(Text(title))
        .onChange(of: date) { _, _ in visibleMonth = nil }
        .onChange(of: timeZone) { _, _ in visibleMonth = nil }
        .onChange(of: environmentCalendar) { _, _ in visibleMonth = nil }
        .onChange(of: range) { _, _ in
            visibleMonth = month
            if let focusedDate, CNCalendarGrid.selection(for: focusedDate, in: range, calendar: calendar) == nil {
                self.focusedDate = nil
            }
        }
    }
    private func moveFocus(from day: Date, key: KeyEquivalent) {
        let component: Calendar.Component = key == .pageUp || key == .pageDown ? .month : .day
        let offset: Int
        switch key {
        case .leftArrow: offset = direction == .rightToLeft ? 1 : -1
        case .rightArrow: offset = direction == .rightToLeft ? -1 : 1
        case .upArrow: offset = -7
        case .downArrow: offset = 7
        case .pageUp: offset = -1
        default: offset = 1
        }
        guard let next = calendar.date(byAdding: component, value: offset, to: day),
              CNCalendarGrid.selection(for: next, in: range, calendar: calendar) != nil else { return }
        focusedDate = next
        visibleMonth = CNCalendarGrid.month(containing: next, calendar: calendar)
    }
    private var widthClasses: TWClasses {
        #if os(iOS)
        "w-full max-w-[356]"
        #else
        "w-full max-w-[300]"
        #endif
    }
    private func navigationButton(_ offset: Int, title: LocalizedStringKey, image: String) -> some View {
        CNButton(variant: .outline, size: .icon, classes: "px-0 py-0", action: {
            guard let next = CNCalendarGrid.adjacent(to: month, offset: offset, in: range, calendar: calendar) else { return }
            visibleMonth = next
        }) { Image(systemName: image).tw("text-xs").accessibilityHidden(true) }
            .accessibilityLabel(Text(title))
            .disabled(CNCalendarGrid.adjacent(to: month, offset: offset, in: range, calendar: calendar) == nil)
    }
}

/// Reuse a day cell in a custom calendar layout, or override its shared recipes.
public struct CNCalendarDay: View {
    @Environment(\.isEnabled) private var isEnabled
    @FocusState private var focused: Bool
    private let date: Date
    private let calendar: Calendar
    private let locale: Locale
    private let selected: Bool
    private let today: Bool
    private let outside: Bool
    private let classes: TWClasses
    private let action: () -> Void
    private let externalFocus: Binding<Date?>?
    public init(_ date: Date, calendar: Calendar = .current, locale: Locale = .current,
                isSelected: Bool = false, isToday: Bool = false, isOutsideMonth: Bool = false,
                classes: TWClasses = "", focus: Binding<Date?>? = nil, action: @escaping () -> Void) {
        self.date = date; self.calendar = calendar; self.locale = locale
        selected = isSelected; today = isToday; outside = isOutsideMonth
        self.classes = classes; self.action = action; externalFocus = focus
    }
    private var dayLabel: String {
        date.formatted(Date.FormatStyle(date: .complete, time: .omitted, locale: locale,
                                       calendar: calendar, timeZone: calendar.timeZone))
    }
    public var body: some View {
        Button(action: action) { Text(calendar.component(.day, from: date), format: .number.locale(locale)).tw("w-full") }
            .focusable().focusEffectDisabled().focused($focused)
            .buttonStyle(.tw(cn("calendar-day", cellHeight,
                                outside ? "calendar-outside" : "",
                                today && !selected ? "calendar-today" : "",
                                selected ? "calendar-selected" : "", classes), state: .init(isFocused: focused)))
            .onChange(of: externalFocus?.wrappedValue, initial: true) { _, value in
                focused = value == date
            }
            .onChange(of: focused) { _, value in if value { externalFocus?.wrappedValue = date } }
            .onKeyPress(keys: [.return, .space]) { _ in guard isEnabled else { return .ignored }; action(); return .handled }
            .accessibilityLabel(dayLabel)
            .accessibilityValue(today ? Text("Today") : Text(""))
            .accessibilityAddTraits(selected ? [.isSelected] : [])
    }
    private var cellHeight: TWClasses {
        #if os(iOS)
        "h-[44]"
        #else
        "h-9"
        #endif
    }
}

// Calendar arithmetic uses day addition rather than fixed seconds across DST.
enum CNCalendarGrid {
    static func month(containing date: Date, in range: ClosedRange<Date>, calendar: Calendar) -> Date {
        month(containing: min(max(date, range.lowerBound), range.upperBound), calendar: calendar)
    }
    static func month(containing date: Date, calendar: Calendar) -> Date {
        calendar.dateInterval(of: .month, for: date)?.start ?? calendar.startOfDay(for: date)
    }
    static func days(in month: Date, calendar: Calendar) -> [Date] {
        let start = self.month(containing: month, calendar: calendar)
        let offset = (calendar.component(.weekday, from: start) - calendar.firstWeekday + 7) % 7
        guard let first = calendar.date(byAdding: .day, value: -offset, to: start) else { return [] }
        return (0..<42).compactMap { calendar.date(byAdding: .day, value: $0, to: first) }
    }
    static func selection(for day: Date, in range: ClosedRange<Date>, calendar: Calendar) -> Date? {
        let start = calendar.startOfDay(for: day)
        guard let end = calendar.date(byAdding: .day, value: 1, to: start),
              end > range.lowerBound, start <= range.upperBound else { return nil }
        return max(start, range.lowerBound)
    }
    static func adjacent(to month: Date, offset: Int, in range: ClosedRange<Date>, calendar: Calendar) -> Date? {
        guard let next = calendar.date(byAdding: .month, value: offset, to: self.month(containing: month, calendar: calendar)),
              let interval = calendar.dateInterval(of: .month, for: next),
              interval.end > range.lowerBound, interval.start <= range.upperBound else { return nil }
        return interval.start
    }
}

On this page