Skip to main content

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#

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

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

Playground#

@example.com
Props
Slots

Anatomy#

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.

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.

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.

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.

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.

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.

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

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

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.

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.

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.

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.

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
data-input-groupSlot: input-groupDescription: Identifies a control rendered within an input group.
data-orientationSlot: input-group, input-group-leading, input-group-trailingDescription: Stores the rendered orientation (horizontal or vertical).
data-compactSlot: input-group-leading, input-group-trailingDescription: Present when the component uses its compact presentation.

Props#

InputGroup#

Renders a <div> element by default.

Prop

Leading#

Renders a <div> element by default.

Prop

Trailing#

Renders a <div> element by default.

Prop