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.
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))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.
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())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.
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)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.
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)
}
}
}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.
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)
}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.
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)
}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.
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))
}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.
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))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.
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(""))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.
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)
}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.
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)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.
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)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.
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))
}
}Search bar#
Put search at the top of a list, on Color.backgroundSecondary with a foregroundTertiary prompt and a clear button once there's a query.
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))
}
}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.
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")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.
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")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.
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)
}
}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.
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))
}
}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.
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()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.
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() }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.
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)
}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.
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)
}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.
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)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.
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)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.
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)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.
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)
}
}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.
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)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.
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)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.
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)Next steps
- Setting up themes Recolor a view, a screen or your whole app from one value, with the same contrast in every hue.
- Building for interaction states Give buttons and rows hover, pressed, selected and disabled colors that stay readable in both modes.
- Understanding semantic tokens Learn the 20 tokens by the job each one does, and get dark mode and passing contrast with them.