gpuicn Components

Component 36 of 60

Input OTP

A segmented input for one-time passcodes.

site/src/previews/otp_field.rs

Starting the preview. GPUI compiles to WebAssembly, so the first load takes a few seconds. This browser has neither WebGPU nor WebGL 2, so the live preview cannot run. The code on this page still works. The preview did not start.

Input OTP, running in the browser. The second tab shows the preview's own source, which includes its layout and demo state. For the smallest working example, see Usage below. Download the whole showcase for its imports and helpers.

Installation

After the one-time setup, run the command from your app root. The CLI writes the modules and keeps src/ui/mod.rs in step.

gpuicn add otp-field

If you prefer to copy the files by hand, declare them yourself:

// src/ui/mod.rs
pub mod theme;
pub mod otp_field;

// src/main.rs
mod ui;

Usage

Put this in a view module and return example(window, cx) from its render method.

use crate::ui::otp_field::*;
use gpui_kit::{App, IntoElement, Window};

fn example(window: &mut Window, cx: &mut App) -> impl IntoElement {
    let state = window.use_keyed_state("code", cx, |window, cx| OtpState::new(6, window, cx));
    OtpField::new(&state).aria_label("Verification code")
}

API reference

pub fn new(state: &Entity<OtpState>) -> Self

Creates a Nova code editor using the caller's retained Kit state.

pub fn aria_label(mut self, value: impl Into<SharedString>) -> Self

Sets the accessible name of the control.

pub fn disabled(mut self, value: bool) -> Self

Disables interaction and applies the disabled appearance.

pub fn read_only(mut self, value: bool) -> Self

Prevents user changes while preserving focus and reading.

Installed source, 115 lines
//! Nova OTP cells using GPUI Kit's digit entry and paste handling.

use super::theme::{UiTheme, primitives::disabled};
use gpui_kit::base::OtpInput;
use gpui_kit::base::TestSupportExt as _;
pub use gpui_kit::base::{OtpEvent, OtpState};
use gpui_kit::{
    App, Entity, Focusable as _, InteractiveElement as _, IntoElement, MouseButton,
    ParentElement as _, RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled,
    Window, div, prelude::FluentBuilder as _, px,
};

#[derive(IntoElement)]
/// A Nova presentation of a retained Kit one-time-code editor.
pub struct OtpField {
    state: Entity<OtpState>,
    style: gpui_kit::StyleRefinement,
    label: SharedString,
    disabled: bool,
    read_only: bool,
}
impl OtpField {
    /// Creates a Nova code editor using the caller's retained Kit state.
    pub fn new(state: &Entity<OtpState>) -> Self {
        Self {
            state: state.clone(),
            style: Default::default(),
            label: "One-time code".into(),
            disabled: false,
            read_only: false,
        }
    }
    /// Sets the accessible name of the control.
    pub fn aria_label(mut self, value: impl Into<SharedString>) -> Self {
        self.label = value.into();
        self
    }
    /// Disables interaction and applies the disabled appearance.
    pub fn disabled(mut self, value: bool) -> Self {
        self.disabled = value;
        self
    }
    /// Prevents user changes while preserving focus and reading.
    pub fn read_only(mut self, value: bool) -> Self {
        self.read_only = value;
        self
    }
}
impl Styled for OtpField {
    fn style(&mut self) -> &mut gpui_kit::StyleRefinement {
        &mut self.style
    }
}
impl RenderOnce for OtpField {
    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
        let t = UiTheme::read(cx).clone();
        let state = self.state.read(cx);
        let chars: Vec<_> = state.value().chars().collect();
        let length = state.len();
        let masked = state.is_masked();
        let focus = state.focus_handle(cx);
        let focused = focus.is_focused(window) && !self.disabled;
        let cursor = focused && state.cursor_visible(cx) && chars.len() < length;
        let value = if masked {
            "•".repeat(chars.len())
        } else {
            state.value().to_string()
        };
        let cells = (0..length).map(|index| {
            div()
                .size(t.space(8.))
                .flex()
                .items_center()
                .justify_center()
                .border_1()
                .border_color(t.colors.input)
                .rounded(t.radius.md)
                .font_family(t.fonts.mono.clone())
                .text_size(t.text(14.))
                .text_color(t.colors.foreground)
                .when(
                    focused && index == chars.len().min(length.saturating_sub(1)),
                    |cell| cell.border_color(t.colors.ring),
                )
                .when_some(chars.get(index), |cell, ch| {
                    cell.child(if masked { "•".into() } else { ch.to_string() })
                })
                .when(cursor && index == chars.len(), |cell| {
                    cell.child(div().w(px(1.)).h(t.text(16.)).bg(t.colors.foreground))
                })
        });
        let editor = OtpInput::new(&self.state)
            .disabled(self.disabled || self.read_only)
            .flex()
            .gap(t.space(1.))
            .children(cells);
        let root = div()
            .id(("otp", self.state.entity_id()))
            .test_support()
            .role(Role::TextInput)
            .track_focus(&focus)
            .aria_label(self.label)
            .aria_value(value)
            .when(self.read_only, |root| root.aria_description("Read only"))
            .when(self.disabled, disabled::style)
            .when(!self.disabled, |root| {
                root.on_mouse_down(MouseButton::Left, move |_, window, cx| {
                    focus.focus(window, cx)
                })
            })
            .child(editor);
        super::theme::apply_style(root, &self.style)
    }
}

Platform notes

Visual reference: shadcn/ui 4.19.0, Neutral Nova. Implementation: crates/ui/registry/otp_field/otp_field.rs, using GPUI Kit 0.6.4.

OtpField takes a retained Kit OtpState configured with a slot count. Kit owns digit input, selection, deletion and paste distribution; gpuicn draws the Nova slots. The application reads the state and subscribes to its events.

The Rust source and Usage example define the supported API. Browser DOM/CSS behavior, exact exit animations and cross-platform screen-reader parity are not implied by the native component. See migration qualification for the checks completed on this revision.