Skip to content
Components PulseChart

PulseChart

Native charts with a quiet visual language.

Data Display
interactive demo
Revenue$128K+12.4%

A live browser stand-in using the exact tokens, motion and API of the SwiftUI component.

Native charts with a quiet visual language.

Swift Charts-backed line, area and bar visualisations with semantic accessibility summaries and predictable states.

Line, area and bar styles
Native Swift Charts
Accessible data summary
Loading / empty / error states
Usage
PulseChart("Revenue", points: points, style: .area)
Install
$ pulse add chart

Source files

Sources/PulseUI/Components/PulseChart/PulseChart.swift
Full source Sources/PulseUI/Components/PulseChart/PulseChart.swift
import Charts
import SwiftUI

// MARK: - PulseChart

public struct PulseChartPoint: Identifiable, Sendable, Equatable {
    public let id: String
    public let label: String
    public let value: Double

    public init(id: String, label: String, value: Double) {
        self.id = id
        self.label = label
        self.value = value
    }
}

/// Swift Charts-backed visualisation with native accessibility and state handling.
public struct PulseChart: View {
    @Environment(\.pulseTheme) private var theme
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    public enum Style: Sendable, Hashable {
        case line
        case area
        case bar
    }

    public enum State: Equatable, Sendable {
        case loaded
        case loading
        case empty(String = "No data available")
        case error(String = "Unable to load chart")
    }

    private let title: String
    private let points: [PulseChartPoint]
    private let style: Style
    private let state: State

    public init(
        _ title: String,
        points: [PulseChartPoint],
        style: Style = .area,
        state: State = .loaded
    ) {
        self.title = title
        self.points = points
        self.style = style
        self.state = state
    }

    public var body: some View {
        Group {
            switch state {
            case .loaded:
                chart
            case .loading:
                ProgressView("Loading chart")
                    .tint(theme.colors.accent)
                    .frame(maxWidth: .infinity, minHeight: 180)
            case .empty(let message), .error(let message):
                ContentUnavailableView(message, systemImage: "chart.xyaxis.line")
                    .frame(maxWidth: .infinity, minHeight: 180)
            }
        }
        .animation(reduceMotion ? .none : theme.motion.normal, value: state)
        .accessibilityElement(children: .ignore)
        .accessibilityLabel(title)
        .accessibilityValue(accessibilitySummary)
    }

    private var chart: some View {
        Chart(points) { point in
            switch style {
            case .line:
                LineMark(
                    x: .value("Category", point.label),
                    y: .value("Value", point.value)
                )
                .interpolationMethod(.catmullRom)
                .foregroundStyle(theme.colors.accent)
                PointMark(
                    x: .value("Category", point.label),
                    y: .value("Value", point.value)
                )
                .foregroundStyle(theme.colors.accent)
            case .area:
                AreaMark(
                    x: .value("Category", point.label),
                    y: .value("Value", point.value)
                )
                .interpolationMethod(.catmullRom)
                .foregroundStyle(
                    LinearGradient(
                        colors: [theme.colors.accent.opacity(0.7), theme.colors.accent.opacity(0.05)],
                        startPoint: .top,
                        endPoint: .bottom
                    )
                )
                LineMark(
                    x: .value("Category", point.label),
                    y: .value("Value", point.value)
                )
                .interpolationMethod(.catmullRom)
                .foregroundStyle(theme.colors.accent)
            case .bar:
                BarMark(
                    x: .value("Category", point.label),
                    y: .value("Value", point.value)
                )
                .foregroundStyle(theme.colors.accent.gradient)
                .cornerRadius(5)
            }
        }
        .chartXAxis { AxisMarks(values: .automatic) }
        .chartYAxis { AxisMarks(position: .leading) }
        .chartPlotStyle { plot in
            plot
                .background(theme.colors.backgroundSecondary.opacity(0.35))
                .clipShape(RoundedRectangle(cornerRadius: theme.radius.md, style: .continuous))
        }
        .padding(theme.spacing.sm)
    }

    private var accessibilitySummary: String {
        guard let first = points.first, let last = points.last else { return "No values" }
        return "\(points.count) values, from \(first.value.formatted()) to \(last.value.formatted())"
    }
}