---
title: InputGroup
description: Place addons and actions around a native text control.
package: moraine
version: 0.6.0
repository: https://github.com/subframe7536/moraine
---

# InputGroup

> Place addons and actions around a native text control.

Use InputGroup when an editable control and its addons should share one visual boundary. The inner input or textarea still owns text entry and its accessible name.

## Basic usage

```tsx
import { Field, Input, InputGroup } from 'moraine'

export function Example() {
  return (
    <Field label="Username">
      <InputGroup>
        <InputGroup.Leading>@</InputGroup.Leading>
        <Input placeholder="username" />
      </InputGroup>
    </Field>
  )
}
```

## Anatomy

```text
InputGroup [component; slot=root]
├── InputGroup.Leading [part; slot=leading]
├── InputGroup.Trailing [part; slot=trailing]
└── frame [internal]
```

Place Leading parts before one Input or Textarea, then Trailing parts. The final internal frame paints the shared boundary.

## Usage

### Direction and orientation

In horizontal groups, Leading and Trailing follow the document's writing direction. In vertical
groups, they sit above and below the control. Set `inputGroup.defaultVariants.orientation` in
your Theme to change the shared default, or `inputGroup.variants.orientation` to override its styles.

```tsx
import { Icon, Input, InputGroup } from 'moraine'

export function Alignment() {
  return (
    <div class="gap-4 grid max-w-md w-full">
      <InputGroup>
        <InputGroup.Leading>
          <Icon name="i-lucide:globe" />
          https://
        </InputGroup.Leading>
        <Input aria-label="Website" placeholder="example.com" />
        <InputGroup.Trailing>.com</InputGroup.Trailing>
      </InputGroup>
      <InputGroup orientation="vertical">
        <InputGroup.Leading>Project website</InputGroup.Leading>
        <Input aria-label="Website details" placeholder="example.com" />
        <InputGroup.Trailing>Use your public domain.</InputGroup.Trailing>
      </InputGroup>
      <InputGroup dir="rtl">
        <InputGroup.Leading>
          <Icon name="i-lucide:search" />
        </InputGroup.Leading>
        <Input aria-label="Search" placeholder="بحث..." />
        <InputGroup.Trailing>١٢ نتيجة</InputGroup.Trailing>
      </InputGroup>
    </div>
  )
}
```

### Compact

Set `compact` on a `Leading` or `Trailing` part to reduce its padding while keeping the same layout role. In horizontal groups it uses smaller inline padding; in vertical groups it uses smaller padding on all sides. This is useful for compact buttons, keyboard shortcuts, inline prefixes or suffixes, and other addons that need less surrounding space.

```tsx
import { Button, Icon, Input, InputGroup } from 'moraine'
import { createSignal } from 'solid-js'

export function Compact() {
  const [txt, setTxt] = createSignal('')
  return (
    <InputGroup class="max-w-sm w-full">
      <InputGroup.Leading>https://</InputGroup.Leading>
      <Input aria-label="Website" placeholder="example.com" value={txt()} onValueChange={setTxt} />
      <InputGroup.Trailing compact>
        <Button size="icon-xs" variant="ghost" onClick={() => setTxt('')}>
          <Icon name="i-lucide:x" />
        </Button>
      </InputGroup.Trailing>
    </InputGroup>
  )
}
```

### Styling and forms

`InputGroup` owns the shared border, background, radius, and focus/invalid frame. Set its `size` and `variant` to style the group. Disabled presentation remains on the native `Input` or `Textarea`, and the native control's `variant` applies when it is outside a group.

Size precedence is the control's explicit size, group size, Field size, then theme defaults. `classes.leading`, `classes.trailing`, and their style equivalents set group-wide presentation; each part's `class` and `style` override it.

Keep `name`, `value`, `onValueChange`, `required`, `disabled`, `readOnly`, and ARIA attributes on the control. InputGroup does not register a form field. Clicking non-interactive group space focuses the native control; buttons and links retain their own focus behavior.

## Examples

### Icon

Place one or more icons in either horizontal part.

```tsx
import { Icon, Input, InputGroup } from 'moraine'

export function Icons() {
  return (
    <div class="gap-4 grid max-w-sm w-full">
      <InputGroup>
        <InputGroup.Leading>
          <Icon name="i-lucide:search" />
        </InputGroup.Leading>
        <Input aria-label="Search" placeholder="Search..." />
      </InputGroup>
      <InputGroup>
        <InputGroup.Leading>
          <Icon name="i-lucide:mail" />
        </InputGroup.Leading>
        <Input type="email" aria-label="Email" placeholder="Enter your email" />
      </InputGroup>
      <InputGroup>
        <InputGroup.Leading>
          <Icon name="i-lucide:credit-card" />
        </InputGroup.Leading>
        <Input aria-label="Card number" placeholder="Card number" />
        <InputGroup.Trailing>
          <Icon name="i-lucide:check" />
        </InputGroup.Trailing>
      </InputGroup>
      <InputGroup>
        <Input aria-label="Favorite card" placeholder="Card number" />
        <InputGroup.Trailing>
          <Icon name="i-lucide:star" />
          <Icon name="i-lucide:info" />
        </InputGroup.Trailing>
      </InputGroup>
    </div>
  )
}
```

### Text

Supporting content accepts plain text and JSX without a dedicated text component.

```tsx
import { Input, InputGroup, Textarea } from 'moraine'

export function TextContent() {
  return (
    <div class="gap-4 grid max-w-sm w-full">
      <InputGroup>
        <InputGroup.Leading>$</InputGroup.Leading>
        <Input aria-label="Amount" inputMode="decimal" placeholder="0.00" />
        <InputGroup.Trailing>USD</InputGroup.Trailing>
      </InputGroup>
      <InputGroup>
        <InputGroup.Leading>https://</InputGroup.Leading>
        <Input aria-label="Website" placeholder="example.com" />
        <InputGroup.Trailing compact>.com</InputGroup.Trailing>
      </InputGroup>
      <InputGroup>
        <Input aria-label="Username" placeholder="Enter your username" />
        <InputGroup.Trailing>@company.com</InputGroup.Trailing>
      </InputGroup>
      <InputGroup orientation="vertical">
        <Textarea aria-label="Message" placeholder="Enter your message" rows={3} />
        <InputGroup.Trailing>
          <span class="text-xs">120 characters left</span>
        </InputGroup.Trailing>
      </InputGroup>
    </div>
  )
}
```

### Button

Use the existing `Button` component for copy, toggle, and submit actions. Add `compact` to the containing part when the button should use denser surrounding padding; keep each button's state and disabled behavior explicit.

```tsx
import { Button, Icon, Input, InputGroup } from 'moraine'
import { createSignal, Show } from 'solid-js'

const targetURL = 'https://github.com/subframe7536/moraine'

export function Actions() {
  const [copied, setCopied] = createSignal(false)
  const [favorite, setFavorite] = createSignal(false)

  const copyUrl = () => {
    void navigator.clipboard?.writeText(targetURL)
    setCopied(true)
    setTimeout(() => setCopied(false), 2000)
  }

  return (
    <div class="gap-4 grid max-w-md w-full">
      <InputGroup>
        <Input aria-label="Profile URL" value={targetURL} readOnly />
        <InputGroup.Trailing compact>
          <Button
            type="button"
            size="icon-xs"
            variant="ghost"
            aria-label="Copy URL"
            onClick={copyUrl}
          >
            <Icon name={copied() ? 'i-lucide:check' : 'i-lucide:copy'} />
          </Button>
        </InputGroup.Trailing>
      </InputGroup>
      <InputGroup>
        <InputGroup.Leading>https://</InputGroup.Leading>
        <Input placeholder="example.com" class="-ml-1.5" aria-label="Secure URL" />
        <InputGroup.Trailing compact>
          <Button
            type="button"
            size="icon-xs"
            variant="ghost"
            aria-label="Toggle favorite"
            aria-pressed={favorite()}
            onClick={() => setFavorite((value) => !value)}
          >
            <Show when={favorite()} fallback={<Icon name="i-lucide:star" />}>
              <Icon name="i-lucide:star" class="text-primary fill-primary" />
            </Show>
          </Button>
        </InputGroup.Trailing>
      </InputGroup>
      <InputGroup>
        <Input aria-label="Search" placeholder="Type to search..." />
        <InputGroup.Trailing compact>
          <Button type="button" size="xs" variant="secondary">
            Search
          </Button>
        </InputGroup.Trailing>
      </InputGroup>
    </div>
  )
}
```

### Kbd

Use `Kbd` to show a shortcut without changing the input's keyboard behavior. Add `compact` to the containing part when the shortcut should use denser surrounding padding.

```tsx
import { Icon, Input, InputGroup, KbdGroup } from 'moraine'

export function KeyboardShortcut() {
  return (
    <InputGroup class="max-w-sm">
      <InputGroup.Leading>
        <Icon name="i-lucide:search" />
      </InputGroup.Leading>
      <Input aria-label="Search" placeholder="Search..." />
      <InputGroup.Trailing compact>
        <KbdGroup items={['command', 'K']} />
      </InputGroup.Trailing>
    </InputGroup>
  )
}
```

### Dropdown

`DropdownMenu.Trigger` can render as a button inside either horizontal part.

```tsx
import { Button, DropdownMenu, Icon, Input, InputGroup } from 'moraine'

export function DropdownActions() {
  return (
    <div class="gap-4 grid max-w-sm w-full">
      <InputGroup>
        <Input aria-label="File name" placeholder="Enter file name" />
        <InputGroup.Trailing compact>
          <DropdownMenu placement="bottom" align="end">
            <DropdownMenu.Trigger as={Button} type="button" variant="ghost" size="icon-xs">
              <Icon name="i-lucide:ellipsis" />
              <span class="sr-only">More file actions</span>
            </DropdownMenu.Trigger>
            <DropdownMenu.Content
              items={[
                { label: 'Settings', icon: 'i-lucide:settings' },
                { label: 'Copy path', icon: 'i-lucide:copy' },
                { label: 'Open location', icon: 'i-lucide:folder-open' },
              ]}
            />
          </DropdownMenu>
        </InputGroup.Trailing>
      </InputGroup>
      <InputGroup>
        <Input aria-label="Search query" placeholder="Enter search query" />
        <InputGroup.Trailing compact>
          <DropdownMenu placement="bottom" align="end">
            <DropdownMenu.Trigger
              as={Button}
              type="button"
              variant="ghost"
              size="xs"
              trailing="i-lucide:chevron-down"
            >
              Search in
            </DropdownMenu.Trigger>
            <DropdownMenu.Content
              items={[{ label: 'Documentation' }, { label: 'Blog posts' }, { label: 'Changelog' }]}
            />
          </DropdownMenu>
        </InputGroup.Trailing>
      </InputGroup>
    </div>
  )
}
```

### Spinner

Render a loading icon explicitly wherever progress needs to appear.

```tsx
import { Icon, Input, InputGroup } from 'moraine'

export function LoadingIndicators() {
  return (
    <div class="gap-4 grid max-w-sm w-full">
      <InputGroup>
        <Input aria-label="Searching" placeholder="Searching..." />
        <InputGroup.Trailing>
          <Icon name="icon-loading" class="animate-spin" />
        </InputGroup.Trailing>
      </InputGroup>
      <InputGroup>
        <InputGroup.Leading>
          <Icon name="icon-loading" class="animate-spin" />
        </InputGroup.Leading>
        <Input aria-label="Processing" placeholder="Processing..." />
      </InputGroup>
      <InputGroup>
        <Input aria-label="Saving changes" placeholder="Saving changes..." />
        <InputGroup.Trailing>
          <span>Saving...</span>
          <Icon name="icon-loading" class="animate-spin" />
        </InputGroup.Trailing>
      </InputGroup>
      <InputGroup>
        <InputGroup.Leading>
          <Icon name="i-lucide:loader-circle" class="animate-spin" />
        </InputGroup.Leading>
        <Input aria-label="Refreshing data" placeholder="Refreshing data..." />
        <InputGroup.Trailing>Please wait...</InputGroup.Trailing>
      </InputGroup>
    </div>
  )
}
```

### Textarea

Set `orientation="vertical"` to form a toolbar and status bar around the independent `Textarea`.

```tsx
import { Button, Icon, InputGroup, Textarea } from 'moraine'

export function TextareaEditor() {
  return (
    <InputGroup orientation="vertical" class="max-w-md w-full">
      <InputGroup.Leading>
        <Icon name="i-lucide:file-code-2" />
        <span class="font-medium font-mono">script.js</span>
        <Button type="button" variant="ghost" size="icon-xs" class="ms-auto">
          <Icon name="i-lucide:refresh-cw" />
          <span class="sr-only">Refresh</span>
        </Button>
        <Button type="button" variant="ghost" size="icon-xs">
          <Icon name="i-lucide:copy" />
          <span class="sr-only">Copy</span>
        </Button>
      </InputGroup.Leading>
      <Textarea
        id="input-group-code-editor"
        aria-label="JavaScript source"
        placeholder="console.log('Hello, world!');"
        class="font-mono min-h-50"
      />
      <InputGroup.Trailing>
        <span>Line 1, Column 1</span>
        <Button type="button" size="sm" class="ms-auto" trailing="i-lucide:corner-down-left">
          Run
        </Button>
      </InputGroup.Trailing>
    </InputGroup>
  )
}
```

#### Auto-resize

`Textarea` keeps its own value, ref, native attributes, and autoresize behavior. The same group can provide header and footer regions.

```tsx
import { Button, Icon, InputGroup, Textarea } from 'moraine'
import { createSignal } from 'solid-js'

export function Composer() {
  const [value, setValue] = createSignal('')
  return (
    <InputGroup orientation="vertical" class="max-w-md w-full">
      <InputGroup.Leading>
        <Icon name="i-lucide:message-square" />
        Comment
      </InputGroup.Leading>
      <Textarea
        aria-label="Comment"
        value={value()}
        onValueChange={(next) => setValue(next ?? '')}
        placeholder="Write a comment..."
        autoResize
        rows={3}
        maxRows={8}
      />
      <InputGroup.Trailing>
        <span>{value().length} characters</span>
        <Button
          type="button"
          size="xs"
          class="ms-auto"
          disabled={!value()}
          onClick={() => setValue('')}
        >
          Clear
        </Button>
      </InputGroup.Trailing>
    </InputGroup>
  )
}
```

### RTL

Horizontal Leading and Trailing positions follow the inherited `dir`; vertical positions remain above and below the control.

```tsx
import { Button, Icon, Input, InputGroup, Textarea } from 'moraine'

export function RightToLeft() {
  return (
    <div dir="rtl" class="gap-4 grid max-w-sm w-full">
      <InputGroup>
        <InputGroup.Leading>
          <Icon name="i-lucide:search" />
        </InputGroup.Leading>
        <Input aria-label="بحث" placeholder="بحث..." />
        <InputGroup.Trailing>١٢ نتيجة</InputGroup.Trailing>
      </InputGroup>
      <InputGroup>
        <Input aria-label="جاري البحث" placeholder="جاري البحث..." />
        <InputGroup.Trailing>
          <Icon name="icon-loading" class="animate-spin" />
        </InputGroup.Trailing>
      </InputGroup>
      <InputGroup>
        <Input aria-label="جاري حفظ التغييرات" placeholder="جاري حفظ التغييرات..." />
        <InputGroup.Trailing>
          <span>جاري الحفظ...</span>
          <Icon name="icon-loading" class="animate-spin" />
        </InputGroup.Trailing>
      </InputGroup>
      <InputGroup orientation="vertical">
        <Textarea aria-label="منطقة النص" placeholder="اكتب تعليقًا..." rows={3} />
        <InputGroup.Trailing>
          <span>٠/٢٨٠</span>
          <Button type="button" size="sm" class="ms-auto">
            نشر
          </Button>
        </InputGroup.Trailing>
      </InputGroup>
    </div>
  )
}
```

## Attributes

| Attributes | Slot | Description |
| --- | --- | --- |
| `data-input-group` | `input-group` | Identifies a control rendered within an input group. |
| `data-orientation` | `input-group`, `input-group-leading`, `input-group-trailing` | Stores the rendered orientation (horizontal or vertical). |
| `data-compact` | `input-group-leading`, `input-group-trailing` | Present when the component uses its compact presentation. |

## Props

### InputGroup

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| orientation | 'horizontal' \| 'vertical' \| undefined | 'horizontal' | Axis shared by the group and its supporting parts. |
| size | 'sm' \| 'md' \| 'lg' \| undefined | 'md' | Visual size of the component. |
| variant | 'outline' \| 'subtle' \| 'ghost' \| 'none' \| undefined | 'outline' | Visual treatment of the component. |
| children | JSX.Element \| undefined | — | Place the control before supporting content in DOM order. |
| class | SlotClassValue \| undefined | — | Class applied to the component root or trigger element. |
| classes | Classes \| undefined | — | Family slot class defaults for this instance. |
| style | SlotStyleValue \| undefined | — | Style applied to the component root or trigger element. |
| styles | Styles \| undefined | — | Family slot style defaults for this instance. |

### InputGroup.Leading

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| compact | boolean \| undefined | false | Removes the padding between this part and the adjacent control. |
| children | JSX.Element \| undefined | — | Icons, text, buttons, or other caller-owned content. |
| class | SlotClassValue \| undefined | — | Class applied to the component root or trigger element. |
| style | SlotStyleValue \| undefined | — | Style applied to the component root or trigger element. |

### InputGroup.Trailing

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| compact | boolean \| undefined | false | Removes the padding between this part and the adjacent control. |
| children | JSX.Element \| undefined | — | Icons, text, buttons, or other caller-owned content. |
| class | SlotClassValue \| undefined | — | Class applied to the component root or trigger element. |
| style | SlotStyleValue \| undefined | — | Style applied to the component root or trigger element. |
