Skip to content
Metric Expand preview
An animated render of the SwiftUI source on this page.

Metric Expand

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

Interactive

A small metric tile that opens into a full sparkline, the tiny bars becoming the first points.

Cards · SwiftUI · Flutter · iOS 17 · Flutter 3.27

  • card
  • metric
  • expand
  • sparkline
  • chart
  • morph
  • stat
  • dashboard

The actual source

MorphMetricExpand.swift
// Metric Expand · syxUI · https://syxui.dev/components/morph-metric-expand
// Free in any project. Keep this line and credit syxUI where a person can read it.

import SwiftUI

/// A small metric tile that opens into a full sparkline, its tiny preview
/// bars becoming the first points of the line.
///
/// One `p` drives four overlapping motions: the frame grows, the value text
/// scales up, three mini bars morph into the chart's first three points, and
/// the line trims in behind them. Nothing pops in or out — the bars are the
/// same shapes that end up as the chart's dots, just interpolated.
struct MorphMetricExpand: View {
    var accent: Color = Color(red: 0.086, green: 0.639, blue: 0.290)
    var fillColor: Color = Color(red: 0.086, green: 0.639, blue: 0.290)
    var surface: Color = Color(white: 1.0)
    var textColor: Color = Color(red: 0.078, green: 0.086, blue: 0.109)
    var points: Int = 60
    var lineWidth: CGFloat = 2
    /// Preview override. 0 is the closed tile, 1 the open chart.
    var progress: Double? = nil

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var interactive: Double = 0

    private var p: Double { progress ?? interactive }

    private let closedSize = CGSize(width: 168, height: 110)
    private let openSize = CGSize(width: 340, height: 230)

    private var growth: Double { ramp(p, 0, 0.55) }
    private var barMorph: Double { ramp(p, 0.35, 0.60) }
    private var lineDraw: Double { ramp(p, 0.45, 1.0) }
    private var fillFade: Double { ramp(p, 0.70, 1.0) }

    private var size: CGSize {
        CGSize(
            width: lerp(closedSize.width, openSize.width, growth),
            height: lerp(closedSize.height, openSize.height, growth)
        )
    }

    private var chartInsetTop: CGFloat { lerp(0, 80, growth) }
    private var chartInsetBottom: CGFloat { lerp(0, 26, growth) }
    private let chartInsetSides: CGFloat = 16

    /// A deterministic, gently rising series — enough to read as "real" data
    /// without needing a network call for a component demo.
    private var series: [Double] {
        let n = max(points, 4)
        let raw = (0..<n).map { i -> Double in
            let x = Double(i) / Double(n - 1)
            return 0.5 + sin(x * .pi * 2.4) * 0.28 + sin(x * .pi * 5.1 + 1.3) * 0.12 + x * 0.14
        }
        let lo = raw.min() ?? 0
        let hi = raw.max() ?? 1
        let span = max(hi - lo, 0.0001)
        return raw.map { 0.08 + 0.84 * (($0 - lo) / span) }
    }

    private func allPoints(in size: CGSize) -> [CGPoint] {
        let values = series
        let n = values.count
        let usableWidth = size.width - chartInsetSides * 2
        let usableHeight = size.height - chartInsetTop - chartInsetBottom
        return values.enumerated().map { index, value in
            CGPoint(
                x: chartInsetSides + usableWidth * CGFloat(index) / CGFloat(max(n - 1, 1)),
                y: chartInsetTop + usableHeight * CGFloat(1 - value)
            )
        }
    }

    private func linePath(from pts: [CGPoint]) -> Path {
        Path { path in
            guard var previous = pts.first else { return }
            path.move(to: previous)
            for point in pts.dropFirst() {
                let mid = CGPoint(x: (previous.x + point.x) / 2, y: (previous.y + point.y) / 2)
                path.addQuadCurve(to: mid, control: previous)
                previous = point
            }
            if let last = pts.last { path.addLine(to: last) }
        }
    }

    private func areaPath(from pts: [CGPoint], baseline: CGFloat) -> Path {
        var path = linePath(from: pts)
        guard let first = pts.first, let last = pts.last else { return path }
        path.addLine(to: CGPoint(x: last.x, y: baseline))
        path.addLine(to: CGPoint(x: first.x, y: baseline))
        path.closeSubpath()
        return path
    }

    private let barHeights: [CGFloat] = [8, 14, 10]
    private let barAnchorX: [CGFloat] = [0.70, 0.80, 0.90]
    private let barAnchorYBottom: CGFloat = 0.80

    var body: some View {
        let s = size
        let pts = allPoints(in: s)
        let shape = RoundedRectangle(cornerRadius: 20, style: .continuous)

        return ZStack(alignment: .topLeading) {
            shape.fill(surface)
            shape.strokeBorder(Color.black.opacity(0.06))

            areaPath(from: pts, baseline: s.height - chartInsetBottom)
                .fill(
                    LinearGradient(
                        colors: [fillColor.opacity(0.28 * fillFade), fillColor.opacity(0)],
                        startPoint: .top,
                        endPoint: .bottom
                    )
                )

            linePath(from: pts)
                .trim(from: 0, to: lineDraw)
                .stroke(accent, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round))

            ForEach(0..<3, id: \.self) { index in bar(index, target: pts[safe: index] ?? .zero, in: s) }

            axisLabels(in: s).opacity(fillFade)

            Text(displayValue)
                .font(.system(size: 40, weight: .bold))
                .foregroundStyle(textColor)
                .scaleEffect(lerp(26.0 / 40.0, 1.0, growth), anchor: .topLeading)
                .offset(x: lerp(16, 20, growth), y: lerp(16, 18, growth))

            chip.offset(chipOffset(in: s))
        }
        .frame(width: s.width, height: s.height)
        .clipShape(shape)
        .contentShape(shape)
        .onTapGesture(perform: toggle)
        .sensoryFeedback(.impact(flexibility: .soft), trigger: interactive > 0.5)
        .accessibilityElement()
        .accessibilityAddTraits(.isButton)
        .accessibilityLabel("Revenue, \(displayValue), up 12.4 percent")
        .accessibilityValue(p > 0.5 ? "Chart expanded" : "Chart collapsed")
    }

    private let displayValue = "$482.90"

    private func bar(_ index: Int, target: CGPoint, in size: CGSize) -> some View {
        let closed = CGPoint(
            x: barAnchorX[index] * size.width,
            y: barAnchorYBottom * size.height - barHeights[index] / 2
        )
        let x = lerp(closed.x, target.x, barMorph)
        let y = lerp(closed.y, target.y, barMorph)
        let width: CGFloat = lerp(3, 2, barMorph)
        let height: CGFloat = lerp(barHeights[index], 2, barMorph)

        return Capsule(style: .continuous)
            .fill(accent.opacity(lerp(0.5, 1.0, Double(index) / 2)))
            .frame(width: width, height: height)
            .position(x: x, y: y)
            .accessibilityHidden(true)
    }

    private var chip: some View {
        Text("+12.4%")
            .font(.system(size: 12, weight: .semibold))
            .foregroundStyle(.white)
            .padding(.horizontal, 8)
            .padding(.vertical, 4)
            .background(accent, in: Capsule())
            .accessibilityHidden(true)
    }

    private func chipOffset(in size: CGSize) -> CGSize {
        let closed = CGSize(width: 88, height: 22)
        let open = CGSize(width: size.width - 82, height: 16)
        return CGSize(width: lerp(closed.width, open.width, growth), height: lerp(closed.height, open.height, growth))
    }

    private func axisLabels(in size: CGSize) -> some View {
        HStack {
            Text("30d ago")
            Spacer()
            Text("Today")
        }
        .font(.system(size: 10))
        .foregroundStyle(textColor.opacity(0.45))
        .padding(.horizontal, chartInsetSides)
        .frame(width: size.width, alignment: .leading)
        .offset(y: size.height - 18)
        .accessibilityHidden(true)
    }

    private func toggle() {
        guard progress == nil else { return }
        let next: Double = interactive > 0.5 ? 0 : 1
        withAnimation(reduceMotion ? .linear(duration: 0) : .spring(response: 0.42, dampingFraction: 0.85)) {
            interactive = next
        }
    }
}

private extension Array {
    subscript(safe index: Int) -> Element? {
        indices.contains(index) ? self[index] : nil
    }
}

private func clamp(_ value: Double) -> Double { min(max(value, 0), 1) }

private func ramp(_ value: Double, _ from: Double, _ to: Double) -> Double {
    clamp((value - from) / (to - from))
}

private func lerp(_ a: CGFloat, _ b: CGFloat, _ t: Double) -> CGFloat {
    a + (b - a) * CGFloat(t)
}

private func lerp(_ a: Double, _ b: Double, _ t: Double) -> Double {
    a + (b - a) * t
}

#Preview {
    VStack(spacing: 40) {
        MorphMetricExpand()
        MorphMetricExpand(progress: 1.0)
    }
    .padding(40)
}
iOS 17 · No dependencies

SwiftUI note. The three preview bars are the same Capsule shapes that end up as the chart's first three points — their rect lerps toward pointPosition(i:), it never gets swapped for a different view. `trim(from:to:)` on the sparkline Path is what makes the line draw itself.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27