Skip to content
ColorTokensKitby Penguin Design Ventures
Contents
How-to

Components and examples

Copy the common mobile components, from buttons to paywalls, each built from tokens for both modes.

Updated View as Markdown

Every component below is built from one theme's tokens, so it follows dark mode and keeps its contrast in any hue. They start small and get more involved.

Small pieces#

Primary button#

Use it for the one main action on a screen: invertedBackgroundPrimary behind invertedForegroundPrimary is the strongest pair a theme has, and soften(by: 2) shows the press. CrosswordChef's primary button works this way.

swift
struct PrimaryButtonStyle: ButtonStyle {
    var theme: ProTheme = Color.proGray

    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .font(.headline)
            .foregroundStyle(theme.invertedForegroundPrimary)
            .frame(maxWidth: .infinity)
            .padding(.vertical, 14)
            .background(
                configuration.isPressed
                    ? theme.invertedBackgroundPrimary.soften(by: 2)
                    : theme.invertedBackgroundPrimary,
                in: .rect(cornerRadius: 14)
            )
    }
}

Button("Book flight") {}
    .buttonStyle(PrimaryButtonStyle(theme: brand))
light mode
Book flight

Secondary button#

Use it beside a primary button for the other choice. It stays gray, with the app tokens on Color, so the brand is left for the primary action: an outlinePrimary border and a foregroundPrimary label, and backgroundSecondary while pressed.

swift
struct SecondaryButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .font(.headline)
            .foregroundStyle(Color.foregroundPrimary)
            .frame(maxWidth: .infinity)
            .padding(.vertical, 14)
            .background(
                configuration.isPressed ? Color.backgroundSecondary : .clear,
                in: .rect(cornerRadius: 14)
            )
            .overlay {
                RoundedRectangle(cornerRadius: 14)
                    .strokeBorder(Color.outlinePrimary, lineWidth: 1.5)
            }
    }
}

Button("Compare fares") {}
    .buttonStyle(SecondaryButtonStyle())
light mode
Compare fares

Theme buttons#

Use a theme's own buttons on a screen that belongs to that theme, like a game mode: bold is invertedBackgroundTertiary with invertedForegroundPrimary, and pastel is surfacePrimary with foregroundSecondary. CrosswordChef's bold and pastel buttons are these two, here in the Mini's cobalt.

swift
let mini = Color.proCobalt

HStack(spacing: 12) {
    Button("Play today") {}
        .foregroundStyle(mini.invertedForegroundPrimary)
        .frame(maxWidth: .infinity)
        .padding(.vertical, 12)
        .background(mini.invertedBackgroundTertiary, in: .capsule)
    Button("Archive") {}
        .foregroundStyle(mini.foregroundSecondary)
        .frame(maxWidth: .infinity)
        .padding(.vertical, 12)
        .background(mini.surfacePrimary, in: .capsule)
}
.font(.headline)
.buttonStyle(.plain)
light mode
Play todayArchive

Filter chips#

Use chips to narrow a list by more than one thing: each filter has its own theme, and a selected chip fills with that theme's backgroundTertiary, with a check and an X to remove it. The rest stay gray, on Color.backgroundSecondary. CrosswordChef's pill selector works the same way.

swift
struct FilterChips: View {
    let filters = [("Flights", Color.proBlue), ("Hotels", Color.proOrange),
                   ("Trains", Color.proGreen), ("Food", Color.proPink)]
    @State private var selected: Set<String> = ["Flights", "Hotels"]

    var body: some View {
        ScrollView(.horizontal, showsIndicators: false) {
            HStack(spacing: 8) {
                ForEach(filters.indices, id: \.self) { chip(filters[$0].0, theme: filters[$0].1) }
            }
        }
        .buttonStyle(.plain)
    }

    private func chip(_ name: String, theme: ProTheme) -> some View {
        let isSelected = selected.contains(name)
        return Button {
            if isSelected { selected.remove(name) } else { selected.insert(name) }
        } label: {
            HStack(spacing: 6) {
                if isSelected { Image(systemName: "checkmark").fontWeight(.bold) }
                Text(name)
                if isSelected {
                    Image(systemName: "xmark")
                        .font(.caption.bold())
                        .foregroundStyle(theme.foregroundSecondary)
                }
            }
            .font(.subheadline.weight(.medium))
            .padding(.horizontal, 14)
            .padding(.vertical, 8)
            .foregroundStyle(isSelected ? theme.foregroundPrimary : Color.foregroundSecondary)
            .background(isSelected ? theme.backgroundTertiary : Color.backgroundSecondary,
                        in: .capsule)
        }
    }
}
light mode
Flights
Hotels
TrainsFood

Count badge#

Use a badge to count what's new on an icon: a capsule from a second theme, here Color.proRed's invertedBackgroundTertiary with its invertedForegroundPrimary, stays readable in both modes.

swift
struct BadgedIcon: View {
    let symbol: String
    let count: Int

    var body: some View {
        Image(systemName: symbol)
            .font(.title2)
            .foregroundStyle(brand.foregroundPrimary)
            .padding(8)
            .overlay(alignment: .topTrailing) {
                Text("\(count)")
                    .font(.caption2.bold())
                    .foregroundStyle(Color.proRed.invertedForegroundPrimary)
                    .padding(.horizontal, 5)
                    .frame(minWidth: 17, minHeight: 17)
                    .background(Color.proRed.invertedBackgroundTertiary, in: .capsule)
            }
    }
}

HStack(spacing: 24) {
    BadgedIcon(symbol: "bell", count: 3)
    BadgedIcon(symbol: "envelope", count: 12)
}
light mode
3
12

Avatar#

Use avatars for people, and overlap them for a group: a photo where there is one, and otherwise the initials in foregroundSecondary on backgroundTertiary, in a hue picked from the name, so each person keeps one color. A ring in Color.backgroundPrimary keeps overlapping faces apart. CrosswordChef's avatars fall back to colored initials too.

swift
struct Avatar: View {
    let name: String
    var photoURL: URL? = nil

    var body: some View {
        // The same name lands on the same one of the 36 hues every time.
        let seed = name.unicodeScalars.reduce(0) { $0 + Int($1.value) }
        let theme = ProTheme.primary(forHue: Double(seed % 36 * 10))

        AsyncImage(url: photoURL) { image in
            image.resizable().scaledToFill()
        } placeholder: {
            Text(name.split(separator: " ").compactMap(\.first).prefix(2).map(String.init).joined())
                .font(.subheadline.weight(.semibold))
                .foregroundStyle(theme.foregroundSecondary)
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .background(theme.backgroundTertiary)
        }
        .frame(width: 40, height: 40)
        .clipShape(.circle)
        .padding(2)
        .background(Color.backgroundPrimary, in: .circle)
    }
}

HStack(spacing: -10) {
    Avatar(name: "Ana Souza", photoURL: URL(string: "https://picsum.photos/seed/ana/80"))
    Avatar(name: "Lea Park")      // hue 200
    Avatar(name: "Sam Okafor", photoURL: URL(string: "https://picsum.photos/seed/sam/80"))
    Avatar(name: "Noor Haddad")   // hue 40
    Avatar(name: "Mia Chen", photoURL: URL(string: "https://picsum.photos/seed/mia/80"))
    Text("+3")
        .font(.subheadline.weight(.semibold))
        .foregroundStyle(Color.foregroundSecondary)
        .frame(width: 40, height: 40)
        .background(Color.backgroundTertiary, in: .circle)
        .padding(2)
        .background(Color.backgroundPrimary, in: .circle)
}
light mode
LP
NH
+3

Checkbox#

Use checkboxes for options that are each on or off: only a checked box takes the theme, filling with invertedBackgroundTertiary under an invertedForegroundPrimary check. An empty box is a gray Color.outlinePrimary square, and the labels stay in Color.foregroundPrimary.

swift
struct Checkbox: View {
    let label: String
    @Binding var isOn: Bool
    var theme: ProTheme = Color.proIndigo

    var body: some View {
        Button { isOn.toggle() } label: {
            HStack(spacing: 12) {
                RoundedRectangle(cornerRadius: 6)
                    .fill(isOn ? theme.invertedBackgroundTertiary : .clear)
                    .strokeBorder(isOn ? .clear : Color.outlinePrimary, lineWidth: 1.5)
                    .frame(width: 22, height: 22)
                    .overlay {
                        Image(systemName: "checkmark")
                            .font(.caption.weight(.heavy))
                            .foregroundStyle(theme.invertedForegroundPrimary)
                            .opacity(isOn ? 1 : 0)
                    }
                Text(label).foregroundStyle(Color.foregroundPrimary)
            }
        }
        .buttonStyle(.plain)
    }
}

VStack(alignment: .leading, spacing: 12) {
    Checkbox(label: "Window seat", isOn: .constant(true))
    Checkbox(label: "Extra legroom", isOn: .constant(false))
}
light mode
Window seat
Extra legroom

Toggle row#

Use a toggle row for a setting that takes effect right away: the row sits on backgroundSecondary, and the switch takes the theme's _500 stop, which keeps the white knob visible in both modes.

swift
Toggle(isOn: .constant(true)) {
    VStack(alignment: .leading, spacing: 2) {
        Text("Price alerts")
            .foregroundStyle(brand.foregroundPrimary)
        Text("When fares to Lisbon drop")
            .font(.subheadline)
            .foregroundStyle(brand.foregroundSecondary)
    }
}
.tint(brand._500.toColor())
.padding()
.background(brand.backgroundSecondary, in: .rect(cornerRadius: 16))
light mode
Price alertsWhen fares to Lisbon drop

Text field#

Use a labeled field for typed input. At rest it's all gray, from the app tokens on Color: a foregroundSecondary label, a foregroundTertiary placeholder and an outlinePrimary border, which reaches the 3:1 a field's edge needs. Only the field with focus takes the brand, as a thicker invertedBackgroundTertiary outline.

swift
struct LabeledField: View {
    let label: String
    let placeholder: String
    @Binding var text: String
    @FocusState private var isFocused: Bool

    var body: some View {
        VStack(alignment: .leading, spacing: 6) {
            Text(label)
                .font(.subheadline.weight(.medium))
                .foregroundStyle(Color.foregroundSecondary)
            TextField(label, text: $text,
                      prompt: Text(placeholder).foregroundStyle(Color.foregroundTertiary))
                .focused($isFocused)
                .foregroundStyle(Color.foregroundPrimary)
                .padding(12)
                .overlay {
                    RoundedRectangle(cornerRadius: 12).strokeBorder(
                        isFocused ? brand.invertedBackgroundTertiary : Color.outlinePrimary,
                        lineWidth: isFocused ? 2 : 1
                    )
                }
        }
    }
}

LabeledField(label: "From", placeholder: "City or airport", text: .constant("Lisbon"))
LabeledField(label: "To", placeholder: "City or airport", text: .constant(""))
light mode
FromLisbon
ToCity or airport

Progress bar#

Use a progress bar for steps toward a goal: the track is backgroundTertiary and the fill invertedBackgroundTertiary. CrosswordChef's progress bar uses the same track.

swift
struct ProgressBar: View {
    let value: Double   // 0 to 1

    var body: some View {
        Capsule()
            .fill(brand.backgroundTertiary)
            .overlay(alignment: .leading) {
                GeometryReader { proxy in
                    Capsule()
                        .fill(brand.invertedBackgroundTertiary)
                        .frame(width: proxy.size.width * value)
                }
            }
            .frame(height: 8)
    }
}

VStack(alignment: .leading, spacing: 8) {
    HStack {
        Text("Packing list")
        Spacer()
        Text("6 of 10").foregroundStyle(brand.foregroundSecondary)
    }
    .font(.subheadline)
    .foregroundStyle(brand.foregroundPrimary)
    ProgressBar(value: 0.6)
}
light mode
Packing list6 of 10

Progress ring#

Use a ring for one number out of a whole, like today's goal. The track is a gray Color.backgroundTertiary, and the arc is an angular gradient whose hue turns once around the circle: rotateHue(by:) turns proPink._450, the palette's most vivid middle, in 60° steps at the same lightness, and proAngularGradient() blends them. CrosswordChef's progress circle has the same shape.

swift
struct ProgressRing: View {
    let value: Double   // 0 to 1

    var body: some View {
        // One lightness, with the hue turning once around the ring.
        let start = Color.proPink._450.toColor()
        let hues = stride(from: 0.0, to: 360, by: 60).map { start.rotateHue(by: .degrees($0)) }

        ZStack {
            Circle()
                .stroke(Color.backgroundTertiary, lineWidth: 8)
            Circle()
                .trim(from: 0, to: value)
                .stroke(hues.proAngularGradient(),
                        style: StrokeStyle(lineWidth: 8, lineCap: .round))
                .rotationEffect(.degrees(-90))
            Text(value, format: .percent.precision(.fractionLength(0)))
                .font(.headline.monospacedDigit())
                .foregroundStyle(Color.foregroundPrimary)
        }
        .frame(width: 72, height: 72)
    }
}

ProgressRing(value: 0.72)
light mode
72%

Rows, cards and bars#

Settings list#

Group settings on a Color.backgroundPrimary card over Color.backgroundSecondary, with outlineTertiary hairlines between rows. Each icon tile takes its row's own theme: a solid backgroundTertiary tile under a foregroundSecondary symbol.

swift
func row(_ icon: String, _ tint: ProTheme, _ label: String, _ value: String) -> some View {
    HStack(spacing: 12) {
        Image(systemName: icon)
            .foregroundStyle(tint.foregroundSecondary)
            .frame(width: 30, height: 30)
            .background(tint.backgroundTertiary, in: .rect(cornerRadius: 7))
        Text(label).foregroundStyle(Color.foregroundPrimary)
        Spacer()
        Text(value).foregroundStyle(Color.foregroundSecondary)
        Image(systemName: "chevron.right").foregroundStyle(Color.foregroundTertiary)
    }
    .padding(.horizontal, 16)
    .frame(height: 52)
}

VStack(spacing: 0) {
    row("bell.fill", Color.proRed, "Notifications", "On")
    Divider().overlay(Color.outlineTertiary).padding(.leading, 58)
    row("airplane", Color.proBlue, "Home airport", "LIS")
    Divider().overlay(Color.outlineTertiary).padding(.leading, 58)
    row("moon.fill", Color.proIndigo, "Appearance", "Auto")
}
.background(Color.backgroundPrimary, in: .rect(cornerRadius: 12))
.padding()
.background(Color.backgroundSecondary)
light mode
NotificationsOn
Home airportLIS
AppearanceAuto

Segmented control#

Switch between a few views of one list. It's navigation, so it stays gray, with the app tokens on Color: a backgroundSecondary track, and the selected segment on backgroundPrimary in light mode and backgroundTertiary in dark, so it sits above the track in both.

swift
struct TripFilter: View {
    @State private var selection = "Upcoming"
    let selectedFill = Color(light: Color.backgroundPrimary, dark: Color.backgroundTertiary)

    var body: some View {
        HStack(spacing: 2) {
            ForEach(["Upcoming", "Past", "Saved"], id: \.self) { option in
                let selected = option == selection
                Button { selection = option } label: {
                    Text(option)
                        .font(.subheadline.weight(.semibold))
                        .foregroundStyle(Color.foregroundPrimary)
                        .frame(maxWidth: .infinity, minHeight: 32)
                        .background(selected ? selectedFill : .clear, in: .rect(cornerRadius: 7))
                        .contentShape(.rect)
                }
            }
        }
        .buttonStyle(.plain)
        .padding(2)
        .background(Color.backgroundSecondary, in: .rect(cornerRadius: 9))
    }
}
light mode
UpcomingPastSaved

Put search at the top of a list, on Color.backgroundSecondary with a foregroundTertiary prompt and a clear button once there's a query.

swift
struct SearchBar: View {
    @State private var query = ""

    var body: some View {
        HStack(spacing: 8) {
            Image(systemName: "magnifyingglass")
                .foregroundStyle(Color.foregroundTertiary)
            TextField("Search", text: $query, prompt: Text("Cities or airports")
                .foregroundStyle(Color.foregroundTertiary))
                .foregroundStyle(Color.foregroundPrimary)
            if !query.isEmpty {
                Button("Clear", systemImage: "xmark.circle.fill") { query = "" }
                    .labelStyle(.iconOnly)
                    .foregroundStyle(Color.foregroundTertiary)
                    .buttonStyle(.plain)
            }
        }
        .padding(.horizontal, 10)
        .frame(height: 40)
        .background(Color.backgroundSecondary, in: .rect(cornerRadius: 10))
    }
}
light mode
Cities or airports
Lisbon

Tinted banner#

Point to something new or time-sensitive with a card in one theme's surfacePrimary and text in its foreground tokens. CrosswordChef's BannerComponent works this way.

swift
struct Banner: View {
    let theme: ProTheme, icon: String, title: String, subtitle: String

    var body: some View {
        HStack(spacing: 12) {
            Image(systemName: icon)
                .font(.title3)
                .foregroundStyle(theme.foregroundSecondary)
            VStack(alignment: .leading, spacing: 2) {
                Text(title).font(.headline).foregroundStyle(theme.foregroundPrimary)
                Text(subtitle).font(.subheadline).foregroundStyle(theme.foregroundSecondary)
            }
            Spacer()
            Image(systemName: "chevron.right").foregroundStyle(theme.foregroundTertiary)
        }
        .padding(14)
        .background(theme.surfacePrimary, in: .rect(cornerRadius: 14))
    }
}

Banner(theme: Color.proGreen, icon: "airplane",
       title: "Check-in is open", subtitle: "Flight TP 1331 leaves at 9:40")
Banner(theme: Color.proGold, icon: "star.fill",
       title: "You earned a free night", subtitle: "Use it before March")
light mode
Check-in is openFlight TP 1331 leaves at 9:40
You earned a free nightUse it before March

Toast#

Confirm an action with a pill in invertedBackgroundPrimary that floats over any screen. The check sits in a circle of proGreen's invertedBackgroundTertiary, drawn in its invertedForegroundPrimary, so the success reads at a glance in both modes. CrosswordChef's toast uses the same inverted pair.

swift
struct Toast: View {
    let message: String

    var body: some View {
        HStack(spacing: 10) {
            Image(systemName: "checkmark")
                .font(.caption.weight(.heavy))
                .foregroundStyle(Color.proGreen.invertedForegroundPrimary)
                .frame(width: 24, height: 24)
                .background(Color.proGreen.invertedBackgroundTertiary, in: .circle)
            Text(message)
                .font(.subheadline.weight(.semibold))
                .foregroundStyle(Color.invertedForegroundPrimary)
        }
        .padding(.vertical, 8)
        .padding(.leading, 8)
        .padding(.trailing, 18)
        .background(Color.invertedBackgroundPrimary, in: .capsule)
        .shadow(color: .black.opacity(0.15), radius: 12, y: 6)
    }
}

Toast(message: "Saved to your trips")
light mode
Saved to your trips

Stat tiles#

Show a few numbers at a glance, each tile a two-color gradient from its theme's surfacePrimary to the surfacePrimary of the hue 40° along, with an icon in the theme's invertedBackgroundTertiary. Every hue has the same lightness at each stop, so the text keeps its contrast across the whole gradient. CrosswordChef's post-game stat cards give each card its own theme too.

swift
struct StatTile: View {
    let value: String, label: String, icon: String, theme: ProTheme

    var body: some View {
        let neighbor = theme.rotateHue(by: .degrees(40))
        VStack(alignment: .leading, spacing: 4) {
            HStack(alignment: .firstTextBaseline) {
                Text(value)
                    .font(.title2.bold().monospacedDigit())
                    .foregroundStyle(theme.foregroundPrimary)
                Spacer()
                Image(systemName: icon).foregroundStyle(theme.invertedBackgroundTertiary)
            }
            Text(label)
                .font(.footnote)
                .foregroundStyle(theme.foregroundSecondary)
        }
        .padding(14)
        .background(
            [theme.surfacePrimary, neighbor.surfacePrimary]
                .proGradient(from: .topLeading, to: .bottomTrailing),
            in: .rect(cornerRadius: 14)
        )
    }
}

Grid(horizontalSpacing: 10, verticalSpacing: 10) {
    GridRow {
        StatTile(value: "12", label: "Countries", icon: "globe", theme: Color.proOrange)
        StatTile(value: "48", label: "Nights away", icon: "moon.fill", theme: Color.proIndigo)
    }
    GridRow {
        StatTile(value: "31k", label: "Kilometers flown", icon: "airplane", theme: Color.proSky)
        StatTile(value: "7", label: "Trips this year", icon: "suitcase.fill", theme: Color.proPink)
    }
}
light mode
12
Countries
48
Nights away
31k
Kilometers flown
7
Trips this year

Content card#

List a trip, a product or an article on a backgroundSecondary card. A photo looks the same in both modes, so its stand-in is a soft .analogous gradient from the fixed stop _300, set 2 points in from the card's edge with a corner radius 2 points smaller, so the corners stay concentric. CrosswordChef's archive cards put a small crossword grid where the photo is.

swift
struct TripCard: View {
    let theme = Color.proCoral

    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            Rectangle()
                .fill(theme._300.proGradient(.analogous))
                .frame(height: 120)
                .overlay {
                    Image(systemName: "photo").font(.largeTitle).foregroundStyle(.white)
                }
                .clipShape(.rect(cornerRadius: 14))
                .padding(2)
            HStack(alignment: .top) {
                VStack(alignment: .leading, spacing: 4) {
                    Text("Lisbon, Portugal").font(.headline)
                    Text("3 nights from October 12").font(.subheadline)
                        .foregroundStyle(theme.foregroundSecondary)
                }
                Spacer()
                VStack(alignment: .trailing, spacing: 4) {
                    Text("€420").font(.headline)
                    Text("total").font(.subheadline).foregroundStyle(theme.foregroundSecondary)
                }
            }
            .foregroundStyle(theme.foregroundPrimary)
            .padding(14)
        }
        .background(theme.backgroundSecondary, in: .rect(cornerRadius: 16))
    }
}
light mode
Lisbon, Portugal3 nights from October 12
€420total

Chat bubbles#

Tell your messages from theirs: yours in the theme's invertedBackgroundTertiary, theirs in Color.backgroundSecondary. CrosswordChef's support chat uses bubbles like these.

swift
struct Bubble: View {
    let text: String
    let isMine: Bool
    let theme = Color.proIris

    var body: some View {
        Text(text)
            .foregroundStyle(isMine ? theme.invertedForegroundPrimary : Color.foregroundPrimary)
            .padding(.vertical, 10)
            .padding(.horizontal, 14)
            .background(isMine ? theme.invertedBackgroundTertiary : Color.backgroundSecondary,
                        in: .rect(cornerRadius: 18))
            .padding(isMine ? .leading : .trailing, 48)
            .frame(maxWidth: .infinity, alignment: isMine ? .trailing : .leading)
    }
}

VStack(spacing: 14) {
    Bubble(text: "Hi! Can I move my hotel by one night?", isMine: true)
    Bubble(text: "Of course. Which nights would you like?", isMine: false)
    Bubble(text: "October 13 to 16, please.", isMine: true)
}
.padding()
light mode
Hi! Can I move my hotel by one night?
Of course. Which nights would you like?
October 13 to 16, please.

Tab bar#

Float a tab bar over your content with the look of iOS 26's Liquid Glass, made only from gradients: a Color.backgroundPrimary fill that thins to 75% in the middle, a 1-point white sheen that fades to 25% at both ends, and a capsule inset from the screen's sides. The other tabs stay outlined in Color.foregroundSecondary; the selected tab fills its icon and label in Color.foregroundPrimary, on a white pill that turns Color.backgroundTertiary in dark mode.

swift
struct GlassTabBar: View {
    let tabs = [("house", "Home"), ("magnifyingglass", "Search"),
                ("suitcase", "Trips"), ("person", "Profile")]
    var selected = "Trips"

    let glass = [
        Color.backgroundPrimary, Color.backgroundPrimary.opacity(0.75), Color.backgroundPrimary,
    ].proGradient(from: .leading, to: .trailing)
    let sheen = [Color.white.opacity(0.25), Color.white, Color.white.opacity(0.25)]
        .proGradient(from: .leading, to: .trailing)
    let pill = Color(light: Color.backgroundPrimary, dark: Color.backgroundTertiary)

    var body: some View {
        HStack(spacing: 0) {
            ForEach(tabs, id: \.1) { tab in
                let (icon, title) = tab
                let isSelected = title == selected
                VStack(spacing: 2) {
                    Image(systemName: icon).symbolVariant(isSelected ? .fill : .none)
                    Text(title).font(.caption2.weight(.medium))
                }
                .foregroundStyle(isSelected ? Color.foregroundPrimary : Color.foregroundSecondary)
                .frame(maxWidth: .infinity, minHeight: 48)
                .background(isSelected ? pill : .clear, in: .capsule)
            }
        }
        .padding(6)
        .background(glass, in: .capsule)
        .overlay { Capsule().strokeBorder(sheen, lineWidth: 1) }
        .padding(.horizontal, 16)
    }
}

// Float it over your content: ScrollView { … }.safeAreaInset(edge: .bottom) { GlassTabBar() }
light mode
Home
Search
Trips
Profile

Whole features#

Leaderboard#

Use a leaderboard to rank players: the top three wear medal themes, proGold, proGray and proBrown, as soft surfacePrimary circles with the medal's foregroundPrimary number, each player shows their photo, and your own row sits on proYellow.surfacePrimary. CrosswordChef's board rows work this way.

swift
struct LeaderboardRow: View {
    let rank: Int
    let name: String
    let time: String
    var isYou = false

    var medal: ProTheme? { [1: Color.proGold, 2: Color.proGray, 3: Color.proBrown][rank] }

    var body: some View {
        HStack(spacing: 12) {
            Text("\(rank)")
                .font(.footnote.bold().monospacedDigit())
                .foregroundStyle(medal?.foregroundPrimary ?? Color.foregroundSecondary)
                .frame(width: 28, height: 28)
                .background(medal?.surfacePrimary ?? .clear, in: .circle)
            AsyncImage(url: URL(string: "https://picsum.photos/seed/\(name)/80")) { image in
                image.resizable().scaledToFill()
            } placeholder: {
                Color.backgroundTertiary
            }
            .frame(width: 36, height: 36)
            .clipShape(.circle)
            Text(name).fontWeight(isYou ? .semibold : .regular)
            Spacer()
            Text(time).monospacedDigit()
        }
        .padding(.horizontal, 12)
        .frame(height: 56)
        .foregroundStyle(Color.foregroundPrimary)
        .background(isYou ? Color.proYellow.surfacePrimary : .clear, in: .rect(cornerRadius: 12))
    }
}

VStack(spacing: 2) {
    LeaderboardRow(rank: 1, name: "Maya", time: "0:48")
    LeaderboardRow(rank: 2, name: "Kenji", time: "0:55")
    LeaderboardRow(rank: 3, name: "Ana", time: "1:02")
    LeaderboardRow(rank: 14, name: "You", time: "1:31", isYou: true)
}
light mode
1
Maya0:48
2
Kenji0:55
3
Ana1:02
14
You1:31

Streak calendar#

Use a streak calendar to show a habit day by day, one tall capsule per day. Finished days fill with a soft proGreen gradient from backgroundSecondary to backgroundTertiary and show a check. A day you can still play sits on gray backgroundSecondary with a play icon and a sheen border that runs top to bottom, outlineTertiary into a proGreen.invertedBackgroundTertiary highlight and back. Today gets a 2-point outline in the same green, and days ahead sit on gray surfacePrimary behind a lock. CrosswordChef's archive calendar works this way.

swift
enum DayState { case solved, open, today, upcoming }

struct StreakDay: View {
    let date: Int
    let state: DayState
    private let green = Color.proGreen

    var body: some View {
        VStack(spacing: 10) {
            Text("\(date)").font(.caption.bold().monospacedDigit())
            Image(systemName: icon).font(.caption.bold())
        }
        .foregroundStyle(ink)
        .frame(width: 40, height: 76)
        .background(fill, in: .capsule)
        .overlay(Capsule().strokeBorder(border, lineWidth: state == .today ? 2 : 1.5))
    }

    var icon: String {
        switch state {
        case .solved: "checkmark"
        case .open, .today: "play.fill"
        case .upcoming: "lock.fill"
        }
    }

    var ink: Color {
        switch state {
        case .solved: green.foregroundPrimary
        case .open, .today: Color.foregroundPrimary
        case .upcoming: Color.foregroundTertiary
        }
    }

    var fill: AnyShapeStyle {
        switch state {
        case .solved:
            AnyShapeStyle([green.backgroundSecondary, green.backgroundTertiary].proGradient())
        case .open, .today: AnyShapeStyle(Color.backgroundSecondary)
        case .upcoming: AnyShapeStyle(Color.surfacePrimary)
        }
    }

    var border: AnyShapeStyle {
        switch state {
        case .open:
            let sheen = [
                green.outlineTertiary, green.invertedBackgroundTertiary, green.outlineTertiary,
            ]
            return AnyShapeStyle(sheen.proGradient())
        case .today: return AnyShapeStyle(green.invertedBackgroundTertiary)
        case .solved, .upcoming: return AnyShapeStyle(Color.clear)
        }
    }
}

HStack(spacing: 8) {
    StreakDay(date: 8, state: .solved)
    StreakDay(date: 9, state: .solved)
    StreakDay(date: 10, state: .open)
    StreakDay(date: 11, state: .solved)
    StreakDay(date: 12, state: .solved)
    StreakDay(date: 13, state: .today)
    StreakDay(date: 14, state: .upcoming)
}
light mode
8
9
10
11
12
13
14

Plan picker#

Use a plan picker on an upgrade sheet: only the chosen plan wears the theme, proIndigo here, with a surfacePrimary fill and an invertedBackgroundTertiary outline and check, while the other plan and the title stay gray, drawn from Color.proGray, the family the app tokens use. The button repeats the outline's color. CrosswordChef's upgrade sheet is built this way.

swift
struct PlanTile: View {
    let name: String
    let price: String
    let isSelected: Bool

    var theme: ProTheme { isSelected ? Color.proIndigo : Color.proGray }

    var body: some View {
        HStack {
            VStack(alignment: .leading, spacing: 2) {
                Text(name).font(.headline).foregroundStyle(theme.foregroundPrimary)
                Text(price).font(.subheadline).foregroundStyle(theme.foregroundSecondary)
            }
            Spacer()
            Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
                .font(.title2)
                .foregroundStyle(
                    isSelected ? theme.invertedBackgroundTertiary : theme.outlinePrimary
                )
        }
        .padding(16)
        .background(isSelected ? theme.surfacePrimary : .clear, in: .rect(cornerRadius: 16))
        .overlay {
            RoundedRectangle(cornerRadius: 16).strokeBorder(
                isSelected ? theme.invertedBackgroundTertiary : theme.outlineSecondary,
                lineWidth: isSelected ? 2 : 1
            )
        }
    }
}

VStack(spacing: 12) {
    Text("Travel with Pro").font(.title.bold()).foregroundStyle(Color.foregroundPrimary)
    PlanTile(name: "Yearly", price: "$19.99 a year", isSelected: true)
    PlanTile(name: "Monthly", price: "$3.99 a month", isSelected: false)
    Button("Continue") {}
        .font(.headline)
        .frame(maxWidth: .infinity, minHeight: 50)
        .foregroundStyle(Color.proIndigo.invertedForegroundPrimary)
        .background(Color.proIndigo.invertedBackgroundTertiary, in: .capsule)
}
.padding(20)
light mode
Travel with Pro
Yearly$19.99 a year
Monthly$3.99 a month
Continue

Onboarding step#

Use one step per screen when you onboard people, here in proPink: capsules in invertedBackgroundTertiary over backgroundTertiary show how far along they are, the body text is foregroundSecondary and sits close under the title, and one button moves on. CrosswordChef's onboarding steps share this layout.

swift
struct OnboardingStep: View {
    let step: Int
    let count: Int
    private let theme = Color.proPink
    private var done: Color { theme.invertedBackgroundTertiary }
    private var todo: Color { theme.backgroundTertiary }

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            HStack(spacing: 6) {
                ForEach(0..<count, id: \.self) { index in
                    Capsule().fill(index <= step ? done : todo).frame(height: 4)
                }
            }
            VStack(alignment: .leading, spacing: 6) {
                Text("Where to next?").font(.largeTitle.bold())
                Text("Pick three places you'd love to see, and we'll watch prices for you.")
                    .foregroundStyle(theme.foregroundSecondary)
            }
            Button("Continue") {}
                .font(.headline)
                .frame(maxWidth: .infinity, minHeight: 50)
                .foregroundStyle(theme.invertedForegroundPrimary)
                .background(theme.invertedBackgroundTertiary, in: .capsule)
        }
        .padding(24)
        .foregroundStyle(theme.foregroundPrimary)
    }
}

OnboardingStep(step: 1, count: 4)
light mode
Where to next?Pick three places you'd love to see, and we'll watch prices for you.
Continue

Crossword grid#

Keep a game grid gray and let color mean something: squares use the app's gray tokens, the current word is backgroundTertiary, the focused square gets a foregroundPrimary outline, blocks are a fixed proGray._950, and a solved square turns proGreen's invertedBackgroundTertiary with invertedForegroundPrimary letters. CrosswordChef's grid squares and clue bar have the same states.

swift
enum Square { case empty, block, word, focused, solved }

struct GridSquare: View {
    let letter: String
    let square: Square
    private let solved = Color.proGreen

    var body: some View {
        Text(letter)
            .font(.title2.weight(.semibold))
            .frame(width: 48, height: 48)
            .foregroundStyle(colors.text)
            .background(colors.fill)
            .border(Color.outlinePrimary, width: 0.5)
            .overlay {
                if square == .focused {
                    Rectangle().strokeBorder(Color.foregroundPrimary, lineWidth: 2)
                }
            }
    }

    var colors: (fill: Color, text: Color) {
        switch square {
        case .empty: (Color.backgroundSecondary, Color.foregroundPrimary)
        case .block: (Color.proGray._950.toColor(), .clear)
        case .word, .focused: (Color.backgroundTertiary, Color.foregroundPrimary)
        case .solved: (solved.invertedBackgroundTertiary, solved.invertedForegroundPrimary)
        }
    }
}

let clueBar = HStack {
    Image(systemName: "chevron.left")
    Text("3A French capital").font(.subheadline.weight(.semibold))
    Spacer()
    Image(systemName: "chevron.right")
}
.padding(14)
.foregroundStyle(Color.foregroundPrimary)
.background(Color.backgroundTertiary)
light mode
C
A
F
E
P
A
R
3A French capital

Game keyboard#

Use your own keyboard when a game takes letters: the keys are white on a backgroundSecondary tray in light mode and lift to backgroundTertiary in dark mode, one color made with Color(light:dark:). CrosswordChef's keyboard does the same.

swift
struct GameKeyboard: View {
    let rows = ["QWERTYUIOP", "ASDFGHJKL", "ZXCVBNM"]
    let key = Color(light: Color.backgroundPrimary, dark: Color.backgroundTertiary)

    var body: some View {
        VStack(spacing: 8) {
            ForEach(rows, id: \.self) { row in
                HStack(spacing: 4) {
                    ForEach(Array(row), id: \.self) { letter in
                        Text(String(letter))
                            .font(.title3)
                            .frame(width: 26, height: 40)
                            .background(key, in: .rect(cornerRadius: 6))
                    }
                }
            }
        }
        .padding(6)
        .foregroundStyle(Color.foregroundPrimary)
        .background(Color.backgroundSecondary)
    }
}
light mode
Q
W
E
R
T
Y
U
I
O
P
A
S
D
F
G
H
J
K
L
Z
X
C
V
B
N
M

Game card#

Use a card per course, game or feature on a home feed, here in proOrange: a gentle proGradient from the theme's surfaceTertiary to surfacePrimary keeps it light, the time pill sits on backgroundPrimary, the stat pills on surfacePrimary, and the button is invertedBackgroundTertiary. CrosswordChef's home feed cards follow this recipe, one theme per game.

swift
struct LessonCard: View {
    let theme: ProTheme

    var body: some View {
        VStack(alignment: .leading, spacing: 14) {
            HStack(alignment: .top) {
                VStack(alignment: .leading, spacing: 2) {
                    Text("Portuguese").font(.title2.bold())
                    Text("Lesson 4: Ordering at a café")
                        .font(.subheadline)
                        .foregroundStyle(theme.foregroundSecondary)
                }
                Spacer()
                pill("5 min", "clock", theme.backgroundPrimary)
            }
            HStack {
                pill("12 days", "flame.fill", theme.surfacePrimary)
                pill("320 XP", "bolt.fill", theme.surfacePrimary)
            }
            Button("Continue lesson", systemImage: "play.fill") {}
                .font(.headline)
                .frame(maxWidth: .infinity, minHeight: 48)
                .foregroundStyle(theme.invertedForegroundPrimary)
                .background(theme.invertedBackgroundTertiary, in: .capsule)
        }
        .padding(20)
        .foregroundStyle(theme.foregroundPrimary)
        .background([theme.surfaceTertiary, theme.surfacePrimary].proGradient(),
                    in: .rect(cornerRadius: 24))
    }

    func pill(_ text: String, _ icon: String, _ fill: Color) -> some View {
        Label(text, systemImage: icon)
            .font(.subheadline.weight(.semibold))
            .lineLimit(1)
            .padding(.horizontal, 10)
            .padding(.vertical, 6)
            .background(fill, in: .capsule)
    }
}

LessonCard(theme: Color.proOrange)
light mode
PortugueseLesson 4: Ordering at a café
5 min
12 days
320 XP
Continue lesson

Share card#

Use a share card for an image people post outside your app: it's built from fixed stops, not tokens, a proGradient from _700 to _950 with _50 text, so it looks the same whatever mode the sender is in. CrosswordChef's share cards are made this way.

swift
struct StreakCard: View {
    let theme: ProTheme
    let days: Int

    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            Text("MY STREAK")
                .font(.caption.weight(.bold))
                .tracking(1.5)
                .foregroundStyle(theme._200.toColor())
            Spacer()
            VStack(alignment: .leading, spacing: -10) {
                Text("\(days)")
                    .font(.system(size: 88, weight: .heavy, design: .rounded))
                Text("days in a row").font(.title3.weight(.semibold))
            }
            Spacer()
            Label("Daily Mini", systemImage: "square.grid.2x2.fill")
                .font(.footnote.weight(.semibold))
                .foregroundStyle(theme._200.toColor())
        }
        .foregroundStyle(theme._50.toColor())
        .padding(24)
        .frame(width: 280, height: 280, alignment: .leading)
        .background([theme._700, theme._950].proGradient(), in: .rect(cornerRadius: 28))
    }
}

StreakCard(theme: Color.proPurple, days: 42)
light mode
MY STREAK
42
days in a row
Daily Mini

Charts#

Use Swift Charts for activity, trends and breakdowns, and draw every series at its theme's _500, the vivid middle of the ramp: on the gray backgroundSecondary card it clears the 3:1 that WCAG asks of chart marks in both modes. The bars take their colors from theme.triad, each fare line gets its own theme with the cheapest route's area fading to clear, and every chart keeps a legend as a second cue, as using tokens in charts explains.

swift
struct TripCharts: View {
    let theme: ProTheme
    let days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
    let modes = ["Walk", "Bike", "Train"]
    let kilometers = [[5.2, 7.1, 4.8, 9.4, 6.9, 11.0, 8.3],
                      [3.0, 6.5, 8.2, 4.1, 5.5, 12.4, 6.0],
                      [12.0, 4.5, 9.8, 14.2, 6.3, 18.5, 7.2]]
    let cities = ["Paris", "London", "Berlin"]
    let fares = [[168.0, 175, 160, 164, 150, 158, 141],
                 [212.0, 198, 205, 186, 171, 179, 152],
                 [245.0, 238, 229, 236, 219, 204, 198]]
    let names = ["Stays", "Flights", "Food"]
    let amounts = [540.0, 420, 260]

    var modeColors: [Color] { theme.triad.map { $0._500.toColor() } }
    var cityColors: [Color] {
        [Color.proPink, Color.proIndigo, Color.proGold].map { $0._500.toColor() }
    }
    var budgetColors: [Color] {
        [Color.proBlue, Color.proCoral, Color.proGrass].map { $0._500.toColor() }
    }

    var body: some View {
        VStack(spacing: 12) {
            card("Kilometers this week", "171 km") {
                Chart {
                    ForEach(0..<3, id: \.self) { mode in
                        ForEach(0..<7, id: \.self) { day in
                            BarMark(x: .value("Day", days[day]),
                                    y: .value("Kilometers", kilometers[mode][day]))
                                .foregroundStyle(by: .value("Mode", modes[mode]))
                                .position(by: .value("Mode", modes[mode]))
                                .cornerRadius(3)
                        }
                    }
                }
                .chartForegroundStyleScale(domain: modes, range: modeColors)
                .chartYAxis(.hidden)
                .frame(height: 150)
            }
            card("Fares to Lisbon", "from €141") {
                Chart {
                    ForEach(0..<7, id: \.self) { day in
                        AreaMark(x: .value("Day", day),
                                 yStart: .value("Floor", 120.0),
                                 yEnd: .value("Fare", fares[0][day]))
                            .foregroundStyle(LinearGradient(
                                colors: [cityColors[0].opacity(0.35), .clear],
                                startPoint: .top, endPoint: .bottom))
                    }
                    ForEach(0..<3, id: \.self) { city in
                        ForEach(0..<7, id: \.self) { day in
                            LineMark(x: .value("Day", day), y: .value("Fare", fares[city][day]))
                                .foregroundStyle(by: .value("From", cities[city]))
                                .lineStyle(StrokeStyle(lineWidth: 2.5, lineCap: .round))
                        }
                    }
                }
                .chartForegroundStyleScale(domain: cities, range: cityColors)
                .chartXAxis(.hidden)
                .chartYAxis(.hidden)
                .chartYScale(domain: 120.0...260.0)
                .frame(height: 110)
            }
            card("Trip budget", "€1,220") {
                HStack(spacing: 20) {
                    Chart(0..<3, id: \.self) { index in
                        SectorMark(angle: .value("Euros", amounts[index]),
                                   innerRadius: .ratio(0.66), angularInset: 1.5)
                            .foregroundStyle(budgetColors[index])
                    }
                    .frame(width: 88, height: 88)
                    VStack(alignment: .leading, spacing: 6) {
                        ForEach(0..<3, id: \.self) { index in
                            HStack(spacing: 6) {
                                Circle().fill(budgetColors[index]).frame(width: 8, height: 8)
                                Text("\(names[index]) €\(Int(amounts[index]))")
                                    .font(.caption.weight(.semibold))
                                    .foregroundStyle(Color.foregroundSecondary)
                            }
                        }
                    }
                }
            }
        }
    }

    func card<Content: View>(_ title: String, _ value: String,
                             @ViewBuilder content: () -> Content) -> some View {
        VStack(alignment: .leading, spacing: 10) {
            HStack {
                Text(title).font(.footnote.weight(.semibold))
                    .foregroundStyle(Color.foregroundSecondary)
                Spacer()
                Text(value).font(.headline).foregroundStyle(Color.foregroundPrimary)
            }
            content()
        }
        .padding(14)
        .background(Color.backgroundSecondary, in: .rect(cornerRadius: 16))
    }
}

TripCharts(theme: Color.proViolet)
light mode
Kilometers this week171 km
MonTueWedThuFriSatSun
Walk
Bike
Train
Fares to Lisbonfrom €141
Paris
London
Berlin
Trip budget€1,220
Stays €540
Flights €420
Food €260

Next steps