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
- Introducing Chart3D
- Building a SurfacePlot
- 3D Point and Line Plots
- Styling, Axes, and Legends
- Advanced Usage
- Performance Considerations
- When to Use (and When Not To)
- Summary
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:
Chart3Drequires 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 space — LinePlot3D 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
Chart3Dbrings three-dimensional plotting to Swift Charts with the same declarative API you already use for 2D charts.SurfacePlotrenders continuous surfaces from functions or discrete grids, with automatic tessellation, lighting, and gradient mapping.PointPlot3DandLinePlot3Dhandle 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.