DEV Community

Maximilien Monteil
Maximilien Monteil

Posted on • Edited on

How to start Coding up your Design System in Vue

Imagine this, you create your web app for this new epic idea and you implement the core functionality. The design can come later you think.

Well later is now, you're gaining traction and getting feedback, the time has come to overhaul or completely recreate your design!

In this article I'll go over the tools and methods you can use to achieve just that.

I personally fell into this situation with my web app Mylo, a workout management application. As it grew and came into contact with users, issues arose with color contrast, inconsistencies, and more.

Fixing these issues can be described in two pillars:

  • Design System (how everything will look)
  • Implementation (how to apply the look)

(I focus on look here but understand that design is only about 20% look, the rest must be considerations about the user experience)

Going through this has taught me a lot but also made obvious how much more there is to learn. If you find any mistakes or have some tips please let me know :D

I'll be using TailwindCSS in this article but everything applies just as well without.

Table Of Contents

Pillar 1: Design System

The first step, which is all the rage these days, is having a design system. A design system is essentially a visual codebase of your software, and just like a codebase, it is a complete and specific description of what the application should look like under almost any circumstance.

And so, the more you look into what makes up a design system, the more it feels like an impossible task. A complete design system involves the colors, spacing rules, text styles, buttons, containers, branding, accessibility, and so much more.

The best and most exhaustive resource I've found is the Design System Checklist.

Therefore I feel more comfortable referring to what I have as a design library with loose guidelines. It works out because I can just refer to myself for any design questions ;)

So we'll be going over how to implement elements like buttons, icons, colors and a few input types.

Pillar 2: Implementation

Directory Structure

Coding up design elements is amazing for reusability and consistency but it isn't very useful if components are all across the app in random, hard to access locations. We want them organized.

I recommend putting the smallest/atomic elements into the src/components folder and then into their own subfolders (icons, inputs, buttons, etc.)
Compound components, built out of the smaller ones, can be placed into the src/layouts folder, again with their own subfolders.

Colors

Defining and enforcing colors is a good first step.

You generally have a clear discrete set of colors that your app allows, to bring them in you can either modify the TailwindCSS config file or add them as CSS variables.

Here is how it looks in TailwindCSS, I overwrote the default colors to enforce the use of the system colors but you can also extend the default theme and add your own colors.

// tailwind.config.js

module.exports = {
  theme: {
    colors: {
      white: '#FFFFFF',
      black: '#000000',
      gray: {
        default: '#EEEEEE',
        dark: '#5D5D5C',
        darker: '#414040'
      },
      green: {
        default: '#83E8BC',
        dark: '#56806C',
        darker: '#445F51'
      },
      red: {
        default: '#F25F5C',
        dark: '#C15450',
        darker: '#8B4440'
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

TailwindCSS Docs - Theme Configuration

If you're not using tailwind this can also be achieved using css variables like so:

/* src/App.vue */
:root {
  --white: '#FFFFFF';
  --black: '#000000';
  --gray: '#EEEEEE';
  --gray-dark: '#5D5D5C';
  --gray-darker: '#414040';
  ...
}
Enter fullscreen mode Exit fullscreen mode

Icons

First off I recommend using SVG icons because of how configurable they are. You can change their size without any quality loss, dynamically change their color, and their file size is generally smaller than an equivalent png/jpg.

Getting the actual SVGs can be done through the export options of design tools like Figma, Sketch, or Illustrator.

Once you have the files you can further optimize them with SVGO, there is a command line tool and a web based one.

GitHub logo svg / svgo

⚙️ Node.js tool for optimizing SVG files

GitHub logo jakearchibald / svgomg

Web GUI for SVGO

Both work automatically by pointing them to the file though the web version makes the available options more accessible. Make sure to have a look at the final result to make sure your icons still look fine.

Then we bring the icons into our Vue app, I used a method recommended in the Vue Cookbook. It's a system made by Sarah Drasner, the SVG queen, and you can find the link for it here.

To make it work with TailwindCSS, you'll need to make a couple of changes:

<template functional>
  <svg xmlns="http://www.w3.org/2000/svg"
    :width="props.size"
    :height="props.size"
    viewBox="0 0 16 16"
    :aria-labelledby="props.iconName"
    role="presentation"
    class="fill-current inline-block align-baseline"
    :class="[
      data.class,
      data.staticClass
    ]"
    style="margin-bottom: -2px;"
  >
    <title lang="en">{{ props.icon.replace(/-/g, ' ') }} icon</title>
    <component :is="injections.components[props.iconName]" />
  </svg>
</template>
Enter fullscreen mode Exit fullscreen mode

Since SVGs themselves are rather light, it felt like a lot of overhead to use full components, so I made some further changes to make use of functional components, you can check out my fork here:

GitHub logo MaxMonteil / vue-sample-svg-icons

An opinionated example of how to use SVG icons in a Vue.js application as functional components

Buttons

Initially I wanted to bring in buttons the same way as with icons, using Vue components but that ended up being deceptively complicated. The component had to work with buttons, links, or a router-link (using vue-router).

Supporting links was important for accessibility and semantics as links are meant to take you to another page whereas buttons should not.

As a solution I extracted the common classes into their own utilities in TailwindCSS, which in pure css is just a normal class rule.

Some examples:

@tailwind base;
@tailwind components;

.btn {
    @apply font-medium rounded align-bottom;
}

.btn-primary {
    @apply px-8 py-2;
}

.btn-secondary {
    @apply px-5 py-1;
}

.btn-white {
    @apply text-green-darker bg-white;
}

.btn-green {
    @apply text-white bg-green-dark;
}

@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

Text Inputs

For inputs we can use Vue components but there are a few things to take into consideration.

Our wrapper components need to be lightweight and transparent, we can do that by using functional components and attaching all attributes and event listeners.

I also took the chance to include the label into the component. It fits the design, is more accessible, and ensures I never forget them.

Start off with a BaseInput.vue component:

<!-- src/components/inputs/BaseInput.vue -->
<template functional>
  <label
    :ref="data.ref"
    class="text-sm leading-none font-medium"
    :class="props.makeGray ? 'text-gray-darker' : 'text-green-darker'"
  >
    {{ props.label }}
    <input
      type="text"
      :ref="data.ref"
      class="block mt-2 bg-white rounded w-full outline-none focus:shadow"
      :class="[
        data.class,
        data.staticClass,
      ]"
      :style="[
        data.style,
        data.staticStyle,
      ]"
      v-bind="data.attrs"
      v-on="{ ...listeners, input: e => listeners.input(e.target.value) }"
    >
  </label>
</template>
Enter fullscreen mode Exit fullscreen mode

And here is an example use of BaseInput.vue:

<!-- src/components/inputs/InputLarge.vue -->

<template functional>
  <component
    :is="injections.components.BaseInput"
    :label="props.label"
    :makeGray="props.makeGray"
    class="font-medium text-3xl text-black pl-4 py-px"
    :class="props.makeGray ? 'bg-gray' : 'bg-white'"
    v-bind="data.attrs"
    v-on="listeners"
  />
</template>

<script>
import BaseInput from '@/components/inputs/BaseInput'

export default {
  name: 'inputLarge',
  inject: {
    components: {
      default: {
        BaseInput
      }
    }
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode

Notice how we call the BaseInput.vue component. Surprisingly, imported components are not exposed in functional components when using the template format. So instead we place the imported components into injections. They could also placed into props if you prefer.

This method was brought up in this github issue:

Functional single file component with components option. #7492

Version

2.5.13

Reproduction link

NG pattern (functional) https://codesandbox.io/s/004vv2onw0

OK pattern (no functional) https://codesandbox.io/s/q9k5q8qq56

Steps to reproduce

I found can't use components option when functional single file component.

<template functional>
  <div>
    <some-children />
  </div>
</template>

<script>
import SomeChildren from "./SomeChildren"

export default {
  components: {
    SomeChildren
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode

It's occure Unknown custom element.

What is expected?

Not occure Unknown custom element and use child component

What is actually happening?

It's occure Unknown custom element


In workaround, it not occure when use Vue.component.

import Vue from "vue"
import SomeChildren from "./SomeChildren"
Vue.component("some-children", SomeChildren);

export default {}

// can use  <some-children />
Enter fullscreen mode Exit fullscreen mode

Radio Buttons

After all the elements we've done so far, Radio Buttons are not too different. The difference is that styling them can be more involved. While it is possible to use standard buttons instead, I wanted to use the default radio buttons, again for semantics and accessibility.

The trick I found was to use the behavior of labels wrapping radio buttons.
The buttons by themselves are small and hard to touch/click but if you wrap them in a label, clicking anywhere on the label box will also select the radio button.
Using this, I styled radio buttons by actually making the label look as I wanted and hiding the radio buttons inside the label.

<template functional>
  <div
    :ref="data.ref"
    :class="[
      data.class,
      data.staticClass,
    ]"
    :style="[
      data.style,
      data.staticStyle,
    ]"
  >
    <label
      v-for="label in props.labels"
      :key="label.value || label"
      class="relative px-3 py-1 rounded"
      :class="(label.value || label) === props.modelValue ? '...' : '...'"
    >
      <slot :label="label">
        {{ label.text || label }}
      </slot>
      <input
        :ref="data.ref"
        type="radio"
        class="sr-only"
        :value="label.value || label"
        v-on="{ ...listeners, input: e => listeners.input(e.target.value) }"
        :checked="(label.value || label) === props.modelValue"
      >
    </label>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

Be careful when hiding the radio button as it still needs to be visible to screen readers, tailwind offers a class for this, in standard css that looks like this:

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border-width: 0;
}
Enter fullscreen mode Exit fullscreen mode

As for the v-slot and all the ORs (||), I explain those in the next section about select inputs.

A really helpful resource that also goes over checkboxes:

Smashing Magazine - Creating Custom Inputs in VueJS

Select Input

The select component is a fun one to wrap both in terms of design and functionality.

Design wise it was surprising to discover how "hacky" it is to change the default downward arrow. There are a few ways to do it but the trick I went with is to remove the default style by setting appearance: none; and then bringing in my SVG of choice with the URL function of CSS.

To do something similar you will need to encode your SVG tag into a URL compatible string, I found this site to do just that:

URL Encoder for SVG

Then there are a few more positioning and spacing styles to place the icon where you want.

For functionality, the end user should retain control over how the drop-down values are displayed, the go to solution is to use scoped slots. With this method our component can support any array of values.

This is because the official Vue doc shows examples using a String Array and an Object Array to populate the select.

<template functional>
  <label
    class="text-sm font-medium text-green-darker"
  >
    {{ props.label }}
    <select
      :ref="data.ref"
      class="custom-arrow bg-no-repeat block mt-2 pl-2 pr-6 bg-white rounded text-black text-lg outline-none focus:shadow"
      :class="[
        data.class,
        data.staticClass,
      ]"
      :style="[
        data.style,
        data.staticStyle,
      ]"
      v-bind="data.attrs"
      v-on="{ ...listeners, input: e => listeners.input(e.target.value) }"
    >
      <option disabled value="">-</option>
      <option
        v-for="option in props.values"
        :value="option.value || option"
        :key="option.value || option"
      >
        <slot :option="option" />
      </option>
    </select>
  </label>
</template>

<script>
export default {
  name: 'inputSelect',
  props: {
    label: {
      type: String,
      require: true
    },
    values: {
      type: [Array, Object],
      require: true
    }
  }
}
</script>

<style scoped>
.custom-arrow {
  -moz-appearance: none;
  -webkit-appearance: none;
  appearance: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='10'%3E%3Cpath fill='%23000000' d='M7.443 9.057L5.229 6.843a.666.666 0 01.943-.942l2.214 2.214 2.199-2.199a.666.666 0 11.942.942l-3.142 3.143-.942-.944z'/%3E%3C/svg%3E");
  background-origin: border-box;
  background-position: top 35% right 0.5rem;
}
</style>
Enter fullscreen mode Exit fullscreen mode

These wrapper components also offer the chance to enforce best practices like a label for an input and an empty disabled first option for better iOS support.

Conclusion

Well you've reached the end, this covers all the components I brought in from my design library. It really just scratches the surface of what is possible and I couldn't hope to be fully exhaustive. Nonetheless, I hope this helped you out and encouraged you to tackle that redesign or even just start to design!

The next step after wrapping all these input fields would be to compose them into a form or other layout for reuse, these are some of the components you'd place into the src/layouts folder. You can expect a follow up article once I finish it up myself :D

Feel free to leave any comments below and if you have questions or want to follow up, you can find me on twitter @MaxMonteil

Top comments (7)

Collapse
 
charlesokwuagwu profile image
Charles

Very nice write up. Please do you have a GitHub sample one can get started with?
For me the hardest part of building an app is the page / site layout and structuring. do you also have any suggestions on that as well for vue app?

Collapse
 
maxmonteil profile image
Maximilien Monteil • Edited

Hey, thanks so much for reading it through, glad you enjoyed it!

So I don't currently have an example repo but I'm working on including these features into the todo app repo I have. When I finish that I'll let you know.

In the mean time, here is how I structure my apps:

- src
  - App.vue
  - components/
  |  - TheNavbar.vue    // A unique component for my app's navigation
  |  - BaseCard.vue     // Designed component for all UI cards
  |  - IconBase.vue     // as per the article
  |
  |  - icons/           // this folder is also like in the article
  |  |  - IconMoon.vue
  |  |  - ...
  |  
  |  - inputs/
  |  |  - BaseTextInput.vue   // the one that gets imported into others
  |  |  - InputLarge.vue      // uses BaseTextInput and customizes it

  - layouts/     // for compound components, those made up of the basic ones
  |  - forms/
  |  |  - BookForm.vue
  |  |  - AuthorForm.vue
  |  |  - ShelfForm.vue
  |
  |  - home/     // for compound components used on the home page only
  |  |  - ShelfDisplay.vue

  - pages/       // pages are actual URL endpoints (www.myapp.com/home)
  |  - Home.vue
  |
  |  - library/        // pages with subroutes can be nested
  |  |  - Index.vue
  |  |  - Form.vue     // (www.myapp.com/library/{ form: 'new-book'})
        // I dynamically choose which form to use with a route prop

  - router
  |  - index.js
  |  - modules/

  - store
  |  - index.js
  |  - modules/

  - utils/

  - api/

For the form components, the components are inside a <fieldset> tag and it is the final Page components that wrap them all in a <form> to avoid nested forms.

More on forms here: Vue.js - Forms, components and considerations - Medium

Pages generally only need to import compound components and that might be where I put the logic regarding that page, so forms would v-model to a value here.

The utils/ folder is for functions that are used across the app for specific purposes, like functions a function to calculate a currency conversion. They do all their work locally.

The api/ folder is for organizing calls to your database, or any other requests to a network.

This is a rough explanation of how I've come to organize my medium+ apps, its not final but it works so far.

Collapse
 
charlesokwuagwu profile image
Charles

This is really good.

Just curious, but have you looked into Atomic Design + TailwindCSS + functional Vue components, these pieces all seem like a good fit.

Your article has really given me much to think about.

Thanks again.

Thread Thread
 
maxmonteil profile image
Maximilien Monteil

Yeah those were all readings that inspired me to work as I do now, all great reads and styles.

Collapse
 
charlesokwuagwu profile image
Charles

Thanks for the response.

I looked at one of the documents you referenced:

markus.oberlehner.net/blog/reusabl...
tailwind-css/

Further reading led me to this: vuetensils.stegosource.com/

Would you considered this a better starting point for making base components?

Thread Thread
 
maxmonteil profile image
Maximilien Monteil

Did not know about this library, it is a very good starting point but I still think it misses the mark in a couple of ways, some of which are described in Markus' article:

  • The components aren't functional (that adds some bloat)
  • They aren't transparent (it doesn't bind classes and attributes)
  • Component classes are baked in which reduces flexibility
  • Uses mixins (that's fine but Vue 3 is already moving away from those)

These are the issues I would personally have using this library, but it does an amazing job following accessibility guidelines and keeping overall bundle size down.

Collapse
 
charlesokwuagwu profile image
Charles • Edited

slight correction:

v-on="{ ...listeners, input: e => listeners.input && listeners.input(e.target.value) }"

This variant avoids errors when you do not apply v-model in usage of the component