ber
threshold: number
}
const currentMetric = ref<MetricConfig>({ value: 15, threshold: 100 })
### Step 2: Bind State to Visual Properties via CSS Custom Properties
Map the reactive value to CSS properties using `:style` or dynamic classes. For numeric interpolation, compute derived values that map to visual ranges. Using CSS custom properties (`--var`) decouples Vue’s reactivity from direct inline style manipulation, allowing the browser to interpolate values efficiently while keeping template bindings clean.
```vue
<script setup lang="ts">
import { ref, computed } from 'vue'
const metricValue = ref(35)
const isCritical = computed(() => metricValue.value > 80)
function updateMetric(newValue: number) {
metricValue.value = Math.min(100, Math.max(0, newValue))
}
</script>
<template>
<div class="metric-widget">
<div
class="indicator-track"
:class="{ 'state-critical': isCritical }"
:style="{ '--fill-width': `${metricValue}%` }"
/>
<button @click="updateMetric(metricValue.value + 15)">
Increment
</button>
</div>
</template>
The transition definition must target compositor-friendly properties. Avoid layout-triggering attributes like width, height, margin, or top. Instead, leverage pseudo-elements and CSS variables to isolate the animated layer.
.metric-widget {
display: flex;
flex-direction: column;
gap: 1rem;
font-family: system-ui, sans-serif;
}
.indicator-track {
position: relative;
width: 100%;
height: 8px;
background: #e5e7eb;
border-radius: 4px;
overflow: hidden;
}
.indicator-track::after {
content: '';
position: absolute;
left: 0;
top: 0;
height: 100%;
width: var(--fill-width);
background: #3b82f6;
border-radius: 4px;
/* GPU-accelerated transition */
transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.indicator-track.state-critical::after {
background: #ef4444;
transition: background 0.35s ease, width 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
Architecture Rationale
- CSS Custom Properties (
--fill-width): Vue’s reactivity system updates the inline style, but the browser’s CSS engine handles the interpolation. This prevents JavaScript from manually calculating intermediate frames, reducing main-thread pressure.
::after Pseudo-element: Animating the width of a pseudo-element avoids triggering layout recalculations on the parent container. The parent maintains a fixed footprint while the child scales visually, preserving document flow stability.
cubic-bezier Easing: Linear animations feel mechanical and draw unnecessary attention. The chosen curve (0.4, 0, 0.2, 1) mimics natural deceleration, reducing cognitive load during state changes and aligning with Material Design motion principles.
- Computed Guards: Using
computed for state flags (like isCritical) ensures CSS class toggles only fire when the actual condition changes. This prevents unnecessary style recalculations and keeps the transition engine focused on meaningful state shifts.
For scenarios requiring numeric interpolation (e.g., animated counters, progress values, or continuous sliders), Vue’s reactivity alone doesn’t interpolate numbers. Pair it with @vueuse/core:
import { useTransition } from '@vueuse/core'
const targetValue = ref(0)
const animatedValue = useTransition(targetValue, { duration: 400 })
This bridges the gap between discrete state updates and smooth visual progression without manual requestAnimationFrame management, while maintaining full compatibility with Vue’s dependency tracking.
Pitfall Guide
-
Animating Layout-Triggering Properties
Explanation: Properties like width, height, padding, margin, and top force the browser to recalculate the document flow on every frame. This causes layout thrashing, forces synchronous style recalculations, and drops frame rates significantly.
Fix: Replace with transform: scaleX() or transform: scaleY(). For height animations, use grid-template-rows: 0fr to 1fr transitions or max-height with explicit pixel limits. Always prioritize compositor-only properties.
-
Overusing transition: all
Explanation: Applying all forces the browser to evaluate every changed property, including those that shouldn’t animate. It also makes debugging difficult, increases style recalculation time, and can accidentally trigger expensive transitions on inherited properties.
Fix: Explicitly list animated properties: transition: transform 0.3s ease, opacity 0.3s ease, background-color 0.2s linear;. This gives the browser a clear optimization path and prevents unintended side effects.
-
Ignoring prefers-reduced-motion
Explanation: Users with vestibular disorders, motion sensitivity, or cognitive load constraints experience nausea or disorientation from automatic animations. Browsers respect this media query, but developers often bypass it for aesthetic consistency.
Fix: Always include a fallback that disables transitions or snaps to the final state:
@media (prefers-reduced-motion: reduce) {
.indicator-track::after { transition: none !important; }
}
-
Transition Stutter on Rapid State Changes
Explanation: When reactive state updates faster than the transition duration completes, the browser queues conflicting interpolation requests. This causes visual jitter, "snapping" back to previous values, or complete animation cancellation.
Fix: Implement state debouncing, or use @vueuse/core’s useTransition/useSpring which handle continuous value streams gracefully by interpolating toward the latest target without queue buildup.
-
Forgetting Initial State Synchronization
Explanation: If an animated property starts as auto, inherit, or 0 without an explicit baseline, the first transition may behave unpredictably, skip entirely, or cause a flash of unstyled content (FOUC).
Fix: Define explicit initial values in CSS or via inline styles. Ensure the reactive binding matches the CSS expectation before the first update. Avoid animating from 0 to auto; use explicit pixel or percentage values.
-
Mixing CSS Transitions with Vue <Transition>
Explanation: Applying CSS transitions to elements wrapped in <Transition> can cause conflicts during mount/unmount phases, as Vue injects its own animation classes (v-enter, v-leave) that may override or clash with your custom transition rules.
Fix: Reserve <Transition> strictly for DOM lifecycle events. Use pure CSS transitions for in-place state changes. Never nest them on the same element. If you need both, separate them into parent/child components.
-
Leaving will-change Permanently Applied
Explanation: will-change promotes elements to their own compositor layers. Applying it globally or leaving it on static elements consumes GPU memory, increases paint times, and degrades performance on low-end devices.
Fix: Apply will-change dynamically via JavaScript or Vue watchers only when the animation is about to trigger, and remove it immediately after completion. Prefer explicit property hints: will-change: transform, opacity;.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Simple property changes (opacity, scale, color) | CSS Reactive Transitions | Zero JS overhead, native browser interpolation | $0 |
| Numeric value interpolation (counters, progress) | @vueuse/core useTransition | Handles continuous streams, avoids manual RAF loops | ~2 KB gzipped |
| Complex choreography (staggered lists, physics) | JS Animation Library (GSAP/Framer) | Timeline control, sequencing, spring physics | 15–40 KB+ |
| DOM mount/unmount effects | Vue <Transition> | Framework-native, handles enter/leave classes | ~3 KB (built-in) |
Configuration Template
A production-ready composable for managing animated state with accessibility and performance safeguards:
// useAnimatedState.ts
import { ref, computed } from 'vue'
import { useMediaQuery } from '@vueuse/core'
export function useAnimatedState(initialValue: number, options = { duration: 350 }) {
const target = ref(initialValue)
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)')
const animatedValue = computed(() => {
return prefersReducedMotion.value ? target.value : target.value
})
function setAnimatedValue(newValue: number) {
target.value = Math.max(0, Math.min(100, newValue))
}
return {
target,
animatedValue,
setAnimatedValue,
isReducedMotion: prefersReducedMotion
}
}
Usage in component:
<script setup lang="ts">
import { useAnimatedState } from './useAnimatedState'
const { animatedValue, setAnimatedValue, isReducedMotion } = useAnimatedState(0)
</script>
<template>
<div
class="progress-bar"
:style="{ '--progress': `${animatedValue}%` }"
:class="{ 'motion-reduced': isReducedMotion }"
/>
</template>
Quick Start Guide
- Identify the State Mutation: Locate where reactive data changes in your component (e.g.,
score.value = 85).
- Map to CSS Variables: Bind the reactive value to a CSS custom property via
:style="{ '--var-name': ${value}% }".
- Define Compositor-Safe Transitions: In your stylesheet, apply
transition to transform, opacity, or custom properties. Avoid width/height.
- Inject Accessibility Guard: Add
@media (prefers-reduced-motion: reduce) to disable transitions for sensitive users.
- Validate Performance: Open DevTools → Performance → Rendering → Enable "Paint flashing". Verify no layout thrashing occurs during state updates.