Swift Charts — Foundations: Building Data Visualizations in SwiftUI


You have the data. Revenue per film, audience ratings over a decade, daily ticket sales across regions. The moment you reach for a third-party charting library, you inherit its update cadence, its API quirks, and its inevitable SwiftUI integration friction. Since iOS 16, Apple ships a declarative, composable charting framework that speaks the same language as the rest of your SwiftUI code.

This post covers the five core mark types in Swift Charts, axis and legend customization, mark composition, and interactive chart selection. We will not cover Chart3D or SurfacePlot — those belong to Swift Charts 3D. Accessibility overlays and VoiceOver integration also deserve their own treatment and are out of scope here.

Contents

The Problem

Suppose you are building a dashboard that shows worldwide box office revenue for Pixar films. A naive approach might involve manually drawing rectangles in a Canvas or GeometryReader, computing positions and scales by hand, and wiring up gesture recognizers for interactivity.

struct ManualBarChart: View {
    let revenues: [(title: String, millions: Double)] = [
        ("Toy Story", 373),
        ("Finding Nemo", 940),
        ("Inside Out", 857),
        ("Coco", 807),
        ("Up", 735)
    ]

    var body: some View {
        GeometryReader { proxy in
            HStack(alignment: .bottom, spacing: 4) {
                ForEach(revenues, id: \.title) { film in
                    let maxRevenue = revenues.map(\.millions).max() ?? 1
                    let height = (film.millions / maxRevenue) * proxy.size.height
                    Rectangle()
                        .fill(.blue)
                        .frame(height: height)
                }
            }
        }
    }
}

This gets the job done for a prototype, but it has real problems. You are responsible for computing the scale, drawing axis labels, handling dynamic type, supporting dark mode, animating data changes, and making the chart accessible. Every new chart type means rewriting the layout logic from scratch. This is exactly the kind of undifferentiated heavy lifting a framework should handle.

Introducing Swift Charts

Swift Charts is Apple’s declarative data visualization framework, introduced at WWDC 2022 in the session Hello Swift Charts. It follows the grammar-of-graphics model: you describe what data to plot and how to encode it visually, and the framework handles layout, scaling, accessibility, and animation.

The fundamental building block is the Chart view. Inside it, you place one or more marks — geometric primitives like bars, lines, and points — each bound to your data through properties like x, y, and foregroundStyle.

Here is the Pixar revenue example rewritten with Swift Charts:

import Charts
import SwiftUI

struct FilmRevenue: Identifiable {
    let id = UUID()
    let title: String
    let revenueMillions: Double
}

let pixarFilms: [FilmRevenue] = [
    .init(title: "Toy Story", revenueMillions: 373),
    .init(title: "Finding Nemo", revenueMillions: 940),
    .init(title: "Inside Out", revenueMillions: 857),
    .init(title: "Coco", revenueMillions: 807),
    .init(title: "Up", revenueMillions: 735)
]

struct RevenueChart: View {
    var body: some View {
        Chart(pixarFilms) { film in
            BarMark(
                x: .value("Film", film.title),
                y: .value("Revenue (M)", film.revenueMillions)
            )
        }
        .frame(height: 300)
    }
}

That is it. No manual layout math. The framework infers axis ranges, labels, grid lines, and accessibility descriptions. Dark mode, dynamic type, and animation all come for free.

Apple Docs: Chart — Swift Charts

The Five Core Mark Types

Swift Charts ships five mark types. Each encodes data in a different visual form. The mark you choose depends on the story your data tells.

BarMark

Bars compare discrete categories. Horizontal bars work well when category labels are long.

Chart(pixarFilms) { film in
    BarMark(
        x: .value("Revenue (M)", film.revenueMillions),
        y: .value("Film", film.title)
    )
    .foregroundStyle(.indigo)
}

Swapping the x and y arguments flips the orientation. The framework handles the axis labels and scale automatically.

LineMark

Lines show trends over a continuous or ordered domain. Here is monthly ticket sales data for a Pixar film across its theatrical run:

struct MonthlySales: Identifiable {
    let id = UUID()
    let month: String
    let tickets: Int
}

let cocoSales: [MonthlySales] = [
    .init(month: "Nov", tickets: 42_000),
    .init(month: "Dec", tickets: 78_000),
    .init(month: "Jan", tickets: 31_000),
    .init(month: "Feb", tickets: 12_000),
    .init(month: "Mar", tickets: 5_000)
]

Chart(cocoSales) { data in
    LineMark(
        x: .value("Month", data.month),
        y: .value("Tickets", data.tickets)
    )
    .interpolationMethod(.catmullRom)
    .symbol(.circle)
}

The .interpolationMethod(_:) modifier controls curve smoothness. .catmullRom gives you a smooth curve through the data points, while .linear (the default) draws straight segments.

PointMark

Points are best for scatter plots where you want to show the distribution of individual observations without implying a continuous relationship.

struct FilmRating: Identifiable {
    let id = UUID()
    let title: String
    let criticScore: Double
    let audienceScore: Double
}

let ratings: [FilmRating] = [
    .init(title: "Ratatouille", criticScore: 96, audienceScore: 87),
    .init(title: "Cars 2", criticScore: 39, audienceScore: 49),
    .init(title: "WALL-E", criticScore: 95, audienceScore: 89),
    .init(title: "Brave", criticScore: 78, audienceScore: 73),
    .init(title: "Soul", criticScore: 95, audienceScore: 88)
]

Chart(ratings) { film in
    PointMark(
        x: .value("Critic Score", film.criticScore),
        y: .value("Audience Score", film.audienceScore)
    )
    .annotation(position: .top) {
        Text(film.title)
            .font(.caption2)
    }
}

The .annotation modifier places labels relative to each point. You can use any SwiftUI view as an annotation.

AreaMark

Area marks fill the region between the data line and the axis, emphasizing volume or magnitude. They pair naturally with line marks.

Chart(cocoSales) { data in
    AreaMark(
        x: .value("Month", data.month),
        y: .value("Tickets", data.tickets)
    )
    .foregroundStyle(.orange.opacity(0.3))

    LineMark(
        x: .value("Month", data.month),
        y: .value("Tickets", data.tickets)
    )
    .foregroundStyle(.orange)
}

This composites an area fill beneath a line, a common pattern for showing cumulative trends with a clear boundary.

RuleMark

Rule marks draw reference lines or thresholds. They are useful for annotating targets, averages, or boundaries.

let averageRevenue = pixarFilms.map(\.revenueMillions).reduce(0, +)
    / Double(pixarFilms.count)

Chart {
    ForEach(pixarFilms) { film in
        BarMark(
            x: .value("Film", film.title),
            y: .value("Revenue (M)", film.revenueMillions)
        )
    }

    RuleMark(y: .value("Average", averageRevenue))
        .foregroundStyle(.red)
        .lineStyle(StrokeStyle(lineWidth: 2, dash: [5, 3]))
        .annotation(position: .top, alignment: .leading) {
            Text("Average: \(Int(averageRevenue))M")
                .font(.caption)
                .foregroundStyle(.red)
        }
}

The dashed red rule mark draws a horizontal line across the chart at the average revenue value, giving viewers an immediate reference point.

Apple Docs: BarMark, LineMark, PointMark, AreaMark, RuleMark — Swift Charts

Mark Composition

One of the strongest design decisions in Swift Charts is that marks are composable. You can layer multiple mark types in the same Chart to create rich, multi-dimensional visualizations.

Consider a chart that shows both the trend line and individual data points for two Pixar film series:

struct SeriesSales: Identifiable {
    let id = UUID()
    let series: String
    let installment: Int
    let revenueMillions: Double
}

let seriesData: [SeriesSales] = [
    .init(series: "Toy Story", installment: 1, revenueMillions: 373),
    .init(series: "Toy Story", installment: 2, revenueMillions: 497),
    .init(series: "Toy Story", installment: 3, revenueMillions: 1067),
    .init(series: "Toy Story", installment: 4, revenueMillions: 1073),
    .init(series: "Cars", installment: 1, revenueMillions: 462),
    .init(series: "Cars", installment: 2, revenueMillions: 562),
    .init(series: "Cars", installment: 3, revenueMillions: 383)
]

Chart(seriesData) { entry in
    LineMark(
        x: .value("Installment", entry.installment),
        y: .value("Revenue (M)", entry.revenueMillions)
    )
    .foregroundStyle(by: .value("Series", entry.series))

    PointMark(
        x: .value("Installment", entry.installment),
        y: .value("Revenue (M)", entry.revenueMillions)
    )
    .foregroundStyle(by: .value("Series", entry.series))
    .symbolSize(60)
}

The .foregroundStyle(by:) modifier tells Swift Charts to split the data by the Series field, automatically assigning distinct colors and generating a legend. Both the line mark and point mark use the same grouping, so they align perfectly.

Tip: When composing marks, keep the x and y property values consistent across mark types. Mismatched property labels will produce separate axes or unexpected layout.

Customizing Axes and Legends

The default axes are solid for quick prototyping, but production charts usually need tighter control over labels, grid lines, and formatting.

Axis Customization

Use .chartXAxis and .chartYAxis to replace the default axis with a custom configuration:

Chart(pixarFilms) { film in
    BarMark(
        x: .value("Film", film.title),
        y: .value("Revenue (M)", film.revenueMillions)
    )
    .foregroundStyle(.teal)
}
.chartYAxis {
    AxisMarks(position: .leading, values: .stride(by: 200)) { value in
        AxisGridLine()
            .foregroundStyle(.gray.opacity(0.3))
        AxisValueLabel {
            if let revenue = value.as(Double.self) {
                Text("$\(Int(revenue))M")
                    .font(.caption)
            }
        }
    }
}
.chartXAxis {
    AxisMarks { value in
        AxisValueLabel()
            .rotated(angle: .degrees(-45))
    }
}

AxisMarks gives you fine-grained control over each grid line, tick, and label. The .stride(by:) value strategy places marks at fixed intervals instead of relying on the framework’s automatic selection.

Legend Customization

By default, Swift Charts generates a legend when you use .foregroundStyle(by:). You can reposition or hide it:

Chart(seriesData) { entry in
    LineMark(
        x: .value("Installment", entry.installment),
        y: .value("Revenue (M)", entry.revenueMillions)
    )
    .foregroundStyle(by: .value("Series", entry.series))
}
.chartLegend(position: .bottom, alignment: .center, spacing: 16)

To hide the legend entirely, use .chartLegend(.hidden). This is useful when the chart is embedded in a context where an external legend or a title already provides the necessary disambiguation.

Apple Docs: AxisMarks, chartLegend(position:alignment:spacing:) — Swift Charts

Interactive Selection

Static charts are fine for reports, but interactive charts are what make dashboards feel alive. Swift Charts integrates with SwiftUI’s .chartXSelection modifier (iOS 17+) to give you tap and drag selection with minimal code.

struct InteractiveRevenueChart: View {
    @State private var selectedFilm: String?

    var body: some View {
        VStack {
            if let selected = selectedFilm,
               let film = pixarFilms.first(where: { $0.title == selected }) {
                Text("\(film.title): $\(Int(film.revenueMillions))M")
                    .font(.headline)
                    .padding(.bottom, 4)
            }

            Chart(pixarFilms) { film in
                BarMark(
                    x: .value("Film", film.title),
                    y: .value("Revenue (M)", film.revenueMillions)
                )
                .foregroundStyle(
                    film.title == selectedFilm ? Color.indigo : Color.indigo.opacity(0.4)
                )
            }
            .chartXSelection(value: $selectedFilm)
            .frame(height: 300)
        }
    }
}

The $selectedFilm binding updates as the user taps or drags across the chart’s x-axis. You use that value to drive a detail view, highlight the selected bar, or filter a companion list. For continuous x-axes (like Date), .chartXSelection returns the nearest data point’s value, which you can snap to a specific entry.

Note: .chartXSelection was introduced in iOS 17. If you need to support iOS 16, fall back to .chartOverlay with a DragGesture and manual hit-testing via ChartProxy.value(atX:). The WWDC 2023 session Explore pie charts and interactivity in Swift Charts walks through both approaches.

Advanced Usage

Stacked and Grouped Bars

When your data has a categorical breakdown within each bar, Swift Charts supports stacking and grouping out of the box.

struct RegionalRevenue: Identifiable {
    let id = UUID()
    let film: String
    let region: String
    let revenueMillions: Double
}

let regionalData: [RegionalRevenue] = [
    .init(film: "Inside Out 2", region: "Domestic", revenueMillions: 652),
    .init(film: "Inside Out 2", region: "International", revenueMillions: 1003),
    .init(film: "Coco", region: "Domestic", revenueMillions: 210),
    .init(film: "Coco", region: "International", revenueMillions: 597)
]

// Stacked (default behavior)
Chart(regionalData) { entry in
    BarMark(
        x: .value("Film", entry.film),
        y: .value("Revenue (M)", entry.revenueMillions)
    )
    .foregroundStyle(by: .value("Region", entry.region))
}

// Grouped — use `position(by:)`
Chart(regionalData) { entry in
    BarMark(
        x: .value("Film", entry.film),
        y: .value("Revenue (M)", entry.revenueMillions)
    )
    .foregroundStyle(by: .value("Region", entry.region))
    .position(by: .value("Region", entry.region)) // ← Side-by-side
}

The difference is a single modifier. .foregroundStyle(by:) alone produces stacked bars. Adding .position(by:) with the same grouping key spreads them side by side.

Custom Color Scales

To override the default palette, use .chartForegroundStyleScale:

Chart(seriesData) { entry in
    LineMark(
        x: .value("Installment", entry.installment),
        y: .value("Revenue (M)", entry.revenueMillions)
    )
    .foregroundStyle(by: .value("Series", entry.series))
}
.chartForegroundStyleScale([
    "Toy Story": Color.blue,
    "Cars": Color.red
])

This maps each series key to an explicit color. It is essential for brand consistency or when you need to match colors to an external design system.

Warning: Avoid using more than 6-7 distinct colors in a single chart. Beyond that threshold, the colors become difficult to distinguish, especially in dark mode or for users with color vision deficiencies. Consider faceting into multiple charts instead.

Date-Based Axes

When your x-axis represents time, Swift Charts can infer temporal units and produce well-formatted date labels:

struct DailyBoxOffice: Identifiable {
    let id = UUID()
    let date: Date
    let grossMillions: Double
}

// Simplified for clarity
let calendar = Calendar.current
let startDate = calendar.date(from: DateComponents(year: 2024, month: 6, day: 14))!

let openingWeek: [DailyBoxOffice] = (0..<7).map { day in
    let date = calendar.date(byAdding: .day, value: day, to: startDate)!
    let gross = [120.0, 45.0, 38.0, 28.0, 22.0, 42.0, 55.0][day]
    return DailyBoxOffice(date: date, grossMillions: gross)
}

Chart(openingWeek) { day in
    LineMark(
        x: .value("Date", day.date, unit: .day),
        y: .value("Gross (M)", day.grossMillions)
    )
    AreaMark(
        x: .value("Date", day.date, unit: .day),
        y: .value("Gross (M)", day.grossMillions)
    )
    .foregroundStyle(.blue.opacity(0.15))
}
.chartXAxis {
    AxisMarks(values: .stride(by: .day)) { _ in
        AxisGridLine()
        AxisValueLabel(format: .dateTime.weekday(.abbreviated))
    }
}

The unit: .day parameter in the .value initializer tells Swift Charts that each data point represents a full day. The axis mark strategy .stride(by: .day) ensures one label per day, formatted with abbreviated weekday names.

Performance Considerations

Swift Charts is built on top of SwiftUI’s rendering pipeline and handles most datasets efficiently. That said, there are limits worth knowing.

For data sets under 1,000 points, performance is not a concern on any modern device. Between 1,000 and 5,000 points, you may notice frame drops during animations, especially with line and area marks that involve path computation. Beyond 5,000 points, you should consider downsampling your data before passing it to the chart.

A practical downsampling strategy for time-series data:

extension Array where Element == DailyBoxOffice {
    /// Reduces the array to at most `maxPoints` entries
    /// by keeping every nth element.
    func downsampled(to maxPoints: Int) -> [Element] {
        guard count > maxPoints else { return self }
        let stride = count / maxPoints
        return Swift.stride(from: 0, to: count, by: stride).map { self[$0] }
    }
}

For scatter plots with thousands of points, .symbolSize affects rendering cost. Smaller symbols render faster. If you need truly large datasets (tens of thousands of points), consider rasterizing the chart with ImageRenderer and displaying the result as a static image, updating only when the data changes.

Tip: Profile your charts with Instruments using the SwiftUI template. Look at the “View Body” track to identify whether the Chart body is being re-evaluated more often than necessary. Extract data transformations out of the body property and into onChange or a view model.

The WWDC 2022 session Raise the bar discusses performance and design best practices for Swift Charts in detail.

When to Use (and When Not To)

Use Swift Charts when:

  • You need standard bar, line, area, or scatter charts in a SwiftUI app. It is the right default for data visualization on Apple platforms.

Consider alternatives when:

  • You must support iOS 15 or earlier. Swift Charts requires iOS 16+. Use a third-party library or a custom Canvas-based solution.
  • You need highly custom or artistic visualizations (infographics, novel chart types). Use custom shapes with Path and Canvas instead.
  • You have real-time streaming data with more than 10,000 points. Downsample first, or use Metal-backed rendering.
  • You need 3D surface plots or volumetric data. See Swift Charts 3D (iOS 26+).
  • You need cross-platform charting (Android, web). Swift Charts is Apple-only.

Swift Charts handles the vast majority of charting needs in production iOS apps. The framework is actively maintained by Apple, receives yearly updates (pie and donut charts arrived in iOS 17, scrollable charts in iOS 17, and 3D charts in iOS 26), and integrates seamlessly with SwiftUI’s layout system, accessibility infrastructure, and animation engine.

Summary

  • Swift Charts is a declarative, composable framework for building data visualizations in SwiftUI, available from iOS 16.
  • The five core mark types — BarMark, LineMark, PointMark, AreaMark, and RuleMark — cover the standard chart forms. Compose them freely in a single Chart.
  • Use .foregroundStyle(by:) and .position(by:) to create grouped, stacked, and multi-series charts with automatic legends.
  • Customize axes with AxisMarks, control legends with .chartLegend, and add interactivity with .chartXSelection (iOS 17+).
  • For datasets over 1,000 points, downsample before plotting. Profile with Instruments if animations feel sluggish.

For three-dimensional data visualization with Chart3D and SurfacePlot, continue to Swift Charts 3D.