---
name: payment-card
title: Payment Card
url: https://ui.frontic.com/components/ui/payment-card.md
tier: ui
install: npx @frontic/ui add payment-card
files:
  - components/ui/payment-card/PaymentCard.vue
  - components/ui/payment-card/PaymentCardBrands.vue
  - components/ui/payment-card/PaymentCardCvc.vue
  - components/ui/payment-card/PaymentCardExpiry.vue
  - components/ui/payment-card/PaymentCardHost.vue
  - components/ui/payment-card/PaymentCardLogoAmex.vue
  - components/ui/payment-card/PaymentCardLogoDiscover.vue
  - components/ui/payment-card/PaymentCardLogoMastercard.vue
  - components/ui/payment-card/PaymentCardLogoVisa.vue
  - components/ui/payment-card/PaymentCardNumber.vue
  - components/ui/payment-card/card.ts
  - components/ui/payment-card/context.ts
  - components/ui/payment-card/hosted.ts
  - components/ui/payment-card/index.ts
  - components/ui/payment-card/input.ts
registryDependencies:
  - input-group
dependencies:
  - "@lucide/vue"
  - card-validator
  - credit-card-type
  - reka-ui
---

# Payment Card

A card's number, expiry and security code: spaced as typed, brand shown and checked, each joining a Field; or a payment provider's hosted fields framed in the same look.

## Install

```sh
npx @frontic/ui add payment-card
```

Built from: [Input Group](https://ui.frontic.com/components/ui/input-group.md)

Used by: [Payment Method](https://ui.frontic.com/components/sections/payment-method.md), [Checkout Page](https://ui.frontic.com/components/templates/checkout-page.md)

npm packages: `@lucide/vue`, `card-validator`, `credit-card-type`, `reka-ui`

## Examples

```vue
<script setup lang="ts">
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { PaymentCard, PaymentCardCvc, PaymentCardExpiry, PaymentCardNumber } from '@/components/ui/payment-card'
</script>

<template>
  <PaymentCard>
    <FieldGroup class="w-sm max-w-full">
      <Field name="number" required>
        <FieldLabel>Card number</FieldLabel>
        <PaymentCardNumber />
        <FieldError />
      </Field>
      <div class="grid grid-cols-2 gap-4">
        <Field name="expiry" required>
          <FieldLabel>Expiry</FieldLabel>
          <PaymentCardExpiry />
          <FieldError />
        </Field>
        <Field name="cvc" required>
          <FieldLabel>Security code</FieldLabel>
          <PaymentCardCvc />
          <FieldError />
        </Field>
      </div>
      <Field name="holder">
        <FieldLabel>Name on card</FieldLabel>
        <Input autocomplete="cc-name" />
      </Field>
    </FieldGroup>
  </PaymentCard>
</template>
```

The example also uses `field` and `input`, which `add payment-card` leaves out: `npx @frontic/ui add field input`.

## Anatomy

```vue
<PaymentCard>
  <PaymentCardNumber>
    <PaymentCardBrands />
  </PaymentCardNumber>
  <PaymentCardExpiry />
  <PaymentCardCvc />
</PaymentCard>
```

## API reference

### PaymentCard

Groups a card's number, expiry and security code so they share the brand the number shows.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `brand` | `PaymentCardBrand`: `'visa' \| 'mastercard' \| 'amex' \| 'discover' \| 'diners' \| 'jcb' \| 'unionpay' \| 'maestro' \| 'elo' \| 'mir'` |  | The brand the number's first digits show, or a provider's hosted field reports. |

| Event | Payload | Description |
| --- | --- | --- |
| `update:brand` | `value: PaymentCardBrand \| undefined` | Fires with the new value when `brand` changes; `v-model:brand` binds it. |

### PaymentCardBrands

The card marks at the end of a number field: all it offers until the number shows its brand, then that brand's alone. Renders a `<span>`.

`data-slot="payment-card-brands"` · `PaymentCardBrands.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `brand` | `PaymentCardBrand`: `'visa' \| 'mastercard' \| 'amex' \| 'discover' \| 'diners' \| 'jcb' \| 'unionpay' \| 'maestro' \| 'elo' \| 'mir'` |  | The brand the number shows, if it shows one yet. |
| `brands` | `PaymentCardBrand[]` |  | The brands whose marks show, such as the ones the shop accepts; a brand outside them shows a generic card. Every brand with a mark when left out. |

| Data attribute | Values | Description |
| --- | --- | --- |
| `data-brand` | `'visa' \| 'mastercard' \| 'amex' \| 'discover' \| 'diners' \| 'jcb' \| 'unionpay' \| 'maestro' \| 'elo' \| 'mir'` | Carries the `brand` prop. |

### PaymentCardCvc

Takes a card's security code, as long as the brand the number shows asks for; or frames a payment provider's hosted code field.

`data-slot="payment-card-cvc"` · `PaymentCardCvc.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `hosted` | `PaymentCardHostedField` |  | A provider's hosted field's state, when the default slot holds its iframe instead of our input. |
| `placeholder` | `string` | `'CVC'` | Shown while the field is empty. |
| `incompleteMessage` | `string` | `'Enter the full security code.'` | Shown when the code has too few digits. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `modelValue` | `string` | `''` | The code's digits. |

| Event | Payload | Description |
| --- | --- | --- |
| `update:modelValue` | `value: string` | Fires with the new value when `modelValue` changes; `v-model` binds it. |

| Slot | Slot props | Description |
| --- | --- | --- |
| `end` |  | Sits at the end of the field. |

| Data attribute | Values | Description |
| --- | --- | --- |
| `data-size` |  | The size the part is drawn at. |

### PaymentCardExpiry

Takes a card's expiry as MM/YY, adding the slash as it is typed; or frames a payment provider's hosted expiry field.

`data-slot="payment-card-expiry"` · `PaymentCardExpiry.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `hosted` | `PaymentCardHostedField` |  | A provider's hosted field's state, when the default slot holds its iframe instead of our input. |
| `placeholder` | `string` | `'MM/YY'` | Shown while the field is empty. |
| `incompleteMessage` | `string` | `'Enter the expiry as MM/YY.'` | Shown when the expiry has too few digits. |
| `invalidMessage` | `string` | `'Enter a month from 01 to 12.'` | Shown when the month is not 01 to 12. |
| `expiredMessage` | `string` | `'This card has expired.'` | Shown when the month is already past. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `modelValue` | `string` | `''` | The expiry as MM/YY. |

| Event | Payload | Description |
| --- | --- | --- |
| `update:modelValue` | `value: string` | Fires with the new value when `modelValue` changes; `v-model` binds it. |

| Slot | Slot props | Description |
| --- | --- | --- |
| `end` |  | Sits at the end of the field. |

| Data attribute | Values | Description |
| --- | --- | --- |
| `data-size` |  | The size the part is drawn at. |

### PaymentCardNumber

Takes a card number, spaced as the card prints it, with its brand's logo at the end; or frames a payment provider's hosted number field.

`data-slot="payment-card-number"` · `PaymentCardNumber.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `hosted` | `PaymentCardHostedField` |  | A provider's hosted field's state, when the default slot holds its iframe instead of our input. |
| `brands` | `PaymentCardBrand[]` |  | The brands whose marks show at the end, such as the ones the shop accepts; a brand outside them shows a generic card. Every brand with a mark when left out. |
| `placeholder` | `string` | `'1234 5678 9012 3456'` | Shown while the field is empty. |
| `incompleteMessage` | `string` | `'Enter the full card number.'` | Shown when the number has too few digits. |
| `invalidMessage` | `string` | `'This card number is not valid.'` | Shown when the number is no card's. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `modelValue` | `string` | `''` | The number's digits, without spaces. |

| Event | Payload | Description |
| --- | --- | --- |
| `update:modelValue` | `value: string` | Fires with the new value when `modelValue` changes; `v-model` binds it. |

| Slot | Slot props | Description |
| --- | --- | --- |
| `end` |  | Replaces the brand logos at the end. |

| Data attribute | Values | Description |
| --- | --- | --- |
| `data-size` |  | The size the part is drawn at. |

## Exports

```ts
import { type PaymentCardBrand, type PaymentCardScheme, digitsOf, paymentCardScheme, formatPaymentCardNumber, paymentCardNumberProblem, paymentCardCvcLengths, formatPaymentCardExpiry, paymentCardExpiryProblem, type PaymentCardHostedField } from '@/components/ui/payment-card'
```

- `PaymentCardBrand`: `'visa' \| 'mastercard' \| 'amex' \| 'discover' \| 'diners' \| 'jcb' \| 'unionpay' \| 'maestro' \| 'elo' \| 'mir'`
- `PaymentCardScheme`: below
- `digitsOf`: below
- `paymentCardScheme`: below
- `formatPaymentCardNumber`: below
- `paymentCardNumberProblem`: below
- `paymentCardCvcLengths`: below
- `formatPaymentCardExpiry`: below
- `paymentCardExpiryProblem`: below
- `PaymentCardHostedField`: below

```ts
interface PaymentCardScheme {
  brand: PaymentCardBrand
  /** The lengths a full number may have, shortest first. */
  lengths: number[]
  /** Where a space goes as the number is typed, by digit count. */
  gaps: number[]
  /** The security code's length. */
  cvc: number
}

const digitsOf = (text: string)

function paymentCardScheme(digits: string): PaymentCardScheme | undefined

function formatPaymentCardNumber(digits: string): string

function paymentCardNumberProblem(digits: string): 'incomplete' | 'invalid' | undefined

function paymentCardCvcLengths(brand: PaymentCardBrand | undefined):

function formatPaymentCardExpiry(text: string): string

function paymentCardExpiryProblem(expiry: string): 'incomplete' | 'invalid' | 'expired' | undefined

interface PaymentCardHostedField {
  /** Nothing is typed in it. */
  empty: boolean
  /** It holds a full, valid entry. */
  complete: boolean
  /** The provider's message for what is wrong with the entry. */
  error?: string
  /** The scheme the provider detected, for the number field. */
  brand?: PaymentCardBrand
  /** Moves focus into the provider's field; called when the Field's label is clicked. */
  focus?: () => void
}
```

## Styling

| Part | Recipe |
| --- | --- |
| `brands` | [`payment-card-brands`](https://ui.frontic.com/DESIGN.md#payment-card-brands) |
| `brand` | [`payment-card-brand`](https://ui.frontic.com/DESIGN.md#payment-card-brand) |

## Source

`components/ui/payment-card/PaymentCard.vue`

```vue
<script lang="ts">
/** Groups a card's number, expiry and security code so they share the brand the number shows. */
export default {}
</script>

<script setup lang="ts">
import type { PaymentCardBrand } from './card'
import { providePaymentCardContext } from './context'

/** The brand the number's first digits show, or a provider's hosted field reports. */
const brand = defineModel<PaymentCardBrand | undefined>('brand')

providePaymentCardContext({ brand })
</script>

<template>
  <slot />
</template>
```

`components/ui/payment-card/PaymentCardBrands.vue`

```vue
<script lang="ts">
/** The card marks at the end of a number field: all it offers until the number shows its brand, then that brand's alone. */
export default {}
</script>

<script setup lang="ts">
import { CreditCardIcon } from '@lucide/vue'

import type { Component } from 'vue'
import type { PaymentCardBrand } from './card'
import { computed } from 'vue'
import PaymentCardLogoAmex from './PaymentCardLogoAmex.vue'
import PaymentCardLogoDiscover from './PaymentCardLogoDiscover.vue'
import PaymentCardLogoMastercard from './PaymentCardLogoMastercard.vue'
import PaymentCardLogoVisa from './PaymentCardLogoVisa.vue'

const props = defineProps<{
  /** The brand the number shows, if it shows one yet. */
  brand?: PaymentCardBrand
  /** The brands whose marks show, such as the ones the shop accepts; a brand outside them shows a generic card. Every brand with a mark when left out. */
  brands?: PaymentCardBrand[]
}>()

const LOGOS: Partial<Record<PaymentCardBrand, Component>> = { visa: PaymentCardLogoVisa, mastercard: PaymentCardLogoMastercard, amex: PaymentCardLogoAmex, discover: PaymentCardLogoDiscover }

const offered = computed(() => (props.brands ?? (Object.keys(LOGOS) as PaymentCardBrand[])).filter((brand) => LOGOS[brand]))
const shown = computed(() => (props.brand ? offered.value.filter((brand) => brand === props.brand) : offered.value))
</script>

<template>
  <span data-slot="payment-card-brands" :data-brand="brand" class="fui-payment-card-brands">
    <span v-for="name in shown" :key="name" data-slot="payment-card-brand" :data-brand="name" class="fui-payment-card-brand inline-flex overflow-hidden">
      <component :is="LOGOS[name]" class="h-full w-auto" />
    </span>
    <CreditCardIcon v-if="brand && !shown.length" data-icon />
  </span>
</template>
```

`components/ui/payment-card/PaymentCardCvc.vue`

```vue
<script lang="ts">
/** Takes a card's security code, as long as the brand the number shows asks for; or frames a payment provider's hosted code field. */
export default {}
</script>

<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { PaymentCardHostedField } from './hosted'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@library/components/ui/input-group'
import { digitsOf, paymentCardCvcLengths } from './card'
import { injectPaymentCardContext } from './context'
import { retype, useValidityMessage } from './input'
import PaymentCardHost from './PaymentCardHost.vue'

defineOptions({ inheritAttrs: false })

const props = withDefaults(
  defineProps<{
    /** A provider's hosted field's state, when the default slot holds its iframe instead of our input. */
    hosted?: PaymentCardHostedField
    /** Shown while the field is empty. */
    placeholder?: string
    /** Shown when the code has too few digits. */
    incompleteMessage?: string
    class?: HTMLAttributes['class']
  }>(),
  { placeholder: 'CVC', incompleteMessage: 'Enter the full security code.' },
)

/** The code's digits. */
const cvc = defineModel<string>({ default: '' })

defineSlots<{
  /** Holds a payment provider's hosted code field in place of our input; pass its state as `hosted`. */
  default?: () => unknown
  /** Sits at the end of the field. */
  end?: () => unknown
}>()

const group = useTemplateRef<{ $el: HTMLElement }>('group')
const input = () => group.value?.$el.querySelector<HTMLInputElement>('input[data-slot="input-group-control"]')
const card = injectPaymentCardContext(null)
const length = computed(() => paymentCardCvcLengths(card?.brand.value))

const display = ref(digitsOf(cvc.value))
watch(cvc, (next) => {
  if (next !== display.value) display.value = digitsOf(next)
})

function type(raw: string | number) {
  const text = String(raw)
  const formatted = digitsOf(text).slice(0, length.value.max)
  cvc.value = formatted
  return retype(display, input(), text, formatted)
}

// A brand found after the code was typed may ask for fewer digits.
watch(length, ({ max }) => digitsOf(cvc.value).length > max && type(cvc.value))

useValidityMessage(
  input,
  computed(() => {
    const digits = digitsOf(cvc.value)
    return digits && digits.length < length.value.min ? props.incompleteMessage : ''
  }),
)
</script>

<template>
  <InputGroup ref="group" data-slot="payment-card-cvc" :class="props.class">
    <PaymentCardHost v-if="$slots.default" :hosted="hosted" :incomplete-message="incompleteMessage">
      <slot />
    </PaymentCardHost>
    <InputGroupInput v-else v-bind="$attrs" :model-value="display" :placeholder="placeholder" :maxlength="length.max" inputmode="numeric" autocomplete="cc-csc" @update:model-value="type" />
    <InputGroupAddon v-if="$slots.end" align="inline-end">
      <slot name="end" />
    </InputGroupAddon>
  </InputGroup>
</template>
```

`components/ui/payment-card/PaymentCardExpiry.vue`

```vue
<script lang="ts">
/** Takes a card's expiry as MM/YY, adding the slash as it is typed; or frames a payment provider's hosted expiry field. */
export default {}
</script>

<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { PaymentCardHostedField } from './hosted'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@library/components/ui/input-group'
import { formatPaymentCardExpiry, paymentCardExpiryProblem } from './card'
import { retype, useValidityMessage } from './input'
import PaymentCardHost from './PaymentCardHost.vue'

defineOptions({ inheritAttrs: false })

const props = withDefaults(
  defineProps<{
    /** A provider's hosted field's state, when the default slot holds its iframe instead of our input. */
    hosted?: PaymentCardHostedField
    /** Shown while the field is empty. */
    placeholder?: string
    /** Shown when the expiry has too few digits. */
    incompleteMessage?: string
    /** Shown when the month is not 01 to 12. */
    invalidMessage?: string
    /** Shown when the month is already past. */
    expiredMessage?: string
    class?: HTMLAttributes['class']
  }>(),
  { placeholder: 'MM/YY', incompleteMessage: 'Enter the expiry as MM/YY.', invalidMessage: 'Enter a month from 01 to 12.', expiredMessage: 'This card has expired.' },
)

/** The expiry as MM/YY. */
const expiry = defineModel<string>({ default: '' })

defineSlots<{
  /** Holds a payment provider's hosted expiry field in place of our input; pass its state as `hosted`. */
  default?: () => unknown
  /** Sits at the end of the field. */
  end?: () => unknown
}>()

const group = useTemplateRef<{ $el: HTMLElement }>('group')
const input = () => group.value?.$el.querySelector<HTMLInputElement>('input[data-slot="input-group-control"]')

const display = ref(formatPaymentCardExpiry(expiry.value))
watch(expiry, (next) => {
  if (next !== display.value) display.value = formatPaymentCardExpiry(next)
})

function type(raw: string | number) {
  const text = String(raw)
  const formatted = formatPaymentCardExpiry(text)
  expiry.value = formatted
  return retype(display, input(), text, formatted)
}

const problem = computed(() => paymentCardExpiryProblem(expiry.value))
useValidityMessage(
  input,
  computed(() => ({ incomplete: props.incompleteMessage, invalid: props.invalidMessage, expired: props.expiredMessage, none: '' })[problem.value ?? 'none']),
)
</script>

<template>
  <InputGroup ref="group" data-slot="payment-card-expiry" :class="props.class">
    <PaymentCardHost v-if="$slots.default" :hosted="hosted" :incomplete-message="incompleteMessage">
      <slot />
    </PaymentCardHost>
    <InputGroupInput v-else v-bind="$attrs" :model-value="display" :placeholder="placeholder" inputmode="numeric" autocomplete="cc-exp" @update:model-value="type" />
    <InputGroupAddon v-if="$slots.end" align="inline-end">
      <slot name="end" />
    </InputGroupAddon>
  </InputGroup>
</template>
```

`components/ui/payment-card/PaymentCardHost.vue`

```vue
<script lang="ts">
/** Frames a payment provider's hosted field inside a card part and joins it to a Field around it, through a hidden input the Field validates. */
export default {}
</script>

<script setup lang="ts">
import type { PaymentCardHostedField } from './hosted'
import { injectFieldRootContext, VisuallyHidden } from 'reka-ui'
import { computed, nextTick, onBeforeUnmount, onMounted, useTemplateRef, watch } from 'vue'
import { useValidityMessage } from './input'

const props = defineProps<{
  /** The provider's field's state. */
  hosted?: PaymentCardHostedField
  /** Shown while the provider's field holds a partial entry and reports no error of its own. */
  incompleteMessage: string
}>()

const field = injectFieldRootContext(null)
const validity = useTemplateRef<{ $el: HTMLInputElement }>('validity')
const input = () => validity.value?.$el

const value = computed(() => (props.hosted && !props.hosted.empty ? 'entered' : ''))
const message = computed(() => (!props.hosted || props.hosted.empty ? '' : (props.hosted.error ?? (props.hosted.complete ? '' : props.incompleteMessage))))
useValidityMessage(input, message)

watch([message, value], async () => {
  await nextTick()
  field?.handleControlInput()
})

let unregister: (() => void) | undefined
onMounted(() => {
  unregister = field?.registerControl({
    id: () => field.fieldId.value,
    element: () => input(),
    getValue: () => value.value || undefined,
    validityElement: () => input(),
  })
})
onBeforeUnmount(() => unregister?.())
</script>

<template>
  <div
    data-slot="payment-card-host"
    :aria-invalid="(field?.invalid.value && !field.disabled.value) || undefined"
    class="flex h-full min-w-0 flex-1 items-center"
    @focusin="field?.handleControlFocus()"
    @focusout="field?.handleControlBlur()"
  >
    <slot />
  </div>
  <VisuallyHidden
    :id="field?.fieldId.value"
    ref="validity"
    as="input"
    feature="focusable"
    aria-hidden="true"
    tabindex="-1"
    :value="value"
    :required="field?.required.value"
    @focus="hosted?.focus?.()"
  />
</template>
```

`components/ui/payment-card/PaymentCardLogoAmex.vue`

```vue
<!--
  The American Express mark from datatrans/payment-logos, https://github.com/datatrans/payment-logos, assets/cards/american-express.svg.
  Licensed CC BY-SA 4.0, https://creativecommons.org/licenses/by-sa/4.0/. Changed: set into a Vue component, without its fixed width and height.
  This file is under CC BY-SA 4.0, not the registry's MIT licence. The mark itself is a trademark of its owner.
-->
<script lang="ts">
/** The American Express card mark. */
export default {}
</script>

<template>
  <svg viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
    <rect x="40" width="80" height="80" rx="4" fill="#fff" fill-rule="evenodd" />
    <path
      d="m120 76v-8.6763h-9.651l-4.969-5.4944-4.994 5.4944h-31.822v-25.607h-10.27l12.74-28.831h12.286l4.3857 9.877v-9.877h15.208l2.64 7.4429 2.658-7.4429h11.789v-8.8854c0-2.2091-1.7909-4-4-4h-112c-2.2091 4.4409e-16 -4 1.7909-4 4v72c4.4409e-16 2.2091 1.7909 4 4 4h112c2.2091 0 4-1.7909 4-4zm-8.026-11.882h8.026l-10.616-11.258 10.616-11.13h-7.898l-6.556 7.1645-6.4935-7.1645h-8.0275l10.554 11.194-10.554 11.194h7.8041l6.5889-7.2283 6.556 7.2283zm1.878-11.249 6.148 6.5406v-13.027l-6.148 6.4861zm-35.78 6.0675v-3.4864h12.633v-5.0534h-12.633v-3.4859h12.953l5e-4 -5.1815h-19.062v22.388h19.062l-5e-4 -5.1813h-12.953zm35.883-20.456h6.045v-22.388h-9.403l-5.022 13.944-4.989-13.944h-9.5631v22.388h6.0446v-15.672l5.7575 15.672h5.373l5.757-15.704v15.704zm-29.809 0h6.8765l-9.8824-22.388h-7.8682l-9.8833 22.388h6.7166l1.8554-4.4776h10.298l1.887 4.4776zm-3.9976-9.4992h-6.0773l3.0387-7.3242 3.0386 7.3242z"
      fill="#0690FF"
    />
  </svg>
</template>
```

`components/ui/payment-card/PaymentCardLogoDiscover.vue`

```vue
<!--
  The Discover mark from datatrans/payment-logos, https://github.com/datatrans/payment-logos, assets/cards/discover.svg.
  Licensed CC BY-SA 4.0, https://creativecommons.org/licenses/by-sa/4.0/. Changed: set into a Vue component, without its fixed width and height.
  This file is under CC BY-SA 4.0, not the registry's MIT licence. The mark itself is a trademark of its owner.
-->
<script lang="ts">
/** The Discover card mark. */
export default {}
</script>

<script setup lang="ts">
import { useId } from 'vue'

const gradient = useId()
</script>

<template>
  <svg viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
    <rect width="120" height="80" rx="4" fill="white" />
    <path fill-rule="evenodd" clip-rule="evenodd" d="M29 80H116.002C118.21 80 120 78.211 120 75.9957V48C120 48 87.8616 70.1063 29 80Z" fill="#E7792B" />
    <path
      fill-rule="evenodd"
      clip-rule="evenodd"
      d="M113.088 33.8624C113.088 30.7125 110.888 28.8951 107.053 28.8951H102.12V45.7197H105.443V38.9609H105.877L110.481 45.7197H114.571L109.202 38.6314C111.708 38.129 113.088 36.4383 113.088 33.8624ZM106.414 36.6411H105.443V31.5451H106.467C108.538 31.5451 109.665 32.4018 109.665 34.0385C109.665 35.7305 108.538 36.6411 106.414 36.6411Z"
      fill="#1A1918"
    />
    <path fill-rule="evenodd" clip-rule="evenodd" d="M90.4839 45.7197H99.9176V42.8713H93.8077V38.3298H99.6923V35.4802H93.8077V31.746H99.9176V28.8951H90.4839V45.7197Z" fill="#1A1918" />
    <path fill-rule="evenodd" clip-rule="evenodd" d="M80.7677 40.1959L76.2205 28.8951H72.5864L79.8236 46.1512H81.613L88.9799 28.8951H85.3742L80.7677 40.1959Z" fill="#1A1918" />
    <path
      d="M64.6178 46.7197C69.7118 46.7197 73.8414 42.6454 73.8414 37.6197C73.8414 32.5939 69.7118 28.5197 64.6178 28.5197C59.5238 28.5197 55.3943 32.5939 55.3943 37.6197C55.3943 42.6454 59.5238 46.7197 64.6178 46.7197Z"
      :fill="`url(#${gradient})`"
    />
    <path
      fill-rule="evenodd"
      clip-rule="evenodd"
      d="M41.2231 37.3191C41.2231 42.2643 45.159 46.0986 50.224 46.0986C51.6556 46.0986 52.8817 45.8211 54.3943 45.1184V41.2555C53.0642 42.5685 51.8869 43.0982 50.3788 43.0982C47.0287 43.0982 44.651 40.7017 44.651 37.2944C44.651 34.0645 47.1038 31.5165 50.224 31.5165C51.8104 31.5165 53.0115 32.0749 54.3943 33.4093V29.5483C52.9344 28.8177 51.7334 28.5148 50.3024 28.5148C45.2631 28.5148 41.2231 32.4272 41.2231 37.3191Z"
      fill="#1A1918"
    />
    <path
      fill-rule="evenodd"
      clip-rule="evenodd"
      d="M35.2687 35.3515C33.2725 34.6229 32.6868 34.1419 32.6868 33.2332C32.6868 32.173 33.731 31.3683 35.1646 31.3683C36.1614 31.3683 36.9803 31.772 37.8467 32.7307L39.5873 30.4824C38.157 29.248 36.446 28.6169 34.5763 28.6169C31.5589 28.6169 29.2576 30.6839 29.2576 33.4379C29.2576 35.7558 30.3295 36.9421 33.453 38.0516C34.7555 38.5047 35.4182 38.8063 35.7529 39.0097C36.417 39.4381 36.7497 40.0439 36.7497 40.7504C36.7497 42.1135 35.6515 43.1236 34.1671 43.1236C32.5807 43.1236 31.3032 42.341 30.537 40.8798L28.3879 42.9214C29.9204 45.1405 31.7611 46.124 34.2923 46.124C37.7485 46.124 40.1736 43.8568 40.1736 40.5996C40.1736 37.9268 39.0523 36.7165 35.2687 35.3515Z"
      fill="#1A1918"
    />
    <path fill-rule="evenodd" clip-rule="evenodd" d="M23.8091 28.8951H27.1355V45.7197H23.8091V28.8951Z" fill="#1A1918" />
    <path
      fill-rule="evenodd"
      clip-rule="evenodd"
      d="M13.1242 28.8951H8.2417V45.7197H13.0985C15.6811 45.7197 17.5456 45.1184 19.1828 43.7775C21.1283 42.1889 22.2786 39.7949 22.2786 37.319C22.2786 32.3537 18.5187 28.8951 13.1242 28.8951ZM17.01 41.5336C15.9644 42.4651 14.6073 42.8713 12.4582 42.8713H11.5655V31.746H12.4582C14.6073 31.746 15.9111 32.1249 17.01 33.1064C18.1603 34.1171 18.8521 35.683 18.8521 37.2943C18.8521 38.9096 18.1603 40.5235 17.01 41.5336Z"
      fill="#1A1918"
    />
    <path
      fill-rule="evenodd"
      clip-rule="evenodd"
      d="M115.21 29.5275C115.21 29.233 115.005 29.0712 114.643 29.0712H114.162V30.5499H114.52V29.9766L114.939 30.5499H115.376L114.883 29.9402C115.094 29.8843 115.21 29.7329 115.21 29.5275ZM114.58 29.7296H114.52V29.3429H114.584C114.761 29.3429 114.853 29.4059 114.853 29.5327C114.853 29.664 114.76 29.7296 114.58 29.7296Z"
      fill="#1A1918"
    />
    <path
      fill-rule="evenodd"
      clip-rule="evenodd"
      d="M114.715 28.5187C113.987 28.5187 113.41 29.092 113.41 29.8077C113.41 30.5233 113.994 31.0973 114.715 31.0973C115.424 31.0973 116.005 30.5175 116.005 29.8077C116.005 29.1018 115.424 28.5187 114.715 28.5187ZM114.71 30.8672C114.138 30.8672 113.669 30.3966 113.669 29.8096C113.669 29.2207 114.132 28.7508 114.71 28.7508C115.28 28.7508 115.745 29.2318 115.745 29.8096C115.745 30.3914 115.28 30.8672 114.71 30.8672Z"
      fill="#1A1918"
    />
    <defs>
      <radialGradient :id="gradient" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(71.5 44) rotate(-142.431) scale(16.4012 16.1816)">
        <stop stop-color="#F59900" />
        <stop offset="0.210082" stop-color="#F39501" />
        <stop offset="0.908163" stop-color="#CE3C0B" />
        <stop offset="1" stop-color="#A4420A" />
      </radialGradient>
    </defs>
  </svg>
</template>
```

`components/ui/payment-card/PaymentCardLogoMastercard.vue`

```vue
<!--
  The Mastercard mark from datatrans/payment-logos, https://github.com/datatrans/payment-logos, assets/cards/mastercard.svg.
  Licensed CC BY-SA 4.0, https://creativecommons.org/licenses/by-sa/4.0/. Changed: set into a Vue component, without its fixed width and height.
  This file is under CC BY-SA 4.0, not the registry's MIT licence. The mark itself is a trademark of its owner.
-->
<script lang="ts">
/** The Mastercard card mark. */
export default {}
</script>

<template>
  <svg viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
    <rect width="120" height="80" rx="4" fill="white" />
    <path
      fill-rule="evenodd"
      clip-rule="evenodd"
      d="M97.5288 54.6562V53.7384H97.289L97.0137 54.3698L96.7378 53.7384H96.498V54.6562H96.6675V53.9637L96.9257 54.5609H97.1011L97.36 53.9624V54.6562H97.5288ZM96.0111 54.6562V53.8947H96.318V53.7397H95.5361V53.8947H95.843V54.6562H96.0111Z"
      fill="#F79E1B"
    />
    <path fill-rule="evenodd" clip-rule="evenodd" d="M49.6521 58.595H70.3479V21.4044H49.6521V58.595Z" fill="#FF5F00" />
    <path
      fill-rule="evenodd"
      clip-rule="evenodd"
      d="M98.2675 40.0003C98.2675 53.063 87.6791 63.652 74.6171 63.652C69.0996 63.652 64.0229 61.7624 60 58.5956C65.5011 54.2646 69.0339 47.5448 69.0339 40.0003C69.0339 32.4552 65.5011 25.7354 60 21.4044C64.0229 18.2376 69.0996 16.348 74.6171 16.348C87.6791 16.348 98.2675 26.937 98.2675 40.0003Z"
      fill="#F79E1B"
    />
    <path
      fill-rule="evenodd"
      clip-rule="evenodd"
      d="M50.966 40.0003C50.966 32.4552 54.4988 25.7354 59.9999 21.4044C55.977 18.2376 50.9003 16.348 45.3828 16.348C32.3208 16.348 21.7324 26.937 21.7324 40.0003C21.7324 53.063 32.3208 63.652 45.3828 63.652C50.9003 63.652 55.977 61.7624 59.9999 58.5956C54.4988 54.2646 50.966 47.5448 50.966 40.0003Z"
      fill="#EB001B"
    />
  </svg>
</template>
```

`components/ui/payment-card/PaymentCardLogoVisa.vue`

```vue
<!--
  The Visa mark from datatrans/payment-logos, https://github.com/datatrans/payment-logos, assets/cards/visa.svg.
  Licensed CC BY-SA 4.0, https://creativecommons.org/licenses/by-sa/4.0/. Changed: set into a Vue component, without its fixed width and height.
  This file is under CC BY-SA 4.0, not the registry's MIT licence. The mark itself is a trademark of its owner.
-->
<script lang="ts">
/** The Visa card mark. */
export default {}
</script>

<template>
  <svg viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
    <rect width="120" height="80" rx="4" fill="white" />
    <path
      fill-rule="evenodd"
      clip-rule="evenodd"
      d="M86.6666 44.9375L90.3239 35.0625L92.3809 44.9375H86.6666ZM100.952 52.8375L95.8086 27.1625H88.7383C86.3525 27.1625 85.7723 29.0759 85.7723 29.0759L76.1904 52.8375H82.8868L84.2269 49.0244H92.3947L93.1479 52.8375H100.952Z"
      fill="#1434CB"
    />
    <path
      fill-rule="evenodd"
      clip-rule="evenodd"
      d="M77.1866 33.5711L78.0952 28.244C78.0952 28.244 75.2896 27.1625 72.3648 27.1625C69.2031 27.1625 61.6955 28.5638 61.6955 35.3738C61.6955 41.7825 70.5071 41.8621 70.5071 45.2266C70.5071 48.5912 62.6034 47.9901 59.9955 45.8676L59.0476 51.4362C59.0476 51.4362 61.8919 52.8375 66.2397 52.8375C70.5869 52.8375 77.1467 50.5544 77.1467 44.3455C77.1467 37.8964 68.2552 37.296 68.2552 34.4921C68.2552 31.6882 74.4602 32.0484 77.1866 33.5711Z"
      fill="#1434CB"
    />
    <path fill-rule="evenodd" clip-rule="evenodd" d="M54.6517 52.8375H47.6191L52.0144 27.1625H59.0477L54.6517 52.8375Z" fill="#1434CB" />
    <path
      fill-rule="evenodd"
      clip-rule="evenodd"
      d="M42.3113 27.1625L35.9217 44.8213L35.1663 41.0185L35.167 41.0199L32.9114 29.4749C32.9114 29.4749 32.6394 27.1625 29.7324 27.1625H19.1709L19.0476 27.5966C19.0476 27.5966 22.2782 28.2669 26.057 30.5326L31.8793 52.8375H38.8617L49.5238 27.1625H42.3113Z"
      fill="#1434CB"
    />
  </svg>
</template>
```

`components/ui/payment-card/PaymentCardNumber.vue`

```vue
<script lang="ts">
/** Takes a card number, spaced as the card prints it, with its brand's logo at the end; or frames a payment provider's hosted number field. */
export default {}
</script>

<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { PaymentCardBrand } from './card'
import type { PaymentCardHostedField } from './hosted'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@library/components/ui/input-group'
import { digitsOf, formatPaymentCardNumber, paymentCardNumberProblem, paymentCardScheme } from './card'
import { injectPaymentCardContext } from './context'
import { retype, useValidityMessage } from './input'
import PaymentCardHost from './PaymentCardHost.vue'
import PaymentCardBrands from './PaymentCardBrands.vue'

defineOptions({ inheritAttrs: false })

const props = withDefaults(
  defineProps<{
    /** A provider's hosted field's state, when the default slot holds its iframe instead of our input. */
    hosted?: PaymentCardHostedField
    /** The brands whose marks show at the end, such as the ones the shop accepts; a brand outside them shows a generic card. Every brand with a mark when left out. */
    brands?: PaymentCardBrand[]
    /** Shown while the field is empty. */
    placeholder?: string
    /** Shown when the number has too few digits. */
    incompleteMessage?: string
    /** Shown when the number is no card's. */
    invalidMessage?: string
    class?: HTMLAttributes['class']
  }>(),
  { placeholder: '1234 5678 9012 3456', incompleteMessage: 'Enter the full card number.', invalidMessage: 'This card number is not valid.' },
)

/** The number's digits, without spaces. */
const number = defineModel<string>({ default: '' })

defineSlots<{
  /** Holds a payment provider's hosted number field in place of our input; pass its state as `hosted`. */
  default?: () => unknown
  /** Replaces the brand logos at the end. */
  end?: () => unknown
}>()

const group = useTemplateRef<{ $el: HTMLElement }>('group')
const input = () => group.value?.$el.querySelector<HTMLInputElement>('input[data-slot="input-group-control"]')
const card = injectPaymentCardContext(null)

const display = ref(formatPaymentCardNumber(digitsOf(number.value)))
watch(number, (digits) => {
  if (digitsOf(display.value) !== digits) display.value = formatPaymentCardNumber(digitsOf(digits))
})

function type(raw: string | number) {
  const text = String(raw)
  const formatted = formatPaymentCardNumber(digitsOf(text))
  number.value = digitsOf(formatted)
  return retype(display, input(), text, formatted)
}

const brand = computed(() => props.hosted?.brand ?? paymentCardScheme(number.value)?.brand)
watch(brand, (next) => card && (card.brand.value = next), { immediate: true })

const problem = computed(() => paymentCardNumberProblem(digitsOf(number.value)))
useValidityMessage(
  input,
  computed(() => (problem.value === 'incomplete' ? props.incompleteMessage : problem.value === 'invalid' ? props.invalidMessage : '')),
)
</script>

<template>
  <InputGroup ref="group" data-slot="payment-card-number" :class="props.class">
    <PaymentCardHost v-if="$slots.default" :hosted="hosted" :incomplete-message="incompleteMessage">
      <slot />
    </PaymentCardHost>
    <InputGroupInput
      v-else
      dir="ltr"
      class="rtl:text-right"
      v-bind="$attrs"
      :model-value="display"
      :placeholder="placeholder"
      inputmode="numeric"
      autocomplete="cc-number"
      @update:model-value="type"
    />
    <InputGroupAddon align="inline-end">
      <slot name="end">
        <PaymentCardBrands :brand="brand" :brands="brands" />
      </slot>
    </InputGroupAddon>
  </InputGroup>
</template>
```

`components/ui/payment-card/card.ts`

```ts
import cardValidator from 'card-validator'
import creditCardType from 'credit-card-type'

/** A card scheme the number field recognises from the first digits. */
export type PaymentCardBrand = 'visa' | 'mastercard' | 'amex' | 'discover' | 'diners' | 'jcb' | 'unionpay' | 'maestro' | 'elo' | 'mir'

export interface PaymentCardScheme {
  brand: PaymentCardBrand
  /** The lengths a full number may have, shortest first. */
  lengths: number[]
  /** Where a space goes as the number is typed, by digit count. */
  gaps: number[]
  /** The security code's length. */
  cvc: number
}

/** Braintree's card type for each brand. */
const TYPES: Record<PaymentCardBrand, string> = {
  visa: 'visa',
  mastercard: 'mastercard',
  amex: 'american-express',
  discover: 'discover',
  diners: 'diners-club',
  jcb: 'jcb',
  unionpay: 'unionpay',
  maestro: 'maestro',
  elo: 'elo',
  mir: 'mir',
}
const BRANDS = new Map(Object.entries(TYPES).map(([brand, type]) => [type, brand as PaymentCardBrand]))

const UNKNOWN = { lengths: [16, 19], gaps: [4, 8, 12] }

/** Keeps the digits of what was typed or pasted. */
export const digitsOf = (text: string) => text.replace(/\D/g, '')

/** The scheme a number belongs to, once its first digits tell one brand. */
export function paymentCardScheme(digits: string): PaymentCardScheme | undefined {
  const card = digits ? cardValidator.number(digits).card : null
  const brand = card && BRANDS.get(card.type)
  return brand ? { brand, lengths: card.lengths, gaps: card.gaps, cvc: card.code.size } : undefined
}

/** Cuts a number to its scheme's longest length and spaces it the way the card prints it. */
export function formatPaymentCardNumber(digits: string): string {
  const { lengths, gaps } = paymentCardScheme(digits) ?? UNKNOWN
  const kept = digits.slice(0, lengths.at(-1))
  return [...kept].map((digit, index) => (index > 0 && gaps.includes(index) ? ` ${digit}` : digit)).join('')
}

/** What keeps a number from being a card's: too few digits, or digits no card has; a number as long as its card's shortest that still fails is invalid, not short. */
export function paymentCardNumberProblem(digits: string): 'incomplete' | 'invalid' | undefined {
  if (!digits) return undefined
  const { isValid, isPotentiallyValid, card } = cardValidator.number(digits)
  if (isValid) return undefined
  return isPotentiallyValid && digits.length < (card?.lengths[0] ?? Infinity) ? 'incomplete' : 'invalid'
}

/** The security code's shortest and longest length: the brand's, or three to four while the brand is not known. */
export function paymentCardCvcLengths(brand: PaymentCardBrand | undefined): { min: number; max: number } {
  const cvc = brand && creditCardType.getTypeInfo(TYPES[brand]).code.size
  return cvc ? { min: cvc, max: cvc } : { min: 3, max: 4 }
}

/** Writes an expiry as MM/YY while it is typed; a first digit above 1 is a month on its own. */
export function formatPaymentCardExpiry(text: string): string {
  let digits = digitsOf(text)
  if (/^[2-9]/.test(digits)) digits = `0${digits}`
  digits = digits.slice(0, 4)
  return digits.length > 2 ? `${digits.slice(0, 2)}/${digits.slice(2)}` : digits
}

/** What keeps an MM/YY expiry from being a card's: too few digits, no such month or year, or a month already past. */
export function paymentCardExpiryProblem(expiry: string): 'incomplete' | 'invalid' | 'expired' | undefined {
  const digits = digitsOf(expiry)
  if (!digits) return undefined
  if (digits.length < 4) return 'incomplete'
  if (cardValidator.expirationDate(`${digits.slice(0, 2)}/${digits.slice(2)}`).isValid) return undefined
  if (!cardValidator.expirationMonth(digits.slice(0, 2)).isValid) return 'invalid'
  return Number(digits.slice(2)) <= new Date().getFullYear() % 100 ? 'expired' : 'invalid'
}
```

`components/ui/payment-card/context.ts`

```ts
import type { Ref } from 'vue'
import type { PaymentCardBrand } from './card'
import { createContext } from 'reka-ui'

/** The brand PaymentCardNumber detected, for the security code's length and the logos. */
export const [injectPaymentCardContext, providePaymentCardContext] = createContext<{ brand: Ref<PaymentCardBrand | undefined> }>('PaymentCard')
```

`components/ui/payment-card/hosted.ts`

```ts
import type { PaymentCardBrand } from './card'

/** What a payment provider's hosted field reports from its events, such as Stripe Elements' `change` event. */
export interface PaymentCardHostedField {
  /** Nothing is typed in it. */
  empty: boolean
  /** It holds a full, valid entry. */
  complete: boolean
  /** The provider's message for what is wrong with the entry. */
  error?: string
  /** The scheme the provider detected, for the number field. */
  brand?: PaymentCardBrand
  /** Moves focus into the provider's field; called when the Field's label is clicked. */
  focus?: () => void
}
```

`components/ui/payment-card/input.ts`

```ts
import type { Ref } from 'vue'
import { nextTick, onMounted, watch } from 'vue'
import { digitsOf } from './card'

/**
 * Shows the formatted text in place of what was typed, keeping the caret behind the digit it followed.
 * Input keeps its own copy of the value, so it is handed the typed text first: else a keystroke the formatting drops, such as a letter, would stay on screen.
 */
export async function retype(display: Ref<string>, input: HTMLInputElement | null | undefined, raw: string, formatted: string) {
  const caret = input?.selectionStart ?? raw.length
  const atEnd = caret >= raw.length
  const before = digitsOf(raw.slice(0, caret)).length
  display.value = raw
  await nextTick()
  display.value = formatted
  await nextTick()
  if (!input || document.activeElement !== input) return
  let position = formatted.length
  if (!atEnd) {
    let seen = 0
    position = [...formatted].findIndex((char) => /\d/.test(char) && ++seen > before)
    if (position === -1) position = formatted.length
  }
  input.setSelectionRange(position, position)
}

/** Keeps the input's own validity message in step with what is wrong with its value, so a Field around it shows the message. */
export function useValidityMessage(input: () => HTMLInputElement | null | undefined, message: Ref<string>) {
  const apply = () => input()?.setCustomValidity(message.value)
  watch(message, apply, { flush: 'sync' })
  onMounted(apply)
  return apply
}
```

## API of the items the example uses

### Field

[Full docs](https://ui.frontic.com/components/ui/field.md) · `npx @frontic/ui add field`

#### Field

Groups a label, an input, a description and an error of one form field, and validates its input.

`data-slot="field"` · `Field.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `name` | `string` |  | The name of the field. Submitted with its owning form as part of a name/value pair, and used to match server `errors` on a `FormRoot`. Takes precedence over the `name` of the control. |
| `disabled` | `boolean` | `false` | When `true`, prevents the user from interacting with the field's control. Takes precedence over the `disabled` of the control. |
| `required` | `boolean` | `false` | When `true`, indicates that the user must set the value before the owning form can be submitted. |
| `invalid` | `boolean` |  | When `true`, marks the field invalid regardless of its own validation. Useful when the field state is controlled by an external library. |
| `dirty` | `boolean` |  | Whether the field's value has changed from its initial value. Useful when the field state is controlled by an external library. |
| `touched` | `boolean` |  | Whether the field has been touched (its control blurred). Useful when the field state is controlled by an external library. |
| `validate` | `FieldValidateFn`: `(value: unknown, formValues: Record<string, unknown>): FieldValidateResult \| Promise<FieldValidateResult>` |  | Custom validation function. Return an error message (or array of messages) when invalid, or `null`/`undefined` when valid. Receives the control's value and the values of every named field in the owning form.<br><br>Can be async, but an async `validate` does not prevent form submission when `validationMode` is `onSubmit`. |
| `validationMode` | `FieldValidationMode`: `'onBlur' \| 'onChange' \| 'onSubmit'` | `'onSubmit'` | When the field (re-)runs validation. Takes precedence over the `validationMode` of an ancestor `FormRoot`.<br>- `onSubmit`: when the form is submitted, then on every change after that.<br>- `onBlur`: when the control loses focus.<br>- `onChange`: on every change to the control's value. |
| `validationDebounceTime` | `number` |  | How long to wait (in ms) between `validate` calls when validating on change. |
| `asChild` | `boolean` | `false` | Change the default rendered element for the one passed as a child, merging their props and behavior.<br><br>Read our [Composition](https://www.reka-ui.com/docs/guides/composition) guide for more details. |
| `as` | `AsTag \| Component`: `ComponentOptions<any, any, any, ComputedOptions, MethodOptions, any, any, any, string, {}, {}, string, {}, {}, {}, string, ComponentProvideOptions> \| (props: any, ctx: Omit<{ attrs: Attrs; slots: Readonly<InternalSlots>; emit: (event: string, ...args: any[]) => void; expose: <Exposed extends Record<string, any> = Record<string, any>>(exposed?: Exposed \| undefined) => void; }, 'expose'>): any \| ComponentPublicInstanceConstructor<any, any, any, any, ComputedOptions, MethodOptions> \| 'a' \| 'button' \| 'div' \| 'form' \| 'h2' \| 'h3' \| 'img' \| 'input' \| 'label' \| 'li' \| 'nav' \| 'ol' \| 'p' \| 'span' \| 'svg' \| 'ul' \| 'template' \| {} & string` |  | The element or component this component should render as. Can be overwritten by `asChild`. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `orientation` | `FieldOrientation`: `'vertical' \| 'horizontal' \| 'responsive'` | `'vertical'` | Label above the control, beside it, or `responsive`: beside it once the field group is wide enough. |

| Slot | Slot props | Description |
| --- | --- | --- |
| `default` | `{ invalid: boolean; errors: string[]; }` | The label, control, description and error; gets `invalid` and the current `errors`. |

| Data attribute | Values | Description |
| --- | --- | --- |
| `data-horizontal` |  | Present when the part is laid out horizontally. |
| `data-orientation` | `'vertical' \| 'horizontal' \| 'responsive'` | Carries the `orientation` prop. |

#### FieldContent

Groups the label and description that sit beside the control of a field. Renders a `<div>`.

`data-slot="field-content"` · `FieldContent.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` | `false` | Change the default rendered element for the one passed as a child, merging their props and behavior.<br><br>Read our [Composition](https://www.reka-ui.com/docs/guides/composition) guide for more details. |
| `as` | `AsTag \| Component`: `ComponentOptions<any, any, any, ComputedOptions, MethodOptions, any, any, any, string, {}, {}, string, {}, {}, {}, string, ComponentProvideOptions> \| (props: any, ctx: Omit<{ attrs: Attrs; slots: Readonly<InternalSlots>; emit: (event: string, ...args: any[]) => void; expose: <Exposed extends Record<string, any> = Record<string, any>>(exposed?: Exposed \| undefined) => void; }, 'expose'>): any \| ComponentPublicInstanceConstructor<any, any, any, any, ComputedOptions, MethodOptions> \| 'a' \| 'button' \| 'div' \| 'form' \| 'h2' \| 'h3' \| 'img' \| 'input' \| 'label' \| 'li' \| 'nav' \| 'ol' \| 'p' \| 'span' \| 'svg' \| 'ul' \| 'template' \| {} & string` | `'div'` | The element or component this component should render as. Can be overwritten by `asChild`. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |

#### FieldDescription

Gives help text for a field, such as what to enter.

`data-slot="field-description"` · `FieldDescription.vue`

Styling: `field-description`, which extends [`hint`](https://ui.frontic.com/DESIGN.md#hint) and [`link-primary`](https://ui.frontic.com/DESIGN.md#link-primary). Change them there and every part that extends them follows.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` |  | Id of the element. Auto-generated when not provided. |
| `asChild` | `boolean` | `false` | Change the default rendered element for the one passed as a child, merging their props and behavior.<br><br>Read our [Composition](https://www.reka-ui.com/docs/guides/composition) guide for more details. |
| `as` | `AsTag \| Component`: `ComponentOptions<any, any, any, ComputedOptions, MethodOptions, any, any, any, string, {}, {}, string, {}, {}, {}, string, ComponentProvideOptions> \| (props: any, ctx: Omit<{ attrs: Attrs; slots: Readonly<InternalSlots>; emit: (event: string, ...args: any[]) => void; expose: <Exposed extends Record<string, any> = Record<string, any>>(exposed?: Exposed \| undefined) => void; }, 'expose'>): any \| ComponentPublicInstanceConstructor<any, any, any, any, ComputedOptions, MethodOptions> \| 'a' \| 'button' \| 'div' \| 'form' \| 'h2' \| 'h3' \| 'img' \| 'input' \| 'label' \| 'li' \| 'nav' \| 'ol' \| 'p' \| 'span' \| 'svg' \| 'ul' \| 'template' \| {} & string` |  | The element or component this component should render as. Can be overwritten by `asChild`. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |

#### FieldError

Shows why the input of a field is invalid, from its own validation, from the server or from a form library.

`data-slot="field-error"` · `FieldError.vue`

Styling: `field-error`, which extends [`error`](https://ui.frontic.com/DESIGN.md#error). Change it there and every part that extends it follows.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` |  | Id of the element. Auto-generated when not provided. |
| `match` | `boolean \| keyof ValidityState`: `boolean \| 'badInput' \| 'customError' \| 'patternMismatch' \| 'rangeOverflow' \| 'rangeUnderflow' \| 'stepMismatch' \| 'tooLong' \| 'tooShort' \| 'typeMismatch' \| 'valid' \| 'valueMissing'` |  | Restricts when this error renders:<br>- a `ValidityState` key (e.g. `"valueMissing"`) — renders when that constraint fails.<br>- `true` — always renders, letting an external library control visibility.<br>- omitted — renders whenever the field is invalid. |
| `forceMount` | `boolean` | `false` | Used to force mounting when more control is needed. Useful when controlling animation with Vue animation libraries. |
| `asChild` | `boolean` | `false` | Change the default rendered element for the one passed as a child, merging their props and behavior.<br><br>Read our [Composition](https://www.reka-ui.com/docs/guides/composition) guide for more details. |
| `as` | `AsTag \| Component`: `ComponentOptions<any, any, any, ComputedOptions, MethodOptions, any, any, any, string, {}, {}, string, {}, {}, {}, string, ComponentProvideOptions> \| (props: any, ctx: Omit<{ attrs: Attrs; slots: Readonly<InternalSlots>; emit: (event: string, ...args: any[]) => void; expose: <Exposed extends Record<string, any> = Record<string, any>>(exposed?: Exposed \| undefined) => void; }, 'expose'>): any \| ComponentPublicInstanceConstructor<any, any, any, any, ComputedOptions, MethodOptions> \| 'a' \| 'button' \| 'div' \| 'form' \| 'h2' \| 'h3' \| 'img' \| 'input' \| 'label' \| 'li' \| 'nav' \| 'ol' \| 'p' \| 'span' \| 'svg' \| 'ul' \| 'template' \| {} & string` |  | The element or component this component should render as. Can be overwritten by `asChild`. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `errors` | `(string \| { message: string \| undefined; } \| undefined)[]` |  | Messages from outside the Field, such as vee-validate's or TanStack Form's; shown instead of the Field's own, duplicates dropped. |

| Slot | Slot props | Description |
| --- | --- | --- |
| `default` | `{ errors: string[]; }` | Replaces the messages; gets the current `errors`. |

#### FieldGroup

Stacks several fields of a form together. Renders a `<div>`.

`data-slot="field-group"` · `FieldGroup.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` | `false` | Change the default rendered element for the one passed as a child, merging their props and behavior.<br><br>Read our [Composition](https://www.reka-ui.com/docs/guides/composition) guide for more details. |
| `as` | `AsTag \| Component`: `ComponentOptions<any, any, any, ComputedOptions, MethodOptions, any, any, any, string, {}, {}, string, {}, {}, {}, string, ComponentProvideOptions> \| (props: any, ctx: Omit<{ attrs: Attrs; slots: Readonly<InternalSlots>; emit: (event: string, ...args: any[]) => void; expose: <Exposed extends Record<string, any> = Record<string, any>>(exposed?: Exposed \| undefined) => void; }, 'expose'>): any \| ComponentPublicInstanceConstructor<any, any, any, any, ComputedOptions, MethodOptions> \| 'a' \| 'button' \| 'div' \| 'form' \| 'h2' \| 'h3' \| 'img' \| 'input' \| 'label' \| 'li' \| 'nav' \| 'ol' \| 'p' \| 'span' \| 'svg' \| 'ul' \| 'template' \| {} & string` | `'div'` | The element or component this component should render as. Can be overwritten by `asChild`. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |

#### FieldLabel

Labels the input of a field.

`data-slot="field-label"` · `FieldLabel.vue`

Styling: `field-label`, which extends [`label`](https://ui.frontic.com/DESIGN.md#label). Change it there and every part that extends it follows.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `nativeLabel` | `boolean` | `true` | Whether the label renders a native `label` element. Set to `false` when rendering another element (e.g. `as="div"`): it then drops `for`, focuses the control on click, and relies on the control's `aria-labelledby`.<br><br>Useful for button controls like `SelectTrigger`, where a native label would forward clicks and `:hover` to the button. |
| `for` | `string` |  | The id of the element the label is associated with. |
| `asChild` | `boolean` | `false` | Change the default rendered element for the one passed as a child, merging their props and behavior.<br><br>Read our [Composition](https://www.reka-ui.com/docs/guides/composition) guide for more details. |
| `as` | `AsTag \| Component`: `ComponentOptions<any, any, any, ComputedOptions, MethodOptions, any, any, any, string, {}, {}, string, {}, {}, {}, string, ComponentProvideOptions> \| (props: any, ctx: Omit<{ attrs: Attrs; slots: Readonly<InternalSlots>; emit: (event: string, ...args: any[]) => void; expose: <Exposed extends Record<string, any> = Record<string, any>>(exposed?: Exposed \| undefined) => void; }, 'expose'>): any \| ComponentPublicInstanceConstructor<any, any, any, any, ComputedOptions, MethodOptions> \| 'a' \| 'button' \| 'div' \| 'form' \| 'h2' \| 'h3' \| 'img' \| 'input' \| 'label' \| 'li' \| 'nav' \| 'ol' \| 'p' \| 'span' \| 'svg' \| 'ul' \| 'template' \| {} & string` |  | The element or component this component should render as. Can be overwritten by `asChild`. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |

#### FieldLegend

Titles a set of related fields. Renders a `<legend>`.

`data-slot="field-legend"` · `FieldLegend.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` | `false` | Change the default rendered element for the one passed as a child, merging their props and behavior.<br><br>Read our [Composition](https://www.reka-ui.com/docs/guides/composition) guide for more details. |
| `as` | `AsTag \| Component`: `ComponentOptions<any, any, any, ComputedOptions, MethodOptions, any, any, any, string, {}, {}, string, {}, {}, {}, string, ComponentProvideOptions> \| (props: any, ctx: Omit<{ attrs: Attrs; slots: Readonly<InternalSlots>; emit: (event: string, ...args: any[]) => void; expose: <Exposed extends Record<string, any> = Record<string, any>>(exposed?: Exposed \| undefined) => void; }, 'expose'>): any \| ComponentPublicInstanceConstructor<any, any, any, any, ComputedOptions, MethodOptions> \| 'a' \| 'button' \| 'div' \| 'form' \| 'h2' \| 'h3' \| 'img' \| 'input' \| 'label' \| 'li' \| 'nav' \| 'ol' \| 'p' \| 'span' \| 'svg' \| 'ul' \| 'template' \| {} & string` | `'legend'` | The element or component this component should render as. Can be overwritten by `asChild`. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `variant` | `'label' \| 'legend'` | `'legend'` | Whether the legend reads as a section heading or as a field label. |

| Data attribute | Values | Description |
| --- | --- | --- |
| `data-variant` | `'label' \| 'legend'` | Carries the `variant` prop. |

#### FieldSeparator

Separates fields of a form, optionally with a short text in between. Renders a `<div>`.

`data-slot="field-separator"` · `FieldSeparator.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |

| Data attribute | Values | Description |
| --- | --- | --- |
| `data-content` | `'true' \| 'false'` | `true` when the separator has text in it. |

#### FieldSet

Groups related fields under one legend. Renders a `<fieldset>`.

`data-slot="field-set"` · `FieldSet.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` | `false` | Change the default rendered element for the one passed as a child, merging their props and behavior.<br><br>Read our [Composition](https://www.reka-ui.com/docs/guides/composition) guide for more details. |
| `as` | `AsTag \| Component`: `ComponentOptions<any, any, any, ComputedOptions, MethodOptions, any, any, any, string, {}, {}, string, {}, {}, {}, string, ComponentProvideOptions> \| (props: any, ctx: Omit<{ attrs: Attrs; slots: Readonly<InternalSlots>; emit: (event: string, ...args: any[]) => void; expose: <Exposed extends Record<string, any> = Record<string, any>>(exposed?: Exposed \| undefined) => void; }, 'expose'>): any \| ComponentPublicInstanceConstructor<any, any, any, any, ComputedOptions, MethodOptions> \| 'a' \| 'button' \| 'div' \| 'form' \| 'h2' \| 'h3' \| 'img' \| 'input' \| 'label' \| 'li' \| 'nav' \| 'ol' \| 'p' \| 'span' \| 'svg' \| 'ul' \| 'template' \| {} & string` | `'fieldset'` | The element or component this component should render as. Can be overwritten by `asChild`. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |

#### FieldTitle

Shows the title of a field next to its description. Renders a `<div>`.

`data-slot="field-title"` · `FieldTitle.vue`

Styling: `field-label`, which extends [`label`](https://ui.frontic.com/DESIGN.md#label). Change it there and every part that extends it follows.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` | `false` | Change the default rendered element for the one passed as a child, merging their props and behavior.<br><br>Read our [Composition](https://www.reka-ui.com/docs/guides/composition) guide for more details. |
| `as` | `AsTag \| Component`: `ComponentOptions<any, any, any, ComputedOptions, MethodOptions, any, any, any, string, {}, {}, string, {}, {}, {}, string, ComponentProvideOptions> \| (props: any, ctx: Omit<{ attrs: Attrs; slots: Readonly<InternalSlots>; emit: (event: string, ...args: any[]) => void; expose: <Exposed extends Record<string, any> = Record<string, any>>(exposed?: Exposed \| undefined) => void; }, 'expose'>): any \| ComponentPublicInstanceConstructor<any, any, any, any, ComputedOptions, MethodOptions> \| 'a' \| 'button' \| 'div' \| 'form' \| 'h2' \| 'h3' \| 'img' \| 'input' \| 'label' \| 'li' \| 'nav' \| 'ol' \| 'p' \| 'span' \| 'svg' \| 'ul' \| 'template' \| {} & string` | `'div'` | The element or component this component should render as. Can be overwritten by `asChild`. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |

#### FieldValidity

Renders from the validation state of a field, such as a custom message or a strength meter.

| Slot | Slot props | Description |
| --- | --- | --- |
| `default` | `{ validity: Omit<Record<keyof ValidityState, boolean>, "valid"> & { valid: boolean \| null; }; errors: string[]; error: string; value: unknown; initialValue: unknown; }` | Gets the field's `validity` (`valid` is `null` until there is one), its `errors`, the first `error`, the `value` validated last and the `initialValue`. |

### Input

[Full docs](https://ui.frontic.com/components/ui/input.md) · `npx @frontic/ui add input`

#### Input

Lets the user enter a single line of text. Renders a `<input>`.

Styling: `input`, which extends [`control`](https://ui.frontic.com/DESIGN.md#control) and [`control-line`](https://ui.frontic.com/DESIGN.md#control-line). Change them there and every part that extends them follows.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultValue` | `string \| number` |  | The starting value when the field is not bound with v-model. |
| `modelValue` | `string \| number` |  | The field's value, bound with v-model. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `size` | `'md' \| 'sm'` |  | The height of the input. |

| Event | Payload | Description |
| --- | --- | --- |
| `update:modelValue` | `payload: string \| number` | Fires with the new value when `modelValue` changes; `v-model` binds it. |

| Data attribute | Values | Description |
| --- | --- | --- |
| `data-size` | `'md' \| 'sm'` | Carries the `size` prop. |
