PinInput

Enters a code one character per cell.

<script setup lang="ts">
  import { ref } from 'vue'
  import { PinInput } from '@hina-ui/vue'

  const code = ref('')
</script>

<template>
  <PinInput v-model="code" type="number" otp aria-label="Verification code" />
</template>
vue

Usage

import { PinInput } from '@hina-ui/vue'
ts

A pin input splits a fixed-length code into cells that hold one character each. v-model binds the whole string and length sets the number of cells, six by default. The group contains a single input and the cells display its value: typing a character advances by one cell, Backspace deletes, and pasting distributes the text across the cells. aria-label, aria-labelledby, aria-describedby and id land on the input and every other undeclared attribute lands on the root; name the input with aria-label or aria-labelledby.

Current: empty

<script setup lang="ts">
  import { ref } from 'vue'
  import { PinInput, Stack, Text } from '@hina-ui/vue'

  const code = ref('')
  const submitted = ref('')
</script>

<template>
  <Stack gap="sm" align="start">
    <PinInput v-model="code" :length="4" aria-label="Invite code" @complete="submitted = $event" />
    <Text tone="muted" size="sm">
      {{ submitted ? `Completed: ${submitted}` : `Current: ${code || 'empty'}` }}
    </Text>
  </Stack>
</template>
vue

Examples

Digits and one-time codes

type="number" accepts digits only and brings up the numeric keyboard on mobile devices. otp sets autocomplete to one-time-code, so browsers and operating systems can fill in a code received by SMS.

<script setup lang="ts">
  import { ref } from 'vue'
  import { PinInput } from '@hina-ui/vue'

  const code = ref('')
</script>

<template>
  <PinInput v-model="code" type="number" otp aria-label="SMS code" />
</template>
vue

Masked

mask shows the entered characters as dots and makes the input a password field, which suits PIN codes.

<script setup lang="ts">
  import { ref } from 'vue'
  import { PinInput } from '@hina-ui/vue'

  const pin = ref('')
</script>

<template>
  <PinInput v-model="pin" :length="4" type="number" mask aria-label="PIN" />
</template>
vue

Placeholder

placeholder shows in empty cells and hides in the current cell while it shows the caret.

<script setup lang="ts">
  import { ref } from 'vue'
  import { PinInput } from '@hina-ui/vue'

  const code = ref('')
</script>

<template>
  <PinInput v-model="code" placeholder="○" aria-label="Verification code" />
</template>
vue

Sizes

size has three steps, sm, md and lg. Every cell is a square whose side equals the height of an input of the same step.

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

<template>
  <Stack gap="sm" align="start">
    <PinInput size="sm" :length="4" model-value="2048" aria-label="Small" />
    <PinInput size="md" :length="4" model-value="2048" aria-label="Medium" />
    <PinInput size="lg" :length="4" model-value="2048" aria-label="Large" />
  </Stack>
</template>
vue

States

invalid gives every cell a warning border and disabled disables the group. variant="secondary" is the flat form used inside a surface.

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

<template>
  <Stack gap="sm" align="start">
    <PinInput :length="4" model-value="1234" invalid aria-label="Invalid" />
    <PinInput :length="4" model-value="1234" disabled aria-label="Disabled" />
    <PinInput :length="4" model-value="1234" variant="secondary" aria-label="Secondary" />
  </Stack>
</template>
vue

In a form

Inside a FormField the label is linked to the input and clicking it focuses the input; the description and error message are rendered by the field, and validation rules and submission belong to the Form.

Sent to your email

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

  const schema = v.object({
    code: v.pipe(v.string('Enter the code'), v.length(6, 'The code is 6 digits')),
  })

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

  async function save() {
    await new Promise(resolve => setTimeout(resolve, 600))
    saved.value = true
  }
</script>

<template>
  <Form v-slot="{ submitting }" :values="values" :rules="schema" class="w-80" @submit="save">
    <FormField name="code" label="Verification code" description="Sent to your email" required>
      <PinInput v-model="values.code" :length="6" type="number" otp />
    </FormField>
    <Button type="submit" :loading="submitting" class="self-start">Verify</Button>
    <Text v-if="saved" tone="muted" size="sm">Verified.</Text>
  </Form>
</template>
vue

Behavior

  • Clicking a cell moves the current position to it; clicking an empty cell lands on the first empty one, and a double click selects everything.
  • Typing a character advances by one cell; a character in the current cell is overwritten, and the position stays on the last cell once the group is full.
  • Backspace deletes the character in the current cell and the following characters move up; on the empty position it deletes the previous character.
  • Text that arrives as a whole, from a paste, autofill or an input method, overwrites the cells from the current one onward and anything beyond the last cell is dropped; text as long as the group replaces the whole value.
  • Whitespace is removed and full-width letters and digits are folded to their half-width forms; type="number" keeps digits only.
  • Characters being composed with an input method show in the cells and are written to the value as they change; the committed text replaces them.
  • Left and right arrow keys, Home and End move the current cell, and holding Shift extends the selection. Keyboard focus lands on the first empty cell, or selects everything when the group is full.
  • complete fires with the whole string when the value changes and fills every cell.

Accessibility

  • The group is one text input, named by aria-label, aria-labelledby or the label of a FormField; the cells are hidden from assistive technology.
  • invalid sets aria-invalid on the input.

API

Props

Prop
Type
Default
Description
modelValue
string
''
The whole value
length
number
6
Number of cells
type
'text' | 'number'
'text'
Characters accepted
mask
boolean
false
Whether to show dots and use a password input
otp
boolean
false
Whether to accept one-time-code autofill
placeholder
string
''
Placeholder shown in empty cells
name
string
—
Form field name; the whole value is submitted
variant
'primary' | 'secondary'
'primary'
Variant
size
'sm' | 'md' | 'lg'
'md'
Size
disabled
boolean
false
Whether the group is disabled
invalid
boolean
false
Whether the group failed validation
required
boolean
false
Whether the form requires a value
dir
'ltr' | 'rtl'
—
Direction; follows the provider by default
class
string
—
Classes appended to the root element

Events

Event
Payload
Description
update:modelValue
value: string
The value changed
complete
value: string
A change filled every cell