Install any skill in seconds. Free to start, no credit card required.
Get Started Free →3D chart visualization with Swift Charts using Chart3D, SurfacePlot, interactive pose control, and surface styling — plus a 2D Swift Charts construction reference (marks, axes, selection, SectorMark, scrollable charts). Use when creating data visualizations with Swift Charts.
.claude/skills/rshankras-charts-3d/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 17% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 43% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 29% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 182% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 183% | 0% |
Create 3D data visualizations using Chart3D and SurfacePlot. Covers math-driven surfaces, data-driven surfaces, interactive camera pose control, surface styling, and camera projection modes.
Use this skill when the user:
Chart3D, SurfacePlot, or 3D surface plotsWhat 3D chart feature do you need?
|
+-- Visualize a math function f(x, y) -> z
| +-- Use SurfacePlot(x:y:z:function:)
|
+-- Visualize data points as a surface
| +-- Use Chart3D(data) { point in SurfacePlot(...) }
|
+-- Interactive drag-to-rotate
| +-- Bind pose: .chart3DPose($pose) with @State var pose: Chart3DPose
|
+-- Fixed viewing angle (no interaction)
| +-- Read-only pose: .chart3DPose(Chart3DPose.front) or custom
|
+-- Style the surface color
| +-- Solid color -> .foregroundStyle(Color.blue)
| +-- Gradient -> .foregroundStyle(LinearGradient(...))
| +-- Height-based -> .foregroundStyle(.heightBased(gradient, yRange:))
| +-- Normal-based -> .foregroundStyle(.normalBased)
|
+-- Camera projection
| +-- Perspective (depth) -> .chart3DCameraProjection(.perspective)
| +-- Orthographic (flat) -> .chart3DCameraProjection(.orthographic)
| +-- System default -> .chart3DCameraProjection(.automatic)
|
+-- Multiple surfaces in one chart
+-- Place multiple SurfacePlot calls inside a single Chart3D { }| API | Minimum Version | Import | Notes | |-----|----------------|--------|-------| | Chart3D | iOS 26 / macOS 26 | Charts | Main 3D chart container | | SurfacePlot | iOS 26 / macOS 26 | Charts | 3D surface mark | | Chart3DPose | iOS 26 / macOS 26 | Charts | Viewing angle control | | Chart3DCameraProjection | iOS 26 / macOS 26 | Charts | .automatic, .perspective, .orthographic | | Chart3DSurfaceStyle | iOS 26 / macOS 26 | Charts | .heightBased, .normalBased |
Render a surface from a function f(x, y) -> z:
swiftimport SwiftUI import Charts struct WaveSurfaceView: View { var body: some View { Chart3D { SurfacePlot( x: "X", y: "Height", z: "Z", function: { x, z in sin(x) * cos(z) } ) .foregroundStyle(.blue) } } }
Render a surface from an array of data points:
swiftimport SwiftUI import Charts struct DataPoint: Identifiable { let id = UUID() let x: Double let y: Double let z: Double } struct DataSurfaceView: View { let points: [DataPoint] var body: some View { Chart3D(points) { point in SurfacePlot( x: .value("X", point.x), y: .value("Height", point.y), z: .value("Z", point.z) ) } } }
Allow the user to drag to rotate the chart:
swiftimport SwiftUI import Charts struct InteractiveChartView: View { @State private var pose = Chart3DPose.default var body: some View { Chart3D { SurfacePlot( x: "X", y: "Height", z: "Z", function: { x, z in sin(x) * cos(z) } ) .foregroundStyle(.blue) } .chart3DPose($pose) } }
swiftSurfacePlot(x: "X", y: "Y", z: "Z", function: { x, z in x * z }) .foregroundStyle(.blue)
swiftSurfacePlot(x: "X", y: "Y", z: "Z", function: { x, z in x * z }) .foregroundStyle( LinearGradient( colors: [.blue, .green, .yellow], startPoint: .bottom, endPoint: .top ) )
Color the surface based on height values, mapping a gradient across the y-axis range:
swiftSurfacePlot(x: "X", y: "Y", z: "Z", function: { x, z in sin(x) * cos(z) }) .foregroundStyle( Chart3DSurfaceStyle.heightBased( Gradient(colors: [.blue, .cyan, .green, .yellow, .red]), yRange: -1...1 ) )
Color based on surface normals, giving a lighting-aware appearance:
swiftSurfacePlot(x: "X", y: "Y", z: "Z", function: { x, z in sin(x) * cos(z) }) .foregroundStyle(Chart3DSurfaceStyle.normalBased)
Control surface shininess (0 = reflective, 1 = matte):
swiftSurfacePlot(x: "X", y: "Y", z: "Z", function: { x, z in sin(x) * cos(z) }) .foregroundStyle(.blue) .roughness(0.3)
Chart3DPose provides built-in presets for common viewing angles:
swift.chart3DPose(.default) // Standard 3/4 angle .chart3DPose(.front) // Viewing from front .chart3DPose(.back) // Viewing from back .chart3DPose(.top) // Top-down view .chart3DPose(.bottom) // Bottom-up view .chart3DPose(.right) // Right side view .chart3DPose(.left) // Left side view
Specify exact azimuth (horizontal rotation) and inclination (vertical tilt):
swift.chart3DPose( Chart3DPose(azimuth: .degrees(45), inclination: .degrees(30)) )
swift// ❌ Passing a literal where a binding is needed for interactivity .chart3DPose(.default) // This is read-only; drag gestures will not work // ✅ Use a @State binding for interactive rotation @State private var pose = Chart3DPose.default // ... .chart3DPose($pose)
Control how 3D depth is rendered:
swiftChart3D { SurfacePlot(x: "X", y: "Y", z: "Z", function: { x, z in sin(x) * cos(z) }) .foregroundStyle(.blue) } .chart3DCameraProjection(.perspective) // Objects farther away appear smaller // .chart3DCameraProjection(.orthographic) // No perspective distortion // .chart3DCameraProjection(.automatic) // System decides
Render multiple surfaces in a single chart for comparison:
swiftimport SwiftUI import Charts struct ComparisonChartView: View { @State private var pose = Chart3DPose.default var body: some View { Chart3D { SurfacePlot( x: "X", y: "Wave A", z: "Z", function: { x, z in sin(x) * cos(z) } ) .foregroundStyle(.blue.opacity(0.8)) SurfacePlot( x: "X", y: "Wave B", z: "Z", function: { x, z in cos(x) * sin(z) } ) .foregroundStyle(.red.opacity(0.8)) } .chart3DPose($pose) .chart3DCameraProjection(.perspective) } }
A full-featured 3D chart with height-based coloring, interactive rotation, and perspective projection:
swiftimport SwiftUI import Charts struct TerrainView: View { @State private var pose = Chart3DPose( azimuth: .degrees(30), inclination: .degrees(25) ) var body: some View { VStack { Text("Terrain Visualization") .font(.headline) Chart3D { SurfacePlot( x: "Longitude", y: "Elevation", z: "Latitude", function: { x, z in let distance = sqrt(x * x + z * z) return sin(distance) / max(distance, 0.1) } ) .foregroundStyle( Chart3DSurfaceStyle.heightBased( Gradient(colors: [ .blue, .cyan, .green, .yellow, .orange, .red ]), yRange: -0.5...1.0 ) ) .roughness(0.4) } .chart3DPose($pose) .chart3DCameraProjection(.perspective) } .padding() } }
These apply to every chart you build — 2D or 3D. The pillars: focused, approachable, accessible.
The design fundamentals above apply to every chart; this is the API layer for standard 2D charts (iOS 16+ unless noted; selection, SectorMark, and scrolling are iOS 17+).
A Chart is a composition of marks — BarMark, LineMark, PointMark, AreaMark, RuleMark, RectangleMark. The .value("Label", v) factory arguments do double duty: they bind data AND drive the automatic axes and legend, so label them meaningfully:
swiftChart(salesData) { sale in // Identifiable data — no explicit ForEach needed BarMark( x: .value("Day", sale.day, unit: .day), // unit: .day buckets temporal values per day y: .value("Sales", sale.count) ) }
unit: (.day, .month, .hour) controls temporal bucketing; omit it and every timestamp is its own position..foregroundStyle(by:) splits marks into series; pair it with .symbol(by:) so series stay distinguishable without color (WWDC22):
swiftChart(data) { point in LineMark(x: .value("Day", point.day), y: .value("Sales", point.sales)) .foregroundStyle(by: .value("City", point.city)) .symbol(by: .value("City", point.city)) }
.chartForegroundStyleScale(["Cupertino": .indigo, "San Francisco": .teal])..position(by: .value("City", point.city)) converts stacked bars into grouped bars.Pin scales so a filtered dataset doesn't make the whole chart jump:
swift.chartYScale(domain: 0...maxExpectedSales) .chartXScale(domain: startDate...endDate)
Marks compose freely, so summary statistics are just more marks:
swiftChart { ForEach(data) { point in AreaMark( // min–max band x: .value("Day", point.day), yStart: .value("Min", point.min), yEnd: .value("Max", point.max) ) .opacity(0.3) LineMark(x: .value("Day", point.day), y: .value("Average", point.average)) } RuleMark(y: .value("Overall", overallAverage)) // overall average line .foregroundStyle(.secondary) .annotation(position: .top, alignment: .leading) { Text("Avg: \(overallAverage, format: .number.precision(.fractionLength(0)))") .font(.caption) .foregroundStyle(.secondary) } }
swift.chartXAxis { AxisMarks(values: .stride(by: .month)) { value in AxisGridLine() AxisTick() AxisValueLabel(format: .dateTime.month(.narrow)) } } .chartYAxis { AxisMarks(position: .leading) // move the value axis to the leading edge }
value.as(Date.self) in the closure to, say, bold only the first month of each quarter..chartXAxis(.hidden) removes an axis entirely; .chartPlotStyle { $0.frame(height: 200).background(.gray.opacity(0.05)).border(.quaternary) } sizes and styles the plot area itself.Prefer the built-in selection binding over hand-rolled overlay gestures (iOS 17):
swift@State private var selectedDay: Date? Chart(data) { ... } .chartXSelection(value: $selectedDay)
Render the selection as marks — a RuleMark with zIndex(-1) so it draws behind the data, and an annotation that stays inside the plot:
swiftif let selectedDay { RuleMark(x: .value("Selected", selectedDay, unit: .day)) .foregroundStyle(.gray.opacity(0.3)) .zIndex(-1) .annotation( position: .top, spacing: 0, overflowResolution: .init(x: .fit(to: .chart), y: .disabled) ) { SelectionDetailCard(day: selectedDay) } }
For fully custom hit-testing, drop to ChartProxy inside .chartOverlay with a GeometryReader: proxy.value(atX:) converts gesture locations to data values, proxy.position(forX:) converts back.
swiftChart(data) { item in SectorMark( angle: .value("Sales", item.sales), innerRadius: .ratio(0.62), // donut hole angularInset: 1.5 // 1.5 per side = 3pt gaps between sectors ) .cornerRadius(4) .foregroundStyle(by: .value("Name", item.name)) } .chartBackground { proxy in GeometryReader { geo in // headline metric in the donut hole if let anchor = proxy.plotFrame { let frame = geo[anchor] Text("Best: \(topSellerName)") .position(x: frame.midX, y: frame.midY) } } }
Don't cram a year into one screen — show a window and scroll (WWDC23):
swiftChart(yearOfData) { ... } .chartScrollableAxes(.horizontal) .chartXVisibleDomain(length: 3600 * 24 * 30) // 30-day window .chartScrollPosition(x: $scrollDate) // read/write the scroll offset .chartScrollTargetBehavior( .valueAligned( matching: DateComponents(hour: 0), // land on day boundaries majorAlignment: .matching(DateComponents(day: 1)) // snap paging to month starts ) )
Auto-generated VoiceOver descriptions read raw values; per-mark labels beat them:
swiftBarMark(x: .value("Day", sale.day, unit: .day), y: .value("Sales", sale.count)) .accessibilityLabel(sale.day.formatted(date: .abbreviated, time: .omitted)) .accessibilityValue("\(sale.count) pancakes sold")
With hundreds or thousands of points, bucket into reasonable intervals and expose one accessibility element per interval rather than per point (WWDC21 10122).
Audio Graphs let VoiceOver play a data series as a continuous tone — pitch = Y value, time = X position — with an explorer view (rotor → "Audio Graph" → Chart Details) that plays the sonification, scrubs it (double-tap-and-hold; pausing speaks the value at the current position), and shows automatically computed summary statistics. Swift Charts generates a default descriptor; custom-drawn charts conform to AXChart (UIKit) or use the .accessibilityChartDescriptor(_:) modifier with an AXChartDescriptorRepresentable (SwiftUI, iOS 15+):
swiftvar accessibilityChartDescriptor: AXChartDescriptor? { let xAxis = AXNumericDataAxisDescriptor( title: "Cups of coffee", range: 0...10, gridlinePositions: [], // gridlines render as haptics during playback valueDescriptionProvider: { "\(Int($0)) cups" }) // "5 cups", never a bare "5" let yAxis = AXNumericDataAxisDescriptor( title: "Lines of code", range: 0...100, gridlinePositions: [], valueDescriptionProvider: { "\(Int($0)) lines of code" }) let series = AXDataSeriesDescriptor( name: "Productivity", isContinuous: true, // line → one continuous tone; false for bars/points → discrete tones dataPoints: model.points.map { AXDataPoint(x: $0.x, y: $0.y) }) return AXChartDescriptor( title: model.title, summary: model.summary, // 1–2 sentence alt text; spoken in the explorer view xAxis: xAxis, yAxis: yAxis, additionalAxes: [], series: [series]) }
AXCategoricalDataAxisDescriptor; localize and pluralize thevalueDescriptionProvider output in production.
(verify with Accessibility Inspector's Color Contrast Calculator); never pair red + green (the most common color blindness) and avoid blue + yellow (the second most common); use symbols in addition to color so series stay distinguishable with no color perception at all.
| # | Mistake | Fix | |---|---------|-----| | 1 | Forgetting to import Charts | Both SwiftUI and Charts imports are required | | 2 | Using .chart3DPose(.default) and expecting drag-to-rotate | Use a @State binding: .chart3DPose($pose) for interactive rotation | | 3 | Setting yRange that does not cover actual function output | Match the yRange in .heightBased() to the actual min/max of your function output | | 4 | Applying .roughness() without .foregroundStyle() | Roughness modifies existing surface appearance; set a foreground style first | | 5 | Using orthographic projection for presentation/demo contexts | Prefer .perspective for visual appeal; use .orthographic for precise data reading |
import SwiftUI and import Charts are presentChart3D wraps all SurfacePlot contentx:, y:, z:) are descriptive and meaningfulforegroundStyle applied to each SurfacePlot for clear visual distinctionyRange in .heightBased() matches the actual output range of the functionroughness value makes sense for the use case (0 = reflective, 1 = matte)@State binding if drag-to-rotate is intended.perspective for visual, .orthographic for precision).automatic, verified the system choice looks acceptableAXChartDescriptor (Audio Graphs) — or keep Swift Charts' default intactOther measured skills in the registry, with their headline benchmark lift.