Recently I was asked about how I would go about creating style variations for a Vue component, and though it seems like a simple question, I decided to delve further and found multiple ways to approach this.
In this article I will talk about four common possibilities, this should not be considered a complete list, but a good starting point.
Table of Contents
2. Dynamic Style (v-bind() + reactive data)
1. Props + :class
Button.vue
<template>
<button :class="['base-button', variantClass]">
<slot />
</button>
</template>
<script setup lang="ts">
import { computed } from "vue";
type Variant = 'primary' | 'secondary' | 'error'
const props = defineProps<{
variant?: Variant
}>()
const variantClass = computed(() => `button-${props.variant ?? 'primary'}`)
</script>
<style scoped>
.base-button {
padding: 1rem;
border: none;
margin-bottom: 1rem;
}
.button-primary {
background-color: blue;
color: white;
}
.button-secondary {
background-color: gray;
color: black;
}
.button-error {
background-color: red;
color: white;
}
</style>
In this example we are passing the variant
prop to the bound class
of this simple button component. The passed prop string is used to construct a CSS class name via a computed
property inside the component.
Example.vue
Here's an example demonstrating how this variant
prop would be passed to the component:
<script setup lang="ts">
import Button from "./components/Button.vue"
</script>
<template>
<div class="variant-example">
<h2>Props + :class</h2>
<p>Using props to determine button styles with computed classes.</p>
<!-- No variant passed, as a default we will apply the primary style -->
<Button>Primary Variant</Button>
<!-- Variant of 'secondary' passed -->
<Button variant='secondary'>Secondary Variant</Button>
<!-- Variant of 'error' passed -->
<Button variant='error'>Error Variant</Button>
</div>
</template>
Using TypeScript in this example, we can ensure that the user of this component can only use three possible variations: primary
, secondary
, error
, any other string that doesn't match these will throwing a TS error to the developer.
Pros
Simplicity
Very easy to understand and implement.
Ideal for beginners and small to medium-sized projects.
Clear API
-
Props make the API of the component explicit:
<BaseButton variant="primary" />
Flexible
-
You can conditionally combine multiple classes or even switch between classes and inline styles as needed:
:class="[baseClass, variantClass, { disabled: isDisabled }]"
Scoped Styling Works Well
- Works seamlessly with
<style scoped>
since you’re controlling class names at the component level.
No Build Step or External Setup Required
- Unlike CSS Modules or utility frameworks, no additional tooling or configuration is needed.
Cons
Can Get Verbose
-
As the number of variants grows (e.g., size, color, state, type), logic inside the template or computed properties can get messy:
:class="['btn', sizeClass, colorClass, { 'btn-disabled': disabled }]"
Not Very Scalable
Managing many combinations of variants manually becomes hard to maintain.
No namespacing or encapsulation like CSS Modules provide.
Harder to Reuse Across Projects
Styles are tightly coupled with class names defined in the component's own CSS.
You’d need to port both the logic and the styles.
Prone to Inconsistencies
- If class names are mistyped or not aligned with defined styles, you won’t get compile-time errors (especially without TypeScript + CSS Module support).
Limited Type Safety
- Variants passed in as props aren’t strongly typed unless you explicitly define types/enums. Mistakes like
variant="primray"
won’t be caught unless you manually guard against them.
2. Dynamic Style (v-bind() + reactive data)
Button.vue
<template>
<button class="base-button" @click="onButtonClick">
<slot />
</button>
</template>
<script setup lang="ts">
import { ref } from 'vue';
const props = defineProps<{
bgColor?: string;
}>();
const dynamicColor = ref<string>(props.bgColor || 'purple');
const onButtonClick = () => {
dynamicColor.value = dynamicColor.value !== 'orange' ? 'orange' : props.bgColor || 'purple';
}
</script>
<style scoped>
.base-button {
padding: 1rem;
border: none;
margin-bottom: 1rem;
background-color: v-bind(dynamicColor);
}
</style>
This example is Vue 3 specific. In this example instead of a variant
prop passed, we are passing specific style props, in this case bgColor
. Using v-bind
we are able to apply the passed prop into our CSS as a reactive property of background-color
. In this example clicking the button will alternate the background between purple and orange. This example also allows for a custom bgColor to be passed in via props.
Example.vue
<script setup lang="ts">
import Button from "./components/Button.vue"
</script>
<template>
<div class="variant-example">
<h2>Dynamic Style (v-bind() + reactive data)</h2>
<p>Using reactive state to change styles dynamically.</p>
<Button>Click to change background color</Button>
<!-- This example allows for a custom color of blue.
<Button bgColor="blue">Click to change background color</Button>
</div>
</template>
Pros
Encapsulation with Scoped Styles
You keep styling logic close to the component, and everything is self-contained.
Helps avoid leaking styles globally.
Clean API for Consumers
- Consumers can pass a prop (
bgColor
) to customize the initial style without touching internal logic.
Runtime Interactivity
- Enables interactive style changes (like color toggling) without needing to touch CSS classes or use
:style
bindings in the template.
Declarative and Readable
- Keeps template simple (no inline styles), and separates logic and style cleanly.
Cons
Limited Browser Support and Tooling
v-bind()
in CSS is relatively new (supported since Vue 3.2+), and not supported in all CSS tooling or browser devtools.Autocompletion, linting, and type-checking in style blocks may not work reliably.
Lack of Fine-Grained Control
- You can't easily apply conditional logic (like fallback styles, transitions, or multiple props) as you could with inline styles or computed class bindings.
Debugging is Harder
- Inspecting dynamically-bound styles in browser dev tools is more difficult than regular class-based or inline styles.
Reactivity Caveats
- While
v-bind()
is reactive, changes might not trigger re-render if you’re not binding to a reactive source correctly (like a prop that isn’t watched).
Harder to Test or Override
- Dynamic styles inside
<style scoped>
are less flexible to override via parent styles or media queries.
3. CSS Modules
Button.vue
<template>
<button :class="[styles['base-button'], styles[`button-${variant}`]]">
<slot />
</button>
</template>
<script setup lang="ts">
defineProps<{
variant: string
}>();
import styles from './ButtonCSSModules.module.css'
</script>
Button.modules.css
.base-button {
padding: 1rem;
border: none;
margin-bottom: 1rem;
}
.button-primary {
background-color: blue;
color: white;
}
.button-secondary {
background-color: gray;
color: black;
}
.button-danger {
background-color: red;
color: white;
}
In this example we are taking advantage of CSS modules in order to determine the variant
. This can be used if you want to have more granular control of your components style and if you're not heavily relying on global design systems or utility frameworks like Tailwind.
Example.vue
<script setup lang="ts">
import Button from "./components/Button.vue"
</script>
<template>
<h2>CSS Modules</h2>
<p>Using CSS Modules for scoped styles.</p>
<Button variant="primary">Primary Variant</Button>
<Button variant="secondary">Secondary Variant</Button>
<Button variant="danger">Danger Variant</Button>
</template>
Pros
Scoped Styles by Default
- CSS Modules automatically scope class names locally, avoiding naming collisions, especially helpful in large apps or design systems.
Static Analysis & Type Safety (with tooling)
- When used with TypeScript and tools like
typed-css-modules
, you can get autocomplete and type checking for class names.
Explicit Mapping of Variants
- The syntax
styles[button-${variant}]
makes it easy to map style variations to a variant prop, keeping logic close to the template.
Cleaner Separation of Concerns
- Keeps style logic in CSS rather than inline in JS/TS. This can be more maintainable for teams with dedicated designers or CSS engineers.
Predictable and Maintainable
- You can clearly define all variants (.button-primary, .button-secondary, etc.) in the CSS file, making it easier to audit styles.
Cons
Limited Dynamic Styling
- You can’t dynamically change styles based on runtime conditions beyond class name switching. You’re limited to predefined class names.
Verbosity & Indirection
- The syntax
styles[button-${variant}]
is less readable than using plain class strings or utility-first CSS like Tailwind.
Harder to Share Logic Between Components
- If multiple components need similar style variations, you may end up duplicating class logic or manually importing the same CSS module across files.
No Theme Awareness (Out of the Box)
- CSS Modules don’t natively support themes, dark mode, or design tokens unless you bring in additional tooling (like CSS variables or PostCSS plugins).
Less Design System Flexibility
- Compared to utility-first or inline style systems (like Tailwind, Emotion, or :style bindings), CSS Modules are more rigid for highly dynamic or token-based design systems.
4. CSS Variables via Props
Button.vue
<template>
<button class="button" :style="buttonStyle">
<slot />
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
bgColor?: string;
color?: string;
}>()
const buttonStyle = computed(() => ({
'--bg-color': props.bgColor || 'white',
'--color': props.color || 'black',
}))
</script>
<style scoped>
.button {
padding: 1rem;
color: var(--color);
border: none;
margin-bottom: 1rem;
background-color: var(--bg-color);
}
</style>
This example is similar to example #2, except in this instance CSS variables are bound to the style
prop of the button. This exposes the variables to the CSS in the style
tag which allows the button to have a custom color
and background-color
value. Unlike example #2 we are not binding the values, so changing them dynamically would not be as straightforward.
Example.vue
<script setup lang="ts">
import Button from "./components/Button.vue"
</script>
<template>
<div class="variant-example">
<h2>Dynamic Style (v-bind() + reactive data)</h2>
<p>Using reactive state to change styles dynamically.</p>
<Button bgColor="lightblue">Light Blue Background</Button>
<Button bgColor="lightgreen">Light Green Background</Button>
<Button bgColor="lightcoral">Light Coral Background</Button>
</div>
</template>
Pros
Dynamic Styling with CSS Separation
Props drive the style without hardcoding styles in the style block.
Keeps visual design centralized in CSS rather than inline styles.
Easier Theming
- CSS variables (
--bg-color
,--color
) can easily be overridden or extended for theming (e.g. dark mode).
Scoped and Encapsulated
- Styles remain encapsulated in the component (
<style scoped>
), and variables don’t leak globally.
Clean Component API
- Consumers of the component don’t need to know about CSS internals, just pass props like
bgColor="blue"
.
Better Performance than Fully Inline Styles
- Only the variable values change, not the entire style block. This can be more efficient for rendering.
Scales Well
- As more style variants are needed, you can expand your CSS to include fallbacks or multiple CSS variables without cluttering logic.
Cons
Limited CSS Feature Support
- CSS variables can't be used in some places (e.g. media queries, pseudo-elements like ::before unless declared at a higher scope).
Harder Debugging
- Debugging styles might be trickier because computed styles rely on runtime JS-generated variables.
Can Become Implicit
- If too many variables are controlled via props, it might be hard to know which prop controls which CSS rule without checking both template and style sections.
Not Ideal for All Use Cases
- Doesn’t work well when styles depend on conditions that require multiple changes or logic (e.g. responsive layout shifts, dynamic units like
%
,vw
, etc.).
Slight Runtime Overhead
- Computed styles are reactive and recalculated when props change, not usually a problem, but not as static as class-based approaches.
Summary
So there you have it, four ways to implement style variations in Vue. Which approach you use will depend on your circumstances, whether it be a small stand-alone application, or a large application with a design system being used across many teams.
Top comments (0)