Swift Charts: The Complete Guide to Data Visualization in SwiftUI

Master Swift Charts from basic bar and line charts to SectorMark donuts, scrollable axes, interactive selection, and iOS 18 function plots. Practical SwiftUI code examples for every mark type.

Apple introduced Swift Charts at WWDC22 as a declarative, data-driven charting framework built right into SwiftUI. Instead of wrestling with Core Graphics paths or pulling in third-party dependencies, you can describe your charts the same way you describe your views — with simple, composable Swift code. The framework ships as part of the SDK, so it gets Apple's rendering pipeline, automatic Dark Mode support, Dynamic Type, and solid accessibility through VoiceOver and Audio Graphs for free.

Here's the thing, though: Swift Charts has changed a lot since that initial release.

iOS 16 launched the core framework with six mark types — BarMark, LineMark, AreaMark, PointMark, RuleMark, and RectangleMark. iOS 17 added SectorMark for pie and donut charts, scrollable chart axes, and the chart selection API. And then iOS 18 brought function plots with LinePlot and AreaPlot, vectorized plotting for performance, and the chartGesture modifier for more advanced interaction patterns.

So, let's walk through every major feature across all three releases. Whether you need a simple bar chart or a scrollable, interactive donut chart with function overlays, you'll find practical, compilable examples for each one.

Getting Started with Swift Charts

Swift Charts lives in the Charts framework. Import it alongside SwiftUI, define a data model, and wrap your marks in a Chart view. That's really it.

Here's a complete example that renders a vertical bar chart showing weekly step counts:

import SwiftUI
import Charts

struct StepData: Identifiable {
    let id = UUID()
    let day: String
    let steps: Int
}

let weeklySteps: [StepData] = [
    StepData(day: "Mon", steps: 8_241),
    StepData(day: "Tue", steps: 6_832),
    StepData(day: "Wed", steps: 10_534),
    StepData(day: "Thu", steps: 9_187),
    StepData(day: "Fri", steps: 7_643),
    StepData(day: "Sat", steps: 12_405),
    StepData(day: "Sun", steps: 11_098)
]

struct StepChartView: View {
    var body: some View {
        Chart(weeklySteps) { item in
            BarMark(
                x: .value("Day", item.day),
                y: .value("Steps", item.steps)
            )
            .foregroundStyle(.blue.gradient)
        }
        .frame(height: 300)
        .padding()
    }
}

The Chart view takes a collection and a closure that produces one or more marks for each element. Axis generation, label positioning, scaling — the framework handles all of that automatically.

Understanding Chart Marks

Marks are the visual building blocks of every chart. Swift Charts gives you six mark types in iOS 16, and each one's suited to different kinds of data relationships.

BarMark

BarMark renders vertical or horizontal bars and supports grouping and stacking out of the box. Swap the x and y parameters to flip orientation, and use .foregroundStyle(by:) to automatically group or stack by a categorical dimension.

struct MonthlySales: Identifiable {
    let id = UUID()
    let month: String
    let revenue: Double
    let department: String
}

let salesData: [MonthlySales] = [
    MonthlySales(month: "Jan", revenue: 4_200, department: "Online"),
    MonthlySales(month: "Jan", revenue: 3_100, department: "Retail"),
    MonthlySales(month: "Feb", revenue: 5_800, department: "Online"),
    MonthlySales(month: "Feb", revenue: 2_900, department: "Retail"),
    MonthlySales(month: "Mar", revenue: 6_100, department: "Online"),
    MonthlySales(month: "Mar", revenue: 3_500, department: "Retail")
]

// Stacked bar chart
Chart(salesData) { item in
    BarMark(
        x: .value("Month", item.month),
        y: .value("Revenue", item.revenue)
    )
    .foregroundStyle(by: .value("Department", item.department))
}

// Grouped bar chart — add position modifier
Chart(salesData) { item in
    BarMark(
        x: .value("Month", item.month),
        y: .value("Revenue", item.revenue)
    )
    .foregroundStyle(by: .value("Department", item.department))
    .position(by: .value("Department", item.department))
}

Honestly, the stacked-to-grouped switch being a single modifier is one of those things that makes you appreciate how well this API is designed.

LineMark

LineMark connects data points with a line. You can control the curve shape with .interpolationMethod() and add symbols at each data point with .symbol().

struct TemperatureReading: Identifiable {
    let id = UUID()
    let hour: Int
    let celsius: Double
}

let todayTemps: [TemperatureReading] = [
    .init(hour: 6, celsius: 12.3), .init(hour: 9, celsius: 15.8),
    .init(hour: 12, celsius: 21.4), .init(hour: 15, celsius: 23.1),
    .init(hour: 18, celsius: 19.7), .init(hour: 21, celsius: 14.5)
]

Chart(todayTemps) { reading in
    LineMark(
        x: .value("Hour", reading.hour),
        y: .value("Temp (°C)", reading.celsius)
    )
    .interpolationMethod(.catmullRom)
    .symbol(.circle)
    .lineStyle(StrokeStyle(lineWidth: 2))
}

The .catmullRom interpolation gives you that nice smooth curve without any extra work. If you want sharper transitions between points, try .linear or .stepCenter instead.

AreaMark

AreaMark fills the region between a line and the axis (or between two value boundaries). Stack multiple series to create a stacked area chart. I find it works best when paired with a LineMark on top for clarity:

Chart(todayTemps) { reading in
    AreaMark(
        x: .value("Hour", reading.hour),
        y: .value("Temp (°C)", reading.celsius)
    )
    .foregroundStyle(.orange.opacity(0.3))
    .interpolationMethod(.catmullRom)

    LineMark(
        x: .value("Hour", reading.hour),
        y: .value("Temp (°C)", reading.celsius)
    )
    .foregroundStyle(.orange)
    .interpolationMethod(.catmullRom)
}

PointMark

PointMark plots individual data points as a scatter plot. It pairs well with .foregroundStyle(by:) for categorical coloring and .symbolSize(by:) for bubble charts.

struct ExerciseLog: Identifiable {
    let id = UUID()
    let duration: Double  // minutes
    let calories: Double
    let type: String
}

let logs: [ExerciseLog] = [
    .init(duration: 30, calories: 280, type: "Running"),
    .init(duration: 45, calories: 190, type: "Cycling"),
    .init(duration: 20, calories: 320, type: "Running"),
    .init(duration: 60, calories: 250, type: "Cycling"),
    .init(duration: 25, calories: 350, type: "Running")
]

Chart(logs) { log in
    PointMark(
        x: .value("Duration", log.duration),
        y: .value("Calories", log.calories)
    )
    .foregroundStyle(by: .value("Type", log.type))
    .symbolSize(100)
}

RuleMark

RuleMark draws horizontal or vertical lines across the chart — perfect for thresholds, averages, or reference lines. I use this constantly for things like daily goals or budget limits.

Chart {
    ForEach(weeklySteps) { item in
        BarMark(
            x: .value("Day", item.day),
            y: .value("Steps", item.steps)
        )
    }

    RuleMark(y: .value("Goal", 10_000))
        .foregroundStyle(.red)
        .lineStyle(StrokeStyle(lineWidth: 2, dash: [5, 3]))
        .annotation(position: .top, alignment: .leading) {
            Text("Daily Goal")
                .font(.caption)
                .foregroundStyle(.red)
        }
}

RectangleMark

RectangleMark creates rectangular regions, and it's most commonly used for heatmap-style visualizations where color intensity represents magnitude.

struct HeatmapCell: Identifiable {
    let id = UUID()
    let weekday: String
    let hour: Int
    let value: Double
}

let heatmapData: [HeatmapCell] = [
    .init(weekday: "Mon", hour: 9, value: 0.8),
    .init(weekday: "Mon", hour: 14, value: 0.5),
    .init(weekday: "Tue", hour: 9, value: 0.9),
    .init(weekday: "Tue", hour: 14, value: 0.3),
    .init(weekday: "Wed", hour: 9, value: 0.6),
    .init(weekday: "Wed", hour: 14, value: 0.7)
]

Chart(heatmapData) { cell in
    RectangleMark(
        x: .value("Hour", cell.hour),
        y: .value("Day", cell.weekday)
    )
    .foregroundStyle(by: .value("Intensity", cell.value))
}
.chartForegroundStyleScale(range: Gradient(colors: [.green.opacity(0.2), .green]))

Building Pie and Donut Charts with SectorMark

iOS 17 introduced SectorMark, and honestly, this was probably the most requested feature since Swift Charts launched. You build pie charts by mapping a value to the angle parameter — the framework normalizes everything into a full circle for you. Adding an innerRadius transforms it into a donut chart, and angularInset creates spacing between sectors.

struct ExpenseCategory: Identifiable {
    let id = UUID()
    let name: String
    let amount: Double
}

let expenses: [ExpenseCategory] = [
    ExpenseCategory(name: "Housing", amount: 1_800),
    ExpenseCategory(name: "Food", amount: 650),
    ExpenseCategory(name: "Transport", amount: 320),
    ExpenseCategory(name: "Entertainment", amount: 200),
    ExpenseCategory(name: "Utilities", amount: 180)
]

struct DonutChartView: View {
    var body: some View {
        Chart(expenses) { category in
            SectorMark(
                angle: .value("Amount", category.amount),
                innerRadius: .ratio(0.6),
                angularInset: 1.5
            )
            .cornerRadius(4)
            .foregroundStyle(by: .value("Category", category.name))
        }
        .chartBackground { chartProxy in
            GeometryReader { geometry in
                let frame = geometry[chartProxy.plotFrame!]
                VStack {
                    Text("Total")
                        .font(.callout)
                        .foregroundStyle(.secondary)
                    Text("$3,150")
                        .font(.title2.bold())
                }
                .position(x: frame.midX, y: frame.midY)
            }
        }
        .frame(height: 300)
    }
}

The .chartBackground modifier places a SwiftUI view behind the chart, which is how you add a center label inside a donut. That cornerRadius on each sector? It gives the whole thing a polished, modern look that honestly rivals custom-drawn solutions.

Function Plots with LinePlot and AreaPlot

iOS 18 introduced a fundamentally different way to create charts. Instead of passing discrete data points, you just provide a mathematical function. LinePlot draws the curve; AreaPlot fills the region between two functions.

The framework samples the function using vectorized rendering, which means you get smooth curves without generating thousands of data points yourself. Pretty nice.

LinePlot: Sine and Cosine Comparison

// Available iOS 18+
struct TrigPlotView: View {
    var body: some View {
        Chart {
            LinePlot(x: "Angle", y: "sin(x)") { x in
                sin(x)
            }
            .foregroundStyle(.blue)

            LinePlot(x: "Angle", y: "cos(x)") { x in
                cos(x)
            }
            .foregroundStyle(.red)
        }
        .chartXScale(domain: 0 ... 2 * .pi)
        .chartYScale(domain: -1.5 ... 1.5)
        .chartXAxisLabel("Radians")
        .chartYAxisLabel("Amplitude")
        .frame(height: 300)
    }
}

AreaPlot: Normal Distribution Curve

Use AreaPlot with yStart and yEnd closures to fill the area under a curve, or between two curves. This is great for statistical visualizations.

import Foundation

struct NormalDistributionView: View {
    func gaussian(_ x: Double, mean: Double = 0, sd: Double = 1) -> Double {
        let coefficient = 1.0 / (sd * sqrt(2.0 * .pi))
        let exponent = -pow(x - mean, 2) / (2 * pow(sd, 2))
        return coefficient * exp(exponent)
    }

    var body: some View {
        Chart {
            AreaPlot(x: "x", yStart: "base", yEnd: "density") { x in
                (yStart: 0.0, yEnd: gaussian(x))
            }
            .foregroundStyle(.blue.opacity(0.3))

            LinePlot(x: "x", y: "density") { x in
                gaussian(x)
            }
            .foregroundStyle(.blue)
        }
        .chartXScale(domain: -4 ... 4)
        .chartYScale(domain: 0 ... 0.5)
        .frame(height: 300)
    }
}

The framework handles domain sampling automatically. Constrain the visible range with .chartXScale(domain:) and .chartYScale(domain:). For piecewise functions with undefined regions, just return .nan to create a gap in the curve.

Customizing Chart Appearance

Axis Customization

The .chartXAxis and .chartYAxis modifiers give you full control over grid lines, tick marks, and value labels through the AxisMarks builder. This is where you can really dial in how your chart looks.

Chart(weeklySteps) { item in
    BarMark(
        x: .value("Day", item.day),
        y: .value("Steps", item.steps)
    )
}
.chartYAxis {
    AxisMarks(position: .leading, values: .stride(by: 3000)) { value in
        AxisGridLine()
            .foregroundStyle(.gray.opacity(0.3))
        AxisTick()
            .foregroundStyle(.gray)
        AxisValueLabel {
            if let steps = value.as(Int.self) {
                Text("\(steps / 1000)k")
                    .font(.caption)
            }
        }
    }
}
.chartXAxis {
    AxisMarks { value in
        AxisValueLabel()
            .font(.caption.bold())
    }
}

Colors and Foreground Styles

Apply .foregroundStyle directly on marks for a uniform color, or use .chartForegroundStyleScale to map categories to a specific palette:

Chart(salesData) { item in
    BarMark(
        x: .value("Month", item.month),
        y: .value("Revenue", item.revenue)
    )
    .foregroundStyle(by: .value("Department", item.department))
}
.chartForegroundStyleScale([
    "Online": Color.indigo,
    "Retail": Color.mint
])

Chart Legend

Control legend visibility and positioning with .chartLegend. You can hide it entirely, change its placement, or provide a completely custom legend view.

.chartLegend(position: .bottom, alignment: .center, spacing: 16)

// Or hide the legend
.chartLegend(.hidden)

Annotations

The .annotation modifier on any mark places a SwiftUI view relative to that mark's position. Super useful for labeling specific data points or calling out outliers.

Chart(weeklySteps) { item in
    BarMark(
        x: .value("Day", item.day),
        y: .value("Steps", item.steps)
    )
    .annotation(position: .top) {
        Text("\(item.steps)")
            .font(.caption2)
            .foregroundStyle(.secondary)
    }
}

Chart Background and Plot Style

Use .chartPlotStyle to modify the plot area itself — add a background, border, or tweak the frame:

.chartPlotStyle { plotArea in
    plotArea
        .background(.gray.opacity(0.05))
        .border(.gray.opacity(0.2), width: 1)
}

Making Charts Scrollable

When your dataset is too large to show at once, iOS 17's scrollable charts let users pan through the data. You control the visible window size and can track the scroll position programmatically. This works really well for time-series data spanning weeks or months.

struct DailyRevenue: Identifiable {
    let id = UUID()
    let day: Date
    let amount: Double
}

struct ScrollableChartView: View {
    @State private var scrollPosition: Date = .now

    let revenueData: [DailyRevenue] = {
        let calendar = Calendar.current
        return (0..<30).map { offset in
            let date = calendar.date(
                byAdding: .day, value: -29 + offset, to: .now
            )!
            let amount = Double.random(in: 800...2400)
            return DailyRevenue(day: date, amount: amount)
        }
    }()

    var body: some View {
        Chart(revenueData) { item in
            BarMark(
                x: .value("Day", item.day, unit: .day),
                y: .value("Revenue", item.amount)
            )
            .foregroundStyle(.teal.gradient)
        }
        .chartScrollableAxes(.horizontal)
        .chartXVisibleDomain(length: 3600 * 24 * 7) // 7 days visible
        .chartScrollPosition(x: $scrollPosition)
        .chartScrollTargetBehavior(
            .valueAligned(
                matching: DateComponents(hour: 0),
                majorAlignment: .matching(DateComponents(day: 1))
            )
        )
        .frame(height: 250)
    }
}

A quick note on units: .chartXVisibleDomain(length:) takes a value in the same unit as your x-axis. For dates, that's seconds, so seven days is 3600 * 24 * 7. The .chartScrollTargetBehavior modifier adds snapping so the chart aligns to day boundaries when scrolling stops. And the bound scrollPosition property updates as the user scrolls, so you can display something like "Week of March 1" above the chart.

Adding Interactivity with Selection

iOS 17 introduced .chartXSelection(value:), which handles all gesture recognition and stores the selected x-axis value into a binding. On iOS it's triggered by a tap or drag; on macOS by hover. For range selection, bind to a ClosedRange — the gesture is a two-finger tap on iOS.

struct InteractiveLineChart: View {
    @State private var selectedDate: Date?

    let temperatures: [(date: Date, high: Double)] = {
        let cal = Calendar.current
        return (0..<14).map { offset in
            let date = cal.date(byAdding: .day, value: -13 + offset, to: .now)!
            return (date, Double.random(in: 18...32))
        }
    }()

    var body: some View {
        Chart {
            ForEach(temperatures, id: \.date) { entry in
                LineMark(
                    x: .value("Date", entry.date, unit: .day),
                    y: .value("High °C", entry.high)
                )
                .interpolationMethod(.catmullRom)
                .symbol(.circle)
            }

            if let selectedDate,
               let match = temperatures.first(where: {
                   Calendar.current.isDate($0.date, inSameDayAs: selectedDate)
               }) {
                RuleMark(x: .value("Selected", match.date, unit: .day))
                    .foregroundStyle(.gray.opacity(0.5))
                    .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 2]))
                    .annotation(position: .top, alignment: .center) {
                        VStack(spacing: 4) {
                            Text(match.date.formatted(.dateTime.month().day()))
                                .font(.caption2)
                                .foregroundStyle(.secondary)
                            Text("\(match.high, specifier: "%.1f")°C")
                                .font(.caption.bold())
                        }
                        .padding(6)
                        .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 6))
                    }
            }
        }
        .chartXSelection(value: $selectedDate)
        .frame(height: 280)
    }
}

In iOS 18, the .chartGesture modifier gives you even more control. It hands you a ChartProxy along with any gesture you want, replacing the older .chartOverlay approach.

// iOS 18+ — advanced gesture handling
.chartGesture { proxy in
    SpatialTapGesture()
        .onEnded { value in
            if let date: Date = proxy.value(atX: value.location.x) {
                selectedDate = date
            }
        }
}

Combining Multiple Mark Types

One of the real strengths of Swift Charts is composability. You can layer different mark types in the same Chart to create rich, multi-dimensional visualizations. Marks render in the order they appear, but you can use .zIndex to override that.

struct WeatherDay: Identifiable {
    let id = UUID()
    let date: Date
    let high: Double
    let low: Double
}

struct CompositeChartView: View {
    let forecast: [WeatherDay] = {
        let cal = Calendar.current
        return (0..<7).map { offset in
            let date = cal.date(byAdding: .day, value: offset, to: .now)!
            let high = Double.random(in: 22...30)
            return WeatherDay(date: date, high: high, low: high - Double.random(in: 6...10))
        }
    }()

    var body: some View {
        Chart {
            // Threshold rule (behind everything)
            RuleMark(y: .value("Heat Warning", 28))
                .foregroundStyle(.red.opacity(0.4))
                .lineStyle(StrokeStyle(lineWidth: 1, dash: [6, 3]))
                .zIndex(0)

            // Line connecting high temperatures
            ForEach(forecast) { day in
                LineMark(
                    x: .value("Date", day.date, unit: .day),
                    y: .value("High", day.high)
                )
                .foregroundStyle(.orange)
                .interpolationMethod(.catmullRom)
                .zIndex(1)
            }

            // Points at each data position
            ForEach(forecast) { day in
                PointMark(
                    x: .value("Date", day.date, unit: .day),
                    y: .value("High", day.high)
                )
                .foregroundStyle(.orange)
                .symbolSize(40)
                .zIndex(2)

                PointMark(
                    x: .value("Date", day.date, unit: .day),
                    y: .value("Low", day.low)
                )
                .foregroundStyle(.blue)
                .symbolSize(40)
                .zIndex(2)
            }
        }
        .frame(height: 300)
    }
}

Accessibility in Swift Charts

Swift Charts is accessible by default, which is something I really appreciate about the framework. It automatically generates an accessibility tree from your data, so VoiceOver users can navigate individual marks and hear values without any extra code from you. Audio Graphs — introduced in iOS 15 and fully integrated with Swift Charts — let users play a sonification of the chart where pitch corresponds to value.

That said, you can (and should) improve the default experience by adding custom labels:

BarMark(
    x: .value("Day", item.day),
    y: .value("Steps", item.steps)
)
.accessibilityLabel("\(item.day)")
.accessibilityValue("\(item.steps) steps")

A few accessibility best practices worth keeping in mind:

  • Add an accessibilityLabel to the chart view itself so VoiceOver can identify it (e.g., "Weekly step count chart").
  • Use meaningful labels on marks when the auto-generated description doesn't quite cut it.
  • Test with VoiceOver enabled — navigate through the chart and make sure each data point reads clearly.
  • Don't rely solely on color to convey meaning. Combine color with symbols or annotations for users with color vision deficiencies.

Performance Tips for Large Datasets

Swift Charts handles hundreds of data points without breaking a sweat. But once you're working with thousands or more, you'll want to keep these strategies in mind:

  • Use vectorized plotting (iOS 18+): For mathematical functions, LinePlot and AreaPlot are significantly faster than generating equivalent discrete data. The framework samples the function optimally and uses vectorized rendering under the hood.
  • Leverage scrollable charts: Rather than rendering everything at once, use .chartScrollableAxes with .chartXVisibleDomain to render only the visible subset. It's basically lazy loading for charts.
  • Go easy on per-mark customization: Applying .annotation or complex .foregroundStyle computations to thousands of marks adds overhead. Use static styles and reserve annotations for selected items only.
  • Downsample when it makes sense: If you've got per-second data but you're showing a year-long trend, aggregate into daily averages first. The screen physically can't display millions of distinct bars anyway.
  • Profile with Instruments: Use the SwiftUI Instruments template to spot expensive view updates. Watch for unnecessary chart redraws triggered by unrelated state changes.

Frequently Asked Questions

What iOS version do I need for Swift Charts?

The core framework requires iOS 16 or later (also macOS 13, watchOS 9, or tvOS 16). Pie and donut charts via SectorMark, scrollable axes, and the selection API need iOS 17. Function plots with LinePlot and AreaPlot, plus the chartGesture modifier, need iOS 18.

Can I create pie charts with Swift Charts?

Yes — starting with iOS 17. Use SectorMark with the angle parameter for a pie chart, and add innerRadius to make it a donut. Your values don't need to sum to 360 degrees; the framework proportions them into a full circle automatically.

How do I make a Swift Chart scrollable?

Apply .chartScrollableAxes(.horizontal) to the Chart view, then use .chartXVisibleDomain(length:) to set how much data is visible at once. Bind the scroll position with .chartScrollPosition(x:) for programmatic control. Available from iOS 17.

Does Swift Charts support real-time data updates?

It does. Since Swift Charts is built on SwiftUI, it reacts to state changes automatically. When your @State, @Observable, or @Published data changes, the chart re-renders with smooth animations. For high-frequency updates, throttle your data source to avoid excessive redraws, and use .chartYScale(domain:) to prevent axis jitter.

How do I add interactivity to Swift Charts?

On iOS 17+, apply .chartXSelection(value:) with a @State binding. The framework handles gesture recognition and updates the binding with the selected axis value. Use that value to conditionally show a RuleMark or annotation overlay. For more advanced gestures on iOS 18, reach for .chartGesture, which gives you a ChartProxy for coordinate conversion.

About the Author Editorial Team

Our team of expert writers and editors.