skills/swiftui/charts-3d/SKILL.md
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.
npx skillsauth add rshankras/claude-code-apple-skills charts-3dInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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:
import 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:
import 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:
import 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)
}
}
SurfacePlot(x: "X", y: "Y", z: "Z", function: { x, z in x * z })
.foregroundStyle(.blue)
SurfacePlot(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:
SurfacePlot(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:
SurfacePlot(x: "X", y: "Y", z: "Z", function: { x, z in sin(x) * cos(z) })
.foregroundStyle(Chart3DSurfaceStyle.normalBased)
Control how shiny or matte the surface appears. A value of 0 is perfectly smooth (reflective), and 1 is fully rough (matte):
SurfacePlot(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:
.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):
.chart3DPose(
Chart3DPose(azimuth: .degrees(45), inclination: .degrees(30))
)
// ✅ Read-only — user cannot rotate the chart
.chart3DPose(Chart3DPose.front)
// ✅ Interactive — user can drag to rotate, pose updates automatically
@State private var pose = Chart3DPose.default
// ...
.chart3DPose($pose)
// ❌ 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:
Chart3D {
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:
import 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:
import 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:
Chart(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):
Chart(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.Automatic scales recalculate on every data update — a filtered dataset makes the whole chart jump. Pin them:
.chartYScale(domain: 0...maxExpectedSales)
.chartXScale(domain: startDate...endDate)
Marks compose freely, so summary statistics are just more marks:
Chart {
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)
}
}
.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):
@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:
if 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.
Chart(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):
Chart(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:
BarMark(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, do NOT create one accessibility element per point — bucket the chart into reasonable intervals and expose one element per interval, each summarizing its bucket (WWDC21 10122). Better navigation and performance, still understandable.
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+):
var 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 the
valueDescriptionProvider output in production.| # | 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 intactdevelopment
US web checkout via the StoreKit External Purchase Link entitlement — currently 0% Apple commission (litigation ongoing), how to ship it safely, and how to architect for a commission flip so a future ruling is a config change, not a rewrite. Use when adding external purchase links, weighing web checkout vs IAP, or planning US-storefront pricing strategy.
tools
Revenue beyond the single-app price tag — own-app bundles, Family Sharing as a conversion lever, cross-developer bundles & suites, and institutional licensing via Group Purchases / Apple School & Business Manager. Use when a developer has multiple apps, a subscription worth sharing, complementary indie partners, or school/clinic/business buyers.
testing
Run a structured accessibility audit on an iOS/macOS app — automated XCUITest audits, Accessibility Inspector, manual VoiceOver/Dynamic Type passes, and App Store Accessibility Nutrition Label evaluation. Use before release, when preparing Nutrition Label declarations, or for EU Accessibility Act compliance.
tools
Stage-by-stage audit of an app's App Store growth machinery against a 54-item P0–P9 playbook — every item scored from an App Store Connect MCP call, a codebase check, or an explicit question to the user, then routed to the skill or command that fixes it. Read-only on App Store Connect. Use for a growth audit or scorecard, a pre-launch growth plan, a quarterly re-audit, or "which growth levers am I missing."