Keyboard shortcut: Ctrl + K
Get started

Input group

Joins two or more form controls into a single object: a country code against a phone number, a search field against its button. The shared borders melt into one line and only the two ends of the row stay rounded.

Usage

vue
Phone number

Pick the country code, then type the rest

<script setup lang="ts">
import { ref } from 'vue'
import { VCombobox, VInput, VInputGroup } from 'vectis-ui'

const code = ref('+33')
const number = ref('')

const codes = [
  { value: '+33', label: '+33 France' },
  { value: '+32', label: '+32 Belgium' },
  { value: '+41', label: '+41 Switzerland' },
  { value: '+1', label: '+1 United States' },
]
</script>

<template>
  <VInputGroup label="Phone number" hint="Pick the country code, then type the rest">
    <VCombobox v-model="code" :options="codes" aria-label="Country code" class="code" />
    <VInput v-model="number" type="tel" aria-label="Number" />
  </VInputGroup>
</template>

<style scoped>
.code {
  flex: 0 0 9rem;
}
</style>

Examples

Several fields in one row

A row takes as many segments as needed. Every segment holding a field takes an equal share of what is left, the others keeping their natural width.

vue
Amount
Address

The scheme, the name and the extension

<script setup lang="ts">
import { ref } from 'vue'
import { VCombobox, VInput, VInputGroup } from 'vectis-ui'

const amount = ref('1200')
const currency = ref('eur')
const currencies = [
  { value: 'eur', label: 'EUR' },
  { value: 'usd', label: 'USD' },
  { value: 'gbp', label: 'GBP' },
]

const scheme = ref('https')
const domain = ref('vectis-ui')
const tld = ref('com')
const schemes = [
  { value: 'https', label: 'https://' },
  { value: 'http', label: 'http://' },
]
const tlds = [
  { value: 'com', label: '.com' },
  { value: 'dev', label: '.dev' },
  { value: 'fr', label: '.fr' },
]
</script>

<template>
  <div class="column">
    <VInputGroup label="Amount">
      <VInput v-model="amount" aria-label="Amount" inputmode="decimal" />
      <VCombobox v-model="currency" :options="currencies" aria-label="Currency" class="currency" />
    </VInputGroup>

    <!-- A row is not limited to two: each segment holding a field takes an equal share,
         and the ones given a width of their own keep it. -->
    <VInputGroup label="Address" hint="The scheme, the name and the extension">
      <VCombobox v-model="scheme" :options="schemes" aria-label="Scheme" class="scheme" />
      <VInput v-model="domain" aria-label="Domain name" />
      <VCombobox v-model="tld" :options="tlds" aria-label="Extension" class="tld" />
    </VInputGroup>
  </div>
</template>

<style scoped>
.column {
  display: flex;
  flex-direction: column;
  gap: var(--vectis-space-5);
  max-inline-size: 30rem;
}
.currency {
  flex: 0 0 7rem;
}
.scheme {
  flex: 0 0 8rem;
}
.tld {
  flex: 0 0 6rem;
}
</style>

A field and its button

A solid or soft button covers the shared edge with its own background, and an outline button in tone="neutral" draws the same border colour as the fields. A ghost button has no frame at all, so it is the one variant to avoid here.

vue
A solid button
An outline button, in the border colour of the fields
<script setup lang="ts">
import { VButton, VIconButton, VInput, VInputGroup } from 'vectis-ui'
import { search } from 'vectis-ui/icons'
</script>

<template>
  <div class="column">
    <VInputGroup label="A solid button">
      <VInput type="search" aria-label="Search terms" placeholder="Search the archive" />
      <VIconButton :icon="search" variant="solid" tone="accent" label="Search" />
    </VInputGroup>

    <VInputGroup label="An outline button, in the border colour of the fields">
      <VInput type="search" aria-label="Search terms" placeholder="Search the archive" />
      <VButton variant="outline" tone="neutral" :icon-start="search">Search</VButton>
    </VInputGroup>
  </div>
</template>

<style scoped>
.column {
  display: flex;
  flex-direction: column;
  gap: var(--vectis-space-5);
  max-inline-size: 26rem;
}
</style>

Two fields that open a panel

Each panel is anchored to its own field's box, so it opens under the segment it belongs to rather than under the group. The buttons a panel contains are not segments of the row and keep the size their own component gave them.

vue
Scheduled for

The date, then the time of day

<script setup lang="ts">
import { ref } from 'vue'
import { VDateInput, VInputGroup, VTimeInput } from 'vectis-ui'

const date = ref<string | null>('2026-09-21')
const time = ref<string | null>('09:30')
</script>

<template>
  <!-- Two anchored panels in one row. Each is anchored to its own field's box rather
       than to the group, so each opens under the segment it belongs to. -->
  <VInputGroup label="Scheduled for" hint="The date, then the time of day">
    <VDateInput v-model="date" show-picker aria-label="Date" />
    <VTimeInput v-model="time" show-picker aria-label="Time" class="time" />
  </VInputGroup>
</template>

<style scoped>
.time {
  flex: 0 0 13rem;
}
</style>

Naming the row and its segments

The group renders one label and one hint for the whole row. Each segment then needs an aria-label of its own, and one bringing its own label is warned about in development.

vue
Budget
<script setup lang="ts">
import { VButton, VInput, VInputGroup } from 'vectis-ui'
</script>

<template>
  <div class="column">
    <!-- A visible label names the row, and each segment takes an aria-label of its own. -->
    <VInputGroup label="Budget">
      <VInput aria-label="Amount" placeholder="0.00" />
      <VInput aria-label="Currency" placeholder="EUR" class="currency" />
    </VInputGroup>

    <!-- No visible label: the group is named by aria-label instead. -->
    <VInputGroup aria-label="Search the archive">
      <VInput type="search" aria-label="Search terms" placeholder="Search the archive" />
      <VButton variant="solid" tone="accent">Search</VButton>
    </VInputGroup>
  </div>
</template>

<style scoped>
.column {
  display: flex;
  flex-direction: column;
  gap: var(--vectis-space-5);
  max-inline-size: 24rem;
}

.currency {
  flex: 0 0 6rem;
}
</style>

Widths

Proportions are set on the segment itself, with a class or an inline flex. A consumer rule is unlayered, so it wins over the share the group hands out.

vue
Equal shares
A fixed currency field
<script setup lang="ts">
import { VCombobox, VInput, VInputGroup } from 'vectis-ui'

const currencies = [
  { value: 'eur', label: 'EUR' },
  { value: 'usd', label: 'USD' },
  { value: 'gbp', label: 'GBP' },
]
</script>

<template>
  <div class="column">
    <!-- Left alone, the two fields share the row equally. -->
    <VInputGroup label="Equal shares">
      <VCombobox :options="currencies" model-value="eur" aria-label="Currency" />
      <VInput aria-label="Amount" />
    </VInputGroup>

    <!-- An inline flex on the segment fixes its width; the other one takes the rest. -->
    <VInputGroup label="A fixed currency field">
      <VCombobox :options="currencies" model-value="eur" aria-label="Currency" class="currency" />
      <VInput aria-label="Amount" />
    </VInputGroup>
  </div>
</template>

<style scoped>
.column {
  display: flex;
  flex-direction: column;
  gap: var(--vectis-space-5);
  max-inline-size: 26rem;
}

.currency {
  flex: 0 0 7rem;
}
</style>

Size and density

size and compact are set on the row and travel to every segment, winning over what a segment asks for itself.

vue
sm
md
lg
md, compact
<script setup lang="ts">
import { VCombobox, VInput, VInputGroup } from 'vectis-ui'

const sizes = ['sm', 'md', 'lg'] as const

const currencies = [
  { value: 'eur', label: 'EUR' },
  { value: 'usd', label: 'USD' },
]
</script>

<template>
  <div class="column">
    <VInputGroup v-for="size in sizes" :key="size" :size="size" :label="size">
      <VCombobox :options="currencies" model-value="eur" aria-label="Currency" class="currency" />
      <VInput aria-label="Amount" />
    </VInputGroup>

    <VInputGroup size="md" compact label="md, compact">
      <VCombobox :options="currencies" model-value="eur" aria-label="Currency" class="currency" />
      <VInput aria-label="Amount" />
    </VInputGroup>
  </div>
</template>

<style scoped>
.column {
  display: flex;
  flex-direction: column;
  gap: var(--vectis-space-5);
  max-inline-size: 26rem;
}

.currency {
  flex: 0 0 7rem;
}
</style>

States

A group with none of the shape props set leaves every segment exactly as it was. disabled adds up instead of replacing, so a segment switched off on its own stays off under a row that says nothing.

vue
The whole row switched off

Nothing here can be reached

One segment disabled on its own
A segment in error
<script setup lang="ts">
import { VButton, VInput, VInputGroup } from 'vectis-ui'
</script>

<template>
  <div class="column">
    <VInputGroup disabled label="The whole row switched off" hint="Nothing here can be reached">
      <VInput aria-label="Amount" model-value="1200" />
      <VButton variant="solid" tone="accent">Convert</VButton>
    </VInputGroup>

    <VInputGroup label="One segment disabled on its own">
      <VInput aria-label="Amount" model-value="1200" />
      <VInput disabled aria-label="Converted amount" model-value="1284.50" />
    </VInputGroup>

    <VInputGroup label="A segment in error">
      <VInput invalid aria-label="Email" model-value="not-an-address" />
      <VButton variant="outline" tone="neutral">Check</VButton>
    </VInputGroup>
  </div>
</template>

<style scoped>
.column {
  display: flex;
  flex-direction: column;
  gap: var(--vectis-space-5);
  max-inline-size: 26rem;
}
</style>

API

Props

PropTypeDefault
labelstringnone
The label above the row, rendered once for all of its segments and used as the accessible name of the group. A segment carrying one of its own is pushed out of line, so name each of them with aria-label instead.
hintstringnone
A line of help under the row, tied to the group so assistive technology reads it out along with the label.
sizeInputGroupSize'sm' | 'md' | 'lg'none
The height every segment takes, whatever it names for itself: a row of controls of two heights stops reading as one object. Left out, each segment keeps its own.
compactbooleannone
Takes 4px off the height of every segment, the way compact does on a lone field.
disabledbooleannone
Makes the whole row unusable. It adds to what each segment says rather than replacing it: a segment disabled on its own stays disabled under a row that says nothing.

Slots

SlotType
default{}
The fields and buttons to join. Each one is a segment of the row.