Skip to content
TutorialBeginner

Class and style bindings, without the mess

Vue's :class and :style accept strings, objects and arrays. Pick the shape that keeps the template readable, and move the decisions into a computed.

vuejs.school team22 May 20255 min readVue 3.5

Every app toggles classes from state: an active tab, a row with an error, a button that is busy. Vue's :class and :style accept objects and arrays as well as strings, so most of these bindings fit on one line. Pick the wrong shape, though, and the template fills up with logic that is hard to read.

Match the shape to the job: an object for toggles, an array when the class name comes from data, a computed once there are more than two conditions, and :style only for values the app works out while it runs. The second half answers a question that catches people out: when you put a class on a component, which element gets it?

Toggle classes with the object syntax

The object form maps a class name to a condition. The class is present when the value is truthy and absent when it is not. It sits next to a plain class attribute, and Vue merges the two.

components/StatusBadge.vue
<script setup lang="ts">
import { ref } from 'vue'

const isActive = ref(true)
const hasError = ref(false)
</script>

<template>
  <span class="badge" :class="{ 'badge-active': isActive, 'badge-error': hasError }">
    Status
  </span>
</template>

This renders <span class="badge badge-active">. Flip hasError to true and badge-error is added; nothing else moves. Class names with a hyphen go in quotes because they are object keys. This is the shape for toggles, and most class bindings are toggles.

Build class names from values with the array syntax

Sometimes the class name itself comes from data: a size, a colour, a variant. Then you want the array form, which takes a list of strings and adds them all. An object inside the array still toggles.

components/BaseButton.vue
<script setup lang="ts">
const props = defineProps<{
  size?: 'sm' | 'md' | 'lg'
  busy?: boolean
}>()
</script>

<template>
  <button type="button" :class="['btn', `btn-${props.size ?? 'md'}`, { 'btn-busy': props.busy }]">
    <slot />
  </button>
</template>

With size="lg" and busy set, the result is class="btn btn-lg btn-busy"; with no size, the fallback gives btn-md. Use the array when a name is built from a value, and keep the toggles as an object inside it. A ternary in the array, isActive ? 'on' : '', works but reads worse than { on: isActive }, so prefer the object.

Move three or more conditions into a computed

Templates are for showing state, not for working it out. Once a class binding has a third condition, or a condition that depends on two values, the inline version becomes a chain of ternaries that nobody can check at a glance. Here is a task row's template written that way:

components/TaskRow.vueBreaks
<template>
  <li :class="task.done ? 'row row-done' : task.dueSoon && !task.done ? 'row row-urgent' : task.assignee ? 'row row-assigned' : 'row'">
    {{ task.title }}
  </li>
</template>

Read it twice and you will spot the bug. A task that is both urgent and assigned gets row-urgent but not row-assigned, because a ternary chain stops at the first match.

The fix is a computed that returns an object. Each class gets its own condition, so one can no longer hide another, and Vue caches the result until a value it reads changes.

components/TaskRow.vueWorks
<script setup lang="ts">
import { computed } from 'vue'

interface Task {
  title: string
  done: boolean
  dueSoon: boolean
  assignee?: string
}

const props = defineProps<{ task: Task }>()

const rowClass = computed(() => ({
  'row-done': props.task.done,
  'row-urgent': props.task.dueSoon && !props.task.done,
  'row-assigned': Boolean(props.task.assignee),
}))
</script>

<template>
  <li class="row" :class="rowClass">{{ task.title }}</li>
</template>

The static row class moved to the plain attribute, so the computed only holds decisions. The urgent, assigned task now renders class="row row-urgent row-assigned".

Set inline styles with camelCase keys and units

:style takes an object too, with CSS properties as its keys. Write a property in camelCase, backgroundColor for background-color, or quote the kebab-case name.

Vue sets each value on the element's style property as it is and never adds a unit. A length needs its unit in the string: the browser ignores width: 50 but applies width: '50%'. A property without a unit, such as opacity, takes a plain number. In this progress bar, the width arrives as a number and is set as a string with %:

components/ProgressBar.vue
<script setup lang="ts">
const props = defineProps<{ value: number; color?: string }>()
</script>

<template>
  <div class="track" role="progressbar" :aria-valuenow="value" aria-valuemin="0" aria-valuemax="100">
    <div class="fill" :style="{ width: `${props.value}%`, backgroundColor: props.color ?? 'currentColor' }" />
  </div>
</template>

Vue adds a vendor prefix at runtime when the browser needs one for a property. :style also accepts an array of style objects, merged in order, so a later object wins when two set the same property.

Keep :style for values the app only knows while it runs: a width in percent, a translate in pixels, a colour the user picked. Everything that can be a class should be a class. The styling then stays in one stylesheet, where it can use hover states and media queries, which an inline style cannot.

Pass numbers to CSS through custom properties

A neat use of :style is to set a CSS custom property (a variable such as --progress) and let the stylesheet do the rest. The component passes one number; the CSS decides what it means.

components/Ring.vue
<script setup lang="ts">
const props = defineProps<{ progress: number }>()
</script>

<template>
  <div class="ring" :style="{ '--progress': props.progress }" />
</template>

<style scoped>
.ring {
  width: calc(var(--progress) * 1%);
  height: 4px;
  background: var(--brand, currentColor);
  transition: width 200ms;
}
</style>

calc(var(--progress) * 1%) turns the bare number into a percentage inside the CSS, so the script never builds strings. Vue passes a key that starts with -- to the browser unchanged, so write it in quotes exactly as the CSS reads it.

Vue has a shortcut for the same idea: v-bind() inside a <style> block. Writing width: v-bind('progress + "%"') compiles to a custom property that Vue sets on the component's root element and keeps up to date. Its name is generated and hashed, though, so in practice only that component's own styles use it. Set the variable yourself, as above, when child components or other stylesheets need to read it.

Where a class goes when you put it on a component

Put class="ml-2" on <StatusBadge /> and the class ends up on the badge's root <span>, merged with the badge class already there. This is Vue's fallthrough behaviour: any attribute you pass that the component did not declare as a prop or an emit is added to its single root element. :class and :style bindings fall through the same way, and so do listeners such as @click.

Place the class yourself when a component has two roots

A component with several root elements gets no automatic fallthrough, because Vue does not know which element should receive the class. The usual case is a <dt> and <dd> pair. A list of statistics is a <dl>, each item is a term and a value, and the component that renders one item has two root elements because a wrapping <div> would break the grid the parent lays them out on.

Pass that component a class and nothing is applied. In development Vue prints a warning instead: Extraneous non-props attributes (class) were passed to component but could not be automatically inherited because component renders fragment or text or teleport root nodes. Bind $attrs.class on the element that should carry the class:

components/StatItem.vue
<script setup lang="ts">
defineProps<{ label: string; value: string }>()
</script>

<template>
  <dt class="stat-label">{{ label }}</dt>
  <dd class="stat-value" :class="$attrs.class">{{ value }}</dd>
</template>

Now <StatItem label="Open" value="12" class="is-high" /> renders <dd class="stat-value is-high">, the <dt> keeps its own class only, and the warning goes away. There is no inheritAttrs: false here. A component with two roots never inherits automatically, and reading $attrs in the template is what tells Vue you have placed the attributes yourself.

Send attributes to an inner element instead of the root

Often the element that should receive the class is not the root. A text field wraps its <input> in a <label>, and a placeholder or an aria-describedby passed from outside belongs on the input. Turn automatic inheritance off with defineOptions, then bind $attrs where you want it.

components/TextField.vue
<script setup lang="ts">
defineOptions({ inheritAttrs: false })

defineProps<{ label: string }>()
const model = defineModel<string>()
</script>

<template>
  <label class="field">
    <span class="field-label">{{ label }}</span>
    <input v-model="model" v-bind="$attrs" />
  </label>
</template>

v-bind="$attrs" with no argument spreads every fallthrough attribute onto the input, listeners included. Now <TextField label="Email" class="wide" placeholder="you@example.com" /> puts wide and the placeholder on the input, and the label keeps its own class.

The template always sees the latest $attrs. If the component's script needs one of these values, in a computed or a watch, declare it as a prop instead: the Vue docs on fallthrough attributes say the attributes object is not reactive and recommend a prop when you need reactivity.

Check it stuck3 questions from the Class and style bindings quiz.
Practice now
Was this helpful?

Keep reading