Input Group

Use InputGroup to place text, icons, buttons, or toolbars around an input or textarea inside one frame. For a simple prefix or suffix, use Input.

Install

The examples below define views for an initialized uni-kit application. See Getting Started for application setup.

#Input with a clear button

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// The field keeps its own `prefix` slot, inside the frame; the clear control
// is an addon after it, and appears only while there is something to clear.
let mut search = InputGroup::new().input(
    Input::new("group-search")
        .value(app.search.as_str())
        .placeholder("Search the docs…")
        .prefix(glyph(SEARCH))
        .on_input(Message::SearchChanged),
);
if !app.search.is_empty() {
    search = search.addon_end(
        InputGroupAddon::new()
            .divider(true)
            .child(icon_button(X, Message::ClearSearch)),
    );
}
let clear = column![
    search,
    muted_line(&if app.search.is_empty() {
        "The clear control appears once there is something to clear.".to_string()
    } else {
        format!("Value: {}", app.search)
    }),
]
.spacing(8.0);

Create an InputState once in your view and pass it to InputGroupInput. Subscribe to InputEvent::Change to refresh anything that depends on the text. Keep the returned Subscription in the view so the callback stays active.

This view shows a character count and lets the user clear the input:

use gpui_kit::{
    AppContext as _, ClickEvent, Context, Entity, IntoElement, ParentElement as _,
    Render, Styled as _, Subscription, Window, rems,
};
use gpui_kit::assets::IconName;
use gpui_kit::component::{
    Disableable as _, Icon,
    input::{
        InputEvent, InputGroup, InputGroupAddon, InputGroupAddonAlignment,
        InputGroupButton, InputGroupInput, InputGroupText, InputState,
    },
};

struct SearchField {
    query: Entity<InputState>,
    _change: Subscription,
}

impl SearchField {
    fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
        let query = cx.new(|cx| InputState::new(window, cx).placeholder("Search…"));
        let change = cx.subscribe(&query, |_, _, event: &InputEvent, cx| {
            if matches!(event, InputEvent::Change) {
                cx.notify();
            }
        });
        Self { query, _change: change }
    }

    fn clear(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
        self.query.update(cx, |state, cx| {
            state.set_value("", window, cx);
            state.focus(window, cx);
        });
        cx.notify();
    }
}

impl Render for SearchField {
    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let count = self.query.read(cx).value().chars().count();
        InputGroup::new("search")
            .max_w(rems(24.))
            .input(InputGroupInput::new(&self.query).aria_label("Search"))
            .addon(InputGroupAddon::new("search-icon")
                .child(Icon::new(IconName::Search).size_4()))
            .addon(InputGroupAddon::new("search-actions")
                .align(InputGroupAddonAlignment::InlineEnd)
                .child(InputGroupText::new().child(format!("{count} characters")))
                .child(InputGroupButton::new("clear").label("Clear")
                    .disabled(count == 0)
                    .on_click(cx.listener(Self::clear))))
    }
}
// Installed in ui/components. That directory is on the Slint include path, so import by file name:
import { InputGroup, InputGroupAddon } from "input-group.slint";

Read or set the value through the same state:

let value = self.query.read(cx).value();

self.query.update(cx, |state, cx| {
    state.set_value("gpui", window, cx);
});
cx.notify();
InputGroup {
    Input {
        placeholder: "Search the docs…";
        appearance: false;
        prefix-icon: "search";
        cleanable: true;
    }
}

InputEvent::Change reports user edits. Setting a value with set_value does not emit this event; call cx.notify() when other content in your view must refresh after a programmatic update.

#Parts and alignment

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// Addons before and after the field sit in one track. Slint's group is a
// single horizontal row, so the block sides GPUI's part has are the caller's
// own column rather than a side of this component.
let parts = column![
    InputGroup::new()
        .input(
            Input::new("group-site")
                .value(app.site.as_str())
                .placeholder("gpui-kit.com")
                .on_input(Message::SiteChanged),
        )
        .addon_start(text_addon("https://"))
        .addon_end(text_addon(".com")),
    InputGroup::new()
        .input(
            Input::new("group-find")
                .value(app.find.as_str())
                .placeholder("Search")
                .on_input(Message::FindChanged),
        )
        .addon_start(InputGroupAddon::new().child(glyph(SEARCH)))
        .addon_end(text_addon("Ctrl K")),
]
.spacing(12.0);
PartUse
InputGroupCombine one input with any number of addons
InputGroupInputAn Input placed in the group, using InputState
InputGroupTextareaA Textarea placed in the group, using TextareaState
InputGroupAddonPosition text, icons, buttons, or custom content
InputGroupButtonA Button with compact input-group presentation
InputGroupTextDisplay helper text, a prefix, suffix, or counter

InputGroupInput and InputGroupTextarea are the ordinary Input and Textarea types under the names the group uses for them, so every builder those controls have — aria_label, content_type, on_paste, cleanable, mask_toggle, Styled methods — works inside a group. The group removes the control’s own border, background, and focus ring and draws them around the whole frame instead.

Pass the input to .input(...) and each addon to .addon(...). Use .child(...) or .children(...) inside an addon. A later .input(...) replaces the earlier input; repeated .addon(...) calls keep all addons.

Set an addon’s position with .align(InputGroupAddonAlignment::...):

AlignmentPosition
InlineStart (default)Before the input
InlineEndAfter the input
BlockStartAbove the input row
BlockEndBelow the input row

You can combine all four positions. Addons on the same side and children within an addon appear in the order you add them. Give each part a stable, distinct ID. Clicking text, icons, or empty space in an addon focuses the input.

For example, add a protocol prefix and domain suffix to a single-line input:

InputGroup::new("website")
    .input(InputGroupInput::new(&self.query).aria_label("Website"))
    .addon(InputGroupAddon::new("protocol")
        .child(InputGroupText::new().child("https://")))
    .addon(InputGroupAddon::new("domain")
        .align(InputGroupAddonAlignment::InlineEnd)
        .child(InputGroupText::new().child(".com")))
VerticalLayout {
    spacing: 12px;
    InputGroup {
        InputGroupAddon {
            Text {
                text: "https://";
                color: Theme.muted-foreground;
                font-size: Theme.font-size-sm;
                vertical-alignment: center;
            }
        }
        Input {
            placeholder: "gpui-kit.com";
            appearance: false;
        }
    }
    InputGroup {
        InputGroupAddon {
            Icon {
                name: "search";
                size: 14px;
                color: Theme.muted-foreground;
            }
        }
        Input {
            placeholder: "Search";
            appearance: false;
        }
        InputGroupAddon {
            Text {
                text: "⌘K";
                color: Theme.muted-foreground;
                font-size: Theme.font-size-xs;
                vertical-alignment: center;
            }
        }
    }
}

#Buttons, icons, and menus

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// A primary action fills the trailing edge — `flush(true)` drops the addon's
// outer padding so the button reaches the frame's own edge and corner, and
// the compact ghost controls keep the inset the Slint example gives them. The
// ellipsis opens nothing here: the group carries no menu state, and a real
// dropdown belongs to the `menu` component.
let buttons = column![
    InputGroup::new()
        .input(
            Input::new("group-query")
                .value(app.query.as_str())
                .placeholder("Search the docs…")
                .on_input(Message::QueryChanged),
        )
        .addon_end(
            InputGroupAddon::new().flush(true).child(
                Button::new("Search")
                    .variant(Variant::Primary)
                    .on_press(Message::Notice("Search")),
            )
        ),
    InputGroup::new()
        .input(
            Input::new("group-menu")
                .value(app.menu.as_str())
                .placeholder("More actions…")
                .on_input(Message::MenuChanged),
        )
        .addon_end(
            InputGroupAddon::new()
                .divider(true)
                .child(icon_button(ELLIPSIS, Message::Notice("More actions"))),
        ),
]
.spacing(12.0);

Use .label(...) for a text button or .icon(...) for an icon button. Give icon-only buttons an .accessibility_label(...); .tooltip(...) adds a visible hint.

InputGroupButton::new("clear-icon")
    .icon(IconName::X)
    .accessibility_label("Clear search")
    .tooltip("Clear search")
    .on_click(cx.listener(Self::clear))
VerticalLayout {
    spacing: 12px;
    InputGroup {
        Input {
            placeholder: "Search the docs…";
            appearance: false;
            prefix-icon: "search";
        }
        Button {
            text: "Search";
            variant: ButtonVariant.primary;
        }
    }
    InputGroup {
        InputGroupAddon {
            Text {
                text: "https://";
                color: Theme.muted-foreground;
                font-size: Theme.font-size-sm;
                vertical-alignment: center;
            }
        }
        Input {
            placeholder: "gpui-kit.com";
            appearance: false;
        }
        Button {
            icon: "ellipsis";
            variant: ButtonVariant.ghost;
        }
    }
}

Buttons size through Sizable like every other control. .xsmall() is the default compact size and .small() the larger one; a button with only an icon is square at either size. .medium() and .large() keep the standard button sizes for a prominent action in a block addon.

Buttons default to ghost styling. Import button::ButtonVariants to use .primary(), .secondary(), or .danger(). Use .outline() for an outline, .disabled(true) to disable an action, and .loading(true) to show progress and prevent repeated clicks. Clicking a button runs its action without moving focus back to the input afterwards.

For an action menu, use .dropdown_menu(...) with the menu API; .dropdown_caret(true) draws the caret after the label. For contextual help, pass an InputGroupButton to Popover’s .trigger(...), then add the Popover to an addon.

#Textarea with a counter and submit action

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// The group's track is one fixed-height row, so the taller textarea stays
// outside it — which is what the Slint example does too. The Slint comment
// says the frame is generic and a taller child works the same way; iced's
// group has a definite height, so its field is the textarea's own frame.
let count = app.reply.text().chars().count();
let composer = column![
    Textarea::new(&app.reply)
        .placeholder("Write a reply…")
        .rows(3)
        .on_action(Message::Reply),
    row![
        text(format!("{count} / 280"))
            .size(theme::FONT_SIZE_XS)
            .style(|theme: &Theme| text::Style {
                color: Some(Tokens::of(theme).muted_foreground),
            }),
        Space::new().width(Length::Fill),
        Button::new("Reply")
            .variant(Variant::Primary)
            .on_press(Message::Notice("Reply")),
    ]
    .spacing(8.0)
    .align_y(Alignment::Center),
]
.spacing(8.0);

Use TextareaState with InputGroupTextarea. .auto_grow(min, max) grows the input between the given row counts; longer content scrolls. Use .rows(n) for a fixed row count or InputGroupTextarea::h(...) for a fixed height.

This complete view counts characters, disables submission for empty or oversized drafts, and displays the submitted text below the composer. Submitting clears and focuses the textarea.

use gpui_kit::{
    AppContext as _, ClickEvent, Context, Entity, IntoElement, ParentElement as _,
    Render, SharedString, Styled as _, Subscription, Window, rems,
};
use gpui_kit::component::{
    Disableable as _, button::ButtonVariants as _, v_flex,
    input::{
        InputEvent, InputGroup, InputGroupAddon, InputGroupAddonAlignment,
        InputGroupButton, InputGroupText, InputGroupTextarea, TextareaState,
    },
};

struct MessageComposer {
    message: Entity<TextareaState>,
    submitted: Option<SharedString>,
    _change: Subscription,
}

impl MessageComposer {
    fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
        let message = cx.new(|cx| {
            TextareaState::new(window, cx)
                .placeholder("Write a message…")
                .auto_grow(2, 6)
        });
        let change = cx.subscribe(&message, |_, _, event: &InputEvent, cx| {
            if matches!(event, InputEvent::Change) {
                cx.notify();
            }
        });
        Self { message, submitted: None, _change: change }
    }

    fn submit(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
        let value = self.message.read(cx).value();
        if value.trim().is_empty() || value.chars().count() > 280 {
            return;
        }
        self.submitted = Some(value);
        self.message.update(cx, |state, cx| {
            state.set_value("", window, cx);
            state.focus(window, cx);
        });
        cx.notify();
    }
}

impl Render for MessageComposer {
    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let value = self.message.read(cx).value();
        let count = value.chars().count();
        v_flex().max_w(rems(28.)).gap_2()
            .child(InputGroup::new("message")
                .invalid(count > 280)
                .input(InputGroupTextarea::new(&self.message).aria_label("Message"))
                .addon(InputGroupAddon::new("message-footer")
                    .align(InputGroupAddonAlignment::BlockEnd)
                    .child(InputGroupText::new().child(format!("{count}/280")))
                    .child(InputGroupButton::new("submit").ml_auto().primary().label("Submit")
                        .disabled(value.trim().is_empty() || count > 280)
                        .on_click(cx.listener(Self::submit)))))
            .children(self.submitted.as_ref().map(|text| format!("Submitted: {text}")))
    }
}
VerticalLayout {
    spacing: 8px;
    Textarea {
        placeholder: "Write a reply…";
        text <=> root.reply;
    }
    HorizontalLayout {
        horizontal-stretch: 1;
        spacing: 8px;
        alignment: center;
        Text {
            text: "58 / 280";
            color: Theme.muted-foreground;
            font-size: Theme.font-size-xs;
            vertical-alignment: center;
        }
        Button {
            text: "Reply";
            variant: ButtonVariant.primary;
        }
    }
}

Use BlockStart for a heading or toolbar above the textarea. Addons stay in place while the text scrolls. See Textarea for more text options.

#Disabled, read-only, and validation

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// Validation stays visible while editing is disabled, the way the field's
// own edge does. iced's single-line field has no read-only mode, so the
// first card stays live and the note names what is missing.
let states = column![
    InputGroup::new()
        .input(
            Input::new("group-readonly")
                .value("copied from the registry")
                .on_input(Message::EmailChanged),
        )
        .addon_end(
            InputGroupAddon::new()
                .divider(true)
                .child(icon_button(COPY, Message::Notice("Copy"))),
        ),
    InputGroup::new()
        .invalid(true)
        .input(
            Input::new("group-invalid")
                .value(app.email.as_str())
                .placeholder("you@example.com")
                .on_input(Message::EmailChanged),
        )
        .addon_end(InputGroupAddon::new().child(danger_glyph(CIRCLE_X))),
    InputGroup::new()
        .disabled(true)
        .input(Input::new("group-disabled").placeholder("Disabled"))
        .addon_end(
            InputGroupAddon::new()
                .flush(true)
                .divider(true)
                .child(Button::new("Go")),
        ),
    muted_line("Read-only has no single-line iced field; the textarea is the one that has it."),
]
.spacing(12.0);
MethodEffect
.disabled(true)Disables the input and direct InputGroupButton children
.readonly(true)Prevents editing while allowing focus, selection, copying, and addon actions
.invalid(true)Shows an error state while allowing further edits

An input part with .disabled(true) also disables its group. Pass the disabled flag to custom interactive addon content and wrapped controls separately.

Set .invalid(...) from your validation result and show an explanation next to the group. To reject particular edits, use InputState::validate. Give each input an .aria_label(...), even if you also name the group.

Use .content_type(...) on InputGroupInput for hints such as a URL or email address. Password masking is configured with InputState::masked. Both input parts support .context_menu(...) for a custom right-click menu.

On touch devices, long-press the text to select a word, drag the selection handles, and use the edit menu to cut, copy, paste, or select all.

In Rust, both input parts also support .on_paste(...) to handle clipboard images and files before text is inserted. Return true to consume the paste, or false to allow the default text insertion. The handler is not called while the input is disabled or read-only. See Paste Hook for an attachment example and web limitations.

GPUINo GPUI version of this example yet.
VerticalLayout {
    spacing: 12px;
    InputGroup {
        Input {
            placeholder: "Read only";
            appearance: false;
            read-only: true;
            text <=> root.registry;
        }
        Button {
            icon: "copy";
            variant: ButtonVariant.ghost;
        }
    }
    InputGroup {
        invalid: true;
        Input {
            placeholder: "you@example.com";
            appearance: false;
            invalid: true;
            text <=> root.address;
        }
        Icon {
            name: "circle-x";
            size: 14px;
            color: Theme.danger;
        }
    }
    InputGroup {
        Input {
            placeholder: "Disabled";
            appearance: false;
            enabled: false;
        }
        Button {
            text: "Go";
            enabled: false;
        }
    }
}

#Sizes and custom styles

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// The size sets the frame's height, the field's text size and the addons'
// inset together. The frame follows the field's height, so a large group is
// 44px rather than the 40px a button uses.
let sizes = column![
    InputGroup::new()
        .size(ControlSize::Small)
        .input(
            Input::new("group-small")
                .value(app.small.as_str())
                .placeholder("Small")
                .on_input(Message::SmallChanged),
        )
        .addon_end(
            InputGroupAddon::new().flush(true).child(
                Button::new("Go")
                    .size(ControlSize::Small)
                    .on_press(Message::Notice("Go")),
            )
        ),
    InputGroup::new()
        .size(ControlSize::Medium)
        .input(
            Input::new("group-medium")
                .value(app.medium.as_str())
                .placeholder("Medium")
                .on_input(Message::MediumChanged),
        )
        .addon_end(
            InputGroupAddon::new().flush(true).child(
                Button::new("Go")
                    .variant(Variant::Default)
                    .on_press(Message::Notice("Go")),
            )
        ),
    InputGroup::new()
        .size(ControlSize::Large)
        .input(
            Input::new("group-large")
                .value(app.large.as_str())
                .placeholder("Large")
                .on_input(Message::LargeChanged),
        )
        .addon_end(
            InputGroupAddon::new().flush(true).child(
                Button::new("Go")
                    .size(ControlSize::Large)
                    .on_press(Message::Notice("Go")),
            )
        ),
]
.spacing(12.0);

The default group size is Medium. Import Sizable to use .xsmall(), .small(), .large(), or .with_size(Size::Medium); the size sets the frame height, the text size, and the insets the addons share with the control. Colors, corners, the focus ring, and the invalid ring follow your Theme. Use Styled methods to set the group’s width, spacing, and other appearance.

The control keeps its own Styled methods for the text it edits, and each addon, button, and text part styles itself the same way:

use gpui_kit::component::{ActiveTheme as _, Sizable as _, StyledExt as _};

InputGroup::new("styled-search")
    .small()
    .max_w(rems(24.))
    .input(InputGroupInput::new(&self.query)
        .aria_label("Search")
        .px_3()
        .text_base())
    .addon(InputGroupAddon::new("styled-actions")
        .align(InputGroupAddonAlignment::InlineEnd)
        .child(InputGroupButton::new("styled-clear").label("Clear").icon(IconName::X)
            .font_semibold()
            .on_click(cx.listener(Self::clear))))
VerticalLayout {
    spacing: 12px;
    InputGroup {
        size: ControlSize.small;
        Input {
            placeholder: "Small";
            appearance: false;
            size: ControlSize.small;
        }
        Button {
            text: "Go";
            size: ControlSize.small;
        }
    }
    InputGroup {
        size: ControlSize.medium;
        Input {
            placeholder: "Medium";
            appearance: false;
        }
        Button {
            text: "Go";
        }
    }
    InputGroup {
        size: ControlSize.large;
        Input {
            placeholder: "Large";
            appearance: false;
            size: ControlSize.large;
        }
        Button {
            text: "Go";
            size: ControlSize.large;
        }
    }
}

Placeholder, caret, and selection colors follow the Theme. Import FocusableExt and use .focus_ring(false) to hide the default ring.

#JavaScript

Import the same parts from gpui-component. Create text states in View.init. Use .value(...) and .on_change(...) for a controlled input:

import { View } from "gpui-kit";
import {
  InputState, InputGroup, InputGroupInput, InputGroupAddon, InputGroupButton,
} from "gpui-component";

export default class Search extends View {
  init() {
    this.input = InputState("Search…");
    this.query = "";
  }

  render() {
    return new InputGroup("search")
      .input(new InputGroupInput(this.input)
        .aria_label("Search").value(this.query)
        .on_change((value, cx) => { this.query = value; cx.notify(); }))
      .addon(new InputGroupAddon("actions").align("inline-end")
        .child(new InputGroupButton("clear").label("Clear")
          .disabled(this.query.length === 0)
          .on_click((_event, cx) => { this.query = ""; cx.notify(); })));
  }
}
SlintNo Slint version of this example yet.

Programmatic .value(...) updates do not call on_change. Setting the same value keeps the selection and undo history. Omit .value(...) to let the input keep its own value, and use on_change(value, cx) when you need to react to edits.

InputGroupTextarea accepts TextareaState and supports .rows(n) and .auto_grow(min, max). Both input parts provide .placeholder(...). InputGroupInput also provides .masked(bool) and .content_type(...), with values such as email_address, url, and new_password.

Set group and button size with .size("small"); available values are xsmall, small, medium, and large. Button icons take an asset path, such as .icon("icons/search.svg"). Style methods apply to each part directly, as in Rust:

new InputGroupInput(this.input).px(12).text_base();

new InputGroupButton("clear").label("Clear").icon("icons/x.svg").font_semibold();
SlintNo Slint version of this example yet.

Run gpui-component-shell types <application> to generate editor completion.

#Inline references

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// iced's field holds one plain `String`, so a reference cannot live inside
// the value as a token the caret and deletion move as a unit. The chip is a
// prefix element beside the value, not part of it.
let references = column![
    InputGroup::new()
        .input(
            Input::new("group-mention")
                .value(app.mention.as_str())
                .placeholder("Ask @alice")
                .prefix(token_chip("src/main.rs"))
                .on_input(Message::MentionChanged),
        )
        .addon_end(
            InputGroupAddon::new().flush(true).child(
                Button::new("Send")
                    .variant(Variant::Primary)
                    .on_press(Message::Notice("Send")),
            )
        ),
    muted_line("The chip is a prefix element; `@alice` in the value is ordinary text."),
]
.spacing(8.0);

To combine inline references with attachments or send buttons, pass an Input or Textarea containing tokens to InputGroup. You can customize its labels as usual:

use gpui_kit::component::{
    IconName,
    input::{InputToken, InputGroup, Textarea},
};

InputGroup::new("composer")
    .input(Textarea::new(&state)
        .token(|token, _, _| InputToken::new(token).icon(IconName::File)))
SlintNo Slint version of this example yet.

The JavaScript group controls also expose token and on_token_click. Retain the same input state across redraws; call set_value with saved content to restore a draft. See atomic inline tokens.