DEV Community

Cover image for Vue Enterprise Patterns - How to Build Components With Design System Variants And Share Them Using Variant Style Provider
Thomas Findlay
Thomas Findlay

Posted on

Vue Enterprise Patterns - How to Build Components With Design System Variants And Share Them Using Variant Style Provider

This article was originally published at The Road To Enterprise.


Many projects, especially larger ones, have a design system that serves as a guide on how components should look and feel. Let's take buttons as an example. Below you can see a quick design for different button variants done in Figma.

Button variants

Buttons can have different variants, such as primary and secondary, for main actions on a website. There can also be variants like warning or danger, which are supposed to make a user cautious about the action they are about to undertake. There are multiple ways in which variants can be implemented. I want to share with you how this can be achieved in an efficient and clean manner using CSS variables and modules.

In this tutorial, we are going to cover:

  • How to create Button and Tag components with variants based on a design system.
  • How to take advantage of CSS variables and CSS modules to implement component variants.
  • How to share variant styles between multiple components using a VariantStyleProvider component.

Project setup

You can find the GitHub repository with a full code example here. I have used Vite to scaffold a new project for this tutorial. If you would like to follow this tutorial, you can create a new project with Vite by running one of the commands shown below:

With NPM:

$ npm init @vitejs/app
Enter fullscreen mode Exit fullscreen mode

With Yarn:

$ yarn create @vitejs/app
Enter fullscreen mode Exit fullscreen mode

After creating the project, install dependencies and start the dev server.


Let's start with creating a new BaseButton.vue component.

src/components/BaseButton.vue

<template>
  <button :class="[$style.baseButton]" v-bind="$attrs">
    <slot />
  </button>
</template>

<script>
export default {
  props: {
    variant: {
      type: String,
      validators: value =>
        ['primary', 'secondary', 'warning', 'danger'].includes(value),
    },
  },
}
</script>

<style module>
.baseButton {
  border: none;
  background: transparent;
  padding: 0.6rem 1rem;
}
</style>
Enter fullscreen mode Exit fullscreen mode

It's a very simple component that has one prop only - variant. Now, let's replace the content of the App.vue component and add four buttons for each variant.

src/App.vue

<template>
  <div :class="$style.container">
    <BaseButton variant="primary"> Primary </BaseButton>
    <BaseButton variant="secondary"> Secondary </BaseButton>
    <BaseButton variant="warning"> Warning </BaseButton>
    <BaseButton variant="danger"> Danger </BaseButton>
  </div>
</template>

<script>
import BaseButton from './components/BaseButton.vue'
export default {
  components: {
    BaseButton,
  },
}
</script>

<style module>
.container {
  width: 300px;
  margin: 2rem auto;
  display: grid;
  gap: 1.5rem;
  grid-template-columns: repeat(4, 1fr);
  justify-content: center;
}
</style>
Enter fullscreen mode Exit fullscreen mode

As you can see above, we render four BaseButton components for each variant: primary, secondary, warning, and danger.

Below is an image showing how the buttons should look like at the moment. The primary one is focused, so there is a default outline colour shown.

Default buttons for each variant

I know it's nothing fancy yet, but we will get there. We have a basic setup, so let's add CSS variables and styles for each of the variants in the BaseButton component. Note that we are not using scoped styles (<style scoped>). Instead, we use CSS modules: <style module>. Any styles defined this way are exposed to the component under the $style property.

CSS variables and BaseButton component variants

First, create a new file called variables.css and add the CSS variables shown below.

src/styles/variables.css

:root {
  --primary-text-color: #eff6ff;
  --primary-bg-color: #1d4ed8;
  --secondary-text-color: #1d4ed8;
  --secondary-bg-color: #eff6ff;
  --warning-text-color: #c2410c;
  --warning-bg-color: #fff7ed;
  --danger-text-color: #b91c1c;
  --danger-bg-color: #fee2e2;
}
Enter fullscreen mode Exit fullscreen mode

Import the variables.css file in the main.js file, as the components in the application will need to be able to access them.

src/main.js

import { createApp } from 'vue'
import './styles/variables.css'
import App from './App.vue'

createApp(App).mount('#app')
Enter fullscreen mode Exit fullscreen mode

Next, we need to update the BaseButton component. There are three things we need to do:

  1. Add a variant style based on the variant prop.
  2. Add color and background-color properties to the baseButton class.
  3. Add CSS variables for each variant class.

src/components/BaseButton.vue

First, change classes that are passed to the button element. Besides the baseButton class, it should also receive an appropriate variant class. This class will be determined by the variant prop.

<template>
  <button :class="[$style.baseButton, $style[variant]]" v-bind="$attrs">
    <slot />
  </button>
</template>
Enter fullscreen mode Exit fullscreen mode

Next, we add color and background-color properties to the baseButton class. These properties will have --btn-text-color and --btn-bg-color variables for values. These will be set by each variant class and will correspond to the CSS variables defined in the variables.css file.

<style module>
.baseButton {
  border: none;
  padding: 0.6rem 1rem;
  color: var(--btn-text-color);
  background-color: var(--btn-bg-color);
}

.primary {
  --btn-text-color: var(--primary-text-color);
  --btn-bg-color: var(--primary-bg-color);
}

.secondary {
  --btn-text-color: var(--secondary-text-color);
  --btn-bg-color: var(--secondary-bg-color);
}

.warning {
  --btn-text-color: var(--warning-text-color);
  --btn-bg-color: var(--warning-bg-color);
}

.danger {
  --btn-text-color: var(--danger-text-color);
  --btn-bg-color: var(--danger-bg-color);
}
</style>
Enter fullscreen mode Exit fullscreen mode

Great, each button rendered by the App.vue component should now have appropriate colours applied, as shown in the image below.

Button components with applied variants

If you would like, you can add more variables for different states, such as hover, active, or disabled.

We have achieved the first objective. We now have a button component that supports multiple variants. But what if we need to create another component that also should have support for the same variants? For instance, a tag component? Let's have a look at how we can handle it.

BaseTag component with variants

Let's start with creating a BaseTag.vue component and updating the App.vue component to render tags for each variant.

src/components/BaseTag.vue

<template>
  <div :class="[$style.baseTag, $style[variant]]" v-bind="$attrs">
    <slot />
  </div>
</template>

<script>
export default {
  props: {
    variant: {
      type: String,
      validators: value =>
        ['primary', 'secondary', 'warning', 'danger'].includes(value),
    },
  },
}
</script>

<style module>
.baseTag {
  border: none;
  border-radius: 1.5rem;
  padding: 0.6rem 1rem;
  color: var(--tag-text-color);
  background: var(--tag-bg-color);
}

.primary {
  --tag-text-color: var(--primary-text-color);
  --tag-bg-color: var(--primary-bg-color);
}

.secondary {
  --tag-text-color: var(--secondary-text-color);
  --tag-bg-color: var(--secondary-bg-color);
}

.warning {
  --tag-text-color: var(--warning-text-color);
  --tag-bg-color: var(--warning-bg-color);
}

.danger {
  --tag-text-color: var(--danger-text-color);
  --tag-bg-color: var(--danger-bg-color);
}
</style>
Enter fullscreen mode Exit fullscreen mode

src/App.vue

<template>
  <div :class="$style.container">
    <BaseButton variant="primary"> Primary </BaseButton>
    <BaseButton variant="secondary"> Secondary </BaseButton>
    <BaseButton variant="warning"> Warning </BaseButton>
    <BaseButton variant="danger"> Danger </BaseButton>
  </div>
  <div :class="$style.container">
    <BaseTag variant="primary"> Primary </BaseTag>
    <BaseTag variant="secondary"> Secondary </BaseTag>
    <BaseTag variant="warning"> Warning </BaseTag>
    <BaseTag variant="danger"> Danger </BaseTag>
  </div>
</template>

<script>
import BaseButton from './components/BaseButton.vue'
import BaseTag from './components/BaseTag.vue'
export default {
  components: {
    BaseButton,
    BaseTag,
  },
}
</script>
Enter fullscreen mode Exit fullscreen mode

After these updates, you should see four buttons and four tags on the screen, as shown in the image below.

Button components with applied variants

It works, right? However, did you spot how similar BaseButton and BaseTag components are? They are almost the same, and we have quite a bit of code duplication for styles. There is also a different problem. What if we would have more than two components with variants, let's say twenty, and then later on we would need to introduce more variants or update existing ones to different CSS variables? Every single component that has variants would need to be updated separately. We can solve these problems by taking variant styles out of the BaseButton and BaseTag components. Instead, we are going to create a new component that will manage variants and pass appropriate styles via slots.


Vue - The Road To Enterprise

Do you want to learn advanced patterns, techniques, and best practices for the development of Vue applications? Check out Vue - The Road To Enterprise, the most advanced Vue book written.


How to share variant styles with a VariantStyleProvider component

A really great thing about CSS modules is that we can easily pass and compose styles. That's exactly what we will do now. Starting with creating a new component called VariantStyleProvider.vue. This component will do 3 things:

  1. Accept a variant prop
  2. Define variant styles for color and background-color properties
  3. Render a slot and pass baseVariant and an appropriate variant class to it.

src/components/VariantStyleProvider.vue

<template>
  <slot :variantStyle="[$style.baseVariant, $style[variant]]" />
</template>

<script>
export default {
  props: {
    variant: {
      type: String,
      validators: value =>
        ['primary', 'secondary', 'warning', 'danger'].includes(value),
    },
  },
}
</script>

<style module>
.baseVariant {
  color: var(--variant-text-color);
  background-color: var(--variant-bg-color);
}

.primary {
  --variant-text-color: var(--primary-text-color);
  --variant-bg-color: var(--primary-bg-color);
}

.secondary {
  --variant-text-color: var(--secondary-text-color);
  --variant-bg-color: var(--secondary-bg-color);
}

.warning {
  --variant-text-color: var(--warning-text-color);
  --variant-bg-color: var(--warning-bg-color);
}

.danger {
  --variant-text-color: var(--danger-text-color);
  --variant-bg-color: var(--danger-bg-color);
}
</style>
Enter fullscreen mode Exit fullscreen mode

Due to the fact that the VariantStyleProvider component is now handling variant styles, we will be able to remove them from the BaseButton and BaseTag components. Besides removing the styles, we also need to import the VariantStyleProvider and use the variantStyle that is passed from it. Below you can see the final versions of both components.

src/components/BaseButton.vue

<template>
  <VariantStyleProvider :variant="variant">
    <template #default="{ variantStyle }">
      <button :class="[$style.baseButton, variantStyle]" v-bind="$attrs">
        <slot />
      </button>
    </template>
  </VariantStyleProvider>
</template>

<script>
import VariantStyleProvider from './VariantStyleProvider.vue'

export default {
  components: {
    VariantStyleProvider,
  },
  props: {
    variant: {
      type: String,
      validators: value =>
        ['primary', 'secondary', 'warning', 'danger'].includes(value),
    },
  },
}
</script>

<style module>
.baseButton {
  border: none;
  padding: 0.6rem 1rem;
}
</style>
Enter fullscreen mode Exit fullscreen mode

src/components/BaseTag.vue

<template>
  <VariantStyleProvider :variant="variant">
    <template #default="{ variantStyle }">
      <div :class="[$style.baseTag, variantStyle]" v-bind="$attrs">
        <slot />
      </div>
    </template>
  </VariantStyleProvider>
</template>

<script>
import VariantStyleProvider from './VariantStyleProvider.vue'

export default {
  components: {
    VariantStyleProvider,
  },
  props: {
    variant: {
      type: String,
      validators: value =>
        ['primary', 'secondary', 'warning', 'danger'].includes(value),
    },
  },
}
</script>

<style module>
.baseTag {
  border: none;
  border-radius: 1.5rem;
  padding: 0.6rem 1rem;
}
</style>
Enter fullscreen mode Exit fullscreen mode

Both components are now much cleaner. We managed to remove unnecessary code duplication, as variant styles are managed in the VariantStyleProvider component. This pattern helps a lot with maintaining and extending variants. If you would need to update them or add new ones, you only have to focus on 2 files: variables.css and VariantStyleProvider.vue, instead of manually updating every single component where variants are used.

I hope you enjoyed this article. If you would like to learn more advanced patterns, techniques and best practices related to Vue, you might want to check out "Vue - The Road To Enterprise" book.

Top comments (0)