OtpInput
A specialized input component for one-time passwords (OTP) that displays multiple input fields in a grid layout. Perfect for SMS verification codes, authenticator app codes, and other numeric verification scenarios.
#Import
use gpui_kit::component::input::{OtpInput, OtpState};// Installed in ui/components. That directory is on the Slint include path, so import by file name:
import { OtpInput } from "otp-input.slint";use iced::widget::{column, container, mouse_area, row, scrollable, svg, text};
use iced::{Alignment, Element, Length, Task, Theme};#Usage
// Slint's example is six boxes — a verification code, a filled code, a masked
// one, the sizes, the disabled field and a four-cell code — and the page puts
// all of its Usage examples under one heading, so they are one box here.
let usage = column![
OtpInput::new("code")
.value(app.code.as_str())
.length(6)
.on_edit(Message::Code),
muted_line(&format!("Enter the 6-digit code. Last edit: {}", app.last)),
OtpInput::new("default")
.value(app.default_code.as_str())
.on_edit(Message::DefaultCode),
muted_line("A code that starts filled: the caller's value is the cells."),
OtpInput::new("masked")
.value(app.masked_code.as_str())
.masked(true)
.on_edit(Message::Masked),
muted_line("masked(true): every cell hides its character."),
row![
OtpInput::new("size-small")
.value(app.size_small.as_str())
.length(4)
.size(ControlSize::Small)
.on_edit(Message::SizeSmall),
OtpInput::new("size-medium")
.value(app.size_medium.as_str())
.size(ControlSize::Medium)
.on_edit(Message::SizeMedium),
OtpInput::new("size-large")
.value(app.size_large.as_str())
.size(ControlSize::Large)
.on_edit(Message::SizeLarge),
]
.spacing(16.0)
.align_y(Alignment::Center),
muted_line("The size ladder: 24 / 32 / 40, the Slint theme's control-height."),
OtpInput::new("disabled").value("123456").disabled(true),
muted_line("disabled(true) refuses input and draws the muted field."),
OtpInput::new("pin")
.value(app.pin.as_str())
.length(4)
.on_edit(Message::Pin),
muted_line("A four-cell PIN: `length` is the cell count."),
]
.spacing(12.0);#Verification
struct SmsVerification {
otp_state: Entity<OtpState>,
phone_number: String,
is_verifying: bool,
}
impl SmsVerification {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let otp_state = cx.new(|cx| OtpState::new(6, window, cx));
cx.subscribe(&otp_state, |this, state, event: &InputEvent, cx| {
if let InputEvent::Change = event {
let code = state.read(cx).value();
this.verify_sms_code(&code, cx);
}
});
Self {
otp_state,
phone_number: "+1234567890".to_string(),
is_verifying: false,
}
}
fn verify_sms_code(&mut self, code: &str, cx: &mut Context<Self>) {
self.is_verifying = true;
// API call to verify SMS code
println!("Verifying SMS code: {}", code);
cx.notify();
}
}
impl Render for SmsVerification {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.gap_4()
.child(format!("Enter the 6-digit code sent to {}", self.phone_number))
.child(OtpInput::new(&self.otp_state))
.when(self.is_verifying, |this| {
this.child("Verifying...")
})
}
}OtpInput {
digits <=> root.code;
edited(index, text) => {
root.last = "cell \{index + 1} → \{text == "" ? "(cleared)" : text}";
}
}
Text {
text: "Enter the 6-digit code.";
color: Theme.muted-foreground;
}
Text {
text: "Last edit: " + root.last;
color: Theme.muted-foreground;
font-size: Theme.font-size-xs;
}#Basic OTP Input
let otp_state = cx.new(|cx| OtpState::new(6, window, cx));
OtpInput::new(&otp_state)#With Default Value
let otp_state = cx.new(|cx|
OtpState::new(6, window, cx)
.default_value("123456")
);
OtpInput::new(&otp_state)OtpInput {
digits: [
"1",
"2",
"3",
"4",
"5",
"6"
];
}#Masked OTP Input
let otp_state = cx.new(|cx|
OtpState::new(6, window, cx)
.masked(true)
.default_value("123456")
);
OtpInput::new(&otp_state)OtpInput {
digits: [
"1",
"2",
"3",
"4",
"5",
"6"
];
masked: true;
}#Different Sizes
// Small size
OtpInput::new(&otp_state).small()
// Medium size (default)
OtpInput::new(&otp_state)
// Large size
OtpInput::new(&otp_state).large()
// Custom size
OtpInput::new(&otp_state).with_size(px(55.))OtpInput {
digits: [
"1",
"2",
"3",
"4"
];
size: ControlSize.small;
}
OtpInput {
digits: [
"1",
"2",
"3",
"4",
"5",
"6"
];
}
OtpInput {
digits: [
"1",
"2",
"3",
"4",
"5",
"6"
];
size: ControlSize.large;
}#Grouped Layout
// Single group (all fields together)
OtpInput::new(&otp_state).groups(1)
// Two groups (default) - splits fields in half
OtpInput::new(&otp_state).groups(2)
// Three groups - splits fields into thirds
OtpInput::new(&otp_state).groups(3)#Disabled State
OtpInput::new(&otp_state).disabled(true)OtpInput {
digits: [
"1",
"2",
"3",
"4",
"5",
"6"
];
enabled: false;
}#Different Length Codes
// 4-digit PIN
let pin_state = cx.new(|cx| OtpState::new(4, window, cx));
OtpInput::new(&pin_state).groups(1)
// 6-digit SMS code (most common)
let sms_state = cx.new(|cx| OtpState::new(6, window, cx));
OtpInput::new(&sms_state)
// 8-digit authenticator code
let auth_state = cx.new(|cx| OtpState::new(8, window, cx));
OtpInput::new(&auth_state).groups(2)OtpInput {
digits: [
"1",
"2",
"3",
"4"
];
}
OtpInput {
digits: [
"",
"",
"",
"",
"",
""
];
}#Handle OTP Events
let otp_state = cx.new(|cx| OtpState::new(6, window, cx));
cx.subscribe(&otp_state, |this, state, event: &InputEvent, cx| {
match event {
InputEvent::Change => {
let code = state.read(cx).value();
if code.len() == 6 {
println!("Complete OTP: {}", code);
// Automatically submit when complete
this.verify_otp(&code, cx);
}
}
InputEvent::Focus => println!("OTP input focused"),
InputEvent::Blur => println!("OTP input lost focus"),
_ => {}
}
});#Programmatic Control
// Set value programmatically
otp_state.update(cx, |state, cx| {
state.set_value("123456", window, cx);
});
// Toggle masking
otp_state.update(cx, |state, cx| {
state.set_masked(true, window, cx);
});
// Focus the input
otp_state.update(cx, |state, cx| {
state.focus(window, cx);
});
// Get current value
let current_value = otp_state.read(cx).value();// The programmatic calls the page documents: `value` is the caller's code,
// `length` the cells, `masked` the hidden characters, `size` the ladder,
// `disabled` the muted field and `on_edit` every cell change.
// `otp_input::focus` is the caret, returned from `update` as a Task. The four
// buttons are the page's four programmatic calls: set the value, clear it,
// toggle masking, and focus. The last card is a usage example, not the API
// listing — `## API Reference` ends the page's example region, so a card named
// for it has no heading to scroll to.
let api = column![
OtpInput::new("api")
.value(app.api.as_str())
.length(4)
.masked(app.api_masked)
.on_edit(Message::Api),
row![
Button::new("Fill 1 2 3 4")
.size(ControlSize::Small)
.on_press(Message::FillApi),
Button::new("Clear")
.size(ControlSize::Small)
.on_press(Message::ClearApi),
Button::new(if app.api_masked { "Show" } else { "Mask" })
.size(ControlSize::Small)
.on_press(Message::ToggleMask),
Button::new("Focus first cell")
.size(ControlSize::Small)
.on_press(Message::FocusApi),
]
.spacing(8.0),
muted_line(&format!(
"value: {:?} · masked: {} · {} / 4 cells",
app.api,
app.api_masked,
app.api.chars().count(),
)),
]
.spacing(8.0);#Two-Factor Authentication
struct TwoFactorAuth {
otp_state: Entity<OtpState>,
is_masked: bool,
}
impl TwoFactorAuth {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let otp_state = cx.new(|cx|
OtpState::new(6, window, cx)
.masked(true)
);
Self {
otp_state,
is_masked: true,
}
}
fn toggle_visibility(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.is_masked = !self.is_masked;
self.otp_state.update(cx, |state, cx| {
state.set_masked(self.is_masked, window, cx);
});
}
}
impl Render for TwoFactorAuth {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.gap_4()
.child("Enter your authenticator code")
.child(OtpInput::new(&self.otp_state))
.child(
Button::new("toggle-visibility")
.label(if self.is_masked { "Show" } else { "Hide" })
.on_click(cx.listener(Self::toggle_visibility))
)
}
}#PIN Entry
struct PinEntry {
pin_state: Entity<OtpState>,
attempts: usize,
max_attempts: usize,
}
impl PinEntry {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let pin_state = cx.new(|cx|
OtpState::new(4, window, cx)
.masked(true)
);
cx.subscribe(&pin_state, |this, state, event: &InputEvent, cx| {
if let InputEvent::Change = event {
let pin = state.read(cx).value();
this.verify_pin(&pin, cx);
}
});
Self {
pin_state,
attempts: 0,
max_attempts: 3,
}
}
fn verify_pin(&mut self, pin: &str, cx: &mut Context<Self>) {
self.attempts += 1;
// Simulate PIN verification
if pin == "1234" {
println!("PIN verified successfully!");
} else {
println!("Incorrect PIN. Attempts: {}/{}", self.attempts, self.max_attempts);
// Clear PIN on incorrect attempt
self.pin_state.update(cx, |state, cx| {
state.set_value("", window, cx);
});
}
cx.notify();
}
}
impl Render for PinEntry {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let is_locked = self.attempts >= self.max_attempts;
v_flex()
.gap_4()
.child("Enter your 4-digit PIN")
.child(
OtpInput::new(&self.pin_state)
.groups(1)
.disabled(is_locked)
)
.when(is_locked, |this| {
this.child("Too many attempts. Please try again later.")
})
.when(self.attempts > 0 && !is_locked, |this| {
this.child(format!(
"Incorrect PIN. {} attempts remaining.",
self.max_attempts - self.attempts
))
})
}
}#Behavior
// The component owns the edit, so these are behaviours rather than settings.
// A character takes the cell it was typed in and hands the caret to the next
// one; Backspace clears the cell and steps back; a paste spreads over the
// cells after this one; anything that is not a digit is refused and leaves
// the code alone; and the code stops at `length`. Type a letter into the
// field below — nothing lands, and the readout says so.
let behaviour = column![
OtpInput::new("behaviour")
.value(app.behaviour.as_str())
.length(4)
.on_edit(Message::Behaviour),
muted_line(&format!(
"{} / 4 cells · {} · last edit: {}",
app.behaviour.chars().count(),
if app.behaviour.chars().count() == 4 {
"complete"
} else {
"filling"
},
app.behaviour_last,
)),
]
.spacing(8.0);#Input Handling
- Numeric Only: Accepts only digits (0-9)
- Auto-Focus: Automatically moves to next field when digit is entered
- Backspace: Removes current digit and moves to previous field
- Length Limit: Prevents input beyond specified length
- Auto-Complete: Emits
Changeevent when all fields are filled
#Visual Feedback
- Focus Indicator: Blue border and blinking cursor on active field
- Masking: Shows asterisk icons instead of numbers when enabled
- Grouping: Visual separation of fields into groups for better readability
- Disabled State: Grayed out appearance when disabled
#Keyboard Navigation
- Arrow Keys: Navigate between fields
- Tab: Move to next focusable element
- Shift+Tab: Move to previous focusable element
- Backspace: Delete current digit and move backward
- Delete: Clear current field
#Common Patterns
// Completion is a read rather than an event: iced's field publishes a
// message per edit, and `Edit::is_complete` is the check that submits the
// code below. Clearing is the caller's too — the code is its own value — and
// `otp_input::focus` is how it asks for the caret back. Two of the page's
// patterns have no iced expression: nothing reports focus, so "clear on
// focus" cannot be written, and a resend countdown is application state and a
// `Subscription` the field never sees.
let patterns = column![
OtpInput::new("pattern")
.value(app.pattern.as_str())
.length(4)
.on_edit(Message::Pattern),
muted_line(&match app.submitted.as_ref() {
Some(code) => format!("Submitted {code} — the caller saw the fourth cell fill."),
None => "Fill all four cells and `update` submits the code.".to_string(),
}),
row![
Button::new("Clear")
.variant(Variant::Secondary)
.outline()
.size(ControlSize::Small)
.on_press(Message::ClearPattern),
// A resend button while the countdown runs: no message, so iced draws
// it disabled, which is the state the field has nothing to do with.
Button::new("Resend in 28s").size(ControlSize::Small),
]
.spacing(8.0),
]
.spacing(8.0);#Auto-Submit on Complete
cx.subscribe(&otp_state, |this, state, event: &InputEvent, cx| {
if let InputEvent::Change = event {
let code = state.read(cx).value();
if code.len() == 6 {
// Auto-submit when complete
this.submit_verification_code(&code, cx);
}
}
});#Clear on Focus
cx.subscribe(&otp_state, |this, state, event: &InputEvent, cx| {
if let InputEvent::Focus = event {
// Clear previous value when user starts entering new code
state.update(cx, |state, cx| {
state.set_value("", window, cx);
});
}
});#Resend Code Timer
struct OtpWithResend {
otp_state: Entity<OtpState>,
resend_timer: Option<Timer>,
can_resend: bool,
}
// Implementation would include timer logic for resend functionality#API Reference
#OtpState
| Method | Description |
|---|---|
new(length, window, cx) | Create a new OTP state with specified length |
default_value(str) | Set initial value |
masked(bool) | Enable masked display (shows asterisks) |
set_value(str, window, cx) | Set OTP value programmatically |
value() | Get current OTP value |
set_masked(bool, window, cx) | Toggle masked display |
focus(window, cx) | Focus the OTP input |
focus_handle(cx) | Get focus handle |
#OtpInput
| Method | Description |
|---|---|
new(state) | Create OTP input with state entity |
groups(n) | Set number of visual groups (default: 2) |
disabled(bool) | Set disabled state |
small() | Small size (6x6 px fields) |
large() | Large size (11x11 px fields) |
with_size(px) | Custom field size |
#InputEvent
| Event | Description |
|---|---|
Change | Emitted when OTP is complete (all digits entered) |
Focus | Input received focus |
Blur | Input lost focus |
#OtpInput
A one-time-password field. Mirrors GPUI Component's `OtpInput`: a row of single-character cells that make up one code. The cells cannot be derived from a single code string. Slint gives a string no indexing and no `substring`, an array only `length`, and a function body neither loops nor recursion — so `["1","2"]` cannot be joined into `"12"` and a `value: string` could never be split across cells. `digits` is therefore the source of truth, one entry per cell, and the host assembles the code itself. The cells are named rather than built by a `for` loop for one reason: typing a character has to move focus to the next cell, that needs a reference to it, and Slint has no focus-by-index. Up to six cells are supported; a longer `digits` array simply does not render its extra entries.
| Name | Kind | Type | Default | Description |
|---|---|---|---|---|
digits | in-out | [string] | ["", "", "", "", "", ""] | One entry per cell. The cells copy it when they are created; a host that replaces the array afterwards moves each cell through `set-digits`. |
masked | in | bool | ||
enabled | in | bool | true | |
size | in | ControlSize | ControlSize.medium | |
edited | callback | (index: int, text: string) | Reports the cell that changed so the host can join `digits` and decide whether the code is complete. |