Dynamic Styles

Drive appearance from state with :class, :style and v-bind() inside the style block, and know which to pick.

Syntax:class="{ name: condition }" | :style="{ prop: value }" | prop: v-bind(stateName)

Three mechanisms turn component state into styling: :class, :style, and v-bind() inside a <style> block. They differ in where the value lands.

Class and style bindings

:class is the shorthand for v-bind:class; the two are the same directive, so :class="x" and v-bind:class="x" compile identically. Pass an object to toggle names, { active: isActive }, or an array to combine them, [sizeClass, { active: isActive }]. A static class attribute on the same element is merged with the bound one rather than replaced. Put a class on a component tag and it merges onto that component's root element.

:style takes an object. Keys may be camelCase, fontSize, or quoted kebab-case, 'font-size', and an array merges several objects. Values are complete CSS strings, so you append the unit yourself.

v-bind() in CSS

Stable since Vue 3.2, v-bind() lets a declaration read component state: background: v-bind(barColor). It compiles to a hashed CSS custom property, set as an inline style on the component's root element and updated when the value changes. The rule itself stays static in the stylesheet. An expression, as opposed to a plain binding name, must be quoted: v-bind('theme.color').

Choosing

Discrete states belong in classes: a class is readable in devtools, assertable in a test, and overridable. Continuous values — a width, a translate, a colour from data — belong in :style or v-bind(). Setting one custom property and using it across several rules is usually tidier than binding each declaration.

Example

Example · vue
<!-- ProgressMeter.vue -->
<script setup lang="ts">
import { computed, ref } from 'vue'

const props = defineProps<{
  max: number
}>()

const value = ref(0)
const width = computed(() => `${(value.value / props.max) * 100}%`)
const isFull = computed(() => value.value >= props.max)
const barColor = computed(() => (isFull.value ? '#1b7f3b' : '#2f6fd0'))

function add(): void {
  value.value = Math.min(value.value + 1, props.max)
}
</script>

<template>
  <!-- :class is the shorthand for v-bind:class; both are the same directive -->
  <div class="meter" :class="{ 'meter-full': isFull }">
    <div class="bar" :style="{ width }"></div>
  </div>
  <button type="button" @click="add">Add one</button>
</template>

<style scoped>
.meter {
  height: 0.5rem;
  background: #e6e6e6;
  border-radius: 999px;
  overflow: hidden;
}

.bar {
  height: 100%;
  background: v-bind(barColor);
  transition: width 150ms ease;
}

.meter-full {
  outline: 1px solid v-bind(barColor);
}
</style>

When to use it

  • Toggle a selected, invalid or disabled appearance from reactive state.
  • Set a progress bar width or a drag offset from a computed number.
  • Theme a component from props by binding CSS custom properties once on its root.

More examples

Array binding for a variant prop

The standard shape for a component with visual variants: one class per tone, selected by a typed union prop. Because the classes exist in the stylesheet, a test can assert tone-error and a page-level override can win on specificity.

Example · vue
<!-- TagChip.vue -->
<script setup lang="ts">
const props = withDefaults(
  defineProps<{
    label: string
    tone?: 'neutral' | 'warn' | 'error'
    dense?: boolean
  }>(),
  { tone: 'neutral', dense: false }
)
</script>

<template>
  <!-- static class merges with the bound array; longhand: v-bind:class -->
  <span class="chip" :class="[`tone-${props.tone}`, { dense: props.dense }]">
    {{ props.label }}
  </span>
</template>

<style scoped>
.chip {
  display: inline-block;
  padding: 0.25rem 0.5rem;
  border-radius: 4px;
  font-size: 0.875rem;
}

.dense {
  padding: 0.125rem 0.375rem;
}

.tone-neutral { background: #eee; color: #333; }
.tone-warn { background: #fdf0d5; color: #7a4f00; }
.tone-error { background: #fdebeb; color: #8a1c1c; }
</style>

Building the class list in script

When the condition set grows past two or three entries, move it out of the template into a plain function and bind the result with :class. The function is directly unit-testable, and the template keeps one short expression.

Example · ts
// src/lib/rowClasses.ts
interface RowState {
  selected: boolean
  disabled: boolean
  depth: number
}

export function rowClasses(state: RowState): Record<string, boolean> {
  return {
    'row-selected': state.selected,
    'row-disabled': state.disabled,
    'row-nested': state.depth > 0
  }
}

console.log(rowClasses({ selected: true, disabled: false, depth: 2 }))
// Output: { 'row-selected': true, 'row-disabled': false, 'row-nested': true }

One custom property, many rules

Bind custom properties once, then consume them from as many rules as you like, including rules that apply to slot content. This keeps inline styles to a single element and lets child elements inherit the values.

Example · vue
<!-- DataGrid.vue -->
<script setup lang="ts">
import { computed } from 'vue'

const props = defineProps<{
  columns: number
  gapPx: number
}>()

const gridVars = computed(() => ({
  '--grid-columns': String(props.columns),
  '--grid-gap': `${props.gapPx}px`
}))
</script>

<template>
  <div class="grid" :style="gridVars">
    <slot />
  </div>
</template>

<style scoped>
.grid {
  display: grid;
  grid-template-columns: repeat(var(--grid-columns), minmax(0, 1fr));
  gap: var(--grid-gap);
}

.grid :slotted(*) {
  padding: calc(var(--grid-gap) / 2);
}
</style>

Discussion

  • Be the first to comment on this lesson.