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.

Install

#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

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// 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)
SlintNo Slint version of this example yet.

#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)
SlintNo Slint version of this example yet.

#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"),
        _ => {}
    }
});
SlintNo Slint version of this example yet.

#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();
SlintNo Slint version of this example yet.
// 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))
            )
    }
}
SlintNo Slint version of this example yet.

#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
                ))
            })
    }
}
SlintNo Slint version of this example yet.

#Behavior

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// 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 Change event 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

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// 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);
        }
    }
});
SlintNo Slint version of this example yet.

#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);
        });
    }
});
SlintNo Slint version of this example yet.

#Resend Code Timer

struct OtpWithResend {
    otp_state: Entity<OtpState>,
    resend_timer: Option<Timer>,
    can_resend: bool,
}

// Implementation would include timer logic for resend functionality
SlintNo Slint version of this example yet.

#API Reference

#OtpState

MethodDescription
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

MethodDescription
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

EventDescription
ChangeEmitted when OTP is complete (all digits entered)
FocusInput received focus
BlurInput 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.

NameKindTypeDefaultDescription
digitsin-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`.
maskedinbool
enabledinbooltrue
sizeinControlSizeControlSize.medium
editedcallback(index: int, text: string)Reports the cell that changed so the host can join `digits` and decide whether the code is complete.