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.

The examples below define views for an initialized GPUI Kit application. See Getting Started for application setup.

#Input with a clear button

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))))
    }
}

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();

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

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")))

#Buttons, icons, and menus

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))

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

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}")))
    }
}

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

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.

#Sizes and custom styles

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))))

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(); })));
  }
}

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();

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