Skip to content

Buttons

Daniel S edited this page Jul 2, 2026 · 1 revision

DFButton

DFButton is DesignFoundation's interactive button component. It wraps a label string, an optional semantic role, and an action closure into a fully-themed, press-animated, accessibility-compliant button. Five built-in styles cover the full range of visual emphasis; the style system is open for extension.

Import

import DesignFoundation

Minimum platform: iOS 18+, macOS 15+, visionOS 2+


Quick Start

DFButton("Save Changes") { save() }

That one line renders a filled, themed button with a press animation, the correct label typography, disabled-state handling, and VoiceOver support.


Initializer Reference

public init(
    _ label: String,
    role: DFButtonRole? = nil,
    action: @escaping () -> Void
)
Parameter Type Default Description
label String required Visible button text. Also used verbatim as the VoiceOver accessibility label.
role DFButtonRole? nil Semantic role. .destructive substitutes theme.colors.destructive for the accent color across all styles and adds a "Destructive action" VoiceOver hint. .cancel carries semantic meaning only — no visual change.
action @escaping () -> Void required Closure executed on tap. Suppressed automatically when the button is disabled.

Applying a Style

// Apply to a single button
DFButton("Cancel") { dismiss() }
    .dfButtonStyle(.outlined)

// Apply to a container — all child DFButtons inherit
VStack {
    DFButton("Option A") { selectA() }
    DFButton("Option B") { selectB() }
}
.dfButtonStyle(.tinted)

The default style when no modifier is applied is .filled.


Style Variants

Filled — .filled

The highest-emphasis style. A solid colored background with white text. Use for the single primary action on a screen.

  • Background: theme.colors.interactiveFill (normal) → opacity 0.75 (pressed) → theme.colors.interactiveDisabled (disabled)
  • Foreground: Color.white (normal) → theme.colors.textDisabled (disabled)
  • Press effect: scale 0.97 + background opacity 0.75, animated at theme.animation.fast
DFButton("Create Account") { createAccount() }

DFButton("Delete Project", role: .destructive) { deleteProject() }
    .dfButtonStyle(.filled)

Outlined — .outlined

A 1.5 pt stroke border with no fill. Use for secondary actions alongside a filled primary button.

  • Border: 1.5 pt theme.colors.primary (normal) → theme.colors.border (disabled)
  • Foreground: theme.colors.primary (normal) → theme.colors.textDisabled (disabled)
DFButton("Cancel") { dismiss() }
    .dfButtonStyle(.outlined)

DFButton("Remove Member", role: .destructive) { removeMember() }
    .dfButtonStyle(.outlined)

Ghost — .ghost

Text and padding only — no background, no border. The lowest emphasis style, for tertiary actions or inline textual links.

  • Foreground: theme.colors.primary (normal) → theme.colors.textDisabled (disabled)
  • Press effect: overall opacity drops to 0.6
DFButton("Learn more") { openURL(learnMoreURL) }
    .dfButtonStyle(.ghost)

DFButton("Skip for now") { skip() }
    .dfButtonStyle(.ghost)

Tinted — .tinted

A translucent, color-tinted background. Sits between filled and outlined in visual weight.

  • Background fill: theme.colors.primary.opacity(0.15) (normal) → 0.25 (pressed)
  • Foreground: theme.colors.primary (normal)
DFButton("Add to Favorites") { favorite() }
    .dfButtonStyle(.tinted)

Glass — .glass (iOS 26+, macOS 26+)

A vibrancy-backed material button for use over images, blurred panels, or layered surfaces.

  • Background: .regularMaterial
  • Foreground: theme.colors.textPrimary
  • Press effect: scale 0.97
if #available(iOS 26, macOS 26, *) {
    DFButton("Open") { openPhoto() }
        .dfButtonStyle(.glass)
}

Button Roles

Role Visual effect VoiceOver hint
.destructive Substitutes theme.colors.destructive for the primary color in every built-in style "Destructive action"
.cancel No visual change No hint
nil (default) Primary theme color everywhere No hint
DFButton("Delete Account", role: .destructive) { deleteAccount() }
DFButton("Remove", role: .destructive) { remove() }
    .dfButtonStyle(.outlined)
DFButton("Cancel", role: .cancel) { dismiss() }
    .dfButtonStyle(.outlined)

Disabled State

DFButton("Continue") { advance() }
    .disabled(!formIsValid)

Disabled appearances by style:

Style Background Foreground
Filled theme.colors.interactiveDisabled theme.colors.textDisabled
Outlined None; border at 50% opacity theme.colors.textDisabled at 50%
Ghost None theme.colors.textDisabled at 50%
Tinted theme.colors.interactiveDisabled theme.colors.textDisabled
Glass .regularMaterial at 50% theme.colors.textDisabled

Full-Width Buttons

DFButton("Sign In") { signIn() }
    .frame(maxWidth: .infinity)

Accessibility

Feature What is applied
VoiceOver label .accessibilityLabel(label) — the label string
Trait .accessibilityAddTraits(.isButton)
Destructive hint .accessibilityHint("Destructive action") when role == .destructive
Reduced Motion Scale animations suppressed when Reduce Motion is enabled

Code Examples

Paired Primary and Secondary

HStack(spacing: theme.spacing.md) {
    DFButton("Cancel") { onCancel() }
        .dfButtonStyle(.outlined)
    DFButton("Confirm") { onConfirm() }
        .dfButtonStyle(.filled)
}

Full-Width Onboarding Screen

VStack(spacing: theme.spacing.md) {
    DFButton("Create Free Account") { createAccount() }
        .frame(maxWidth: .infinity)

    DFButton("I already have an account") { signIn() }
        .frame(maxWidth: .infinity)
        .dfButtonStyle(.outlined)

    DFButton("Continue as Guest") { continueAsGuest() }
        .frame(maxWidth: .infinity)
        .dfButtonStyle(.ghost)
}
.padding(.horizontal, theme.spacing.xl)

Destructive Confirm Dialog Pattern

DFButton("Delete My Account", role: .destructive) {
    showDeleteConfirmation = true
}
.dfButtonStyle(.outlined)
.dfAlert(
    isPresented: $showDeleteConfirmation,
    alert: DFAlert(
        title: "Delete your account?",
        message: "All projects, settings, and data will be permanently removed.",
        actions: [
            DFAlertAction(title: "Cancel", role: .cancel) { },
            DFAlertAction(title: "Delete Account", role: .destructive) {
                deleteAccount()
            },
        ]
    )
)

Custom Button Styles

public protocol DFButtonStyle {
    associatedtype Body: View
    @ViewBuilder func makeBody(configuration: DFButtonStyleConfiguration) -> Body
}

DFButtonStyleConfiguration exposes:

Property Type Description
label AnyView The rendered label
isPressed Bool true during an active press
isDisabled Bool Mirrors @Environment(\.isEnabled)
role DFButtonRole? .destructive, .cancel, or nil
theme DFTheme The active theme
struct CapsuleButtonStyle: DFButtonStyle, Sendable {
    func makeBody(configuration: DFButtonStyleConfiguration) -> some View {
        let theme = configuration.theme
        let color = configuration.role == .destructive
            ? theme.colors.destructive
            : theme.colors.primary

        configuration.label
            .font((theme.components.button.labelStyle ?? theme.typography.label).font)
            .foregroundStyle(configuration.isDisabled ? theme.colors.textDisabled : Color.white)
            .padding(.horizontal, theme.spacing.xl)
            .padding(.vertical, theme.spacing.sm)
            .background(
                Capsule().fill(configuration.isDisabled
                               ? theme.colors.interactiveDisabled
                               : color.opacity(configuration.isPressed ? 0.8 : 1.0))
            )
            .scaleEffect(configuration.isPressed ? 0.95 : 1.0)
            .animation(.spring(response: 0.25, dampingFraction: 0.6), value: configuration.isPressed)
    }
}

extension DFButtonStyle where Self == CapsuleButtonStyle {
    static var capsule: CapsuleButtonStyle { CapsuleButtonStyle() }
}

DFButton("Subscribe") { subscribe() }
    .dfButtonStyle(.capsule)

Related Pages

Clone this wiki locally