Swift Charts 3D: `Chart3D` and `SurfacePlot` for Three-Dimensional Data


You have been charting bar marks and line marks for years, but the moment your data gains a third dimension — terrain elevation, animation render heat maps, multi-variable financial models — flat 2D charts start hiding the story. iOS 26 introduces Chart3D and SurfacePlot, giving Swift Charts a proper Z-axis with built-in rotation, perspective gestures, and the same declarative syntax you already know.

This post covers Chart3D, SurfacePlot, PointPlot3D, and LinePlot3D with production-ready examples. We will not cover visionOS volumetric rendering or RealityKit integration — those deserve their own treatment.

Contents

The Problem

Imagine you are building an internal dashboard for a Pixar render farm. Each frame in a scene has two spatial coordinates (X and Y resolution bucket) and a render-time value (Z). Plotting this in 2D forces you into a heat map or a series of stacked line charts, neither of which lets the viewer intuitively grasp peaks and valleys.

import Charts
import SwiftUI

struct RenderBucket: Identifiable {
    let id = UUID()
    let xBucket: Int
    let yBucket: Int
    let renderTimeMs: Double
}

// Flattening 3D data into a 2D heat map loses the depth story
struct RenderHeatMap: View {
    let buckets: [RenderBucket]

    var body: some View {
        Chart(buckets) { bucket in
            RectangleMark(
                x: .value("X", bucket.xBucket),
                y: .value("Y", bucket.yBucket)
            )
            .foregroundStyle(by: .value("Time (ms)", bucket.renderTimeMs))
        }
    }
}

This compiles and renders, but the user has to mentally reconstruct the third dimension from color intensity alone. You lose the intuitive understanding of “this region spikes” that a 3D surface provides. With Chart3D, you can express all three dimensions directly.

Introducing Chart3D

Chart3D is the three-dimensional counterpart to Chart. It accepts 3D plot types — SurfacePlot, PointPlot3D, and LinePlot3D — and provides built-in rotation and perspective gestures out of the box.

Note: Chart3D requires iOS 26 or later. Add @available(iOS 26, *) to any view that uses it.

import Charts
import SwiftUI

@available(iOS 26, *)
struct BasicChart3DView: View {
    var body: some View {
        Chart3D {
            PointPlot3D(sampleRenderBuckets) { bucket in
                .init(
                    x: .value("X Bucket", bucket.xBucket),
                    y: .value("Render Time", bucket.renderTimeMs),
                    z: .value("Y Bucket", bucket.yBucket)
                )
            }
        }
        .chartXAxisLabel("X Resolution")
        .chartYAxisLabel("Render Time (ms)")
        .chartZAxisLabel("Y Resolution")
    }
}

The API mirrors Chart closely. You pass a trailing closure with one or more plot types, and each plot maps data fields to the .value descriptors for the X, Y, and Z axes. Rotation gestures are enabled by default — the user can drag to orbit around the data.

Apple Docs: Chart3D — Swift Charts

Building a SurfacePlot

SurfacePlot is the star of the 3D Charts API. It renders a continuous surface from a grid of (x, z) coordinates, where the Y value represents height. This is exactly what you need for the render farm dashboard.

Defining the Data Function

SurfacePlot can accept either a collection of discrete data points or a continuous function. The function-based approach is ideal when your data maps cleanly to a mathematical expression.

@available(iOS 26, *)
struct RenderTimeSurface: View {
    var body: some View {
        Chart3D {
            SurfacePlot(
                x: "X Bucket",
                y: "Render Time",
                z: "Y Bucket"
            ) { x, z in
                // Simulate render time: peaks at center, lower at edges
                let centerX = x - 0.5
                let centerZ = z - 0.5
                let distance = sqrt(centerX * centerX + centerZ * centerZ)
                return 100 * exp(-3 * distance * distance) + 20
            }
        }
        .chartYScale(domain: 0...120)
        .chartYAxisLabel("Render Time (ms)")
    }
}

The closure receives normalized x and z values in the range [0, 1] and returns the y (height) value. Swift Charts handles tessellation, shading, and lighting automatically.

Using Discrete Data

When your data comes from a server or database rather than a formula, use the collection-based initializer. The data must form a regular grid — every X coordinate should have a value for every Z coordinate.

struct TerrainPoint: Identifiable {
    let id = UUID()
    let row: Int
    let column: Int
    let elevation: Double
}

@available(iOS 26, *)
struct PixarTerrainView: View {
    let terrain: [TerrainPoint] // A regular grid of elevation data

    var body: some View {
        Chart3D {
            SurfacePlot(terrain, x: .value("Column", \.column),
                        y: .value("Elevation", \.elevation),
                        z: .value("Row", \.row))
        }
        .chartYAxisLabel("Elevation (m)")
        .foregroundStyle(
            .linearGradient(
                colors: [.blue, .green, .yellow, .red],
                startPoint: .bottom,
                endPoint: .top
            )
        )
    }
}

The gradient maps low elevation to blue and peaks to red, giving an immediate visual cue to the viewer.

Tip: If your data is sparse or irregularly spaced, you will need to interpolate it onto a regular grid before passing it to SurfacePlot. The framework does not perform implicit interpolation.

3D Point and Line Plots

Not every 3D dataset is a surface. Scatter data and trajectories call for PointPlot3D and LinePlot3D.

PointPlot3D

Use PointPlot3D when your data points are independent observations in three-dimensional space. Think of it as a scatter plot with depth.

struct CharacterPosition: Identifiable {
    let id = UUID()
    let name: String
    let x: Double
    let y: Double
    let z: Double
}

let toyStoryCharacters = [
    CharacterPosition(name: "Woody", x: 1, y: 3, z: 2),
    CharacterPosition(name: "Buzz", x: 4, y: 7, z: 5),
    CharacterPosition(name: "Jessie", x: 2, y: 5, z: 8),
    CharacterPosition(name: "Rex", x: 6, y: 2, z: 3),
    CharacterPosition(name: "Hamm", x: 5, y: 4, z: 6),
]

@available(iOS 26, *)
struct ToyBoxScatterView: View {
    var body: some View {
        Chart3D {
            PointPlot3D(toyStoryCharacters) { character in
                .init(
                    x: .value("Stage X", character.x),
                    y: .value("Height", character.y),
                    z: .value("Stage Z", character.z)
                )
            }
            .foregroundStyle(by: .value("Character", character.name))
            .symbolSize(by: .value("Height", character.y))
        }
        .chartXAxisLabel("Stage X")
        .chartYAxisLabel("Height")
        .chartZAxisLabel("Stage Z")
    }
}

Each character’s position on the toy room “stage” is plotted as a colored sphere. The .symbolSize(by:) modifier scales the point size based on height, adding a fourth visual dimension.

LinePlot3D

LinePlot3D connects data points along a path through 3D space. This is useful for trajectories, animation curves, or time-series data with two spatial dimensions.

struct FlightPathPoint: Identifiable {
    let id = UUID()
    let time: Double
    let altitude: Double
    let distance: Double
}

@available(iOS 26, *)
struct BuzzFlightPathView: View {
    let flightPath: [FlightPathPoint]

    var body: some View {
        Chart3D {
            LinePlot3D(flightPath) { point in
                .init(
                    x: .value("Time (s)", point.time),
                    y: .value("Altitude (m)", point.altitude),
                    z: .value("Distance (m)", point.distance)
                )
            }
            .lineStyle(StrokeStyle(lineWidth: 3))
            .foregroundStyle(.green)
        }
        .chartYAxisLabel("Altitude")
        .chartXAxisLabel("Time")
        .chartZAxisLabel("Distance")
    }
}

This traces Buzz Lightyear’s flight path through three-dimensional space — falling with style, naturally.

Styling, Axes, and Legends

Chart3D supports most of the same styling modifiers you know from 2D charts, plus a few 3D-specific options.

Foreground Styles and Color Mapping

Apply .foregroundStyle to individual plots or the entire chart. For surfaces, gradients map to the Y (height) axis by default.

@available(iOS 26, *)
struct StyledSurfaceView: View {
    var body: some View {
        Chart3D {
            SurfacePlot(x: "X", y: "Height", z: "Z") { x, z in
                sin(x * .pi * 2) * cos(z * .pi * 2) * 50 + 50
            }
            .foregroundStyle(
                .linearGradient(
                    colors: [.blue, .cyan, .green, .yellow, .orange, .red],
                    startPoint: .bottom,
                    endPoint: .top
                )
            )
        }
        .chartSurfaceOpacity(0.85)
    }
}

The .chartSurfaceOpacity modifier controls the translucency of the surface, letting users see through peaks to the data behind them.

Axis Configuration

Configure each axis independently using the familiar chart axis modifiers. The Z-axis works identically to X and Y.

@available(iOS 26, *)
struct ConfiguredAxesView: View {
    var body: some View {
        Chart3D {
            // ... plot content
        }
        .chartXAxis {
            AxisMarks(values: .stride(by: 10)) { value in
                AxisGridLine()
                AxisValueLabel()
            }
        }
        .chartYAxis {
            AxisMarks(position: .leading)
        }
        .chartZAxis {
            AxisMarks(values: .automatic(desiredCount: 5))
        }
    }
}

Tip: Keep axis label counts low in 3D charts. Too many labels create visual clutter when the chart is rotated. Aim for 4-6 labels per axis.

Camera and Perspective

Control the initial viewing angle and projection style with the .chart3DCamera and .chart3DProjection modifiers.

@available(iOS 26, *)
struct CameraControlView: View {
    var body: some View {
        Chart3D {
            // ... plot content
        }
        .chart3DCamera(.init(azimuth: .degrees(45), elevation: .degrees(30)))
        .chart3DProjection(.perspective)
    }
}

The azimuth rotates the camera around the vertical axis, while elevation controls the viewing angle from above. .perspective adds natural depth foreshortening; use .orthographic for technical or engineering visualizations where preserving parallel lines matters.

Advanced Usage

Combining Multiple Plot Types

You can layer multiple 3D plot types within a single Chart3D, just as you layer marks in a 2D Chart.

@available(iOS 26, *)
struct CombinedPlotView: View {
    let surface: (Double, Double) -> Double
    let outliers: [CharacterPosition]

    var body: some View {
        Chart3D {
            // Base surface showing expected render times
            SurfacePlot(x: "X", y: "Time", z: "Z", function: surface)
                .foregroundStyle(.blue.opacity(0.6))

            // Outlier points that deviate from the surface
            PointPlot3D(outliers) { point in
                .init(
                    x: .value("X", point.x),
                    y: .value("Time", point.y),
                    z: .value("Z", point.z)
                )
            }
            .foregroundStyle(.red)
            .symbolSize(80)
        }
        .chartSurfaceOpacity(0.5)
    }
}

This pattern is powerful for anomaly detection — the surface represents the expected baseline, and the red points highlight data that deviates from it. The .chartSurfaceOpacity modifier makes the surface translucent so points behind it remain visible.

Gesture Control and Interaction

Rotation gestures come enabled by default. You can disable or customize them.

@available(iOS 26, *)
struct InteractiveChartView: View {
    @State private var selectedPoint: CharacterPosition?

    var body: some View {
        Chart3D {
            PointPlot3D(toyStoryCharacters) { character in
                .init(
                    x: .value("X", character.x),
                    y: .value("Height", character.y),
                    z: .value("Z", character.z)
                )
            }
        }
        .chart3DRotation(.enabled)  // Default; explicit for clarity
        .chart3DSelection(value: $selectedPoint)
        .overlay(alignment: .topLeading) {
            if let point = selectedPoint {
                Text(point.name)
                    .padding(8)
                    .background(.ultraThinMaterial, in: .capsule)
            }
        }
    }
}

Warning: 3D selection hit-testing is more expensive than 2D. If your chart contains thousands of points, consider debouncing selection updates or using a simplified hit-test geometry.

Animating Surface Data

You can animate transitions between surface states using standard SwiftUI animation.

@available(iOS 26, *)
struct AnimatedSurfaceView: View {
    @State private var wavePhase: Double = 0

    var body: some View {
        Chart3D {
            SurfacePlot(x: "X", y: "Height", z: "Z") { x, z in
                sin((x + wavePhase) * .pi * 4) * cos(z * .pi * 4) * 30 + 50
            }
        }
        .onAppear {
            withAnimation(.linear(duration: 3).repeatForever(autoreverses: false)) {
                wavePhase = 2
            }
        }
    }
}

The surface re-evaluates its function on each animation frame. This works well for small-to-medium grid resolutions but can become expensive at high tessellation levels — see the performance section below.

Performance Considerations

3D rendering is inherently more expensive than 2D. Here are the key factors that affect Chart3D performance.

Grid resolution matters most. SurfacePlot tessellates the surface into a grid of triangles. The default resolution adapts to the view size, but you can control it explicitly with .chartSurfaceResolution. A 100x100 grid produces 20,000 triangles — fine on modern hardware. A 500x500 grid produces 500,000 triangles and will cause frame drops on older devices.

SurfacePlot(x: "X", y: "Y", z: "Z") { x, z in
    computeHeight(x, z)
}
.chartSurfaceResolution(x: 80, z: 80) // 6,400 triangles -- smooth and fast

Point count in PointPlot3D. Each point is rendered as a 3D sphere with its own geometry. Stay under 5,000 points for smooth interaction. For larger datasets, consider aggregating into buckets or using a SurfacePlot instead.

Gesture frame budget. Rotation gestures require re-rendering the scene every frame at 60 or 120 fps. Heavy surface functions (network calls, complex math) should be precomputed into an array and passed as discrete data rather than evaluated on the fly.

Profiling. Use Instruments with the Core Animation and Metal System Trace templates to profile 3D chart performance. Look for GPU-bound frames and excessive vertex shader invocations.

Apple Docs: SurfacePlot — Swift Charts

When to Use (and When Not To)

Three continuous variables forming a surface (elevation, heat maps, probability distributions) — SurfacePlot is the natural fit. The built-in tessellation and lighting handle the heavy lifting.

Sparse 3D scatter data (fewer than 5,000 points) — PointPlot3D with rotation gestures gives intuitive exploration.

Trajectories or paths through 3D spaceLinePlot3D connects sequential points clearly.

Two-variable data that maps well to color — Stick with a 2D Chart and use .foregroundStyle(by:) for the third variable. Simpler and more accessible.

Datasets larger than 10,000 discrete points — Pre-aggregate into a surface or use a 2D projection. 3D scatter at this scale hurts both performance and readability.

Accessibility-critical contexts — 2D charts have better VoiceOver support today. Chart3D accessibility is still maturing. Provide a 2D fallback or data table.

visionOS volumetric experiences — Use RealityKit and Model3D instead. Chart3D renders into a flat view, not a volumetric window.

The golden rule: use 3D when the third dimension carries genuine information, not just for visual flair. If your Z-axis is decorative, a 2D chart with color encoding will be clearer and more accessible.

Summary

  • Chart3D brings three-dimensional plotting to Swift Charts with the same declarative API you already use for 2D charts.
  • SurfacePlot renders continuous surfaces from functions or discrete grids, with automatic tessellation, lighting, and gradient mapping.
  • PointPlot3D and LinePlot3D handle scatter data and trajectories in 3D space.
  • Built-in rotation and perspective gestures let users explore data interactively with no extra code.
  • Keep grid resolution under 100x100 and point counts under 5,000 for smooth performance on current hardware.

If you want to push your chart styling further with custom drawn elements, check out Custom Shapes in SwiftUI for Path, Shape, and Canvas drawing techniques that complement Chart3D visualizations.