swiftcnearly preview
Component library

Input Group

Native input group composition, package imports, and editable Swift source.

Preview

Input Group rendered by native SwiftUI.Input Group rendered by native SwiftUI. Dark appearance.
Captured from the native SwiftUI catalog on macOS.

Use it

Import SwiftCN to use CNInputGroup directly. This complete example also runs in the native component gallery.

InputGroupExample.swift
import SwiftUI
import SwiftCN

struct InputGroupExample: View {
    @State private var text = "Design system"

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            CNInputGroup {
                CNInputGroupAddon { Image(systemName: "magnifyingglass").accessibilityHidden(true) }
                CNInputGroupField("Search projects", text: $text)
                CNButton("Search", variant: .ghost, size: .small, action: {})
            }

        }
    }
}

Native behavior

Compose CNInputGroupField with icons, addons, or native actions. Decoration gaps focus the native editor; child buttons retain their own events. Pass focus: when the host controls focus.

Style and compose

Visual recipes use .tw classes. Append classes: to override a recipe locally, or change the recipe in TWGlobalRules.named for your subtree. Native layout and interaction modifiers remain SwiftUI APIs. For control colors, append cn-tint-[primary], another theme token, or an interpolated Color.

Motion recipes are editable too: control-motion uses a 150 ms smooth response, disclosure-motion uses a 250 ms spring, and feedback-motion uses a 200 ms smooth response. Set a recipe to animate-none in your global rules to turn it off. Presets respect Reduce Motion. Static components stay still; native scrolling, drag tracking, and platform presentation keep their own gestures.

Action groups and card, dialog, and questionnaire footers stack when their content does not fit. The native layout preserves the same child controls during this change.

View builders preserve native child content. Native Button roles, labels, keyboard shortcuts, submit actions, and presentation modifiers still work. A string recipe does not replace a native control API or a typed chart mark.

Own the source

Copy the complete file below into your app alongside import SwiftCN. The file contains related composable parts from the same family. Its dependencies use public package APIs, so copied and imported components can coexist.

To own the styling engine too, download the complete source bundle. It contains every component, the .tw engine, its recipes, and the MIT license. Keep those files in one module and remove import SwiftCN from the usage example.

Input.swift
import SwiftUI
import SwiftCN

/// Native text editing. Attach onSubmit, textContentType, and focus modifiers at the call site.
public struct CNInput: View {
    private let title: LocalizedStringKey
    @Binding private var text: String
    private let isSecure: Bool
    private let focus: FocusState<Bool>.Binding?
    private let classes: TWClasses
    @FocusState private var isFocused: Bool
    public init(_ title: LocalizedStringKey, text: Binding<String>, isSecure: Bool = false, focus: FocusState<Bool>.Binding? = nil, classes: TWClasses = "") {
        self.title = title; _text = text; self.isSecure = isSecure; self.focus = focus; self.classes = classes
    }
    public var body: some View {
        Group {
            if isSecure { SecureField(title, text: $text) }
            else { TextField(title, text: $text) }
        }.textFieldStyle(.plain).focused(focus ?? $isFocused)
            .modifier(TWFieldControlModifier(style: .classes(cn("input", classes)), state: .init(isFocused: focus?.wrappedValue ?? isFocused)))
            .modifier(CNInputActivationSurface(focus: focus ?? $isFocused))
    }
}

public struct CNTextarea: View {
    private let title: String
    @Binding private var text: String
    private let classes: TWClasses
    private let focus: FocusState<Bool>.Binding?
    @FocusState private var isFocused: Bool
    public init(_ title: String, text: Binding<String>, focus: FocusState<Bool>.Binding? = nil, classes: TWClasses = "") {
        self.title = title; _text = text; self.focus = focus; self.classes = classes
    }
    public var body: some View {
        TextEditor(text: $text).scrollContentBackground(.hidden).focused(focus ?? $isFocused)
            .modifier(TWFieldControlModifier(style: .classes(cn("textarea min-h-[100]", classes)), state: .init(isFocused: focus?.wrappedValue ?? isFocused)))
            .modifier(CNInputActivationSurface(focus: focus ?? $isFocused))
            .accessibilityLabel(title)
    }
}

/// Compose one native input with prefix, suffix, icons, or action buttons.
public struct CNInputGroup<Content: View>: View {
    private let classes: TWClasses
    private let content: Content
    private let focus: FocusState<Bool>.Binding?
    @FocusState private var isFocused: Bool
    public init(_ classes: TWClasses = "", focus: FocusState<Bool>.Binding? = nil, @ViewBuilder content: () -> Content) {
        self.classes = classes; self.focus = focus; self.content = content()
    }
    public var body: some View {
        HStack(spacing: 8) { content }
            .modifier(TWFieldControlModifier(style: .classes(cn("input-group", classes)), state: .init(isFocused: focus?.wrappedValue ?? isFocused)))
            .environment(\.twInputGroupFocus, focus ?? $isFocused)
            .modifier(CNInputActivationSurface(focus: focus ?? $isFocused))
    }
}
public struct CNInputGroupField: View {
    private let title: LocalizedStringKey
    @Binding private var text: String
    private let classes: TWClasses
    @Environment(\.twInputGroupFocus) private var groupFocus
    @FocusState private var isFocused: Bool
    public init(_ title: LocalizedStringKey, text: Binding<String>, classes: TWClasses = "") { self.title = title; _text = text; self.classes = classes }
    public var body: some View {
        TextField(title, text: $text).textFieldStyle(.plain).focused(groupFocus ?? $isFocused)
            .tw(cn("input-group-field", classes))
            .modifier(CNInputActivationSurface(focus: groupFocus ?? $isFocused))
    }
}

/// The decorated control participates in native hit testing, including its padding.
private struct CNInputActivationSurface: ViewModifier {
    let focus: FocusState<Bool>.Binding
    @Environment(\.isEnabled) private var isEnabled
    func body(content: Content) -> some View {
        content.contentShape(.interaction, Rectangle())
            .onTapGesture { if isEnabled { focus.wrappedValue = true } }
    }
}

/// One native field supports paste, selection, deletion and one-time-code autofill.
/// It intentionally avoids six independent fields and fragile focus choreography.
public struct CNInputOTP: View {
    @Binding private var code: String
    private let length: Int
    private let classes: TWClasses
    public init(code: Binding<String>, length: Int = 6, classes: TWClasses = "") {
        precondition(length > 0, "Code length must be positive.")
        _code = code; self.length = length; self.classes = classes
    }
    public nonisolated static func normalize(_ value: String, length: Int) -> String {
        String(value.filter { $0.isASCII && $0.isNumber }.prefix(max(0, length)))
    }
    public var body: some View {
        let field = CNInput("Verification code", text: $code, classes: cn("input-otp", classes)).cnTextUtilities()
            .onChange(of: code, initial: true) { _, value in
                let clean = Self.normalize(value, length: length)
                if clean != code { code = clean }
            }
            .onChange(of: length) { _, count in
                let clean = Self.normalize(code, length: count)
                if clean != code { code = clean }
            }
            .accessibilityHint("Enter \(length) digits")
        #if os(iOS)
        field.textContentType(.oneTimeCode).keyboardType(.numberPad)
        #else
        field
        #endif
    }
}

On this page