---
title: "Animated Input"
description: "A text input whose label floats out of the field on focus or on the first character."
canonical_url: "https://nuxt-motion-block.pages.dev/components/forms/animated-input"
---
# Animated Input

> A text input whose label floats out of the field on focus or on the first character.

A text input with a floating label. The label starts centred inside the field and
translates up and down to `scale(0.85)` the moment the field takes focus or holds
a value, then returns when both are gone.

The label is painted with the same background as the input and a `z-index` of
`2`, which is what lets it cut a gap in the input's own ring rather than sitting
behind it. That is also the constraint to know about: the gap shows whatever the
page background is, so the label only reads as "cut out" on a `bg-default`
surface.

The placeholder is suppressed while the label is floating, since a placeholder
under a raised label is unreadable. Unused attributes land on the wrapper `<div>`
rather than the `<input>`, because the component sets `inheritAttrs: false` and
binds `$attrs` explicitly.

## Usage

`label` is required. Bind `v-model` when you need the value in JS, or leave it
unbound and read nothing — the input still works as a native text field. Pass an
`icon` name or fill the `icon` slot for a leading glyph.

<code-preview label="Preview">
<div className="flex,justify-center,w-full,h-full">
<component-example name="animated-input-basic">



</component-example>
</div>

<template v-slot:code="">

```vue
<template>
  <form
    class="flex w-full max-w-sm flex-col gap-4"
    @submit.prevent="submitted = true"
  >
    <MAnimatedInput
      v-model="form.email"
      label="Work email"
      icon="i-lucide-mail"
      placeholder="you@company.com"
    />

    <MAnimatedInput
      v-model="form.workspace"
      label="Workspace"
      placeholder="Acme Inc."
      label-class-name="text-xs"
    >
      <template #icon>
        <UIcon
          name="i-lucide-sparkles"
          class="size-4 text-primary"
        />
      </template>
    </MAnimatedInput>

    <UButton
      type="submit"
      label="Create workspace"
      icon="i-lucide-arrow-right"
      icon-trailing
      class="justify-center"
    />

    <p class="text-xs text-muted">
      {{ submitted ? 'Submitted — the label float is animation-only.' : 'Labels float on focus and on a filled value.' }}
    </p>
  </form>
</template>

<script setup lang="ts">
const form = reactive({
  email: '',
  workspace: '',
})

const submitted = ref(false)
</script>
```

</template>
</code-preview>

## Customize

<code-preview label="Preview">
<div className="w-full">
<component-example name="animated-input-custom">



</component-example>
</div>

<template v-slot:code="">
<playground-code name="AnimatedInput">



</playground-code>
</template>
</code-preview>

## Props

<component-props>
<component-prop name="label" type="string" :required="true">

The visible label text, and also the input's `aria-label`, so it is the
accessible name as well as the floating caption. There is no unlabelled mode.

</component-prop>

<component-prop name="modelValue" type="string">

The current value. While this is `undefined` the component keeps its own copy
seeded from `defaultValue`; passing a value — even once — switches it to
controlled mode permanently, and the internal copy is no longer written to.

</component-prop>

<component-prop name="defaultValue" type="string" default="''">

Seeds the uncontrolled value once, during setup. It is not watched, so changing
it later does nothing.

</component-prop>

<component-prop name="placeholder" type="string" default="''">

Shown only while the label is not floating. The component blanks it as soon as
the label lifts, so the two never overlap.

</component-prop>

<component-prop name="icon" type="string">

An Iconify name such as `i-lucide-mail`, rendered as a `size-4` glyph at
`left-3`. Takes precedence over the `icon` slot. When either is present the
input picks up `pl-10` so the text clears the glyph.

</component-prop>

<component-prop name="disabled" type="boolean" default="false">

Disables the native input. The label keeps animating from `val`, so a disabled
field that held a value still shows its label floated.

</component-prop>

<component-prop name="inputClassName" type="string" default="''">

Extra classes for the `<input>`, merged after the component's own utilities.

</component-prop>

<component-prop name="labelClassName" type="string" default="''">

Extra classes for the `<motion.label>`, merged after the component's own
utilities. Use it to match a non-default background or to restyle the floated
state.

</component-prop>
</component-props>

## Events

<component-events>
<component-event name="update:modelValue" type="(value: string) => void">

Emitted on every `input` event with the input's current `.value`. Fires whether
or not the component is controlled, so it works as a one-way observer too.

</component-event>
</component-events>

## Slots

<component-slots>
<component-slot-row name="icon">

A leading glyph rendered instead of the `icon` prop, inside the same
`aria-hidden`, `pointer-events-none` wrapper. Size it yourself; the component
does not constrain slot content.

</component-slot-row>
</component-slots>

## UI

This component's theme exposes the slots below. Override any of them per instance with the [`ui` prop](https://nuxt-motion-block.pages.dev/getting-started/theming), or app-wide under `ui.animatedInput` in `app.config.ts`.

<component-props>
<component-prop name="ui" type="object">

Per-slot classes merged over the theme defaults.

</component-prop>
</component-props>

<component-slots>
<component-slot-row name="root">

`relative flex items-center`

</component-slot-row>

<component-slot-row name="icon">

`pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-dimmed`

</component-slot-row>

<component-slot-row name="iconGlyph">

`h-4 w-4`

</component-slot-row>

<component-slot-row name="input">

`peer w-full rounded-md border-0 appearance-none placeholder:text-dimmed disabled:cursor-not-allowed disabled:opacity-75 transition-colors px-2.5 py-1.5 text-base/5 md:text-sm gap-1.5 text-highlighted bg-default ring ring-inset ring-accented outline-primary/25 focus-visible:outline-3 focus-visible:ring-primary`

</component-slot-row>

<component-slot-row name="label">

`pointer-events-none absolute top-1/2 left-3 origin-left -translate-y-1/2 rounded-md border border-transparent bg-default px-1 transition-colors`

</component-slot-row>
</component-slots>

## Accessibility

The `<input>` receives `:aria-label="label"`, so the label text is its
accessible name, and `useId()` gives each instance a collision-free `id` that the
visual `<label>` points at with `for`. The visible label is
`aria-hidden="true"` — it is presentation, because the name is already carried by
`aria-label` — and the icon wrapper is `aria-hidden` too, so neither is announced
twice.

The one real limitation: `inheritAttrs: false` with `$attrs` bound to the
wrapping `<div>` means **type, autocomplete, name, required, inputmode
and aria-describedby cannot reach the input**. The element is hardcoded to
`type="text"`, so an email or tel field needs `MAnimatedInput` replaced with
something that exposes `type`, and there is no slot or prop for help text or an
error message.

`prefers-reduced-motion` is read through a `matchMedia` listener that is attached
on mount and removed on unmount. Under reduce the label transform is applied as a
plain inline `transform` with a zero-duration transition, so the label still
floats — it just arrives instantly rather than sliding.

## Performance

- One `MediaQueryList` listener for the whole component, added on mount and
removed on unmount. No `requestAnimationFrame`, no timers, no resize observers.
- Only `borderColor`, `color`, `scale` and `y` are animated. The label's movement
is `transform`, and the input's border change is a paint, so nothing triggers
layout during the float.
- `:initial="false"` on the label means an already-focused or already-filled field
renders in its final position on mount instead of sliding up from centre on the
first frame.
- The label's target transform is a computed plain object rather than a stored
spring value, so reversing direction mid-flight re-targets from the current
position instead of restarting.
- The label is `absolute` and `pointer-events-none`, and the input is a sibling
rather than an ancestor, so focusing the field never re-renders the input.
- `isFloating` depends on `val` and `isFocused` only — it does not read the DOM,
so no layout is forced to decide whether the label is up.

<tip>

The floated label reads as cut out because it is painted with the input's own
`bg-default` at `z-index: 2`. On any other surface the input's ring shows through
the gap. Give the wrapper the same background, or pass the same background into
`label-class-name`, or the label will look like it is sitting on top of the border
rather than in it.

</tip>


## Sitemap

See the full [sitemap](https://nuxt-motion-block.pages.dev/sitemap.md) for all pages.
