Skip to main content
3Nsofts logo3Nsofts
Developer Tools

NSTokenField in macOS: Practical Production Patterns for Tag-Based Input Fields

Production patterns for building fast, accessible tag input with NSTokenField, including delegate data flow, autocomplete, persistence, paste handling, and SwiftUI integration.

By Ehsan Azish · 3NSOFTS·August 2026·11 min read

NSTokenField is one of those AppKit controls that looks simple until you ship it. A tag-based input field sounds reasonable enough: type a name, press comma, get a token. But the moment you add autocomplete, token styles, persistence, or keyboard-only navigation, the delegate contract starts to matter.

This article covers patterns that hold up in production macOS apps: represented objects, responsive completion, stable model identity, paste behavior, accessibility, and a SwiftUI bridge that does not interrupt editing.


What NSTokenField Actually Is

NSTokenField is an NSTextField subclass that converts text into discrete tokens. Each token can be backed by a represented object from your model layer rather than only a display string.

Most specialized behavior belongs in NSTokenFieldDelegate. The core flow is:

  1. The user types text.
  2. The field asks tokenField(_:completionsForSubstring:indexOfToken:indexOfSelectedItem:) for completion strings.
  3. When the user commits a token, tokenField(_:representedObjectForEditing:) converts that editing string into a model object.
  4. tokenField(_:displayStringForRepresentedObject:) supplies the visible label.
  5. tokenField(_:editingStringForRepresentedObject:) supplies the editable textual form used when a represented object becomes text again.

Missing one of these conversions can produce tokens that display correctly but cannot be edited, copied, or restored reliably.


Setting Up the Delegate Correctly

Here is a compact delegate for a free-form tag field backed by a custom model:

import AppKit

struct Tag: Hashable {
    let id: UUID
    var name: String
}

@MainActor
final class TagFieldDelegate: NSObject, NSTokenFieldDelegate {
    var availableTags: [Tag] = []
    var onChange: (([Tag]) -> Void)?

    func tokenField(
        _ tokenField: NSTokenField,
        representedObjectForEditing editingString: String
    ) -> Any? {
        let trimmed = editingString.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return nil }

        return availableTags.first {
            $0.name.localizedCaseInsensitiveCompare(trimmed) == .orderedSame
        } ?? Tag(id: UUID(), name: trimmed)
    }

    func tokenField(
        _ tokenField: NSTokenField,
        displayStringForRepresentedObject representedObject: Any
    ) -> String? {
        (representedObject as? Tag)?.name
    }

    func tokenField(
        _ tokenField: NSTokenField,
        editingStringForRepresentedObject representedObject: Any
    ) -> String? {
        (representedObject as? Tag)?.name
    }

    func tokenField(
        _ tokenField: NSTokenField,
        completionsForSubstring substring: String,
        indexOfToken tokenIndex: Int,
        indexOfSelectedItem selectedIndex: UnsafeMutablePointer<Int>?
    ) -> [Any]? {
        sortedCompletions(for: substring).map(\.name)
    }

    func tokenField(
        _ tokenField: NSTokenField,
        shouldAdd tokens: [Any],
        at index: Int
    ) -> [Any] {
        let existing = Set(
            (tokenField.objectValue as? [Tag] ?? [])
                .map { $0.name.lowercased() }
        )

        return tokens.compactMap { value in
            guard let tag = value as? Tag,
                  !existing.contains(tag.name.lowercased()) else { return nil }
            return tag
        }
    }

    func sortedCompletions(for substring: String) -> [Tag] {
        let query = substring.lowercased()
        let prefix = availableTags.filter { $0.name.lowercased().hasPrefix(query) }
        let contains = availableTags.filter {
            !$0.name.lowercased().hasPrefix(query) &&
            $0.name.lowercased().contains(query)
        }
        return prefix + contains
    }

    @objc func valueChanged(_ sender: NSTokenField) {
        onChange?(sender.objectValue as? [Tag] ?? [])
    }
}

Creating a new Tag when no existing match is found makes this a free-form field. For a controlled vocabulary, return nil for unknown input instead.

Use tokenField(_:shouldAdd:at:) for policies that depend on the whole collection, such as duplicate removal, maximum count, or permission checks. That keeps validation separate from string-to-model conversion.


Autocomplete That Feels Responsive

NSTokenField exposes completionDelay, so you can tune how quickly completion appears:

tagField.completionDelay = 0.12

Do not make the delay zero without testing long lists and assistive input methods. A small delay reduces menu churn while still feeling immediate.

Keep candidates in memory and avoid disk, Core Data, SwiftData, or network work inside the completion delegate. For large collections, build a normalized search index when the data changes rather than on every keystroke.

Substring matching is usually more useful than prefix-only matching:

availableTags.filter {
    $0.name.localizedCaseInsensitiveContains(substring)
}

Rank prefix matches above mid-string matches. A user typing “learn” can still find “machine learning,” while exact and prefix candidates remain predictable at the top.


Token Styles Without Rebuilding the Control

NSTokenField.TokenStyle includes .default, .none, .plainSquared, .rounded, and .squared. Set one style for the field:

tagField.tokenStyle = .rounded

Or choose among those built-in styles per represented object:

func tokenField(
    _ tokenField: NSTokenField,
    styleForRepresentedObject representedObject: Any
) -> NSTokenField.TokenStyle {
    guard let tag = representedObject as? Tag else { return .default }
    return tag.name.hasPrefix("#") ? .rounded : .plainSquared
}

AppKit does not expose a supported per-token color property. If brand-colored chips are essential, a custom token editor may be more maintainable than depending on private cell drawing behavior. Keep NSTokenField when its native editing, keyboard, accessibility, and drag-and-drop behavior is more valuable than unrestricted visual styling.


Persist Model Identity, Not Display Text

Read represented objects from objectValue when identity matters. Persist stable identifiers, then resolve those identifiers back to the current models:

if let tags = tagField.objectValue as? [Tag] {
    let ids = tags.map(\.id)
    // Persist ids in your model.
}

let restoredTags = persistedIDs.compactMap { id in
    availableTags.first { $0.id == id }
}
tagField.objectValue = restoredTags as NSArray

This survives renames. A record that references a tag UUID still resolves after “machine learning” becomes “ML”; a record that stores only the old label does not.

For SwiftData or Core Data, keep persistence concerns outside the delegate. The delegate should receive an in-memory collection or index maintained by the owning view model.


Keyboard Navigation and Accessibility

Native token selection, arrow-key movement, deletion, and focus behavior are strong reasons to use NSTokenField instead of recreating the interaction from individual SwiftUI views.

When your code changes objectValue, notify accessibility clients that the value changed:

NSAccessibility.post(element: tagField, notification: .valueChanged)

Avoid posting duplicate notifications for user-driven edits already handled by the control. Test with VoiceOver, Full Keyboard Access, pasted text, empty tokens, and long localized labels—not only mouse input.


Handling Paste and Drag-and-Drop

Tokenization uses the field’s tokenizingCharacterSet. For comma-, newline-, and tab-separated tags:

tagField.tokenizingCharacterSet = CharacterSet(charactersIn: ",\n\t")

For search queries that commonly contain commas, newline-only tokenization may be safer.

When plain text is pasted, each editing string flows through your conversion method. Keep that lookup fast with a normalized dictionary:

var tagIndex: [String: Tag] = [:]

func rebuildIndex() {
    tagIndex = Dictionary(
        uniqueKeysWithValues: availableTags.map {
            ($0.name.lowercased(), $0)
        }
    )
}

For richer copy, paste, or drag payloads, use the delegate’s pasteboard methods rather than relying on your model objects to bridge automatically. Encode stable IDs and a text fallback so another field—or another app—can make a sensible choice.


Integrating NSTokenField with SwiftUI

NSViewRepresentable keeps the mature AppKit control while exposing SwiftUI state. The coordinator must do two jobs: serve as the token delegate and send committed values back to the binding.

import SwiftUI

struct TokenField: NSViewRepresentable {
    @Binding var tokens: [Tag]
    var availableTags: [Tag]

    func makeCoordinator() -> TagFieldDelegate {
        let coordinator = TagFieldDelegate()
        coordinator.availableTags = availableTags
        coordinator.onChange = { newValue in
            tokens = newValue
        }
        return coordinator
    }

    func makeNSView(context: Context) -> NSTokenField {
        let field = NSTokenField()
        field.delegate = context.coordinator
        field.target = context.coordinator
        field.action = #selector(TagFieldDelegate.valueChanged(_:))
        field.tokenStyle = .rounded
        field.completionDelay = 0.12
        field.objectValue = tokens as NSArray
        return field
    }

    func updateNSView(_ field: NSTokenField, context: Context) {
        context.coordinator.availableTags = availableTags

        let current = field.objectValue as? [Tag] ?? []
        if current != tokens {
            field.objectValue = tokens as NSArray
            NSAccessibility.post(element: field, notification: .valueChanged)
        }
    }
}

The equality guard is important. Reassigning objectValue during every SwiftUI update can reset selection and interrupt in-progress editing.

If the surrounding view needs updates at a different boundary—for example, only when editing ends—implement the relevant NSControlTextEditingDelegate notification in the coordinator instead of using the action.


Production Patterns Worth Keeping

Normalize once. Trim whitespace and define one case-folding policy. Apply it to completion, duplicate detection, paste, and persistence lookup.

Validate collections in shouldAdd. Duplicate and count rules belong in tokenField(_:shouldAdd:at:), where you can evaluate proposed objects against the existing collection.

Keep model work out of delegate callbacks. Supply a memory-backed index and let a view model perform persistence or synchronization asynchronously.

Treat paste as a first-class path. Test comma-separated text, multiline text, tokens copied from your own app, and unsupported payloads.

Do not overwrite active editing from SwiftUI. Compare represented values before assigning objectValue.

Use placeholderString for the empty state. Add attributed placeholder content only when the styling is genuinely necessary.


Where NSTokenField Fits in a Larger Architecture

Tag input is the presentation layer of a larger model. In a production app, tags may have stable IDs, aliases, many-to-many relationships, CloudKit synchronization, and conflict-resolution rules. NSTokenField should translate between editing text and those model objects; it should not own the database.

If you are designing the surrounding application, see our macOS app development service and SwiftUI architecture guide. For apps combining document workflows with local intelligence, the Core ML integration patterns cover the model-delivery side.


FAQs

What is the difference between objectValue and stringValue?

objectValue preserves the represented objects behind the tokens. stringValue exposes their textual form. Use objectValue when model identity matters.

How do I prevent duplicate tokens?

Use tokenField(_:shouldAdd:at:). Compare proposed represented objects with the current collection and return only valid additions.

Why does the autocomplete menu not appear?

Confirm that the delegate is retained and assigned, the completion method signature exactly matches the protocol, and it returns completion strings. Also check completionDelay, candidate availability, and filtering logic.

Can I use NSTokenField in a SwiftUI macOS app?

Yes. Wrap it with NSViewRepresentable, retain the delegate in the coordinator, bridge committed objectValue changes back to the binding, and avoid redundant writes during updates.

Is NSTokenField available on iOS?

No. NSTokenField belongs to AppKit on macOS. iOS needs a UIKit or SwiftUI token editor or a suitable third-party component.


NSTokenField rewards a clear delegate boundary. Convert editing strings into stable represented objects, keep completion data in memory, validate proposed collections in the appropriate callback, and make the SwiftUI bridge bidirectional. Those choices preserve the native behavior while keeping the control aligned with a production data model.

Need help designing the model, synchronization, or AppKit integration behind a macOS product? Start a project with 3Nsofts.

Authoritative References