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'
}
}
}
}
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';
...
}
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.
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>
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:
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;
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>
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>
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
2.5.13
NG pattern (functional) https://codesandbox.io/s/004vv2onw0
OK pattern (no functional) https://codesandbox.io/s/q9k5q8qq56
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>
It's occure Unknown custom element
.
Not occure Unknown custom element
and use child component
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 />
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>
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;
}
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:
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>
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)
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?
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:
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.
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.
Yeah those were all readings that inspired me to work as I do now, all great reads and styles.
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?
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:
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.
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