skip to content

A Vue 3 `<BaseInput>` renders an `<input>` inside a root `<label>`; why do a parent's placeholder and @focus miss the input, and how do you fix it?

level: seniorimportance: should knowfreq 48%

answer

  1. which element is the root
  2. focus does not bubble
  3. turn off automatic inheritance
  4. defineOptions in 3.3
  5. v-bind the attrs object

basics

~10 s

Fallthrough attributes land on the root label, where placeholder does nothing and focus never fires. Set inheritAttrs: false with defineOptions and bind v-bind="$attrs" on the input, deciding explicitly where class goes.

solid answer

~40 s

Undeclared attributes fall through to the component's **root**, which here is the `<label>`: `placeholder` is meaningless on a label, and a `focus` listener on it never fires for the input because `focus` does not bubble. The fix is to opt out and place them yourself: `defineOptions({ inheritAttrs: false })` (Vue 3.3+) and `v-bind="$attrs"` on the `<input>`. In Vue 3 `$attrs` also carries `class` and `style`, so they move to the input too; if the consumer's class should style the label, bind `$attrs.class` on the label and the rest on the input. Once inheritance is off, forgetting the `v-bind` drops the attributes silently, with no warning.

code

vue · 22 lines
vue
<!-- BaseInput.vue -->
<script setup lang="ts">
import { useAttrs } from 'vue'

defineOptions({ inheritAttrs: false })
defineProps<{ label: string }>()

const attrs = useAttrs()

// Called from the template, so it reads the current attrs on every render.
function inputAttrs() {
  const { class: _cls, style: _style, ...rest } = attrs
  return rest
}
</script>

<template>
  <label class="field" :class="$attrs.class" :style="$attrs.style">
    <span>{{ label }}</span>
    <input v-bind="inputAttrs()" />
  </label>
</template>

go deeper

for a junior

Recall that undeclared attributes go to the root element and that inheritAttrs: false plus v-bind="$attrs" moves them elsewhere.

for a middle

Explain why placeholder and focus fail on a label root, how defineOptions sets inheritAttrs, and that Vue 3 includes class and style in $attrs.

for a senior

Diagnose the leak from the rendered DOM, decide deliberately where class goes, and cover the silent-loss trap with a DOM-level component test.

for a principal

Set a library rule: wrappers whose root is not the interactive element disable inheritance and forward to it, with the class placement documented.

## The symptom A form library ships a `<BaseInput>` that renders a label and an input together: - the template root is `<label class="field">`, containing a `<span>` for the text and the `<input>` - consumers write `<BaseInput label="Email" placeholder="[email protected]" autocomplete="email" @focus="trackFocus" />` - the input shows no placeholder, the browser ignores `autocomplete`, and `trackFocus` never runs Nothing is broken in Vue; the component is behaving exactly as its template says. ## Why the attributes miss the input Only `label` is declared with `defineProps`. Everything else is a **fallthrough attribute**, and with the default `inheritAttrs: true` Vue applies fallthrough attributes to the **single root element**, the `<label>`: 1. `placeholder` and `autocomplete` are set on the label, where they have no effect. 2. The `@focus` listener is attached to the label. Focus moves to the input, and the native `focus` event **does not bubble**, so the label's listener never sees it. 3. `class` and `style` from the consumer style the label. That part may even be what you want, which is why the bug hides in review. ## The fix: stop inheriting, bind explicitly Two steps: 1. **Turn off automatic inheritance.** In `<script setup>` use `defineOptions({ inheritAttrs: false })`, available since Vue 3.3; before 3.3 the option lived in a separate plain `<script>` block that exports `inheritAttrs: false`. 2. **Bind `$attrs` where it belongs.** `v-bind` without an argument spreads an object onto an element, so `<input v-bind="$attrs" />` applies every fallthrough attribute and listener to the input. Listeners arrive as `onFocus`, `onBlur` and so on, and `v-bind` attaches them as native listeners. ## Deciding where class and style go In Vue 3, `$attrs` includes `class` and `style`. With a plain `v-bind="$attrs"` on the input, a consumer's `class="w-full"` now sizes the input, not the label. Choose deliberately: | Consumer's class should style | Template approach | |---|---| | the input | `v-bind="$attrs"` on the input, nothing on the label | | the whole field (label) | `:class="$attrs.class"` and `:style="$attrs.style"` on the label, the other keys on the input | | both, with separate APIs | declare an explicit prop such as `inputClass` and leave `class` on the label | When splitting, read `$attrs` during render, for example through a small function called from the template, rather than destructuring `useAttrs()` once at the top of `<script setup>`. `setup` runs once and the attrs object is not reactive, so a destructured copy would go stale. ## Traps after the fix - **Silent loss.** With `inheritAttrs: false`, Vue never warns about unbound attributes. Forget the `v-bind` and every consumer attribute disappears quietly. - **Declared interface first.** Props and events you declare are removed from `$attrs`, so they are not spread onto the input; forward them yourself if the input needs them. - **Test it at the DOM level.** A component test that mounts `<BaseInput placeholder="x" />` and asserts the input's `placeholder` attribute catches regressions when someone restructures the template. ## Checklist for any wrapper component The BaseInput case generalises to every component whose root is not the element consumers care about, such as a select inside a styled container or a button inside a tooltip trigger: 1. Identify the **interactive element** that native attributes and listeners should reach. 2. Decide whether consumer `class` and `style` describe the **outer box** (layout, width, margin) or the **inner control** (text colour, border). 3. Disable inheritance with `defineOptions({ inheritAttrs: false })` and bind `$attrs`, or the split you chose, exactly once. 4. Keep the declared interface small: only values the wrapper itself uses become props; everything native passes through. | Symptom in the DOM | Likely cause | |---|---| | attributes on the outer element | inheritance still on, root is the wrapper | | attributes nowhere | inheritance off, `$attrs` never bound | | class on the input, layout broken | Vue 3 `$attrs` carries `class` along with everything else | Keeping this checklist in review catches the leak before consumers notice missing placeholders or dead listeners. ## Why this is a design decision, not a bug fix Choosing where attributes land defines the wrapper's contract. Consumers of a form field expect native input attributes (`name`, `required`, `autocomplete`, `aria-describedby`) to reach the input, so wrappers whose root is not the interactive element should almost always disable inheritance and forward to that element.

  • Why is the @focus listener never called when it lands on the label, even though the input is inside it?
    The native `focus` event does not bubble, so a listener on an ancestor never receives the input's focus. `focusin` bubbles, but the fix is still to put the consumer's listener on the input with `v-bind="$attrs"` rather than rely on a different event name.
  • Before Vue 3.3, how did a script setup component disable attribute inheritance?
    By adding a second, plain `<script>` block next to `<script setup>` whose default export sets `inheritAttrs: false`. `defineOptions` in 3.3 made that extra block unnecessary.
  • With inheritAttrs: false in Vue 3, does the consumer's class still reach the root automatically?
    No. In Vue 3 class and style are ordinary members of `$attrs`, so disabling inheritance stops them too. You must bind them yourself, for example `:class="$attrs.class"` on the root.

saying these in an interview costs you the question

  • Setting inheritAttrs: false makes Vue warn when $attrs is left unbound.
  • With inheritAttrs: false, class and style still apply to the root in Vue 3.
  • A focus listener on the label will fire when the inner input gains focus.
  • Declared props are included in $attrs and get spread onto the input.
  • Destructuring useAttrs() once in script setup keeps the split values current.