Widget Styling

Learn how Shaft’s inherited styling system enables powerful, contextual customization of your UI.

The Core Concept

Shaft uses an inherited styling system where styles cascade down the widget tree, similar to CSS or React Context. When you set a style on a widget, all its children automatically inherit that style unless they specify their own.

┌─────────────────────────────┐
│    Column                   │
│      .buttonStyle(.custom)  │  ← Style defined here
└──────────┬──────────────────┘

           ├──> Button #1       ← Inherits .custom style

           ├──> Row
           │     │
           │     ├──> Button #2 ← Also inherits .custom style
           │     │
           │     └──> Button #3 ← Also inherits .custom style

           └──> Button #4       ← Also inherits .custom style

Key principle: Styles flow down the widget tree automatically. Change the style once at the top, and all children update.

Why This Matters

1. Global Customization Made Easy

Update the appearance of all widgets in your app (or part of it) from a single location:

// Apply custom button style to entire app
Column {
    LoginForm()
    SettingsPanel()
    ActionButtons()
}
.buttonStyle(MyAppButtonStyle())  // All buttons now use this style

No need to hunt down every button in your codebase. Change one line, update everywhere.

2. Context-Aware Styling

Widgets automatically adapt their appearance based on where they’re used:

// Same Button widget, different contexts
Column {
    // Normal button in main UI
    Button { } child: { Text("Save") }
    
    // Button in a Dialog automatically looks like a dialog button
    Dialog {
        Button { } child: { Text("OK") }
    }
    .buttonStyle(.dialogButton)
    
    // Button in a Menu automatically looks like a menu item
    Menu {
        Button { } child: { Text("New File") }
    }
    .buttonStyle(.menuItem)
}

Internally, widgets like Dialog wraps its child with a button style that affects all descendant buttons:

class Dialog: StatelessWidget {
    init(child: Widget) {
        self.child = child
    }

    let child: Widget

    func build(context: BuildContext) -> Widget {
        child
            .buttonStyle(.dialogButton) // <-- Style applied to child tree
    }
}

The same Button widget type, but styled differently based on its context. No need for DialogButton, MenuButton, etc.

Style Inheritance Flow Diagram

Dialog
  └─ .buttonStyle(.dialogButton)  ← Creates Inherited<Button.Style>
      └─ Column
          ├─ Text("Are you sure?")
          └─ Row
              ├─ Button { Text("Cancel") }   ← Inherits .dialogButton
              └─ Button { Text("OK") }       ← Inherits .dialogButton

All buttons automatically use .dialogButton style without explicitly setting it on each button.

How It Works

The Style Protocol

Each styleable widget defines a Style protocol:

// Button.Style protocol
protocol Style {
    func build(context: Context) -> Widget
}

// Context provides widget state
protocol StyleContext {
    var isPressed: Bool { get }
    var isHovered: Bool { get }
    var isEnabled: Bool { get }
    var child: Widget { get }  // The button's content
}

Creating Custom Styles

Implement the protocol to create your own styles:

struct PrimaryButtonStyle: Button.Style {
    func build(context: any Context) -> any Widget {
        context.child
            .padding(.symmetric(vertical: 12, horizontal: 24))
            .textStyle(.init(color: Color(0xFF_FFFFFF)))
            .decoration(.box(
                color: Color(0xFF_007AFF),
                borderRadius: .circular(8)
            ))
    }
}

struct DangerButtonStyle: Button.Style {
    func build(context: any Context) -> any Widget {
        context.child
            .padding(.symmetric(vertical: 12, horizontal: 24))
            .textStyle(.init(color: Color(0xFF_FFFFFF)))
            .decoration(.box(
                color: Color(0xFF_FF3B30),
                borderRadius: .circular(8)
            ))
    }
}

Applying Styles

Use the .buttonStyle() modifier to apply styles to a subtree:

Column {
    // All buttons in this column are primary styled
    Button { } child: { Text("Save") }
    Button { } child: { Text("Submit") }
    Button { } child: { Text("Continue") }
}
.buttonStyle(PrimaryButtonStyle())

Practical Examples

Example 1: App-Wide Theme

struct AppButtonStyle: Button.Style {
    func build(context: any Context) -> any Widget {
        let bgColor = context.isPressed 
            ? Color(0xFF_005BBB)  // Darker when pressed
            : Color(0xFF_007AFF)  // Normal blue
        
        return context.child
            .padding(.all(12))
            .textStyle(.init(color: Color(0xFF_FFFFFF), fontSize: 16))
            .decoration(.box(
                color: bgColor,
                borderRadius: .circular(8)
            ))
    }
}

// Apply to entire app
runApp(
    MyApp()
        .buttonStyle(AppButtonStyle())  // All buttons now have this style
)

Example 2: Context-Specific Styling

class Dashboard: StatelessWidget {
    func build(context: any BuildContext) -> any Widget {
        Column {
            // Primary action buttons
            Row {
                Button { } child: { Text("New") }
                Button { } child: { Text("Open") }
                Button { } child: { Text("Save") }
            }
            .buttonStyle(.primary)
            
            // Danger zone
            Column {
                Text("Danger Zone")
                    .textStyle(.init(fontWeight: .bold))
                
                Button { } child: { Text("Delete Account") }
                Button { } child: { Text("Reset Data") }
            }
            .buttonStyle(.danger)
            
            // Subtle secondary actions
            Row {
                Button { } child: { Text("Cancel") }
                Button { } child: { Text("Back") }
            }
            .buttonStyle(.secondary)
        }
    }
}

Example 3: Segmented Button Group

Creating a button group where buttons adapt their appearance based on position:

class SegmentedControl: StatelessWidget {
    let @WidgetListBuilder children: () -> [Widget]
    
    init(@WidgetListBuilder children: @escaping () -> [Widget]) {
        self.children = children
    }
    
    func build(context: any BuildContext) -> any Widget {
        let buttons = children()
        
        return Row(spacing: 1) {
            for (index, button) in buttons.enumerated() {
                button
                    .buttonStyle(
                        SegmentStyle(
                            isFirst: index == 0,
                            isLast: index == buttons.count - 1
                        )
                    )
            }
        }
    }
    
    struct SegmentStyle: Button.Style {
        let isFirst: Bool
        let isLast: Bool
        
        var borderRadius: BorderRadius {
            BorderRadius(
                topLeft: isFirst ? .circular(8) : .zero,
                topRight: isLast ? .circular(8) : .zero,
                bottomLeft: isFirst ? .circular(8) : .zero,
                bottomRight: isLast ? .circular(8) : .zero
            )
        }
        
        func build(context: any Context) -> any Widget {
            context.child
                .padding(.symmetric(vertical: 8, horizontal: 16))
                .textStyle(.init(color: Color(0xFF_FFFFFF)))
                .decoration(.box(
                    color: Color(0xFF_007AFF),
                    borderRadius: borderRadius
                ))
        }
    }
}

// Usage - buttons automatically get appropriate corner radius
SegmentedControl {
    Button { } child: { Text("Day") }
    Button { } child: { Text("Week") }
    Button { } child: { Text("Month") }
}

Result: The first button has left corners rounded, the last has right corners rounded, and middle buttons have square corners—all from the same Button widget.

Comparison with Other Approaches

Traditional Approach

// Different button types for different contexts
DialogButton { }     // Separate class
MenuButton { }       // Separate class
PrimaryButton { }    // Separate class
SecondaryButton { }  // Separate class

Problem: Code duplication, hard to maintain, rigid structure.

Shaft’s Approach

// One Button widget, styled by context
Button { }
    .buttonStyle(.dialog)   // Looks like dialog button
    .buttonStyle(.menu)     // Looks like menu item
    .buttonStyle(.primary)  // Looks like primary action

Benefit: Flexible, maintainable, contextual.

Next Steps

Technical Details

How Styles Are Stored

Styles are stored using Shaft’s Inherited widget, similar to React Context or Flutter’s InheritedWidget:

extension Widget {
    public func buttonStyle(_ style: any Button.Style) -> some Widget {
        Inherited(style) { self }
    }
}

How Styles Are Retrieved

Widgets look up styles from the widget tree at build time:

public override func build(context: BuildContext) -> Widget {
    let style: any Button.Style = Inherited.valueOf(context) ?? .default
    return style.build(context: self)
}

This ensures styles are always up-to-date and correctly scoped to their subtree.