Input Group

An Input with fixed text before or after the value, such as a domain suffix.

Suffix

Fixed text after the input, such as the domain a site's address ends in. The label names the whole value.

.example.com

Code

<link
  rel="component"
  href="@threadlabs/looma/components/ui-form-field/ui-form-field.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-input-group/ui-input-group.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-input/ui-input.html"
>

<ui-form-field>
  <label
    slot="label"
    for="site-address"
  >Site address</label>
  <ui-input-group>
    <ui-input
      id="site-address"
      name="site"
      placeholder="your-site"
    ></ui-input>
    <span
      slot="suffix"
    >.example.com</span>
  </ui-input-group>
</ui-form-field>
<script setup>
  import { UiFormField, UiInput, UiInputGroup } from "@threadlabs/looma";
</script>

<template>
  <UiFormField>
    <template #label>
      <label
        for="site-address"
      >Site address</label>
    </template>
    <UiInputGroup>
      <UiInput
        id="site-address"
        name="site"
        placeholder="your-site"
      />
      <template #suffix>
        <span>.example.com</span>
      </template>
    </UiInputGroup>
  </UiFormField>
</template>
import { UiFormField, UiInput, UiInputGroup } from "@threadlabs/looma";

export default function Example() {
  return <UiFormField
    slots={{
      label: <label
        htmlFor="site-address"
      >Site address</label>,
    }}
  >
    <UiInputGroup
      slots={{
        suffix: <span>.example.com</span>,
      }}
    >
      <UiInput
        id="site-address"
        name="site"
        placeholder="your-site"
      />
    </UiInputGroup>
  </UiFormField>;
}
/** @jsxImportSource preact */
import { UiFormField, UiInput, UiInputGroup } from "@threadlabs/looma";

export default function Example() {
  return <UiFormField
    slots={{
      label: <label
        htmlFor="site-address"
      >Site address</label>,
    }}
  >
    <UiInputGroup
      slots={{
        suffix: <span>.example.com</span>,
      }}
    >
      <UiInput
        id="site-address"
        name="site"
        placeholder="your-site"
      />
    </UiInputGroup>
  </UiFormField>;
}
<script>
  import { UiFormField, UiInput, UiInputGroup } from "@threadlabs/looma";
</script>

{#snippet label()}
  <label
    for="site-address"
  >Site address</label>
{/snippet}

{#snippet suffix()}
  <span>.example.com</span>
{/snippet}

<UiFormField
  slots={{ label }}
>
  <UiInputGroup
    slots={{ suffix }}
  >
    <UiInput
      id="site-address"
      name="site"
      placeholder="your-site"
    />
  </UiInputGroup>
</UiFormField>
import { Component } from "@angular/core";
import { UiFormField, UiInput, UiInputGroup } from "@threadlabs/looma";

@Component({
  selector: "example-view",
  standalone: true,
  imports: [UiFormField, UiInputGroup, UiInput],
  template: `
    <div
      ui-form-field
    >
      <ng-container ngProjectAs="[slot='label']">
        <label
          for="site-address"
        >Site address</label>
      </ng-container>
      <div
        ui-input-group
      >
        <input
          ui-input
          id="site-address"
          name="site"
          placeholder="your-site"
        >
        <ng-container ngProjectAs="[slot='suffix']">
          <span>.example.com</span>
        </ng-container>
      </div>
    </div>
  `,
})
export default class Example {}

Prefix and suffix

Fixed text on both sides, such as the slashes around a path. An invalid input marks the whole group.

/ /
Use letters, numbers, and hyphens.

Code

<link
  rel="component"
  href="@threadlabs/looma/components/ui-form-field/ui-form-field.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-input-group/ui-input-group.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-input/ui-input.html"
>

<ui-form-field
  invalid
>
  <label
    slot="label"
    for="route"
  >Address</label>
  <ui-input-group>
    <span
      slot="prefix"
    >/</span>
    <ui-input
      id="route"
      name="route"
      value="team notes"
      invalid
    ></ui-input>
    <span
      slot="suffix"
    >/</span>
  </ui-input-group>
  <span
    slot="error"
  >Use letters, numbers, and hyphens.</span>
</ui-form-field>
<script setup>
  import { UiFormField, UiInput, UiInputGroup } from "@threadlabs/looma";
</script>

<template>
  <UiFormField
    :invalid="true"
  >
    <template #label>
      <label
        for="route"
      >Address</label>
    </template>
    <UiInputGroup>
      <template #prefix>
        <span>/</span>
      </template>
      <UiInput
        id="route"
        name="route"
        value="team notes"
        :invalid="true"
      />
      <template #suffix>
        <span>/</span>
      </template>
    </UiInputGroup>
    <template #error>
      <span>Use letters, numbers, and hyphens.</span>
    </template>
  </UiFormField>
</template>
import { UiFormField, UiInput, UiInputGroup } from "@threadlabs/looma";

export default function Example() {
  return <UiFormField
    invalid
    slots={{
      label: <label
        htmlFor="route"
      >Address</label>,
      error: <span>Use letters, numbers, and hyphens.</span>,
    }}
  >
    <UiInputGroup
      slots={{
        prefix: <span>/</span>,
        suffix: <span>/</span>,
      }}
    >
      <UiInput
        id="route"
        name="route"
        value="team notes"
        invalid
      />
    </UiInputGroup>
  </UiFormField>;
}
/** @jsxImportSource preact */
import { UiFormField, UiInput, UiInputGroup } from "@threadlabs/looma";

export default function Example() {
  return <UiFormField
    invalid
    slots={{
      label: <label
        htmlFor="route"
      >Address</label>,
      error: <span>Use letters, numbers, and hyphens.</span>,
    }}
  >
    <UiInputGroup
      slots={{
        prefix: <span>/</span>,
        suffix: <span>/</span>,
      }}
    >
      <UiInput
        id="route"
        name="route"
        value="team notes"
        invalid
      />
    </UiInputGroup>
  </UiFormField>;
}
<script>
  import { UiFormField, UiInput, UiInputGroup } from "@threadlabs/looma";
</script>

{#snippet label()}
  <label
    for="route"
  >Address</label>
{/snippet}

{#snippet prefix()}
  <span>/</span>
{/snippet}

{#snippet suffix()}
  <span>/</span>
{/snippet}

{#snippet error()}
  <span>Use letters, numbers, and hyphens.</span>
{/snippet}

<UiFormField
  invalid
  slots={{ label, error }}
>
  <UiInputGroup
    slots={{ prefix, suffix }}
  >
    <UiInput
      id="route"
      name="route"
      value="team notes"
      invalid
    />
  </UiInputGroup>
</UiFormField>
import { Component } from "@angular/core";
import { UiFormField, UiInput, UiInputGroup } from "@threadlabs/looma";

@Component({
  selector: "example-view",
  standalone: true,
  imports: [UiFormField, UiInputGroup, UiInput],
  template: `
    <div
      ui-form-field
      [invalid]="true"
    >
      <ng-container ngProjectAs="[slot='label']">
        <label
          for="route"
        >Address</label>
      </ng-container>
      <div
        ui-input-group
      >
        <ng-container ngProjectAs="[slot='prefix']">
          <span>/</span>
        </ng-container>
        <input
          ui-input
          id="route"
          name="route"
          [value]="'team notes'"
          [invalid]="true"
        >
        <ng-container ngProjectAs="[slot='suffix']">
          <span>/</span>
        </ng-container>
      </div>
      <ng-container ngProjectAs="[slot='error']">
        <span>Use letters, numbers, and hyphens.</span>
      </ng-container>
    </div>
  `,
})
export default class Example {}

Action

One button at the end of the field, inside its border, for the one thing the field is for. A small button keeps the field at its usual height.

.example.com

Code

<link
  rel="component"
  href="@threadlabs/looma/components/ui-form-field/ui-form-field.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-input-group/ui-input-group.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-input/ui-input.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-button/ui-button.html"
>

<form>
  <ui-form-field>
    <label
      slot="label"
      for="site-address-action"
    >Site address</label>
    <ui-input-group>
      <ui-input
        id="site-address-action"
        name="site"
        placeholder="your-site"
      ></ui-input>
      <span
        slot="suffix"
      >.example.com</span>
      <ui-button
        slot="action"
        type="submit"
        variant="solid"
        size="sm"
      >Continue</ui-button>
    </ui-input-group>
  </ui-form-field>
</form>
<script setup>
  import { UiButton, UiFormField, UiInput, UiInputGroup } from "@threadlabs/looma";
</script>

<template>
  <form>
    <UiFormField>
      <template #label>
        <label
          for="site-address-action"
        >Site address</label>
      </template>
      <UiInputGroup>
        <UiInput
          id="site-address-action"
          name="site"
          placeholder="your-site"
        />
        <template #suffix>
          <span>.example.com</span>
        </template>
        <template #action>
          <UiButton
            type="submit"
            variant="solid"
            size="sm"
          >Continue</UiButton>
        </template>
      </UiInputGroup>
    </UiFormField>
  </form>
</template>
import { UiButton, UiFormField, UiInput, UiInputGroup } from "@threadlabs/looma";

export default function Example() {
  return <form>
    <UiFormField
      slots={{
        label: <label
          htmlFor="site-address-action"
        >Site address</label>,
      }}
    >
      <UiInputGroup
        slots={{
          suffix: <span>.example.com</span>,
          action: <UiButton
            type="submit"
            variant="solid"
            size="sm"
          >Continue</UiButton>,
        }}
      >
        <UiInput
          id="site-address-action"
          name="site"
          placeholder="your-site"
        />
      </UiInputGroup>
    </UiFormField>
  </form>;
}
/** @jsxImportSource preact */
import { UiButton, UiFormField, UiInput, UiInputGroup } from "@threadlabs/looma";

export default function Example() {
  return <form>
    <UiFormField
      slots={{
        label: <label
          htmlFor="site-address-action"
        >Site address</label>,
      }}
    >
      <UiInputGroup
        slots={{
          suffix: <span>.example.com</span>,
          action: <UiButton
            type="submit"
            variant="solid"
            size="sm"
          >Continue</UiButton>,
        }}
      >
        <UiInput
          id="site-address-action"
          name="site"
          placeholder="your-site"
        />
      </UiInputGroup>
    </UiFormField>
  </form>;
}
<script>
  import { UiButton, UiFormField, UiInput, UiInputGroup } from "@threadlabs/looma";
</script>

{#snippet label()}
  <label
    for="site-address-action"
  >Site address</label>
{/snippet}

{#snippet suffix()}
  <span>.example.com</span>
{/snippet}

{#snippet action()}
  <UiButton
    type="submit"
    variant="solid"
    size="sm"
  >Continue</UiButton>
{/snippet}

<form>
  <UiFormField
    slots={{ label }}
  >
    <UiInputGroup
      slots={{ suffix, action }}
    >
      <UiInput
        id="site-address-action"
        name="site"
        placeholder="your-site"
      />
    </UiInputGroup>
  </UiFormField>
</form>
import { Component } from "@angular/core";
import { UiButton, UiFormField, UiInput, UiInputGroup } from "@threadlabs/looma";

@Component({
  selector: "example-view",
  standalone: true,
  imports: [UiFormField, UiInputGroup, UiInput, UiButton],
  template: `
    <form>
      <div
        ui-form-field
      >
        <ng-container ngProjectAs="[slot='label']">
          <label
            for="site-address-action"
          >Site address</label>
        </ng-container>
        <div
          ui-input-group
        >
          <input
            ui-input
            id="site-address-action"
            name="site"
            placeholder="your-site"
          >
          <ng-container ngProjectAs="[slot='suffix']">
            <span>.example.com</span>
          </ng-container>
          <ng-container ngProjectAs="[slot='action']">
            <ng-template
              ui-button
              [type]="'submit'"
              [variant]="'solid'"
              [size]="'sm'"
            >Continue</ng-template>
          </ng-container>
        </div>
      </div>
    </form>
  `,
})
export default class Example {}

Icon prefix

An icon shares the input surface with one compact gap to the value; text affixes keep their separate framing.

Code

<link
  rel="component"
  href="@threadlabs/looma/components/ui-form-field/ui-form-field.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-input-group/ui-input-group.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-icon/ui-icon.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-input/ui-input.html"
>

<ui-form-field>
  <label
    slot="label"
    for="destination"
  >Destination</label>
  <ui-input-group
    prefix-kind="icon"
  >
    <ui-icon
      slot="prefix"
      name="search"
    ></ui-icon>
    <ui-input
      id="destination"
      placeholder="Search or paste a URL…"
    ></ui-input>
  </ui-input-group>
</ui-form-field>
<script setup>
  import { UiFormField, UiIcon, UiInput, UiInputGroup } from "@threadlabs/looma";
</script>

<template>
  <UiFormField>
    <template #label>
      <label
        for="destination"
      >Destination</label>
    </template>
    <UiInputGroup
      prefix-kind="icon"
    >
      <template #prefix>
        <UiIcon
          name="search"
        />
      </template>
      <UiInput
        id="destination"
        placeholder="Search or paste a URL…"
      />
    </UiInputGroup>
  </UiFormField>
</template>
import { UiFormField, UiIcon, UiInput, UiInputGroup } from "@threadlabs/looma";

export default function Example() {
  return <UiFormField
    slots={{
      label: <label
        htmlFor="destination"
      >Destination</label>,
    }}
  >
    <UiInputGroup
      prefixKind="icon"
      slots={{
        prefix: <UiIcon
          name="search"
        />,
      }}
    >
      <UiInput
        id="destination"
        placeholder="Search or paste a URL…"
      />
    </UiInputGroup>
  </UiFormField>;
}
/** @jsxImportSource preact */
import { UiFormField, UiIcon, UiInput, UiInputGroup } from "@threadlabs/looma";

export default function Example() {
  return <UiFormField
    slots={{
      label: <label
        htmlFor="destination"
      >Destination</label>,
    }}
  >
    <UiInputGroup
      prefixKind="icon"
      slots={{
        prefix: <UiIcon
          name="search"
        />,
      }}
    >
      <UiInput
        id="destination"
        placeholder="Search or paste a URL…"
      />
    </UiInputGroup>
  </UiFormField>;
}
<script>
  import { UiFormField, UiIcon, UiInput, UiInputGroup } from "@threadlabs/looma";
</script>

{#snippet label()}
  <label
    for="destination"
  >Destination</label>
{/snippet}

{#snippet prefix()}
  <UiIcon
    name="search"
  />
{/snippet}

<UiFormField
  slots={{ label }}
>
  <UiInputGroup
    prefixKind="icon"
    slots={{ prefix }}
  >
    <UiInput
      id="destination"
      placeholder="Search or paste a URL…"
    />
  </UiInputGroup>
</UiFormField>
import { Component } from "@angular/core";
import { UiFormField, UiIcon, UiInput, UiInputGroup } from "@threadlabs/looma";

@Component({
  selector: "example-view",
  standalone: true,
  imports: [UiFormField, UiInputGroup, UiIcon, UiInput],
  template: `
    <div
      ui-form-field
    >
      <ng-container ngProjectAs="[slot='label']">
        <label
          for="destination"
        >Destination</label>
      </ng-container>
      <div
        ui-input-group
        [prefixKind]="'icon'"
      >
        <ng-container ngProjectAs="[slot='prefix']">
          <span
            ui-icon
            name="search"
          ></span>
        </ng-container>
        <input
          ui-input
          id="destination"
          placeholder="Search or paste a URL…"
        >
      </div>
    </div>
  `,
})
export default class Example {}

Multiline message

A message and its Send action share one frame. The action sits at the upper end, with text space reserved as its label changes.

Code

<link
  rel="component"
  href="@threadlabs/looma/components/ui-form-field/ui-form-field.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-input-group/ui-input-group.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-textarea/ui-textarea.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-button/ui-button.html"
>

<form>
  <ui-form-field>
    <label
      slot="label"
      for="multiline-message"
    >Message</label>
    <ui-input-group
      multiline
      action-position="top-end"
    >
      <ui-textarea
        id="multiline-message"
        name="message"
        rows="3"
        autosize
        placeholder="Write a message…"
      ></ui-textarea>
      <ui-button
        slot="action"
        type="submit"
        variant="solid"
      >Send</ui-button>
    </ui-input-group>
  </ui-form-field>
</form>
<script setup>
  import { UiButton, UiFormField, UiInputGroup, UiTextarea } from "@threadlabs/looma";
</script>

<template>
  <form>
    <UiFormField>
      <template #label>
        <label
          for="multiline-message"
        >Message</label>
      </template>
      <UiInputGroup
        :multiline="true"
        action-position="top-end"
      >
        <UiTextarea
          id="multiline-message"
          name="message"
          :rows="3"
          :autosize="true"
          placeholder="Write a message…"
        />
        <template #action>
          <UiButton
            type="submit"
            variant="solid"
          >Send</UiButton>
        </template>
      </UiInputGroup>
    </UiFormField>
  </form>
</template>
import { UiButton, UiFormField, UiInputGroup, UiTextarea } from "@threadlabs/looma";

export default function Example() {
  return <form>
    <UiFormField
      slots={{
        label: <label
          htmlFor="multiline-message"
        >Message</label>,
      }}
    >
      <UiInputGroup
        multiline
        actionPosition="top-end"
        slots={{
          action: <UiButton
            type="submit"
            variant="solid"
          >Send</UiButton>,
        }}
      >
        <UiTextarea
          id="multiline-message"
          name="message"
          rows={3}
          autosize
          placeholder="Write a message…"
        />
      </UiInputGroup>
    </UiFormField>
  </form>;
}
/** @jsxImportSource preact */
import { UiButton, UiFormField, UiInputGroup, UiTextarea } from "@threadlabs/looma";

export default function Example() {
  return <form>
    <UiFormField
      slots={{
        label: <label
          htmlFor="multiline-message"
        >Message</label>,
      }}
    >
      <UiInputGroup
        multiline
        actionPosition="top-end"
        slots={{
          action: <UiButton
            type="submit"
            variant="solid"
          >Send</UiButton>,
        }}
      >
        <UiTextarea
          id="multiline-message"
          name="message"
          rows={3}
          autosize
          placeholder="Write a message…"
        />
      </UiInputGroup>
    </UiFormField>
  </form>;
}
<script>
  import { UiButton, UiFormField, UiInputGroup, UiTextarea } from "@threadlabs/looma";
</script>

{#snippet label()}
  <label
    for="multiline-message"
  >Message</label>
{/snippet}

{#snippet action()}
  <UiButton
    type="submit"
    variant="solid"
  >Send</UiButton>
{/snippet}

<form>
  <UiFormField
    slots={{ label }}
  >
    <UiInputGroup
      multiline
      actionPosition="top-end"
      slots={{ action }}
    >
      <UiTextarea
        id="multiline-message"
        name="message"
        rows={3}
        autosize
        placeholder="Write a message…"
      />
    </UiInputGroup>
  </UiFormField>
</form>
import { Component } from "@angular/core";
import { UiButton, UiFormField, UiInputGroup, UiTextarea } from "@threadlabs/looma";

@Component({
  selector: "example-view",
  standalone: true,
  imports: [UiFormField, UiInputGroup, UiTextarea, UiButton],
  template: `
    <form>
      <div
        ui-form-field
      >
        <ng-container ngProjectAs="[slot='label']">
          <label
            for="multiline-message"
          >Message</label>
        </ng-container>
        <div
          ui-input-group
          [multiline]="true"
          [actionPosition]="'top-end'"
        >
          <textarea
            ui-textarea
            id="multiline-message"
            name="message"
            [rows]="3"
            [autosize]="true"
            placeholder="Write a message…"
          ></textarea>
          <ng-container ngProjectAs="[slot='action']">
            <ng-template
              ui-button
              [type]="'submit'"
              [variant]="'solid'"
            >Send</ng-template>
          </ng-container>
        </div>
      </div>
    </form>
  `,
})
export default class Example {}

API

Package: @threadlabs/looma · Native root: div

Attributes

NameDescriptionPropertyTypeDefaultAllowed values
action-positionIn a multiline group, below keeps the action under the text. top-end places it inside the textarea at its upper end and reserves text padding for its measured width.actionPositionkeywordbelowbelow, top-end
disabledFades the group's framing to match a disabled input inside it. Disable the input itself too.disabledbooleanfalse—
multilineGroups a Textarea with an action below its text inside the shared frame.multilinebooleanfalse—
prefix-kindText affixes have their own framing. An icon prefix shares the field surface and uses one compact icon-to-value gap.prefixKindkeywordtexttext, icon
variantoutline uses the standard field edge and surface. subtle uses a quiet decorative edge and muted surface for a multiline composer. Both retain the same focus, invalid and disabled states, and the field inside shares the group's one frame.variantkeywordoutlineoutline, subtle

Framework props

NameTypeDefaultAllowed values
actionPosition"below" | "top-end"belowbelow, top-end
disabledbooleanfalse—
multilinebooleanfalse—
prefixKind"text" | "icon"texttext, icon
variant"outline" | "subtle"outlineoutline, subtle

Events

No events.

Slots

NamePurpose
prefixNamed content for the prefix region.
defaultDefault child content.
suffixNamed content for the suffix region.
actionNamed content for the action region.

Design tokens

Set shared theme properties on a theme boundary. Component hooks apply only to their owning component.

Component tokens

TokenDeclared valuesFallback
--ui-input-group-invalid-border—var(--ui-field-danger, var(--ui-danger-solid))
--ui-input-group-invalid-focus-shadow—var(--ui-control-inset), 0 0 0 calc(var(--ui-focus-width) * 1.5) color-mix( in srgb, var(--ui-input-group-invalid-border, var(--ui-field-danger, var(--ui-danger-solid))) 22%, transparent )
--ui-input-group-radius—var(--ui-field-radius, var(--ui-radius-md))

Shared tokens

TokenFallback
--ui-accent-solid—
--ui-border—
--ui-border-divider—
--ui-border-strong—
--ui-border-width—
--ui-control-bordervar(--ui-border-strong)
--ui-control-border-hovervar(--ui-text-secondary)
--ui-control-focusvar(--ui-accent-solid)
--ui-control-focus-halo-shadow—
--ui-control-inset—
--ui-control-size-lg—
--ui-control-size-md—
--ui-control-size-sm—
--ui-control-surfacevar(--ui-surface-elevated)
--ui-control-surface-disabledvar(--ui-surface-muted)
--ui-danger-solid—
--ui-field-dangervar(--ui-danger-solid)
--ui-field-radiusvar(--ui-radius-md)
--ui-focus-width—
--ui-font-family-mono—
--ui-font-size-sm—
--ui-motion-ease—
--ui-motion-fast—
--ui-radius-md—
--ui-space-1—
--ui-space-2—
--ui-space-3—
--ui-surface-elevated—
--ui-surface-highlightnone
--ui-surface-muted—
--ui-text-muted—
--ui-text-secondary—