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
- Explore the Button widget documentation
- Learn about State Management patterns
- Check out Widget Tutorials for styling inspiration
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.