Skip to content
PressboardKit
ThemingBrowse docs

Guides

Theming

The style seam, the six built-in themes, building a palette of your own, and why the native keyboard's backdrop has to stay clear.

Every colour and metric the keyboard draws comes from a KeyboardStyleProvider. The default, NativeKeyboardStyle, reproduces the measured native look in light and dark. You override only what you want; the metrics and fonts keep their native proportions unless you replace those too.

The values inside NativeKeyboardStyle and NativeMetrics are the engine’s own — read them through the API rather than copying numbers out of it, so a build that ships a newer measured snapshot updates your keyboard along with everyone else’s.

The protocol

public protocol KeyboardStyleProvider: Sendable {
    var metrics: NativeMetrics { get }
    func backgroundColor() -> KeyboardColor
    func keyFill(for role: KeyRole) -> KeyboardColor
    func keyFillOnGlass(for role: KeyRole) -> KeyboardColor
    func keyFillPressed(for role: KeyRole) -> KeyboardColor
    func keyTextColor(for role: KeyRole) -> KeyboardColor
    func keyFontSize(for action: KeyboardAction) -> CGFloat
    func accentFill() -> KeyboardColor
    func accentTextColor() -> KeyboardColor
}

Several of those have defaults, so a minimal provider implements three:

  • keyFontSize(for:) returns the native size for the action.
  • keyFillOnGlass(for:) falls back to keyFill(for:).
  • keyFillPressed(for:) falls back to keyFill(for:), which means a custom style that does not override it simply has no pressed state on the keys that have no key-pop of their own — backspace, return, space, emoji, 123, globe, shift.
  • accentFill() and accentTextColor() derive from the rest.

KeyRole is .standard (letters and the space bar) or .control (shift, backspace, 123, globe, and the rest of the chrome). KeyboardColor carries a light and a dark RGBA, and RGBA carries alpha, which is how translucency is expressed.

public struct KeyboardColor: Sendable, Equatable {
    public var light: RGBA
    public var dark: RGBA
}

public struct RGBA: Sendable, Equatable {
    public init(_ red: Double, _ green: Double, _ blue: Double, _ alpha: Double = 1)
}

The native style

NativeKeyboardStyle()                               // measured iOS 26 colours, iPhone portrait
NativeKeyboardStyle(metrics: .iPhoneLandscape)      // the landscape metrics

NativeMetrics carries the measured geometry — row heights, spacing, key shape, type sizes — plus the two derived values a host actually needs: keyAreaHeight(rowCount:) for sizing the input view, and popOverflow(for:) for the headroom a given key-pop style needs above the top row. Ask it for those rather than hardcoding a height; they change with the design snapshot.

The built-in themes

KeyboardBehavior.theme selects one of six. .native is the system look and the default; the others recolour the entire keyboard — keys, text, accent, bubbles, callouts and the suggestion bar — from a single palette.

public enum KeyboardTheme: String, Sendable, Equatable, Codable, CaseIterable {
    case native, midnight, ocean, sunset, forest, graphite
}

Each case has a displayName for a picker, a palette, and resolves to a provider:

let style = behavior.theme.style()          // any KeyboardStyleProvider

A non-native theme is a solid look, so it opts out of the translucent backdrop — glass would show the app through the very background the theme just painted. PressboardRootView forces translucentBackground off whenever the theme is not .native.

A palette of your own

For a recolour, build a ThemePalette and hand it to ThemedKeyboardStyle rather than implementing the protocol from scratch. You get every derived colour — callouts, the pop, the bar — for free.

// Six colours are the whole of a theme. These are placeholders — put your brand here.
let palette = ThemePalette(
    background:  KeyboardColor(light: RGBA(0.96, 0.93, 0.88), dark: RGBA(0.13, 0.10, 0.16)),
    standardKey: KeyboardColor(light: RGBA(1.00, 0.99, 0.97), dark: RGBA(0.24, 0.20, 0.30)),
    controlKey:  KeyboardColor(light: RGBA(0.86, 0.81, 0.74), dark: RGBA(0.18, 0.15, 0.23)),
    keyText:     KeyboardColor(light: RGBA(0.11, 0.09, 0.07), dark: RGBA(0.97, 0.96, 0.99)),
    accent:      KeyboardColor(light: RGBA(0.78, 0.30, 0.16), dark: RGBA(0.94, 0.47, 0.29)),
    accentText:  KeyboardColor(light: RGBA(1, 1, 1),          dark: RGBA(0.10, 0.06, 0.04)))

let style = ThemedKeyboardStyle(palette: palette)

For the native look, do not build a palette at all — NativeKeyboardStyle() is the measured one, and it tracks the design snapshot the running system matches.

For anything beyond a recolour, implement the protocol:

struct BrandStyle: KeyboardStyleProvider {
    // Keep the native geometry; only the paint is yours.
    var metrics: NativeMetrics = .iPhonePortrait

    func backgroundColor() -> KeyboardColor {
        KeyboardColor(light: RGBA(0.96, 0.93, 0.88), dark: RGBA(0.13, 0.10, 0.16))
    }

    func keyFill(for role: KeyRole) -> KeyboardColor {
        role == .standard
            ? KeyboardColor(light: RGBA(1.00, 0.99, 0.97), dark: RGBA(0.24, 0.20, 0.30))
            : KeyboardColor(light: RGBA(0.86, 0.81, 0.74), dark: RGBA(0.18, 0.15, 0.23))
    }

    func keyTextColor(for role: KeyRole) -> KeyboardColor {
        KeyboardColor(light: RGBA(0.11, 0.09, 0.07), dark: RGBA(0.97, 0.96, 0.99))
    }

    // Optional, but the difference between blending with the app and sitting on top of it.
    // The alpha is what does the work here: a translucent key reads as part of the system's
    // glass, an opaque one reads as a block laid over it.
    func keyFillOnGlass(for role: KeyRole) -> KeyboardColor {
        role == .standard
            ? KeyboardColor(light: RGBA(1, 1, 1, 0.92), dark: RGBA(1, 1, 1, 0.16))
            : KeyboardColor(light: RGBA(1, 1, 1, 0.62), dark: RGBA(1, 1, 1, 0.06))
    }
}

Pass it to the view as style:, or resolve it from your own theme type.

Translucency and liquid glass

The iOS 26 keyboard is not an opaque rectangle. It floats on the system’s translucent backdrop and blends with the app behind it. translucentBackground is on by default, and reproducing it takes two cooperating pieces — one in the engine, one in your extension.

The engine half

With translucentBackground on, the keyboard’s own background is drawn fully clear. A keyboard extension already renders over the system’s translucent backdrop, so any material or colour fill of our own only adds a grey veil, and a grey veil reads as an opaque rectangle. Clear lets the real system glass through.

The keys are translucent on that glass, through keyFillOnGlass, so they blend rather than sitting on the surface as darker blocks. With translucentBackground off, keys use the opaque keyFill and the background is solid. Both sets of values are measured; a custom style supplies its own.

Your half

The glass only shows if nothing behind the SwiftUI view is opaque:

host.view.backgroundColor = .clear   // the UIHostingController's view
view.backgroundColor = .clear        // the input view controller's own view
host.view.clipsToBounds = false
view.clipsToBounds = false

PressboardInputViewController does this for you. A hand-built controller that skips it puts the default opaque view in front of the system backdrop, and you are back to a rectangle even though the engine is drawing clear.

Why not a visual effect view

A custom keyboard is not handed the host app’s rendered content, so a UIVisualEffectView or .regularMaterial has nothing to blur. It paints a flat grey veil instead. The correct approach is to clear everything and rely on the system-provided backdrop — which is what the native keyboard does.

Key-pop headroom

The character preview for the top key row extends above the keyboard. An extension cannot draw outside its frame; the system clips it, and un-clipping the system’s container views does not help. Instead, reserve the overflow as headroom inside a slightly taller keyboard: pass PressboardKeyboardView(topInset:) the value

max(0, style.metrics.popOverflow(for: behavior.characterPreviewStyle) - toolbarHeight)

and add the same amount to your input view’s height. The inset is filled by the translucent backdrop, so it reads as a marginally taller keyboard rather than a gap. The controller exposes this as popTopInset and PressboardRootView passes it through.

Design snapshots

Parity is measured against a specific iOS release, and Apple redraws the keyboard between releases. nativeDesignID selects which measured snapshot to render. nil, the default, is automatic: the newest snapshot measured on an iOS no newer than the device’s. Pin an id only to hold one look deliberately; an id a build does not ship resolves back to automatic rather than failing.

Two smaller appearance switches

  • uniformKeyColor fills every key, including shift, backspace, return, globe, mic and 123, with the letter-key colour instead of the darker native control colour. Off is native; it is on in .pressboardDefault.
  • showSpaceLabel shows the word “space” on the space bar. Turn it off to leave it blank — for example when you want only a language indicator in the corner, supplied through the view’s keyOverlay slot.

Next: Layouts and languages.

Edit this page on GitHub