# Input Group

Puts icons, text and buttons inside a single input frame.

```tsx
import * as stylex from "@stylexjs/stylex"
import { Input } from "@/components/ui/input"
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupText } from "@/components/ui/input-group"
import { ArrowUp, Search } from "lucide-react"
import { VStack } from "@/components/ui/stack"

const styles = stylex.create({
  stack: { width: "100%", maxWidth: "20rem" },
})

export default function InputGroupDemo() {
  return (
    <VStack gap={4} xstyle={styles.stack}>
      <InputGroup>
        <Input placeholder="Search projects" aria-label="Search projects" />
        <InputGroupAddon>
          <Search />
        </InputGroupAddon>
        <InputGroupAddon align="inline-end">
          <InputGroupText>12 results</InputGroupText>
        </InputGroupAddon>
      </InputGroup>
      <InputGroup>
        <Input placeholder="Ask a question" aria-label="Ask a question" />
        <InputGroupAddon align="inline-end">
          <InputGroupButton variant="default" size="icon-xs" aria-label="Send">
            <ArrowUp />
          </InputGroupButton>
        </InputGroupAddon>
      </InputGroup>
    </VStack>
  )
}
```

## Installation

1. Install the following dependencies:

   ```bash
   npm install @base-ui/react @stylexjs/stylex clsx
   ```

2. Set up the token layer and the StyleX compiler, if you have not already. See
   [Installation](https://carbon.style/docs/installation), and paste `states.ts` from the same page.
   The frame's field surface, its focus ring, its invalid ring and its disabled
   dim come from it. The
   group also needs `interop.css`, which sizes the icons its addons do not render.

   Paste `field-state.ts` and `state-styles.ts` from the same installation page.

3. Add [Input](https://carbon.style/docs/components/input) and [Button](https://carbon.style/docs/components/button). The
   group imports both, and `InputGroupContext` is declared in `input.tsx` so the
   input stays pasteable without the group.

4. Copy and paste the following code into your project.

   ```tsx title="components/ui/input-group.tsx"
   "use client"

   import { FieldStateContext } from "@/lib/field-state"
   import { useRender } from "@base-ui/react/use-render"
   import type { ComponentProps, FocusEvent, MouseEvent } from "react"
   import { createContext, use, useState } from "react"
   import * as stylex from "@stylexjs/stylex"
   import type { StyleXStyles } from "@stylexjs/stylex"
   import { clsx } from "clsx"

   import { Button, type ButtonSize, type ButtonVariant } from "@/components/ui/button"
   import { InputGroupContext } from "@/components/ui/input"
   import { states } from "@/theme/states"
   import { color, duration, ease, leading, radius, space, text } from "@/theme/tokens.stylex"

   type InputGroupAlign = "inline-start" | "inline-end" | "block-start" | "block-end"

   const InputGroupAddonContext = createContext<{ align: InputGroupAlign } | null>(null)

   const styles = stylex.create({
     group: {
       display: "flex",
       flexWrap: "wrap",
       alignItems: "center",
       alignContent: "center",
       width: "100%",
       minWidth: 0,
       minHeight: space.s8,
       borderRadius: radius.lg,
       borderWidth: 1,
       borderStyle: "solid",
       borderColor: "var(--carbon-border-color)",
       transitionProperty: "color, background-color, border-color, box-shadow",
       transitionDuration: duration.color,
       transitionTimingFunction: ease.standard,
       outlineStyle: "none",
     },
     disabledFill: { backgroundColor: color.inputDisabledBg },
     addon: {
       display: "flex",
       alignItems: "center",
       justifyContent: "center",
       gap: space.s2,
       cursor: "text",
       userSelect: "none",
       color: color.mutedForeground,
       fontSize: text.sm,
       lineHeight: leading.sm,
       fontWeight: 500,
       "--carbon-icon-size": space.s4,
     },
     text: {
       display: "flex",
       alignItems: "center",
       gap: space.s2,
       color: color.mutedForeground,
       fontSize: text.sm,
       lineHeight: leading.sm,
       "--carbon-icon-size": space.s4,
     },
     pullStart: { marginInlineStart: `calc(-1 * ${space.s1})` },
     pullEnd: { marginInlineEnd: `calc(-1 * ${space.s1})` },
   })

   const buttons = stylex.create({
     xs: { boxShadow: "none", "--carbon-button-radius": `calc(${radius.lg} - ${space.s1})` },
     sm: { boxShadow: "none", "--carbon-button-radius": `calc(${radius.lg} - ${space.s0_5})` },
   })

   const aligns = stylex.create({
     "inline-start": {
       order: -1,
       paddingInlineStart: space.s2,
       marginInlineEnd: `calc(-1 * ${space.s1})`,
     },
     "inline-end": {
       order: 1,
       paddingInlineEnd: space.s2,
       marginInlineStart: `calc(-1 * ${space.s1})`,
     },
     "block-start": {
       order: -2,
       flexBasis: "100%",
       justifyContent: "flex-start",
       paddingInline: space.s2_5,
       paddingBlockStart: space.s2,
     },
     "block-end": {
       order: 2,
       flexBasis: "100%",
       justifyContent: "flex-start",
       paddingInline: space.s2_5,
       paddingBlockEnd: space.s2,
     },
   })

   const inputGroupStyles = {
     base: [styles.group, states.fieldSurface],
     focused: states.fieldFocused,
     invalid: states.invalidRing,
     disabled: [styles.disabledFill, states.disabled],
     addon: styles.addon,
     aligns,
     text: styles.text,
     buttons,
   }

   type Overrides = { xstyle?: StyleXStyles; className?: string }

   type InputGroupProps = Omit<
     useRender.ComponentProps<"div", { disabled: boolean }>,
     "onFocus" | "onBlur"
   > &
     Overrides & {
       disabled?: boolean
       invalid?: boolean
       onFocus?: (event: FocusEvent<HTMLDivElement>) => void
       onBlur?: (event: FocusEvent<HTMLDivElement>) => void
     }

   function InputGroup({
     render,
     ref,
     style,
     disabled: disabledProp = false,
     invalid: invalidProp = false,
     xstyle,
     className,
     onFocus,
     onBlur,
     ...props
   }: InputGroupProps) {
     const fieldState = use(FieldStateContext)
     const disabled = disabledProp || fieldState?.disabled === true
     const invalid = invalidProp || fieldState?.valid === false
     const [focused, setFocused] = useState(false)

     const sx = stylex.props(
       styles.group,
       states.fieldSurface,
       focused && states.fieldFocused,
       invalid && states.invalidRing,
       disabled && styles.disabledFill,
       disabled && states.disabled,
       xstyle,
     )

     const isControl = (target: EventTarget) =>
       target instanceof Element && target.matches('[data-slot="input-group-control"]')

     return (
       <InputGroupContext value={{ disabled, invalid }}>
         {/* oxlint-disable-next-line jsx-a11y/prefer-tag-over-role, jsx-a11y/no-noninteractive-element-interactions */}
         {useRender({
           defaultTagName: "div",
           render,
           ref,
           state: { disabled },
           props: {
             role: "group",
             "data-slot": "input-group",
             ...sx,
             ...props,
             onFocus: (event: FocusEvent<HTMLDivElement>) => {
               if (isControl(event.target)) setFocused(true)
               onFocus?.(event)
             },
             onBlur: (event: FocusEvent<HTMLDivElement>) => {
               if (isControl(event.target)) setFocused(false)
               onBlur?.(event)
             },
             className: clsx(sx.className, className),
             style: { ...sx.style, ...style },
           },
         })}
       </InputGroupContext>
     )
   }

   type InputGroupAddonProps = useRender.ComponentProps<"div"> &
     Overrides & { align?: InputGroupAlign }

   function InputGroupAddon({
     render,
     ref,
     style,
     align = "inline-start",
     xstyle,
     className,
     onClick,
     ...props
   }: InputGroupAddonProps) {
     const sx = stylex.props(styles.addon, aligns[align] ?? aligns["inline-start"], xstyle)

     return (
       <InputGroupAddonContext value={{ align }}>
         {/* oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
         {useRender({
           defaultTagName: "div",
           render,
           ref,
           props: {
             "data-slot": "input-group-addon",
             "data-align": align,
             ...sx,
             ...props,
             onClick: (event: MouseEvent<HTMLDivElement>) => {
               if (!(event.target as HTMLElement).closest("button")) {
                 event.currentTarget.parentElement
                   ?.querySelector<HTMLElement>('[data-slot="input-group-control"]')
                   ?.focus()
               }
               onClick?.(event)
             },
             className: clsx("carbon-icons", sx.className, className),
             style: { ...sx.style, ...style },
           },
         })}
       </InputGroupAddonContext>
     )
   }

   type InputGroupButtonProps = Omit<ComponentProps<typeof Button>, "size"> & {
     size?: Extract<ButtonSize, "xs" | "sm" | "icon-xs" | "icon-sm">
     variant?: ButtonVariant
   }

   function InputGroupButton({
     variant = "ghost",
     size = "xs",
     xstyle,
     ...props
   }: InputGroupButtonProps) {
     const align = use(InputGroupAddonContext)?.align

     return (
       <Button
         data-slot="input-group-button"
         variant={variant}
         size={size}
         xstyle={[
           buttons[size === "xs" || size === "icon-xs" ? "xs" : "sm"],
           align === "inline-start" && styles.pullStart,
           align === "inline-end" && styles.pullEnd,
           xstyle,
         ]}
         {...props}
       />
     )
   }

   function InputGroupText({
     render,
     ref,
     style,
     xstyle,
     className,
     ...props
   }: useRender.ComponentProps<"span"> & Overrides) {
     const sx = stylex.props(styles.text, xstyle)

     return useRender({
       defaultTagName: "span",
       render,
       ref,
       props: {
         "data-slot": "input-group-text",
         ...sx,
         ...props,
         className: clsx("carbon-icons", sx.className, className),
         style: { ...sx.style, ...style },
       },
     })
   }

   export {
     InputGroup,
     InputGroupAddon,
     InputGroupButton,
     InputGroupText,
     inputGroupStyles,
     type InputGroupAlign,
   }
   ```

5. Update the import paths to match your project setup.

## Usage

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

```tsx
<InputGroup>
  <Input placeholder="Search" aria-label="Search" />
  <InputGroupAddon>
    <Search />
  </InputGroupAddon>
</InputGroup>
```

## Composition

```text
InputGroup
├── Input
└── InputGroupAddon
    ├── InputGroupButton
    └── InputGroupText
```

There is no `InputGroupInput`. `Input` finds the group through context and drops
its own border, fill and ring, so only the group draws a frame. Any other control
joins the frame when it has `data-slot="input-group-control"`.

There is no `InputGroupTextarea` either. A [Textarea](https://carbon.style/docs/components/textarea)
joins the frame through the same `data-slot`, plus `textareaStyles.inGroup`, which
removes the textarea's own frame.

## Align

Use the `align` prop on `InputGroupAddon` to place the addon. The inline values
put the addon beside the control. The block values put it on its own row and
make the frame taller.

```tsx
import * as stylex from "@stylexjs/stylex"
import { Input } from "@/components/ui/input"
import { InputGroup, InputGroupAddon, InputGroupText } from "@/components/ui/input-group"
import { VStack } from "@/components/ui/stack"

const styles = stylex.create({
  stack: { width: "100%", maxWidth: "20rem" },
})

export default function InputGroupAlign() {
  return (
    <VStack gap={4} xstyle={styles.stack}>
      <InputGroup>
        <Input placeholder="inline-start" aria-label="inline-start" />
        <InputGroupAddon align="inline-start">
          <InputGroupText>https://</InputGroupText>
        </InputGroupAddon>
      </InputGroup>
      <InputGroup>
        <Input placeholder="inline-end" aria-label="inline-end" />
        <InputGroupAddon align="inline-end">
          <InputGroupText>.com</InputGroupText>
        </InputGroupAddon>
      </InputGroup>
      <InputGroup>
        <Input placeholder="block-start" aria-label="block-start" />
        <InputGroupAddon align="block-start">
          <InputGroupText>Domain</InputGroupText>
        </InputGroupAddon>
      </InputGroup>
      <InputGroup>
        <Input placeholder="block-end" aria-label="block-end" />
        <InputGroupAddon align="block-end">
          <InputGroupText>Lowercase letters and dashes only.</InputGroupText>
        </InputGroupAddon>
      </InputGroup>
    </VStack>
  )
}
```

> **Warning**
>
> Render every addon **after** the control. `align` positions it with `order`, so
> the control stays first in the DOM and therefore first in the tab order. An
> addon written before the control appears in the same place but comes before
> the control in the tab order.

## Icon

```tsx
import * as stylex from "@stylexjs/stylex"
import { Input } from "@/components/ui/input"
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group"
import { VStack } from "@/components/ui/stack"
import { Check, CreditCard, Search } from "lucide-react"

const styles = stylex.create({
  stack: { width: "100%", maxWidth: "20rem" },
})

export default function InputGroupIcon() {
  return (
    <VStack gap={4} xstyle={styles.stack}>
      <InputGroup>
        <Input placeholder="Search" aria-label="Search" />
        <InputGroupAddon>
          <Search />
        </InputGroupAddon>
      </InputGroup>
      <InputGroup>
        <Input placeholder="Card number" aria-label="Card number" />
        <InputGroupAddon>
          <CreditCard />
        </InputGroupAddon>
        <InputGroupAddon align="inline-end">
          <Check />
        </InputGroupAddon>
      </InputGroup>
    </VStack>
  )
}
```

## Text

Use `InputGroupText` for a prefix, a unit or a counter.

```tsx
import * as stylex from "@stylexjs/stylex"
import { Input } from "@/components/ui/input"
import { InputGroup, InputGroupAddon, InputGroupText } from "@/components/ui/input-group"
import { VStack } from "@/components/ui/stack"

const styles = stylex.create({
  stack: { width: "100%", maxWidth: "20rem" },
})

export default function InputGroupTextDemo() {
  return (
    <VStack gap={4} xstyle={styles.stack}>
      <InputGroup>
        <Input placeholder="0.00" inputMode="decimal" aria-label="Amount" />
        <InputGroupAddon>
          <InputGroupText>$</InputGroupText>
        </InputGroupAddon>
        <InputGroupAddon align="inline-end">
          <InputGroupText>USD</InputGroupText>
        </InputGroupAddon>
      </InputGroup>
      <InputGroup>
        <Input placeholder="ada" aria-label="Username" />
        <InputGroupAddon align="inline-end">
          <InputGroupText>@carbon.style</InputGroupText>
        </InputGroupAddon>
      </InputGroup>
    </VStack>
  )
}
```

## Button

Use `InputGroupButton` for an action inside the frame. It defaults to the
`ghost` variant at size `xs` and shifts toward its addon's edge so its padding
does not stack with the addon's.

```tsx
"use client"

import { useState } from "react"
import * as stylex from "@stylexjs/stylex"
import { Input } from "@/components/ui/input"
import { InputGroup, InputGroupAddon, InputGroupButton } from "@/components/ui/input-group"
import { VStack } from "@/components/ui/stack"
import { Eye, EyeOff } from "lucide-react"

const styles = stylex.create({
  stack: { width: "100%", maxWidth: "20rem" },
})

export default function InputGroupButtonDemo() {
  const [visible, setVisible] = useState(false)

  return (
    <VStack gap={4} xstyle={styles.stack}>
      <InputGroup>
        <Input
          type={visible ? "text" : "password"}
          defaultValue="correcthorsebatterystaple"
          aria-label="Password"
        />
        <InputGroupAddon align="inline-end">
          <InputGroupButton
            size="icon-xs"
            aria-label={visible ? "Hide password" : "Show password"}
            onClick={() => setVisible(!visible)}
          >
            {visible ? <EyeOff /> : <Eye />}
          </InputGroupButton>
        </InputGroupAddon>
      </InputGroup>
      <InputGroup>
        <Input placeholder="Type to search" aria-label="Search" />
        <InputGroupAddon align="inline-end">
          <InputGroupButton variant="secondary">Search</InputGroupButton>
        </InputGroupAddon>
      </InputGroup>
    </VStack>
  )
}
```

## Dropdown

Pass `InputGroupButton` to a menu trigger through `render`.

```tsx
import * as stylex from "@stylexjs/stylex"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { InputGroup, InputGroupAddon, InputGroupButton } from "@/components/ui/input-group"
import { ChevronDown } from "lucide-react"

const styles = stylex.create({
  group: { maxWidth: "20rem" },
})

export default function InputGroupDropdown() {
  return (
    <InputGroup xstyle={styles.group}>
      <Input placeholder="Search" aria-label="Search" />
      <InputGroupAddon align="inline-end">
        <DropdownMenu>
          <DropdownMenuTrigger render={<InputGroupButton />}>
            Everywhere
            <ChevronDown />
          </DropdownMenuTrigger>
          <DropdownMenuContent align="end" sideOffset={8}>
            <DropdownMenuItem>Everywhere</DropdownMenuItem>
            <DropdownMenuItem>Documentation</DropdownMenuItem>
            <DropdownMenuItem>Changelog</DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      </InputGroupAddon>
    </InputGroup>
  )
}
```

## Disabled and invalid

Set `disabled` or `invalid` on the enclosing `Field` or directly on the group. The group passes both to the
control through context, so one prop styles the frame and marks the input.

```tsx
import { Field, FieldLabel } from "@/components/ui/field"
import * as stylex from "@stylexjs/stylex"
import { Input } from "@/components/ui/input"
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group"
import { VStack } from "@/components/ui/stack"
import { Mail } from "lucide-react"

const styles = stylex.create({
  stack: { width: "100%", maxWidth: "20rem" },
})

export default function InputGroupStates() {
  return (
    <VStack gap={4} xstyle={styles.stack}>
      <InputGroup disabled>
        <Input defaultValue="me@example.com" aria-label="Email" />
        <InputGroupAddon>
          <Mail />
        </InputGroupAddon>
      </InputGroup>
      <InputGroup invalid>
        <Input defaultValue="not-an-email" aria-label="Email" />
        <InputGroupAddon>
          <Mail />
        </InputGroupAddon>
      </InputGroup>
      <Field invalid disabled>
        <FieldLabel>Email</FieldLabel>
        <InputGroup>
          <Input defaultValue="not-an-email" />
          <InputGroupAddon>
            <Mail />
          </InputGroupAddon>
        </InputGroup>
      </Field>
    </VStack>
  )
}
```

> **Note**
>
> Setting `disabled` on the control alone greys the text but leaves the frame
> undimmed. The frame cannot read its children's state, because `:has()` is below
> this project's browser baseline. Set the state on the group instead.

## API reference

Pass `render` to replace a part's element or compose it with another component.

Every part renders a `div` (or a `span`, for `InputGroupText`) and takes
`xstyle` and `className`.

### InputGroup

| Prop        | Type           | Default |
| ----------- | -------------- | ------- |
| `disabled`  | `boolean`      | `false` |
| `invalid`   | `boolean`      | `false` |
| `xstyle`    | `StyleXStyles` | —       |
| `className` | `string`       | —       |

The group draws its focus ring from React state. It sets that state from a
`focus` event that bubbles up from an element with
`data-slot="input-group-control"`. A button inside an addon keeps its own ring
and does not light the frame's ring.

### InputGroupAddon

| Prop    | Type                                                             | Default          |
| ------- | ---------------------------------------------------------------- | ---------------- |
| `align` | `"inline-start" \| "inline-end" \| "block-start" \| "block-end"` | `"inline-start"` |

Clicking an addon focuses the group's control, unless the click landed on a
button.

### InputGroupButton

| Prop      | Type                                     | Default   |
| --------- | ---------------------------------------- | --------- |
| `variant` | every `Button` variant                   | `"ghost"` |
| `size`    | `"xs" \| "sm" \| "icon-xs" \| "icon-sm"` | `"xs"`    |

Takes every other `Button` prop. The larger `Button` sizes are left out because
they are taller than the frame.

### InputGroupText

A muted run of text at the control's size, with a `0.5rem` gap for an icon.

### inputGroupStyles

The style maps behind the component. Spread them onto any element.

| Key        | What it is                                           |
| ---------- | ---------------------------------------------------- |
| `base`     | The frame: field surface, wrap layout, min height    |
| `focused`  | The ring, with no pseudo-class on it, over the drop  |
| `invalid`  | The destructive border and ring                      |
| `disabled` | The disabled fill, dimmed and inert                  |
| `addon`    | An addon's row, type and cursor                      |
| `aligns`   | One key per `align` value, each an `order` and inset |
| `text`     | `InputGroupText`                                     |
| `buttons`  | An in-frame button, flat and concentric, `xs`/`sm`   |

`base` is the frame's own box plus `states.fieldSurface`. `focused` is `ring`
with the field's shadow kept under it. `invalid` is `invalidRing`. All three
come from `states` in `theme/states.ts`. `disabled` is two styles, the group's own fill and
the shared `disabled`. `buttons.xs` and `buttons.sm` set
`--carbon-button-radius` to the frame's radius less the button's inset, for a
24px and a 28px button. The frame tracks focus itself, from a `focus` event
the control raises, so its ring has no pseudo-class.

### Data attributes

| Attribute                         | On          | Effect                                        |
| --------------------------------- | ----------- | --------------------------------------------- |
| `data-slot="input-group"`         | the frame   | Targets the frame from a parent               |
| `data-disabled`                   | the frame   | Present when `disabled` is set                |
| `data-slot="input-group-addon"`   | an addon    | Targets an addon from a parent                |
| `data-align`                      | an addon    | Reflects the `align` prop                     |
| `data-slot="input-group-button"`  | a button    | Targets a button from a parent                |
| `data-slot="input-group-text"`    | a text run  | Targets a text run from a parent              |
| `data-slot="input-group-control"` | the control | What the frame watches to draw its focus ring |
