Skip to content
Spark Line preview
Rendered from the SwiftUI source on this page.

Spark Line

From syxUI — written for both platforms, not translated between them.

A trend card with a smoothed sparkline, gradient fill, and delta pill.

Charts · SwiftUI · Flutter · iOS 17 · Flutter 3.27

  • chart
  • sparkline
  • trend
  • stats
  • graph
  • dashboard
  • finance

The actual source

SparkLine.swift
// Spark Line · syxUI · https://syxui.dev/components/spark-line
// Free in any project. Keep this line and credit syxUI where a person can read it.

import SwiftUI

/// A compact trend card: sparkline, gradient fill, and a delta pill.
///
/// The path is built once from the values with a Catmull-style smoothing, so
/// the curve stays readable at small sizes without a charting framework.
struct SparkLine: View {
    var title: String = "Revenue"
    var value: String = "$48.2k"
    var delta: String = "+12.4%"
    var isPositive: Bool = true
    var values: [Double] = [12, 18, 14, 22, 19, 27, 24, 33, 30, 38, 36, 47]
    var accent: Color = Color(red: 0.13, green: 0.71, blue: 0.44)

    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            Text(title)
                .font(.system(size: 12, weight: .medium))
                .foregroundStyle(.secondary)

            HStack(alignment: .firstTextBaseline, spacing: 8) {
                Text(value)
                    .font(.system(size: 26, weight: .bold, design: .rounded))
                    .monospacedDigit()

                Text(delta)
                    .font(.system(size: 11, weight: .semibold))
                    .foregroundStyle(accent)
                    .padding(.horizontal, 7)
                    .padding(.vertical, 3)
                    .background(accent.opacity(0.12), in: Capsule())
            }
            .padding(.top, 2)

            chart
                .frame(height: 62)
                .padding(.top, 14)
        }
        .padding(18)
        .frame(width: 250)
        .background(.white, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
        .overlay {
            RoundedRectangle(cornerRadius: 22, style: .continuous)
                .strokeBorder(Color(white: 0.91), lineWidth: 1)
        }
        .accessibilityElement(children: .combine)
        .accessibilityLabel("\(title) \(value), \(delta)")
    }

    private var chart: some View {
        GeometryReader { geo in
            let points = normalised(in: geo.size)

            ZStack {
                // Fill first so the stroke sits cleanly on top of it.
                area(points, height: geo.size.height)
                    .fill(
                        LinearGradient(
                            colors: [accent.opacity(0.22), accent.opacity(0)],
                            startPoint: .top,
                            endPoint: .bottom
                        )
                    )

                line(points)
                    .stroke(accent, style: StrokeStyle(lineWidth: 2.4,
                                                       lineCap: .round,
                                                       lineJoin: .round))

                if let last = points.last {
                    Circle()
                        .fill(accent)
                        .frame(width: 7, height: 7)
                        .position(last)
                }
            }
        }
    }

    /// Map values into the drawing rect, flipping y so larger reads as higher.
    private func normalised(in size: CGSize) -> [CGPoint] {
        guard values.count > 1 else { return [] }
        let lowest = values.min() ?? 0
        let highest = values.max() ?? 1
        let span = max(highest - lowest, 0.0001)
        let step = size.width / CGFloat(values.count - 1)
        let inset: CGFloat = 4

        return values.enumerated().map { index, value in
            let ratio = (value - lowest) / span
            return CGPoint(
                x: CGFloat(index) * step,
                y: inset + (1 - ratio) * (size.height - inset * 2)
            )
        }
    }

    private func line(_ points: [CGPoint]) -> Path {
        Path { path in
            guard let first = points.first else { return }
            path.move(to: first)
            for (index, point) in points.enumerated().dropFirst() {
                let previous = points[index - 1]
                let midX = (previous.x + point.x) / 2
                path.addCurve(
                    to: point,
                    control1: CGPoint(x: midX, y: previous.y),
                    control2: CGPoint(x: midX, y: point.y)
                )
            }
        }
    }

    private func area(_ points: [CGPoint], height: CGFloat) -> Path {
        var path = line(points)
        guard let first = points.first, let last = points.last else { return path }
        path.addLine(to: CGPoint(x: last.x, y: height))
        path.addLine(to: CGPoint(x: first.x, y: height))
        path.closeSubpath()
        return path
    }
}

#Preview {
    SparkLine()
        .padding(30)
}
iOS 17 · No dependencies

SwiftUI note. No Swift Charts dependency. Curves are cubic segments with midpoint controls, which stays smooth without overshooting between points.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27