CBPinEntryView
July 19, 2026 · View on GitHub
A SwiftUI view for entering pins, one-time codes, or passwords — backspace, paste, and .oneTimeCode autofill all work correctly out of the box, with first-class accessibility (VoiceOver progress announcements, Dynamic Type, no colour-only states).
Requirements
- iOS 16.0+
- Swift 5.9+
- No external dependencies
Installation
Swift Package Manager only:
dependencies: [
.package(url: "https://github.com/Fawxy/CBPinEntryView.git", from: "2.0.0")
]
Upgrading from 1.x? CBPinEntryView 2.0 is a SwiftUI-only rewrite with no UIKit compatibility shim. See MIGRATION.md.
Usage
import CBPinEntryView
struct LoginView: View {
@State private var pin = ""
@State private var isError = false
var body: some View {
PinEntryView(pin: $pin, length: 6, isError: $isError) { pin in
print("Completed: \(pin)")
}
}
}
pin is the single source of truth — read it directly, no getPinAsString() needed. Set pin = "" to clear.
Configuration
Behavioural configuration is applied via pin-prefixed modifiers, not the standard SwiftUI ones (see "Why pin-prefixed modifiers?" below):
PinEntryView(pin: $pin, length: 4, isError: $isError)
.pinAllowedEntry(.numerical)
.pinSecure(true, character: "●")
.pinKeyboardType(.numberPad)
.pinTextContentType(.oneTimeCode)
.pinTextInputAutocapitalization(.never)
.pinHaptics(.default)
.pinFocused($isFocused)
Custom cell rendering
The library ships one default cell (DefaultPinEntryCell) but imposes no particular look. Pass your own @ViewBuilder closure over PinEntryCellState:
PinEntryView(pin: $pin, length: 4, isError: $isError) { state in
VStack {
Text(state.character ?? "")
Rectangle()
.fill(state.isFocused ? Color.accentColor : Color.secondary)
.frame(height: state.isFocused ? 3 : 1)
}
}
PinEntryCellState is already masked when secure — a custom cell never sees the raw digit in secure mode.
Why pin-prefixed modifiers?
A wrapper view can't give a propagating standard modifier (.keyboardType, .textContentType, .textInputAutocapitalization) an overridable default: SwiftUI's environment resolves closest-to-the-field-wins, and the field inside PinEntryView is always closer than anything wrapped around it. Applying the standard modifier outside PinEntryView would silently do nothing. The pin-prefixed modifiers avoid that footgun — apply them directly on PinEntryView, before any type-erasing modifier like .frame().
Accessibility
The field carries a single accessibility element (label + a live "N of M entered" progress value); the cell overlay is hidden from VoiceOver. Secure entry reports count only, never characters. Error is surfaced non-visually as well as visually. This is guaranteed by the library regardless of the cell closure you provide.
UIKit interop
PinEntryView is a SwiftUI-only view. Host it from UIKit with UIHostingController:
let pinView = PinEntryView(pin: pinBinding, length: 4, isError: errorBinding) { pin in
// handle completion
}
let hosting = UIHostingController(rootView: pinView)
addChild(hosting)
view.addSubview(hosting.view)
// ... Auto Layout constraints ...
hosting.didMove(toParent: self)
See Example/Example/HostingControllerInteropScreen.swift for a complete, running example.
Example app
Open Example/Example.xcodeproj in Xcode and run the Example scheme (iOS 17+ simulator). It exercises length, secure toggle, error toggle, allowed-character restriction, clear, programmatic focus, a custom underlined cell with a shake-on-error animation, an @Observable feature-model screen, and the UIHostingController interop screen.
Migrating from 1.x
See MIGRATION.md for the full API mapping.
License
CBPinEntryView is available under the MIT license. See the LICENSE file for more info.