# Input Group

> Add visible context or actions around an input without separating them from the control.

Source: https://www.honestui.com/docs/components/input-group

```tsx
"use client";

import * as React from "react";
import { Search as SearchIcon, X as XIcon } from "honestui/icons";

import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
} from "@/components/honest-ui/ui/input-group";

export function InputGroupSearch() {
  const [query, setQuery] = React.useState("");

  return (
    <div className="w-full max-w-sm space-y-2">
      <label className="text-sm font-medium" htmlFor="component-search">
        Search components
      </label>
      <InputGroup>
        <InputGroupAddon>
          <SearchIcon aria-hidden="true" />
        </InputGroupAddon>
        <InputGroupInput
          id="component-search"
          value={query}
          onChange={(event) => setQuery(event.target.value)}
          placeholder="Try “dialog”"
          type="search"
        />
        {query && (
          <InputGroupAddon align="inline-end">
            <InputGroupButton
              aria-label="Clear search"
              onClick={() => setQuery("")}
              size="icon-xs"
            >
              <XIcon aria-hidden="true" />
            </InputGroupButton>
          </InputGroupAddon>
        )}
      </InputGroup>
      <p className="text-sm text-muted-foreground" aria-live="polite">
        {query ? `Current query: ${query}` : "Enter a component name."}
      </p>
    </div>
  );
}

```

## Overview [#overview]

Input Group places an input or textarea inside one shared border with supporting text, icons, or buttons. It is useful when the surrounding content changes how people interpret or operate the value, such as a fixed URL prefix, a search icon, or a clear button.

The group is visual structure, not a replacement for a field. Give the control a visible label and use [Field](/docs/components/field) when you also need a description, validation message, required indicator, or shared disabled state.

## Anatomy [#anatomy]

`InputGroup` is the outer container, rendered as a `div` with `role="group"`. Add one `InputGroupInput` or `InputGroupTextarea`, then place `InputGroupAddon` before or after it. An addon can contain `InputGroupText` for non-interactive context or `InputGroupButton` for an action.

Inline addons sit at the start or end of a single-line input. Block addons sit above or below the control and let a textarea or input grow to its natural height — the container switches from a fixed 32 px line to an auto-height column whenever it detects a textarea or a block-aligned addon.

## Behavior [#behavior]

Clicking anywhere in a non-interactive addon focuses the group's input, so the prefix area behaves as part of the field rather than dead space. Clicking an `InputGroupButton` keeps its own action instead. Note this focus hand-off targets `<input>` elements only; clicking a textarea's addon does not move focus into it.

The shared border reflects the control's state: focus shows a ring, `aria-invalid="true"` turns the border and ring to the danger color, and disabling the control dims the whole group. Put these attributes on the input or textarea itself, not on the container.

Prefix and suffix text is not submitted with the input value. If the server needs the complete value, combine the visible context with the submitted value in your application logic.

## Accessibility [#accessibility]

Keep a visible label outside the group, connected through `htmlFor`/`id`. The container's `role="group"` carries no name of its own, and addons are never announced as part of the field's name or description — a `https://` prefix visible on screen does nothing for someone navigating by screen reader unless the accessible name covers it.

Decorative icons need `aria-hidden="true"`; icon-only buttons need an `aria-label` that names the action. Do not put required instructions or error messages only inside an addon, because they will not be associated with the control — pair the group with Field below it for descriptions and errors.

Keyboard users reach the input with <kbd>Tab</kbd> and type normally; the clear button and any other addon buttons are ordinary tab stops. The single-line group stands 32 px tall, clearing the WCAG 2.2 minimum target size of 24 px, though addon buttons are smaller and should stay easy to hit with surrounding spacing. Colors come from theme tokens, so focus rings, danger states, and dimmed disabled groups adapt to dark mode automatically.

Addon placement relies on flex ordering rather than fixed margins, so start and end addons swap sides correctly in right-to-left layouts; check the spacing next to each addon visually after localizing. Avoid stacking so many inline actions that the text entry area becomes too narrow — long values already scroll horizontally inside the control.

## Installation [#installation]


  

  
    <CliBlock commands="[&#x22;input-group&#x22;]" />
  

  
    
      
        Copy the Input Group component into your project.
      

      ### components/ui/input-group.tsx

```tsx
"use client"

import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"
import { Button } from "./button"
import { Input } from "./input"
import { Textarea } from "./textarea"

function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="input-group"
      role="group"
      className={cn(
        "group/input-group relative flex h-8 w-full min-w-0 items-center rounded-[var(--hui-radius-2)] border border-[var(--hui-color-border-base-tertiary)] transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:outline-none has-disabled:bg-[var(--hui-color-background-base-secondary)] has-[[data-slot=input-group-control]:focus-visible]:border-[var(--hui-color-border-accent-emphasis)] has-[[data-slot=input-group-control]:focus-visible]:[outline:var(--hui-focus-ring)] has-[[data-slot=input-group-control]:focus-visible]:outline-offset-[var(--hui-focus-ring-offset-inset-border)] has-[[data-slot][aria-invalid=true]]:border-[var(--hui-color-border-danger-emphasis)] has-[[data-slot][aria-invalid=true]]:[outline-color:var(--hui-color-border-danger-emphasis)] has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
        className
      )}
      {...props}
    />
  )
}

const inputGroupAddonVariants = cva(
  "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 [font-size:var(--hui-font-size-control,13px)] [line-height:var(--hui-line-height-regular)] font-medium text-[var(--hui-color-foreground-base-secondary)] select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--hui-radius-2)-5px)] [&>svg:not([class*='size-'])]:size-4",
  {
    variants: {
      align: {
        "inline-start":
          "order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
        "inline-end":
          "order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
        "block-start":
          "order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
        "block-end":
          "order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
      },
    },
    defaultVariants: {
      align: "inline-start",
    },
  }
)

function InputGroupAddon({
  className,
  align = "inline-start",
  ...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
  return (
    <div
      role="group"
      data-slot="input-group-addon"
      data-align={align}
      className={cn(inputGroupAddonVariants({ align }), className)}
      onClick={(e) => {
        if ((e.target as HTMLElement).closest("button")) {
          return
        }
        e.currentTarget.parentElement?.querySelector("input")?.focus()
      }}
      {...props}
    />
  )
}

const inputGroupButtonVariants = cva(
  "flex items-center gap-2 [font-size:var(--hui-font-size-control,13px)] [line-height:var(--hui-line-height-regular)] shadow-none",
  {
    variants: {
      size: {
        xs: "h-6 gap-1 rounded-[calc(var(--hui-radius-2)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
        sm: "",
        "icon-xs":
          "size-6 rounded-[calc(var(--hui-radius-2)-3px)] p-0 has-[>svg]:p-0",
        "icon-sm": "size-8 p-0 has-[>svg]:p-0",
      },
    },
    defaultVariants: {
      size: "xs",
    },
  }
)

function InputGroupButton({
  className,
  type = "button",
  variant = "ghost",
  size = "xs",
  ...props
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
  VariantProps<typeof inputGroupButtonVariants> & {
    type?: "button" | "submit" | "reset"
  }) {
  return (
    <Button
      type={type}
      data-size={size}
      variant={variant}
      className={cn(inputGroupButtonVariants({ size }), className)}
      {...props}
    />
  )
}

function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
  return (
    <span
      className={cn(
        "flex items-center gap-2 [font-size:var(--hui-font-size-control,13px)] [line-height:var(--hui-line-height-regular)] text-[var(--hui-color-foreground-base-secondary)] [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      {...props}
    />
  )
}

function InputGroupInput({
  className,
  ...props
}: React.ComponentProps<"input">) {
  return (
    <Input
      data-slot="input-group-control"
      className={cn(
        "flex-1 rounded-none border-0 bg-transparent shadow-none focus-within:bg-transparent focus-within:outline-none data-disabled:bg-transparent",
        className
      )}
      {...props}
    />
  )
}

function InputGroupTextarea({
  className,
  ...props
}: React.ComponentProps<"textarea">) {
  return (
    <Textarea
      data-slot="input-group-control"
      className={cn(
        "flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none [&:focus:not(:disabled)]:bg-transparent [&:focus:not(:disabled)]:outline-none disabled:bg-transparent",
        className
      )}
      {...props}
    />
  )
}

export {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupText,
  InputGroupInput,
  InputGroupTextarea,
}

```

      
        Make sure the local Button, Input, Textarea, and utility imports match your
        project.
      
    
  


## Usage [#usage]

```tsx
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
  InputGroupText,
} from "@/components/ui/input-group";
```

```tsx
<InputGroup>
  <InputGroupAddon>
    <InputGroupText>https://</InputGroupText>
  </InputGroupAddon>
  <InputGroupInput aria-label="Website address" name="website" />
</InputGroup>
```

## Don't do this [#dont-do-this]

### Letting the addon act as the label [#letting-the-addon-act-as-the-label]

```tsx
// Bad
<InputGroup>
  <InputGroupAddon>
    <InputGroupText>honestui.com/</InputGroupText>
  </InputGroupAddon>
  <InputGroupInput name="slug" />
</InputGroup>
```

```tsx
// Good
<Label htmlFor={id}>Project URL</Label>
<InputGroup>
  <InputGroupAddon>
    <InputGroupText>honestui.com/</InputGroupText>
  </InputGroupAddon>
  <InputGroupInput id={id} name="slug" />
</InputGroup>
```

The prefix explains the value's shape but never becomes its name: the field is announced as "edit text" (or by placeholder alone), leaving screen reader users to guess what belongs there. Keep a real label outside the group; the addon adds context on top of it.

### Unnamed icon buttons in an addon [#unnamed-icon-buttons-in-an-addon]

```tsx
// Bad
<InputGroupButton size="icon-xs" onClick={clear}>
  <XIcon />
</InputGroupButton>
```

```tsx
// Good
<InputGroupButton size="icon-xs" aria-label="Clear search" onClick={clear}>
  <XIcon aria-hidden="true" />
</InputGroupButton>
```

An icon-only button has no accessible name, so assistive technology announces just "button" and voice-control users have nothing to say to trigger it. Name every icon-only addon action, and mark its icon decorative.

## Examples [#examples]

These examples focus on the relationships that are unique to Input Group: an action beside a control, contextual text that is not part of the value, and a block addon below a textarea.

### Prefix and suffix [#prefix-and-suffix]

Both sides add context; only the slug itself is submitted.

```tsx
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
  InputGroupText,
} from "@/components/honest-ui/ui/input-group";

export function InputGroupUrl() {
  return (
    <div className="w-full max-w-sm space-y-2">
      <label className="text-sm font-medium" htmlFor="project-slug">
        Project URL
      </label>
      <InputGroup>
        <InputGroupAddon>
          <InputGroupText>honestui.com/</InputGroupText>
        </InputGroupAddon>
        <InputGroupInput
          id="project-slug"
          name="slug"
          placeholder="my-project"
          spellCheck={false}
        />
        <InputGroupAddon align="inline-end">
          <InputGroupText>.tsx</InputGroupText>
        </InputGroupAddon>
      </InputGroup>
      <p className="text-sm text-muted-foreground">
        Prefixes and suffixes add context; the input value remains only the
        slug.
      </p>
    </div>
  );
}

```

### Textarea with a block addon [#textarea-with-a-block-addon]

A block-end addon holds guidance that would not fit beside the control.

```tsx
import {
  InputGroup,
  InputGroupAddon,
  InputGroupText,
  InputGroupTextarea,
} from "@/components/honest-ui/ui/input-group";

export function InputGroupTextareaExample() {
  return (
    <div className="w-full max-w-sm space-y-2">
      <label className="text-sm font-medium" htmlFor="release-note">
        Release note
      </label>
      <InputGroup>
        <InputGroupTextarea
          id="release-note"
          maxLength={180}
          placeholder="Describe what changed"
          rows={4}
        />
        <InputGroupAddon align="block-end" className="justify-end border-t">
          <InputGroupText>180 characters maximum</InputGroupText>
        </InputGroupAddon>
      </InputGroup>
    </div>
  );
}

```

### Keyboard shortcut hint [#keyboard-shortcut-hint]

A `kbd` element in an end addon advertises the shortcut; wiring the hint to real behavior keeps it honest.

<ComponentSource name="input-group-kbd" title="examples/input-group-kbd.tsx" />

## API reference [#api-reference]

| Part                 | Renders                   | Notable props                                                                                                                                  |
| -------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `InputGroup`         | `div` with `role="group"` | Native div props                                                                                                                               |
| `InputGroupAddon`    | `div` with `role="group"` | `align`: `inline-start` (default), `inline-end`, `block-start`, `block-end`; clicking it focuses the group's input unless a button was clicked |
| `InputGroupButton`   | `Button`                  | `type` defaults to `button`; `variant` defaults to `ghost`; `size`: `xs` (default), `sm`, `icon-xs`, `icon-sm`                                 |
| `InputGroupText`     | `span`                    | Native span props; muted styling                                                                                                               |
| `InputGroupInput`    | `input`                   | Native input props; borderless and stretched inside the group                                                                                  |
| `InputGroupTextarea` | `textarea`                | Native textarea props; resizing is disabled so the group controls height                                                                       |

State attributes (`aria-invalid`, `disabled`) belong on the input or textarea; the container reacts to them through selectors.
