NumberInput

A specialized input component for numeric values with built-in increment/decrement buttons and support for min/max values, step values, and number formatting with thousands separators.

Install

#Import

use gpui_kit::component::input::{InputState, NumberInput, NumberInputEvent, StepAction};
// Installed in ui/components. That directory is on the Slint include path, so import by file name:
import { NumberInput } from "number-input.slint";
use iced::widget::{Id, column, container, mouse_area, operation, row, scrollable, svg, text};
use iced::{font, Alignment, Element, Font, Length, Subscription, Task, Theme};

#Usage

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// The Slint example's gallery in one panel: the bounds and the step, the
// prefix and suffix slots, the four sizes, the `− value +` shape, the disabled
// state and a value that is not a number. Every control is `value` — the number
// a step works from — plus `text`, the caller's editing buffer.
let usage = column![
    row![
        NumberInput::new("quantity")
            .value(app.quantity.value)
            .text(app.quantity.text.as_str())
            .minimum(0.0)
            .maximum(99.0)
            .on_input(|text| Message::Edited(Slot::Quantity, text))
            .on_change(|value| Message::Changed(Slot::Quantity, value)),
        NumberInput::new("price")
            .value(app.price.value)
            .text(app.price.text.as_str())
            .step(0.5)
            .prefix(slot_text("$", ControlSize::Medium))
            .on_input(|text| Message::Edited(Slot::Price, text))
            .on_change(|value| Message::Changed(Slot::Price, value)),
    ]
    .spacing(12.0),
    row![
        row_label("Seats, in twos", false),
        NumberInput::new("seats")
            .value(app.seats.value)
            .text(app.seats.text.as_str())
            .minimum(0.0)
            .maximum(20.0)
            .step(2.0)
            .width(150.0)
            .on_input(|text| Message::Edited(Slot::Seats, text))
            .on_change(|value| Message::Changed(Slot::Seats, value)),
    ]
    .spacing(10.0)
    .align_y(Alignment::Center),
    row![
        row_label("At the minimum", false),
        NumberInput::new("at-minimum")
            .value(app.at_minimum.value)
            .text(app.at_minimum.text.as_str())
            .minimum(0.0)
            .maximum(99.0)
            .width(150.0)
            .on_input(|text| Message::Edited(Slot::AtMinimum, text))
            .on_change(|value| Message::Changed(Slot::AtMinimum, value)),
    ]
    .spacing(10.0)
    .align_y(Alignment::Center),
    row![
        row_label("At the maximum", false),
        NumberInput::new("at-maximum")
            .value(app.at_maximum.value)
            .text(app.at_maximum.text.as_str())
            .minimum(0.0)
            .maximum(100.0)
            .step(5.0)
            .suffix(slot_text("%", ControlSize::Medium))
            .width(170.0)
            .on_input(|text| Message::Edited(Slot::AtMaximum, text))
            .on_change(|value| Message::Changed(Slot::AtMaximum, value)),
    ]
    .spacing(10.0)
    .align_y(Alignment::Center),
    NumberInput::new("salary")
        .value(app.salary.value)
        .text(app.salary.text.as_str())
        .step(50.0)
        .prefix(slot_text("$", ControlSize::Medium))
        .suffix(slot_text("/mo", ControlSize::Medium))
        .on_input(|text| Message::Edited(Slot::Salary, text))
        .on_change(|value| Message::Changed(Slot::Salary, value)),
    NumberInput::new("rejected")
        .value(app.rejected.value)
        .text(app.rejected.text.as_str())
        .minimum(0.0)
        .maximum(99.0)
        .invalid(true)
        .on_input(|text| Message::Edited(Slot::Rejected, text))
        .on_change(|value| Message::Changed(Slot::Rejected, value)),
    row![
        NumberInput::new("size-small")
            .value(app.size_small.value)
            .text(app.size_small.text.as_str())
            .size(ControlSize::Small)
            .on_input(|text| Message::Edited(Slot::SizeSmall, text))
            .on_change(|value| Message::Changed(Slot::SizeSmall, value)),
        NumberInput::new("size-medium")
            .value(app.size_medium.value)
            .text(app.size_medium.text.as_str())
            .size(ControlSize::Medium)
            .on_input(|text| Message::Edited(Slot::SizeMedium, text))
            .on_change(|value| Message::Changed(Slot::SizeMedium, value)),
        NumberInput::new("size-large")
            .value(app.size_large.value)
            .text(app.size_large.text.as_str())
            .size(ControlSize::Large)
            .on_input(|text| Message::Edited(Slot::SizeLarge, text))
            .on_change(|value| Message::Changed(Slot::SizeLarge, value)),
    ]
    .spacing(12.0),
    row![
        NumberInput::new("centered")
            .value(app.centered.value)
            .text(app.centered.text.as_str())
            .minimum(0.0)
            .maximum(99.0)
            .prefix(slot_text("$", ControlSize::Medium))
            .centered(true)
            .width(220.0)
            .on_input(|text| Message::Edited(Slot::Centered, text))
            .on_change(|value| Message::Changed(Slot::Centered, value)),
        NumberInput::new("disabled")
            .value(8.0)
            .minimum(0.0)
            .maximum(20.0)
            .disabled(true)
            .width(220.0),
    ]
    .spacing(12.0),
    muted_line(&format!(
        "Quantity {} — a step clamps to 0..=99, and the stepper that would cross a bound is disabled.",
        number_input::shown(app.quantity.value)
    )),
]
.spacing(14.0);

#Quantity

struct QuantitySelector {
    quantity_input: Entity<InputState>,
}

impl QuantitySelector {
    fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
        // Step by 1 and clamp to 1..=99, no event handling needed.
        let quantity_input = cx.new(|cx|
            InputState::new(window, cx)
                .default_value("1")
                .min(1.)
                .max(99.)
        );

        Self { quantity_input }
    }
}

// Usage
NumberInput::new(&self.quantity_input).small()
NumberInput {
    value: 3;
    minimum: 0;
    maximum: 99;
    step: 1;
}
NumberInput {
    value: 19.99;
    prefix: "$";
    step: 0.5;
}

#Basic Number Input

let number_input = cx.new(|cx|
    InputState::new(window, cx)
        .placeholder("Enter number")
        .default_value("1")
);

NumberInput::new(&number_input)
SlintNo Slint version of this example yet.
// The basic control in the centred-stepper shape: `−` at the leading edge, the
// value in the middle and `+` at the trailing edge, so the row reads
// `− 12 +` rather than `12 − +`. It is the same field — the frame, the 32px
// medium height and the two extra-small ghost steppers — rearranged by one
// property, not a second control.
let basic = NumberInput::new("basic")
    .value(app.basic.value)
    .text(app.basic.text.as_str())
    .minimum(0.0)
    .maximum(99.0)
    .centered(true)
    .on_input(|text| Message::Edited(Slot::Basic, text))
    .on_change(|value| Message::Changed(Slot::Basic, value));

#Input Restriction and Normalization

By default, the NumberInput only accepts a valid number: an optional leading +/- sign, digits and a single decimal point (e.g. -1.5), other characters are rejected on typing and pasting.

Full-width number characters are normalized into their ASCII equivalents automatically, for CJK IME users:

  • Full-width digits: 123 → 123
  • Full-width signs: + → +, - → -
  • Full-width dot and ideographic full stop: ., 。 → .

A bare leading decimal point is kept as-is (e.g. .5, parsed as 0.5), matching the web behavior, so deleting the integer part of 1.2 keeps .2 and stays editable.

To opt out of the default restriction, set an explicit mask: state.set_mask_pattern(MaskPattern::None, window, cx).

To further restrict the input (e.g. positive integers only), use pattern:

// Integer input with validation
let integer_input = cx.new(|cx|
    InputState::new(window, cx)
        .placeholder("Integer value")
        .pattern(Regex::new(r"^\d+$").unwrap()) // Only positive integers
);

NumberInput::new(&integer_input)
SlintNo Slint version of this example yet.

#With Min/Max/Step

By default, the NumberInput updates the value internally with step(1.): the ↑/↓ keys and the +/- buttons step the value by 1 and emit InputEvent::Change. Set min/max to clamp the range, or set a custom step.

To fall back to emitting NumberInputEvent::Step only (the subscriber is responsible for updating the value), call state.set_step(None, window, cx).

A typed out-of-range value is kept while typing, and clamped on blur. Stepping follows the web behavior: a step that cannot move the value in the pressed direction (e.g. ↓ on a value at or below the min) does nothing.

let stepper_input = cx.new(|cx|
    InputState::new(window, cx)
        .default_value("50")
        .step(5.)
        .min(0.)
        .max(100.)
);

NumberInput::new(&stepper_input)
NumberInput {
    value: 1;
    minimum: 1;
    maximum: 12;
    step: 1;
}
NumberInput {
    value: 100;
    minimum: 0;
    maximum: 100;
    step: 5;
    suffix: "%";
}

#Dynamic Step

Use step_by to calculate the step value from the current value and the step direction, e.g. a step size that varies by range. Because the step can differ by direction at a boundary, the closure receives the StepAction; here 1.0 steps by 0.1 going down and 0.5 going up. The closure also receives a Context for reading or updating other entities:

let price_input = cx.new(|cx|
    InputState::new(window, cx)
        .step_by(|value, action, _cx| match action {
            StepAction::Increment => if value < 1.0 { 0.1 } else { 0.5 },
            StepAction::Decrement => if value <= 1.0 { 0.1 } else { 0.5 },
        })
        .min(0.)
);

NumberInput::new(&price_input)
SlintNo Slint version of this example yet.

The step strategy can also be updated at runtime via set_step:

use gpui_kit::component::input::NumberStep;

state.set_step(NumberStep::Fixed(0.01), window, cx);
state.set_step(NumberStep::by_value(|v, _, _cx| if v < 1. { 0.01 } else { 0.1 }), window, cx);
state.set_step(None, window, cx); // Fall back to NumberInputEvent::Step
SlintNo Slint version of this example yet.

#With Number Formatting

use gpui_kit::component::input::MaskPattern;

// Currency input with thousands separator
let currency_input = cx.new(|cx|
    InputState::new(window, cx)
        .placeholder("Amount")
        .mask_pattern(MaskPattern::Number {
            separator: Some(','),
            fraction: Some(2), // 2 decimal places
        })
);

NumberInput::new(&currency_input)
NumberInput {
    value: 1250;
    step: 50;
    prefix: "$";
    suffix: "/mo";
}
NumberInput {
    value: 7.5;
    step: 0.5;
    suffix: "kg";
}

#Different Sizes

// Large size
NumberInput::new(&input).large()

// Medium size (default)
NumberInput::new(&input)

// Small size
NumberInput::new(&input).small()
NumberInput {
    value: 4;
    size: ControlSize.small;
}
NumberInput {
    value: 4;
}
NumberInput {
    value: 4;
    size: ControlSize.large;
}

#With Prefix and Suffix

use gpui_kit::component::{button::{Button, ButtonVariants}, IconName};

// With currency prefix
NumberInput::new(&input)
    .prefix(div().child("$"))

// With info button suffix
NumberInput::new(&input)
    .suffix(
        Button::new("info")
            .ghost()
            .icon(IconName::Info)
            .xsmall()
    )
SlintNo Slint version of this example yet.

#Disabled State

NumberInput::new(&input).disabled(true)
NumberInput {
    value: 8;
    enabled: false;
}

#Without Default Styling

// For custom container styling
div()
    .w_full()
    .bg(cx.theme().secondary)
    .rounded(cx.theme().radius)
    .child(NumberInput::new(&input).appearance(false))
description: "The value is centred, with a stepper at each end.";
VerticalLayout {
    spacing: 12px;
    NumberInput {
        centered: true;
        value: 3;
        minimum: 0;
        maximum: 99;
    }
    NumberInput {
        centered: true;
        value: 42;
        minimum: 0;
        maximum: 99;
        prefix: "$";
    }
    NumberInput {
        centered: true;
        value: 8;
        size: ControlSize.large;
    }
    HorizontalLayout {
        spacing: 10px;
        Text {
            horizontal-stretch: 1;
            horizontal-alignment: left;
            text: "Seats, in twos";
            color: Theme.foreground;
            font-size: Theme.font-size-sm;
            vertical-alignment: center;
        }
        NumberInput {
            centered: true;
            width: 140px;
            value: 6;
            minimum: 0;
            maximum: 20;
            step: 2;
        }
    }
}

#Handle Number Input Events

By default, the NumberInput updates the value internally. To fall back to NumberInputEvent::Step (the subscriber is responsible for updating the value), call state.set_step(None, window, cx):

let number_input = cx.new(|cx| InputState::new(window, cx));
let mut value: i64 = 0;

// Subscribe to input changes
cx.subscribe_in(&number_input, window, |view, state, event, window, cx| {
    match event {
        InputEvent::Change => {
            let text = state.read(cx).value();
            if let Ok(new_value) = text.parse::<i64>() {
                view.value = new_value;
            }
        }
        _ => {}
    }
});

// Subscribe to increment/decrement actions
cx.subscribe_in(&number_input, window, |view, state, event, window, cx| {
    match event {
        NumberInputEvent::Step(step_action) => {
            match step_action {
                StepAction::Increment => {
                    view.value += 1;
                    state.update(cx, |input, cx| {
                        input.set_value(view.value.to_string(), window, cx);
                    });
                }
                StepAction::Decrement => {
                    view.value -= 1;
                    state.update(cx, |input, cx| {
                        input.set_value(view.value.to_string(), window, cx);
                    });
                }
            }
        }
    }
});
SlintNo Slint version of this example yet.

#Programmatic Control

// Increment programmatically
NumberInput::increment(&number_input, window, cx);

// Decrement programmatically
NumberInput::decrement(&number_input, window, cx);
SlintNo Slint version of this example yet.
// The programmatic step the page documents: one control built with every
// method the reference lists, and the two helpers a programmatic step needs.
// `step_value` is the arithmetic the steppers perform; `shown` is the text they
// write back. It is the last card because it is the last usage example, not
// because it lists the API — `## API Reference` ends the page's example region,
// so a card named for it has no heading to scroll to and reads as broken.
let api = column![
    NumberInput::new("api")
        .value(app.api.value)
        .text(app.api.text.as_str())
        .minimum(0.0)
        .maximum(100.0)
        .step(5.0)
        .placeholder("0 to 100")
        .size(ControlSize::Medium)
        .prefix(slot_text("$", ControlSize::Medium))
        .suffix(slot_text("%", ControlSize::Medium))
        .invalid(false)
        .centered(false)
        .width(Length::Fill)
        .on_input(|text| Message::Edited(Slot::Api, text))
        .on_change(|value| Message::Changed(Slot::Api, value))
        .on_submit(Message::Commit(Slot::Api, app.api.text.clone())),
    row![
        Button::new("Decrease")
            .size(ControlSize::Small)
            .on_press(Message::ApiStep(StepAction::Decrement)),
        Button::new("Increase")
            .size(ControlSize::Small)
            .on_press(Message::ApiStep(StepAction::Increment)),
        muted_line(&format!(
            "step_value({}, Decrement, 5.0, 0.0, 100.0) = {:?}",
            number_input::shown(app.api.value),
            number_input::step_value(app.api.value, StepAction::Decrement, 5.0, 0.0, 100.0)
                .map(number_input::shown),
        )),
    ]
    .spacing(8.0)
    .align_y(Alignment::Center),
]
.spacing(12.0);

#Integer Counter

struct CounterView {
    counter_input: Entity<InputState>,
    counter_value: i32,
}

impl CounterView {
    fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
        let counter_input = cx.new(|cx|
            InputState::new(window, cx)
                .placeholder("Count")
                .default_value("0")
                .pattern(Regex::new(r"^-?\d+$").unwrap()) // Allow negative integers
        );

        let _subscription = cx.subscribe_in(&counter_input, window, Self::on_number_event);

        Self {
            counter_input,
            counter_value: 0,
        }
    }

    fn on_number_event(
        &mut self,
        state: &Entity<InputState>,
        event: &NumberInputEvent,
        window: &mut Window,
        cx: &mut Context<Self>,
    ) {
        match event {
            NumberInputEvent::Step(StepAction::Increment) => {
                self.counter_value += 1;
                state.update(cx, |input, cx| {
                    input.set_value(self.counter_value.to_string(), window, cx);
                });
            }
            NumberInputEvent::Step(StepAction::Decrement) => {
                self.counter_value -= 1;
                state.update(cx, |input, cx| {
                    input.set_value(self.counter_value.to_string(), window, cx);
                });
            }
        }
    }
}

// Usage
NumberInput::new(&self.counter_input)
VerticalLayout {
    spacing: 14px;
    HorizontalLayout {
        spacing: 10px;
        Text {
            horizontal-stretch: 1;
            horizontal-alignment: left;
            text: "Quantity";
            color: Theme.foreground;
            font-size: Theme.font-size-sm;
            vertical-alignment: center;
        }
        NumberInput {
            width: 96px;
            value: 3;
            minimum: 0;
            maximum: 99;
        }
    }
    HorizontalLayout {
        spacing: 10px;
        Text {
            horizontal-stretch: 1;
            horizontal-alignment: left;
            text: "Seats, in twos";
            color: Theme.foreground;
            font-size: Theme.font-size-sm;
            vertical-alignment: center;
        }
        NumberInput {
            width: 96px;
            value: 12;
            minimum: 0;
            maximum: 20;
            step: 2;
        }
    }
    // At the bound, the stepper that would cross it is disabled.
    HorizontalLayout {
        spacing: 10px;
        Text {
            horizontal-stretch: 1;
            horizontal-alignment: left;
            text: "At the minimum";
            color: Theme.foreground;
            font-size: Theme.font-size-sm;
            vertical-alignment: center;
        }
        NumberInput {
            width: 96px;
            value: 0;
            minimum: 0;
            maximum: 99;
        }
    }
    HorizontalLayout {
        spacing: 10px;
        Text {
            horizontal-stretch: 1;
            horizontal-alignment: left;
            text: "Disabled";
            color: Theme.muted-foreground;
            font-size: Theme.font-size-sm;
            vertical-alignment: center;
        }
        NumberInput {
            width: 96px;
            value: 8;
            enabled: false;
        }
    }
}

#Currency Input

struct PriceInput {
    price_input: Entity<InputState>,
    price_value: f64,
}

impl PriceInput {
    fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
        let price_input = cx.new(|cx|
            InputState::new(window, cx)
                .placeholder("0.00")
                .mask_pattern(MaskPattern::Number {
                    separator: Some(','),
                    fraction: Some(2),
                })
        );

        Self {
            price_input,
            price_value: 0.0,
        }
    }
}

// Usage with currency prefix
h_flex()
    .gap_2()
    .child(div().child("$"))
    .child(NumberInput::new(&self.price_input))
SlintNo Slint version of this example yet.

#Floating Point Input

// Step by 0.1, the fraction digits of the value are kept on stepping,
// e.g. 0.2 -> 0.3 (not 0.30000000000000004).
let float_input = cx.new(|cx|
    InputState::new(window, cx)
        .placeholder("0.0")
        .step(0.1)
);

NumberInput::new(&float_input)
SlintNo Slint version of this example yet.

#Keyboard Navigation

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// The table's keys are the application's, because iced's field reports text
// and submit only: `↑` and `↓` step through the subscription above, `Escape`
// clears through it, `Enter` arrives as `on_submit`, and `Tab`/`Shift+Tab` are
// iced's own focus traversal. iced gives the application no focus report, so
// the subscription steps this one field whatever is focused. The field
// captures these keys itself, so the subscription reads them with
// `event::listen_with` rather than `keyboard::listen`.
let keyboard = column![
    NumberInput::new("keyboard")
        .value(app.keyboard.value)
        .text(app.keyboard.text.as_str())
        .minimum(0.0)
        .maximum(10.0)
        .on_input(|text| Message::Edited(Slot::Keyboard, text))
        .on_change(|value| Message::Changed(Slot::Keyboard, value))
        .on_submit(Message::Commit(Slot::Keyboard, app.keyboard.text.clone())),
    muted_line(&format!(
        "↑ / ↓ step it, Escape clears it, Enter clamps what is typed{}. Tab and Shift+Tab are iced's.",
        match app.committed.as_deref() {
            Some(value) => format!(" — last committed {value}"),
            None => String::new(),
        }
    )),
]
.spacing(8.0);
KeyAction
↑Increment value
↓Decrement value
TabNavigate to next field
Shift+TabNavigate to previous field
EnterSubmit/confirm value
EscapeClear input (if enabled)

#Best Practices

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// The seven rules are prose, and prose is not a widget. The card builds the
// rules the control itself enforces — a step, a range, and the two bounds that
// clamp and disable — and leaves validation, formatting and error copy to the
// application, which the danger edge and the readout report.
let practices = column![
    row![
        row_label("Seats, in twos", false),
        NumberInput::new("practice-seats")
            .value(app.practice_seats.value)
            .text(app.practice_seats.text.as_str())
            .minimum(0.0)
            .maximum(20.0)
            .step(2.0)
            .width(120.0)
            .on_input(|text| Message::Edited(Slot::PracticeSeats, text))
            .on_change(|value| Message::Changed(Slot::PracticeSeats, value)),
    ]
    .spacing(10.0)
    .align_y(Alignment::Center),
    row![
        row_label("Amount, two decimals", false),
        NumberInput::new("practice-price")
            .value(app.practice_price.value)
            .text(app.practice_price.text.as_str())
            .minimum(0.0)
            .maximum(1000.0)
            .step(0.5)
            .prefix(slot_text("$", ControlSize::Medium))
            .width(120.0)
            .on_input(|text| Message::Edited(Slot::PracticePrice, text))
            .on_change(|value| Message::Changed(Slot::PracticePrice, value)),
    ]
    .spacing(10.0)
    .align_y(Alignment::Center),
    muted_line("The step and the range are the control's: a step clamps, and a bound disables its stepper."),
    muted_line("Validation, formatting and the message beside a field are the application's — Slint has no property for them either."),
]
.spacing(10.0);
  1. Validation: Always validate numeric input on both client and server side
  2. Range Limits: Use min/max to clamp values for user safety
  3. Step Size: Choose appropriate step values for your use case
  4. Error Handling: Provide clear feedback for invalid input
  5. Formatting: Use consistent number formatting across your application
  6. Performance: Debounce rapid increment/decrement actions if needed
  7. Accessibility: Always provide proper labels and descriptions

#API Reference

#NumberInput

MethodDescription
new(state)Create number input with InputState entity
placeholder(str)Set placeholder text
size(size)Set input size (small, medium, large)
prefix(el)Add prefix element
suffix(el)Add suffix element
appearance(bool)Enable/disable default styling
disabled(bool)Set disabled state
increment(state, window, cx)Increment value programmatically
decrement(state, window, cx)Decrement value programmatically

#NumberInputEvent

EventDescription
Step(StepAction)Increment/decrement pressed. Only emitted when step is None (opt out via set_step(None, ...)).

#StepAction

ActionDescription
IncrementValue should be increased
DecrementValue should be decreased

#InputState (Number-specific methods)

MethodDescription
step(impl Into<NumberStep>)Set step value for built-in increment/decrement (default: 1)
step_by(fn(f64, StepAction, &mut Context) -> f64)Calculate step value based on the current value and direction
min(f64)Set minimum value, clamped on stepping and blur
max(f64)Set maximum value, clamped on stepping and blur
set_step(Option<NumberStep>, ...)Update step strategy after construction
set_min(Option<f64>, ...)Update minimum value after construction
set_max(Option<f64>, ...)Update maximum value after construction
pattern(regex)Set regex pattern for validation (e.g., digits only)
mask_pattern(MaskPattern::Number)Set number formatting with separator and decimal places
value()Get current display value (formatted)
unmask_value()Get actual numeric value (unformatted)

#MaskPattern::Number

FieldTypeDescription
separatorOption<char>Thousands separator (e.g., ’,’ or ’ ’)
fractionOption<usize>Number of decimal places

#NumberInput

A numeric field with steppers. Mirrors GPUI Component's `NumberInput`.

NameKindTypeDefaultDescription
valuein-outfloat
minimuminfloat-1000000000
maximuminfloat1000000000
stepinfloat1
placeholderinstring
prefixinstring
suffixinstring
enabledinbooltrue
sizeinControlSizeControlSize.medium
centeredinboolCentres the value and moves the steppers to either end, so the control reads as `− value +` instead of `value − +`. The frame is then this component's own and the field inside it drops its own with `appearance: false`, which is what that flag exists for.
changedcallback(value: float)