# Building for interaction states

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

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:)`](https://colortokenskit.com/api/darken/index.md) and [`desaturate(by:)`](https://colortokenskit.com/api/desaturate/index.md), 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
```

What this draws, with `#00B386`:

| Color                        | light mode                             | dark mode                              |
| ---------------------------- | -------------------------------------- | -------------------------------------- |
| `invertedForegroundPrimary`  | #eaf8f2                                | color(display-p3 0.0176 0.1298 0.086)  |
| `invertedBackgroundTertiary` | color(display-p3 0.1368 0.4655 0.3425) | #79dab5                                |
| `_700/_300`                  | color(display-p3 0.1149 0.4143 0.3028) | #4cd2a5                                |
| `_750/_350`                  | color(display-p3 0.0977 0.3637 0.2645) | color(display-p3 0.3144 0.7788 0.5953) |
| `foregroundPrimary`          | color(display-p3 0.0176 0.1298 0.086)  | #eaf8f2                                |
| `backgroundTertiary`         | #9ce2c5                                | color(display-p3 0.1149 0.4143 0.3028) |
| `#686868/#c8c8c8`            | #686868                                | #c8c8c8                                |

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)
}
```

What this draws, with `#00B386`:

| Color                 | light mode                             | dark mode                              |
| --------------------- | -------------------------------------- | -------------------------------------- |
| `backgroundPrimary`   | #eaf8f2                                | color(display-p3 0.0176 0.1298 0.086)  |
| `foregroundPrimary`   | color(display-p3 0.0176 0.1298 0.086)  | #eaf8f2                                |
| `foregroundSecondary` | color(display-p3 0.0797 0.3134 0.2262) | #9ce2c5                                |
| `backgroundSecondary` | #d2f1e3                                | color(display-p3 0.0797 0.3134 0.2262) |
| `_150/_750`           | #b9ead5                                | color(display-p3 0.0977 0.3637 0.2645) |
| `#8c8c8c/#808080`     | #8c8c8c                                | #808080                                |

Selected goes one stop past hover, so a row under the pointer never looks selected. [Strengthening](https://colortokenskit.com/api/strengthen/index.md) 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](https://www.w3.org/TR/WCAG22/#non-text-contrast), so the checkmark marks it.

Disabled text [softens](https://colortokenskit.com/api/soften/index.md) until it's 9 stops from the page, then loses its color, so the [distance rule](https://colortokenskit.com/advanced/color-theory/index.md#the-distance-rule) keeps it at 3:1 or more in any hue. WCAG exempts inactive controls from its [contrast rule](https://www.w3.org/TR/WCAG22/#contrast-minimum), 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())
}
```

What this draws, with `#00B386`:

| Color                        | light mode                             | dark mode                              |
| ---------------------------- | -------------------------------------- | -------------------------------------- |
| `invertedForegroundPrimary`  | #eaf8f2                                | color(display-p3 0.0176 0.1298 0.086)  |
| `invertedBackgroundTertiary` | color(display-p3 0.1368 0.4655 0.3425) | #79dab5                                |
| `foregroundPrimary`          | color(display-p3 0.0176 0.1298 0.086)  | #eaf8f2                                |
| `backgroundTertiary`         | #9ce2c5                                | color(display-p3 0.1149 0.4143 0.3028) |
| `#686868/#c8c8c8`            | #686868                                | #c8c8c8                                |

`@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](https://colortokenskit.com/advanced/themes/index.md): Recolor a view, a screen or your whole app from one value, with the same contrast in every hue.
- [High contrast modes](https://colortokenskit.com/advanced/high-contrast/index.md): Respect Increase Contrast with one rule for every family: text and borders two stops stronger.
- [.lighten(by:)](https://colortokenskit.com/api/lighten/index.md): Add highlights and lighter steps to any color, landing on an exact palette stop.
- [How colors land on exact stops](https://colortokenskit.com/under-the-hood/palette-snapping/index.md): Derived colors land on exact stops, so you know their contrast before you run the app.

---

From ColorTokensKit, by Penguin Design Ventures: https://colortokenskit.com/getting-started/interaction-states/ (updated September 26, 2026).
