Skip to content
ColorTokensKitby Penguin Design Ventures
Contents
How-to

Building for interaction states

Give buttons and rows hover, pressed, selected and disabled colors that stay readable in both modes.

Updated View as Markdown

Every control needs more than one look: at rest, hovered, pressed, selected and disabled. Draw each state from the control's tokens with color functions such as darken(by:) and desaturate(by:), and each label stays readable in light and dark mode. Here's the travel app's booking button at rest, hovered, pressed, booked and sold out:

swift
let label = brand.invertedForegroundPrimary   // _50 in light mode, _1000 in dark
let fill = brand.invertedBackgroundTertiary   // _650 and _250: label 5.11:1 and 10.20:1

let hovered = fill.darken()                   // _700 and _300: 6.10:1 and 9.00:1
let pressed = fill.darken(by: 2)              // _750 and _350: 7.29:1 and 7.91:1
let booked = brand.backgroundTertiary         // _200 and _700, with foregroundPrimary text
let soldOut = fill.desaturate(by: 1)          // gray, as light as fill: 5.11:1 and 10.20:1
light mode
Book nowBook nowBook nowBookedSold out

These ratios hold in every hue, and rest, hover and pressed are exact stops a design spec can name. darken() makes the fill darker in both modes, the way a pressed button should look, and the label stays above 7:1 when pressed. desaturate(by:) keeps the fill's lightness, so Sold out stays readable.

Color a flight row#

A row has no fill at rest, so its states start from the page. Here it is at rest, hovered, selected and disabled:

swift
struct FlightRow: View {
    let theme: ProTheme
    var isSelected = false
    // Not private, so other files can use the memberwise init.
    @State var isHovered = false
    @Environment(\.isEnabled) var isEnabled

    var body: some View {
        HStack {
            VStack(alignment: .leading, spacing: 4) {
                Text("LIS → KIX")
                    .font(.headline)
                Text("Oct 12 · 14h 20m")
                    .font(.subheadline)
                    .foregroundStyle(isEnabled ? theme.foregroundSecondary : disabledText)
            }
            Spacer()
            Text("€620")
            if isSelected {
                Image(systemName: "checkmark")
            }
        }
        .padding(12)
        .foregroundStyle(isEnabled ? theme.foregroundPrimary : disabledText)
        .background(fill)
        .onHover { isHovered = $0 }
    }

    // Ten stops toward the page, to _500 in light mode and _550 in dark, then gray.
    private var disabledText: Color {
        theme.foregroundPrimary.soften(by: 10).desaturate(by: 1)
    }

    private var fill: Color {
        if isSelected { return theme.backgroundSecondary.strengthen() }   // _150 and _750
        if isHovered && isEnabled { return theme.backgroundSecondary }    // _100 and _800
        return .clear
    }
}

#Preview {
    VStack(spacing: 0) {
        FlightRow(theme: brand)
        FlightRow(theme: brand, isHovered: true)
        FlightRow(theme: brand, isSelected: true)
        FlightRow(theme: brand)
            .disabled(true)
    }
    .background(brand.backgroundPrimary)
}
light mode
LIS → KIXOct 12 · 14h 20m
€620
LIS → KIXOct 12 · 14h 20m
€620
LIS → KIXOct 12 · 14h 20m
€620✓
LIS → KIXOct 12 · 14h 20m
€620

Selected goes one stop past hover, so a row under the pointer never looks selected. Strengthening a fill moves it toward the text, and a second stop would drop the subtitle to 4.48:1 in dark mode. The selected fill alone is under WCAG's 3:1 for non-text contrast, so the checkmark marks it.

Disabled text softens until it's 9 stops from the page, then loses its color, so the distance rule keeps it at 3:1 or more in any hue. WCAG exempts inactive controls from its contrast rule, but people read disabled text to find out what the control would do.

Put the button's states in a style#

swift
struct BookButtonStyle: ButtonStyle {
    var theme = brand
    var isBooked = false

    func makeBody(configuration: Configuration) -> some View {
        BookButton(configuration: configuration, theme: theme, isBooked: isBooked)
    }
}

private struct BookButton: View {
    let configuration: ButtonStyleConfiguration
    let theme: ProTheme
    let isBooked: Bool
    @Environment(\.isEnabled) private var isEnabled
    @State private var isHovered = false

    var body: some View {
        configuration.label
            .font(.headline)
            .padding(.horizontal, 16)
            .padding(.vertical, 10)
            .foregroundStyle(labelColor)
            .background(fill)
            .onHover { isHovered = $0 }
    }

    private var labelColor: Color {
        isBooked ? theme.foregroundPrimary : theme.invertedForegroundPrimary
    }

    private var fill: Color {
        let rest = theme.invertedBackgroundTertiary
        if isBooked { return theme.backgroundTertiary }
        if !isEnabled { return rest.desaturate(by: 1) }
        if configuration.isPressed { return rest.darken(by: 2) }
        if isHovered { return rest.darken() }
        return rest
    }
}

#Preview {
    VStack {
        Button("Book now") {}
        Button("Booked") {}
            .buttonStyle(BookButtonStyle(isBooked: true))
        Button("Sold out") {}
            .disabled(true)
    }
    .buttonStyle(BookButtonStyle())
}
light mode
Book nowBookedSold out

@State lives in views, not styles, so makeBody returns a small view that holds the hover state. Check booked first, since it changes the label too, then disabled, pressed and hover: a disabled button can be under the pointer, and a pressed one usually is. onHover(perform:) isn't available on tvOS or watchOS, so leave hover out of a style you share with them.

Next steps

  • Setting up themes Recolor a view, a screen or your whole app from one value, with the same contrast in every hue.
  • High contrast modes Respect Increase Contrast with one rule for every family: text and borders two stops stronger.
  • .lighten(by:) Add highlights and lighter steps to any color, landing on an exact palette stop.
  • How colors land on exact stops Derived colors land on exact stops, so you know their contrast before you run the app.