InputGroup

Joins addons and buttons with a field into one surface.

https://
.hoshimi.moe
<script setup lang="ts">
  import { ref } from 'vue'
  import { Input, InputGroup, InputGroupAddon } from '@hina-ui/vue'

  const site = ref('shion')
</script>

<template>
  <InputGroup class="w-80">
    <InputGroupAddon>https://</InputGroupAddon>
    <Input v-model="site" aria-label="Site name" />
    <InputGroupAddon>.hoshimi.moe</InputGroupAddon>
  </InputGroup>
</template>
vue

Usage

import { InputGroup, InputGroupAddon } from '@hina-ui/vue'
ts

The group is itself a complete field: the border, shadow and focus ring live on the group, and the fields, addons and buttons inside share that one shell. Size and variant are set on the group, and the fields inside follow it. InputGroupAddon holds a fixed prefix or suffix, text or an icon, separated from the text area by a divider; icons and units inside the border go in the field’s own leading and trailing slots.

CNY
<script setup lang="ts">
  import { ref } from 'vue'
  import { AtSign } from '@lucide/vue'
  import { Input, InputGroup, InputGroupAddon, Stack } from '@hina-ui/vue'

  const handle = ref('')
  const amount = ref('')
</script>

<template>
  <Stack class="w-80">
    <InputGroup>
      <InputGroupAddon>
        <AtSign />
      </InputGroupAddon>
      <Input v-model="handle" aria-label="Username" placeholder="Username" />
    </InputGroup>
    <InputGroup>
      <Input v-model="amount" inputmode="decimal" aria-label="Amount" placeholder="Amount" />
      <InputGroupAddon>CNY</InputGroupAddon>
    </InputGroup>
  </Stack>
</template>
vue

Examples

Attached buttons

A button placed in the group shares the shell with the text area and does not shrink on press.

<script setup lang="ts">
  import { ref } from 'vue'
  import { Copy } from '@lucide/vue'
  import { Button, IconButton, Input, InputGroup, Stack } from '@hina-ui/vue'

  const code = ref('HINA-2026')
  const email = ref('')
</script>

<template>
  <Stack class="w-80">
    <InputGroup>
      <Input v-model="code" readonly aria-label="Invite code" />
      <IconButton label="Copy" variant="outline" tone="neutral">
        <Copy />
      </IconButton>
    </InputGroup>
    <InputGroup>
      <Input v-model="email" type="email" aria-label="Email" placeholder="Email" />
      <Button>Subscribe</Button>
    </InputGroup>
  </Stack>
</template>
vue

Sizes

Size is set on the group and the fields and buttons inside line up with it. Buttons still need the matching size.

https://
https://
https://
<script setup lang="ts">
  import { Button, Input, InputGroup, InputGroupAddon, Stack } from '@hina-ui/vue'
</script>

<template>
  <Stack class="w-80">
    <InputGroup size="sm">
      <InputGroupAddon>https://</InputGroupAddon>
      <Input aria-label="Small" />
      <Button size="sm">OK</Button>
    </InputGroup>
    <InputGroup size="md">
      <InputGroupAddon>https://</InputGroupAddon>
      <Input aria-label="Medium" />
      <Button size="md">OK</Button>
    </InputGroup>
    <InputGroup size="lg">
      <InputGroupAddon>https://</InputGroupAddon>
      <Input aria-label="Large" />
      <Button size="lg">OK</Button>
    </InputGroup>
  </Stack>
</template>
vue

Variants

primary has a border, background and shadow; secondary uses a tinted background; bare is transparent, without a border, shadow, hover fill or container focus ring. Size and padding keep their existing settings.

bare preserves the disabled state and aria-invalid; FormField can display the error message. Internal dividers are also hidden; buttons keep their own interaction feedback.

@
@
@
<script setup lang="ts">
  import { Input, InputGroup, InputGroupAddon, Stack } from '@hina-ui/vue'
</script>

<template>
  <Stack class="w-full max-w-xs">
    <InputGroup variant="primary">
      <InputGroupAddon>@</InputGroupAddon>
      <Input aria-label="primary" placeholder="primary" />
    </InputGroup>
    <InputGroup variant="secondary">
      <InputGroupAddon>@</InputGroupAddon>
      <Input aria-label="secondary" placeholder="secondary" />
    </InputGroup>
    <InputGroup variant="bare">
      <InputGroupAddon>@</InputGroupAddon>
      <Input aria-label="bare" placeholder="bare" />
    </InputGroup>
  </Stack>
</template>
vue

States

invalid and disabled are set on the group and passed down to every field inside.

https://
https://
<script setup lang="ts">
  import { Input, InputGroup, InputGroupAddon, Stack } from '@hina-ui/vue'
</script>

<template>
  <Stack class="w-80">
    <InputGroup invalid>
      <InputGroupAddon>https://</InputGroupAddon>
      <Input model-value="not a domain" aria-label="Invalid" />
    </InputGroup>
    <InputGroup disabled>
      <InputGroupAddon>https://</InputGroupAddon>
      <Input model-value="shion.hoshimi.moe" aria-label="Disabled" />
    </InputGroup>
  </Stack>
</template>
vue

With other fields

Number, search and other fields can all go in a group.

<script setup lang="ts">
  import { ref } from 'vue'
  import { JapaneseYen } from '@lucide/vue'
  import {
    Button,
    InputGroup,
    InputGroupAddon,
    NumberInput,
    SearchInput,
    Stack,
  } from '@hina-ui/vue'

  const price = ref(128)
  const query = ref('')
</script>

<template>
  <Stack class="w-80">
    <InputGroup>
      <InputGroupAddon>
        <JapaneseYen />
      </InputGroupAddon>
      <NumberInput v-model="price" :min="0" aria-label="Price" />
    </InputGroup>
    <InputGroup>
      <SearchInput v-model="query" aria-label="Search" placeholder="Search works" />
      <Button>Search</Button>
    </InputGroup>
  </Stack>
</template>
vue

In a form

With the whole group inside a FormField the label points at the input in the group, and the error message is rendered by the field; validation rules and submission belong to the Form. Keep one input per field.

https://
<script setup lang="ts">
  import { reactive, ref } from 'vue'
  import * as v from 'valibot'
  import { Button, Form, FormField, Input, InputGroup, InputGroupAddon, Text } from '@hina-ui/vue'

  const schema = v.object({
    site: v.pipe(
      v.string('Enter a website'),
      v.trim(),
      v.nonEmpty('Enter a website'),
      v.regex(/^[\w-]+(\.[\w-]+)+(\/.*)?$/, 'Not a valid address; leave out the protocol'),
    ),
  })

  const values = reactive({ site: '' })
  const saved = ref('')

  async function save(data: unknown) {
    await new Promise(resolve => setTimeout(resolve, 600))
    saved.value = `https://${(data as { site: string }).site}`
  }
</script>

<template>
  <Form v-slot="{ submitting }" :values="values" :rules="schema" class="w-80" @submit="save">
    <FormField name="site" label="Website" required>
      <InputGroup>
        <InputGroupAddon>https://</InputGroupAddon>
        <Input v-model="values.site" placeholder="example.com" />
      </InputGroup>
    </FormField>
    <Button type="submit" :loading="submitting" class="self-start">Save</Button>
    <Text v-if="saved" tone="muted" size="sm">Saved: {{ saved }}</Text>
  </Form>
</template>
vue

Behaviour

  • For primary and secondary, focusing any text area inside the group shows the focus ring around the whole group.
  • Clicking an addon focuses the adjacent text area, placing the caret at the start for a prefix and at the end for a suffix. Buttons are unaffected.
  • A button inside the group draws its keyboard focus outline inwards, so the group’s edge never clips it.
  • Hover, invalid and disabled behave the same as Input.

Accessibility

  • Addons are presentational and do not contribute to the field’s accessible name. Name the field with a label element or an aria-label.

API

InputGroup

Prop
Type
Default
Description
variant
'primary' | 'secondary' | 'bare'
'primary'
Variant
size
'sm' | 'md' | 'lg'
'md'
Size
invalid
boolean
false
Whether validation failed
disabled
boolean
false
Whether the group is disabled
class
string
—
Classes appended to the root element

InputGroupAddon

Prop
Type
Default
Description
class
string
—
Classes appended to the root element