SwiftUI Accordion Component

Expand/collapse with the chevron rotating on the same spring as the height change, so the two read as one movement instead of two.

What is included?
Every component, every variant, the full source, and a year of updates.
Commercial use?

Install the free SwiftUI accordion

  1. Add SwiftKitUI.swift to your app once. It holds the tokens and motion curves.
  2. Copy SKAccordion.swift below into your project. It needs nothing else.

Usage

Usage.swift
SKAccordion("What is included?") { Text("Everything.") }
SKAccordion("Advanced", isExpanded: $open) { ... }

Full source · 135 lines · MIT

SKAccordion.swift
//
//  SKAccordion.swift
//  Swift Kit UI
//
//  Requires: SwiftKitUI.swift
//
//  Expand/collapse with the chevron rotating on the same spring as the height
//  change, so the two read as one movement instead of two.
//
//  Usage:
//      SKAccordion("What is included?") { Text("Everything.") }
//      SKAccordion("Advanced", isExpanded: $open) { ... }
//

import SwiftUI

public struct SKAccordion<Content: View>: View {
    private let title: String
    private let systemImage: String?
    private let content: Content

    @Binding private var externalExpanded: Bool
    @State private var internalExpanded: Bool
    private let usesExternal: Bool

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    private var isExpanded: Bool {
        usesExternal ? externalExpanded : internalExpanded
    }

    public init(
        _ title: String,
        systemImage: String? = nil,
        initiallyExpanded: Bool = false,
        @ViewBuilder content: () -> Content
    ) {
        self.title = title
        self.systemImage = systemImage
        self.content = content()
        self._externalExpanded = .constant(false)
        self._internalExpanded = State(initialValue: initiallyExpanded)
        self.usesExternal = false
    }

    public init(
        _ title: String,
        systemImage: String? = nil,
        isExpanded: Binding<Bool>,
        @ViewBuilder content: () -> Content
    ) {
        self.title = title
        self.systemImage = systemImage
        self.content = content()
        self._externalExpanded = isExpanded
        self._internalExpanded = State(initialValue: false)
        self.usesExternal = true
    }

    public var body: some View {
        VStack(spacing: 0) {
            header
            if isExpanded {
                content
                    .font(SK.Type_.callout)
                    .foregroundStyle(SK.Palette.labelSecondary)
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .padding(.horizontal, SK.Space.lg)
                    .padding(.bottom, SK.Space.lg)
                    // Opacity + a slight scale from the top edge, NOT
                    // `.move(edge: .top)`. A move transition slides the content
                    // up THROUGH the header — `.clipped()` on the VStack cannot
                    // help, because the header is inside those same bounds.
                    // The height change alone reads as the expansion.
                    .transition(.opacity.combined(with: .scale(scale: 0.97, anchor: .top)))
            }
        }
        .clipped()
        .skSurface(.bordered, radius: SK.Radius.lg)
        .skAnimation(.settle, value: isExpanded)
    }

    private var header: some View {
        Button(action: toggle) {
            HStack(spacing: SK.Space.md) {
                if let systemImage {
                    Image(systemName: systemImage)
                        .font(SK.Icon.symbol(SK.Icon.md))
                        .foregroundStyle(SK.Palette.accent)
                }
                Text(title)
                    .font(SK.Type_.headline)
                    .foregroundStyle(SK.Palette.label)
                    .multilineTextAlignment(.leading)
                Spacer(minLength: SK.Space.sm)
                Image(systemName: "chevron.down")
                    .font(SK.Icon.symbol(SK.Icon.sm, weight: .semibold))
                    .foregroundStyle(SK.Palette.labelTertiary)
                    .rotationEffect(.degrees(isExpanded ? 180 : 0))
            }
            .padding(SK.Space.lg)
            .frame(minHeight: SK.Icon.hitTarget)
            .contentShape(.rect)
        }
        .buttonStyle(.plain)
        .accessibilityAddTraits(.isButton)
        .accessibilityValue(isExpanded ? "Expanded" : "Collapsed")
    }

    private func toggle() {
        SK.Haptic.play(.light)
        withAnimation(SKMotion.settle.resolved(reduceMotion: reduceMotion)) {
            if usesExternal { externalExpanded.toggle() } else { internalExpanded.toggle() }
        }
    }
}

#Preview("SKAccordion") {
    ScrollView {
        VStack(spacing: SK.Space.md) {
            SKAccordion("What is included?", systemImage: "shippingbox.fill", initiallyExpanded: true) {
                Text("Every component, every variant, the full source, and updates for a year.")
            }
            SKAccordion("Can I use it commercially?", systemImage: "briefcase.fill") {
                Text("Yes. It is MIT licensed, so use it in any app.")
            }
            SKAccordion("Which iOS versions?", systemImage: "iphone") {
                Text("iOS 17 and later. Liquid Glass falls back gracefully below iOS 26.")
            }
        }
        .padding(SK.Space.lg)
    }
    .background(SK.Palette.surfaceSunken)
}

Questions

Is this SwiftUI accordion free for commercial apps?
Yes. Swift Kit UI is MIT licensed, so you can use it in personal and commercial apps with no payment or sign-up. Keep the licence notice with the code.
What does it depend on?
SKAccordion.swift depends only on SwiftKitUI.swift, a single foundation file with the design tokens and motion curves. There are no third-party packages.
Which iOS versions does it support?
iOS 17 and later, plus macOS 14.

Related components

Building a whole app? The Swift Kit adds auth, payments and onboarding, ready to ship.