swiftcnearly preview
Component library

Table

Native table composition, package imports, and editable Swift source.

Preview

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

Use it

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

TableExample.swift
import SwiftUI
import SwiftCN

struct TableExample: View {
    @State private var selected: Set<String> = []
    #if os(iOS)
    @ScaledMetric(relativeTo: .body) private var selectionWidth: CGFloat = 72
    #else
    @ScaledMetric(relativeTo: .body) private var selectionWidth: CGFloat = 48
    #endif
    private let invoices = [
        Invoice(id: "INV-001", status: "Paid", method: "Credit card", amount: "$250.00"),
        Invoice(id: "INV-002", status: "Pending", method: "Bank transfer", amount: "$150.00"),
        Invoice(id: "INV-003", status: "Paid", method: "Apple Pay", amount: "$350.00"),
        Invoice(id: "INV-004", status: "Overdue", method: "Credit card", amount: "$450.00")
    ]
    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            CNAdaptiveActionLayout(spacing: 12) {
                VStack(alignment: .leading, spacing: 4) {
                    Text("Recent invoices").tw("text-lg font-semibold")
                    Text("Keep track of your team's payments.").tw("text-sm text-mutedForeground")
                }
                CNBadge("4 invoices", variant: .secondary)
            }
            CNTable {
                CNTableRow {
                    CNTableHead("w-[\(selectionWidth)] px-3") { Text("") }
                    CNTableHead("Invoice")
                    CNTableHead("Status")
                    CNTableHead("Payment method")
                    CNTableHead("Amount", alignment: .trailing)
                }
                ForEach(invoices) { invoice in
                    CNTableRow(isSelected: selected.contains(invoice.id), onSelect: {
                        if !selected.insert(invoice.id).inserted { selected.remove(invoice.id) }
                    }) {
                        CNTableCell("w-[\(selectionWidth)] px-3") {
                            CNCheckbox("", isOn: Binding(
                                get: { selected.contains(invoice.id) },
                                set: { if $0 { selected.insert(invoice.id) } else { selected.remove(invoice.id) } }))
                                .accessibilityLabel("Select invoice \(invoice.id)")
                        }
                        CNTableCell("\(invoice.id)", classes: "font-medium")
                        CNTableCell {
                            HStack(spacing: 6) {
                                Circle().tw("w-[6] h-[6] \(invoice.status == "Paid" ? "text-[#16a34a]" : invoice.status == "Overdue" ? "text-destructive" : "text-mutedForeground")").accessibilityHidden(true)
                                Text(invoice.status)
                            }
                        }
                        CNTableCell("\(invoice.method)", classes: "text-mutedForeground")
                        CNTableCell("\(invoice.amount)", classes: "cn-mono font-medium", alignment: .trailing).cnTextUtilities()
                    }
                }
                CNTableRow(classes: "bg-muted") {
                    CNTableCell("w-[\(selectionWidth)] px-3") { Text("") }
                    CNTableCell("Total", classes: "font-semibold")
                    CNTableCell { Text("") }
                    CNTableCell { Text("") }
                    CNTableCell("$1,200.00", classes: "cn-mono font-semibold", alignment: .trailing).cnTextUtilities()
                }
            }
            CNTableCaption("\(selected.count) of 4 invoices selected. Scroll horizontally to see all columns on smaller screens.")
        }
    }
    private struct Invoice: Identifiable {
        let id: String
        let status: String
        let method: String
        let amount: String
    }
}

Native behavior

Compose rich aligned columns with native Grid, GridRow, CNTableHead, and CNTableCell parts. The table fills its viewport and scrolls horizontally when columns need more room. Header cells expose accessibility headings. Rows have separators, immediate hover feedback, and a caller-controlled selected appearance. Cells accept leading, center, or trailing alignment. CNTableCaption adds supporting text outside the table. Pass onSelect: to make row whitespace selectable. Child checkboxes, buttons, and editors handle their own events without also activating the row; use CNDataTable for native record sorting. Adding or removing onSelect preserves editable cell identity and child state.

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.

Table.swift
import SwiftUI
import SwiftCN

/// Intrinsic column widths for small, rich tables. Use CNDataTable for large record sets.
public struct CNTable<Content: View>: View {
    @State private var viewportWidth: CGFloat = 0
    @Environment(\.twTheme) private var theme
    private let classes: TWClasses
    private let content: Content
    public init(_ classes: TWClasses = "", @ViewBuilder content: () -> Content) { self.classes = classes; self.content = content() }
    public var body: some View {
        ScrollView(.horizontal) {
            Grid(alignment: .leading, horizontalSpacing: 0, verticalSpacing: 0) { content }
                .frame(minWidth: viewportWidth, alignment: .leading)
        }.fixedSize(horizontal: false, vertical: true)
            .scrollBounceBehavior(.basedOnSize, axes: .horizontal)
            .onGeometryChange(for: CGFloat.self, of: { $0.size.width }) { viewportWidth = $0 }
            .tw(cn("table", classes)).clipShape(RoundedRectangle(cornerRadius: theme.radius(.lg)))
    }
}
public struct CNTableRow<Content: View>: View {
    private let isSelected: Bool
    private let onSelect: (() -> Void)?
    private let classes: TWClasses
    private let content: Content
    public init(isSelected: Bool = false, onSelect: (() -> Void)? = nil, classes: TWClasses = "", @ViewBuilder content: () -> Content) {
        self.isSelected = isSelected; self.onSelect = onSelect; self.classes = classes; self.content = content()
    }
    public var body: some View {
        GridRow { content }.tw(cn("table-row", isSelected ? "table-row-selected" : "", classes))
            .environment(\.cnTableRowAction, onSelect)
            .overlay(alignment: .bottom) { CNSeparator().allowsHitTesting(false) }
            .accessibilityElement(children: .contain).accessibilityAddTraits(isSelected ? .isSelected : [])
            .accessibilityActions {
                if let onSelect { Button("Toggle row selection", action: onSelect) }
            }
    }
}
private struct CNTableRowAction: EnvironmentKey {
    static var defaultValue: (() -> Void)? { nil }
}
private extension EnvironmentValues {
    var cnTableRowAction: (() -> Void)? {
        get { self[CNTableRowAction.self] }
        set { self[CNTableRowAction.self] = newValue }
    }
}
private struct CNTableCellActivation: ViewModifier {
    @Environment(\.cnTableRowAction) private var onSelect
    @Environment(\.isEnabled) private var isEnabled
    func body(content: Content) -> some View {
        content.contentShape(.interaction, Rectangle())
            .gesture(TapGesture().onEnded { if isEnabled { onSelect?() } },
                     including: onSelect == nil ? .subviews : .all)
    }
}
public struct CNTableCell<Content: View>: View {
    private let alignment: Alignment
    private let classes: TWClasses
    private let content: Content
    public init(_ classes: TWClasses = "", alignment: Alignment = .leading, @ViewBuilder content: () -> Content) {
        self.classes = classes; self.alignment = alignment; self.content = content()
    }
    public init(_ text: LocalizedStringKey, classes: TWClasses = "", alignment: Alignment = .leading) where Content == Text {
        self.classes = classes; self.alignment = alignment; content = Text(text)
    }
    public var body: some View {
        content.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: alignment).tw(cn("table-cell", classes))
            .modifier(CNTableCellActivation())
            .gridColumnAlignment(alignment.horizontal)
    }
}
public struct CNTableHead<Content: View>: View {
    private let alignment: Alignment
    private let classes: TWClasses
    private let content: Content
    public init(_ classes: TWClasses = "", alignment: Alignment = .leading, @ViewBuilder content: () -> Content) {
        self.classes = classes; self.alignment = alignment; self.content = content()
    }
    public init(_ text: LocalizedStringKey, classes: TWClasses = "", alignment: Alignment = .leading) where Content == Text {
        self.classes = classes; self.alignment = alignment; content = Text(text)
    }
    public var body: some View {
        content.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: alignment).tw(cn("table-header", classes))
            .gridColumnAlignment(alignment.horizontal).accessibilityAddTraits(.isHeader)
    }
}
public struct CNTableCaption: View {
    private let title: LocalizedStringKey
    private let classes: TWClasses
    public init(_ title: LocalizedStringKey, classes: TWClasses = "") { self.title = title; self.classes = classes }
    public var body: some View { Text(title).tw(cn("table-caption", classes)) }
}

/// A native column builder supports rich cells and sortable key paths without a dynamic-column OS restriction.
public struct CNDataTable<Row: Identifiable, Columns: TableColumnContent>: View
where Row.ID: Sendable, Columns.TableRowValue == Row, Columns.TableColumnSortComparator == KeyPathComparator<Row> {
    private let rows: [Row]
    private let columns: Columns
    @Binding private var selection: Set<Row.ID>
    @Binding private var sortOrder: [KeyPathComparator<Row>]
    private let rowsAreSorted: Bool
    private let selectionScope: CNTableSelection<Row.ID>
    private let classes: TWClasses
    public init(_ rows: [Row], selection: Binding<Set<Row.ID>>, sortOrder: Binding<[KeyPathComparator<Row>]>,
                rowsAreSorted: Bool = false, classes: TWClasses = "",
                @TableColumnBuilder<Row, KeyPathComparator<Row>> columns: () -> Columns) {
        self.rows = rows; self.rowsAreSorted = rowsAreSorted
        selectionScope = CNTableSelection(Set(rows.map(\.id)))
        self.columns = columns(); _selection = selection; _sortOrder = sortOrder; self.classes = classes
    }
    public var body: some View {
        Table(rowsAreSorted || sortOrder.isEmpty ? rows : rows.sorted(using: sortOrder),
              selection: selectionScope.binding($selection), sortOrder: $sortOrder) { columns }.tw(cn("data-table", classes)).cnControlUtilities()
    }
}

/// Native selection edits only the visible records. The host owns selections on other pages.
/// Use stable value IDs, such as UUID, String, or Int, that conform to Sendable.
public struct CNTableSelection<ID: Hashable & Sendable>: Sendable {
    private let visible: Set<ID>
    public init(_ visible: Set<ID>) { self.visible = visible }
    public func binding(_ selection: Binding<Set<ID>>) -> Binding<Set<ID>> {
        Binding(get: { selection.wrappedValue.intersection(visible) }, set: { replacement in
            selection.wrappedValue = selection.wrappedValue.subtracting(visible).union(replacement.intersection(visible))
        })
    }
}

/// Prepare records when data, filters, or ordering change. Paging only slices this snapshot.
/// Keep selection keyed by record ID in the host, including records on other pages.
public struct CNTableRecords<Row> {
    public let rows: [Row]
    public init(_ rows: [Row], sortOrder: [KeyPathComparator<Row>] = [], filter: ((Row) -> Bool)? = nil) {
        let filtered = filter.map { rows.filter($0) } ?? rows
        self.rows = sortOrder.isEmpty || filtered.count < 2 ? filtered : filtered.sorted(using: sortOrder)
    }
    public func page(_ number: Int, size: Int = 50) -> CNTablePage<Row> {
        precondition(size > 0, "Page size must be positive.")
        let count = rows.count / size + (rows.count % size == 0 ? 0 : 1)
        let page = count == 0 ? 0 : min(count, max(1, number))
        let start = page == 0 ? 0 : (page - 1) * size
        let end = start + min(size, rows.count - start)
        return CNTablePage(rows: Array(rows[start..<end]), number: page, pageCount: count, totalCount: rows.count)
    }
}
public struct CNTablePage<Row> {
    public let rows: [Row]
    public let number: Int
    public let pageCount: Int
    public let totalCount: Int
}

On this page