Input OTP
A segmented one-time-password input.
use herogpui::components::input_otp::{InputOTP, OtpState};InputOTP on docs.rsSource at v0.14.0
Usage
Showing Usage, live: HeroGPUI compiled to WebAssembly. The canvas has no accessibility tree; each example's description and code below carry the same information.
Custom slot content inherits 14px/20px text; the built-in digits use 18px/24px text. Runs in a view's render: self is the view holding this example's state and cx is its Context<Self>.
use herogpui::prelude::{ActiveTheme, InputOTP, Label, Link, OtpState};
use herogpui::gpui::prelude::*;
use herogpui::gpui::{App, px};
use herogpui::gpui;
let done = self.otp_done.clone();
gpui::div()
.flex()
.flex_col()
.items_start()
.justify_center()
.gap(px(12.))
.children(vec![
Label::new("Verify account").into_any_element(),
muted_para("We've sent a code to a****@gmail.com", cx),
InputOTP::new(self.otp.clone())
.separator()
.slot_hover_bg(cx.colors().accent.soft())
.radius(px(4.))
.on_complete(cx.listener(|this, code: &str, _, cx| {
this.otp_done = code.to_owned();
cx.notify();
}))
.into_any_element(),
gpui::div()
.flex()
.items_center()
.gap(px(5.))
.child(muted_para(
&if done.is_empty() {
"Didn't receive a code?".to_owned()
} else {
format!("Complete: {done}")
},
cx,
))
.child(Link::new("input-otp-resend").label("Resend").href("#"))
.into_any_element(),
])Also calls muted_para (included when you copy).
Filled slots use the focus background and the active cursor slot keeps its caret and keyboard-visible ring. Runs in a view's render: self is the view holding this example's state and cx is its Context<Self>.
use herogpui::prelude::{InputOTP, OtpState};
InputOTP::new(self.demo_otp("otp-filled-active", 6, cx))
.value("123")
.auto_focus(true)Runs in a view's render: self is the view holding this example's state and cx is its Context<Self>.
use herogpui::prelude::{FieldVariant, InputOTP, OtpState};
use herogpui::gpui::prelude::*;
use herogpui::gpui::px;
use herogpui::gpui;
gpui::div()
.flex()
.flex_col()
.items_start()
.justify_center()
.gap(px(12.))
.children(vec![
InputOTP::new(self.demo_otp("otp-primary", 6, cx)).into_any_element(),
InputOTP::new(self.demo_otp("otp-secondary", 6, cx))
.variant(FieldVariant::Secondary)
.into_any_element(),
])Runs in a view's render: self is the view holding this example's state and cx is its Context<Self>.
use herogpui::prelude::{FieldVariant, InputOTP, OtpState, Surface};
use herogpui::gpui::prelude::*;
use herogpui::gpui::px;
use herogpui::gpui;
gpui::div()
.flex()
.flex_col()
.items_start()
.justify_center()
.gap(px(12.))
.children(vec![Surface::new()
.padding(px(24.))
.child(
InputOTP::new(self.demo_otp("otp-surface", 6, cx))
.variant(FieldVariant::Secondary),
)
.into_any_element()])Runs in a view's render: self is the view holding this example's state and cx is its Context<Self>.
use herogpui::prelude::{InputOTP, OtpState};
use herogpui::gpui::prelude::*;
use herogpui::gpui::px;
use herogpui::gpui;
gpui::div()
.flex()
.flex_col()
.items_start()
.justify_center()
.gap(px(12.))
.children(vec![InputOTP::new(self.demo_otp("otp-disabled", 6, cx))
.is_disabled(true)
.into_any_element()])Runs in a view's render: self is the view holding this example's state and cx is its Context<Self>.
use herogpui::prelude::{InputOTP, OtpState};
use herogpui::gpui::prelude::*;
use herogpui::gpui::px;
use herogpui::gpui;
gpui::div()
.flex()
.flex_col()
.items_start()
.justify_center()
.gap(px(12.))
.children(vec![
InputOTP::new(self.demo_otp("otp-four", 4, cx)).into_any_element()
])Runs in a view's render: self is the view holding this example's state and cx is its Context<Self>.
use herogpui::prelude::{ActiveTheme, InputOTP, OtpState};
use herogpui::gpui::prelude::*;
use herogpui::gpui::{App, px};
use herogpui::gpui;
let otp_typed = self.otp_typed.clone();
gpui::div()
.flex()
.flex_col()
.items_start()
.justify_center()
.gap(px(12.))
.children(vec![
InputOTP::new(self.demo_otp("otp-controlled", 6, cx))
// `value` seeds the caller's copy into the field
// once, which is what "controlled" means here.
.value(otp_typed.as_str())
.on_change(cx.listener(|this, code: &str, _, cx| {
this.otp_typed = code.to_owned();
cx.notify();
}))
.into_any_element(),
para(
&if otp_typed.is_empty() {
"Nothing typed yet".to_owned()
} else {
format!("Value: {otp_typed}")
},
cx,
),
])Also calls para (included when you copy).
Runs in a view's render: self is the view holding this example's state and cx is its Context<Self>.
use herogpui::prelude::{ActiveTheme, InputOTP, OtpState};
use herogpui::gpui::prelude::*;
use herogpui::gpui::{App, px};
use herogpui::gpui;
let done = self.otp_done.clone();
gpui::div()
.flex()
.flex_col()
.items_start()
.justify_center()
.gap(px(12.))
.children(vec![
InputOTP::new(self.demo_otp("otp-complete", 6, cx))
.on_complete(cx.listener(|this, code: &str, _, cx| {
this.otp_done = code.to_owned();
cx.notify();
}))
.into_any_element(),
para(
&if done.is_empty() {
"`onComplete` fires once every slot is filled".to_owned()
} else {
format!("Completed with {done}")
},
cx,
),
])Also calls para (included when you copy).
The GPUI `slot` extension receives each slot's live index and character. Runs in a view's render: self is the view holding this example's state and cx is its Context<Self>.
use herogpui::prelude::{InputOTP, OtpState};
use herogpui::gpui::prelude::*;
use herogpui::gpui::px;
use herogpui::gpui;
gpui::div()
.flex()
.flex_col()
.items_start()
.justify_center()
.gap(px(12.))
.children(vec![InputOTP::new(self.demo_otp(
"otp-custom-slots",
4,
cx
))
.slot(|index, value| {
gpui::div()
.flex()
.flex_col()
.items_center()
.text_size(px(11.))
.child(value.unwrap_or('·').to_string())
.child(format!("#{index}"))
.into_any_element()
})
.into_any_element(),])Runs in a view's render: self is the view holding this example's state and cx is its Context<Self>.
use herogpui::prelude::{Button, Form, FormField, InputOTP, OtpState};
use herogpui::gpui::prelude::*;
use herogpui::gpui::px;
use herogpui::gpui;
gpui::div()
.flex()
.flex_col()
.items_start()
.justify_center()
.gap(px(12.))
.children(vec![{
let state = self.demo_otp("otp-form", 6, cx);
Form::new()
.field(FormField::code("code", state.clone()).is_required(true))
.child(InputOTP::new(state).name("code"))
.child(Button::new("input-otp-form-submit").label("Verify"))
.into_any_element()
}])Runs in a view's render: self is the view holding this example's state and cx is its Context<Self>.
use herogpui::prelude::{InputOTP, OtpPattern, OtpState};
use herogpui::gpui::prelude::*;
use herogpui::gpui::px;
use herogpui::gpui;
gpui::div()
.flex()
.flex_col()
.items_start()
.justify_center()
.gap(px(12.))
.children(vec![
InputOTP::new(self.demo_otp("otp-pat-digits", 4, cx))
.pattern(OtpPattern::Digits)
.into_any_element(),
InputOTP::new(self.demo_otp("otp-pat-alnum", 4, cx))
.pattern(OtpPattern::Alphanumeric)
.into_any_element(),
InputOTP::new(self.demo_otp("otp-pat-any", 4, cx))
.pattern(OtpPattern::Any)
.into_any_element(),
])Runs in a view's render: self is the view holding this example's state and cx is its Context<Self>.
use herogpui::prelude::{InputOTP, OtpState};
use herogpui::gpui::prelude::*;
use herogpui::gpui::px;
use herogpui::gpui;
gpui::div()
.flex()
.flex_col()
.items_start()
.justify_center()
.gap(px(12.))
.children(vec![
InputOTP::new(self.demo_otp("otp-validate", 6, cx))
.validate(|code| {
(code.chars().count() < 6).then(|| "Enter all six digits".into())
})
.into_any_element(),
InputOTP::new(self.demo_otp("otp-invalid", 6, cx))
// `isInvalid` from the outside: what a rejected code
// looks like when the server says so.
.is_invalid(true)
.into_any_element(),
])Helpers used by these examples
Plain functions over the public API, defined once here and copied with each example that calls them.
/// A paragraph of body text.
/// A muted `text-sm` paragraph, for the supporting copy a v3 example composes
/// *inside* its preview (`<p className="text-sm text-muted">`). [`para`] is the
/// gallery's own prose voice and belongs outside the bordered preview.
fn muted_para(text: &str, cx: &App) -> gpui::AnyElement {
let colors = cx.colors();
gpui::div()
.text_size(px(14.))
.line_height(px(20.))
.text_color(colors.muted)
.child(text.to_owned())
.into_any_element()
}fn para(text: &str, cx: &App) -> gpui::AnyElement {
let colors = cx.colors();
gpui::div()
.text_size(px(14.5))
.line_height(px(24.))
.text_color(colors.foreground)
.max_w(px(720.))
.child(text.to_owned())
.into_any_element()
}Anatomy
Rust types
Input OTP is assembled from these builders. The API reference lists each one.
Customization
Shared appearance belongs on ThemeBuilder::components and .recipe("name"). Typed builders change how Input OTP looks at one call site. Every builder also carries sx for placement, width and flex: it takes GPUI's own styling methods and refines them over the component's root after the theme, so an instance override wins. Behaviour builders live in the API reference below.
Styling
| Method | Values | Description |
|---|---|---|
variant(FieldVariant) | FieldVariant::Primary | FieldVariant::Secondary | Visual treatment of the slots. |
// The one slot for caller-owned low-level styling: GPUI's
// styling methods, refined over the root element last.
.sx(|el| {
el.bg(gpui::rgba(0xffa500ff)) // background
.text_color(gpui::rgba(0x000000ff)) // text
.w(gpui::px(13.)) // size
.h(gpui::px(12.))
})API reference
Against HeroUI v3.2.6: 12 entries ported, 13 partial, 8 not ported. Partial rows are marked in the tables; entries not ported are listed under each table with the reason.
Builders
| Builder | Values | Default | Description |
|---|---|---|---|
with_length( | number | — ( | Number of input slots. |
value( | string | — | Controlled value. |
on_change( | Fn( | — | Handler called whenever the value changes. |
on_complete(Partial | Fn( | — | Handler called when all slots are filled. |
variant( | FieldVariant:: | FieldVariant:: | Visual treatment of the slots. |
slot(Partial | AnyElement | — | Composition of Group, Slot and Separator parts. |
is_disabled( | true | | false | Whether the input is disabled. |
is_invalid( | true | | false | Whether the input is invalid. |
validation_errors(Partial | string[] | — | Server-side or custom validation errors. |
pattern(Partial | string | — | Regular expression for accepted characters. |
text_align(Partial | OtpTextAlign:: | OtpTextAlign:: | Text alignment within each slot. |
placeholder( | string | — | Placeholder text for empty slots. |
paste_transformer(Partial | Fn( | — | Transforms pasted text before filling the slots. |
name(Partial | string | — | Name used by form_field(); explicit Form registration is required. |
auto_focus( | true | | — | Focuses the first slot on mount. |
Not ported8 HeroUI entries not ported
| HeroUI v3 | Why it is not ported |
|---|---|
InputOTP.className | For the root container. |
InputOTP.containerClassName | For the inner container. |
InputOTP.validationDetails | Validity details. |
InputOTP.inputMode | Virtual-keyboard hint. |
InputOTP.Group.className | For the group. |
InputOTP.Group.children | Slot children in the group. |
InputOTP.Slot.className | For the slot. |
InputOTP.Separator.className | For the separator. |
Parts
| Part | Description |
|---|---|
InputOTPPartial | Root field and editing surface. |
InputOTPPartial | Group of slots; implicit in the flat GPUI row. |
InputOTPPartial | One indexed character slot. |
InputOTPPartial | Optional separator bar after each three slots. |
States
| State | Builder | Description |
|---|---|---|
Hovered | cell.hover(variant fill | Hovered slot surface and field-border color follow the active variant while active/filled slots retain their focus background. |
Active | active_bg | Focused slot carries the fake caret and focus background; keyboard modality adds the shared focus ring. |
Filled | active_bg | Slot containing a character keeps the focus background and animates the portable value entrance. |
Disabled | is_disabled focus/key gates | Disabled root and slots. |
InvalidPartial | validation::resolve | Invalid field treatment. |