Building a First Component with Vue.js and Tailwind CSS
With our Vue.js component library set up, it's time to build our first component: a simple button. This exercise will help us understand how to structure our components.
We'll use Tailwind CSS and Tailwind Variants for styling. Tailwind Variants helps organize Tailwind CSS classes in a structured way to create and combine variants.
Setting Up the Package
Navigate to packages/huchet-vue
and install the necessary dependencies:
cd packages/huchet-vue && pnpm add -D vue vite
Then, create a src
folder with the Button/Button.vue
component:
mkdir -p src/Button && touch src/Button/Button.vue
Open the Button.vue
file and let's start writing our component.
Writing the Button Component
For our example, we'll create a button with two variants: solid and outline. The solid variant will feature a background color, and the outline variant will include a border.
To ensure class non-conflict and simplify refactoring, maintainability, readability, and structure, we'll use Tailwind Variants. We'll organize our classes into an object, and Tailwind Variants will apply the correct classes based on props.
Add a script
tag at the top of the Button.vue
file:
<script lang="ts">
import { tv, type VariantProps } from 'tailwind-variants'
</script>
Important
Do not add the setup
attribute. This omission is intentional.
The tv
function is a helper for organizing classes. It accepts an object with base
and variants
keys. The base
is a string of shared classes, whereas variants
is a record with variant keys and class values.
<script lang="ts">
// ...
const button = tv({
base: 'border-2 px-2.5 py-1.5 text-sm font-semibold focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
variants: {
variant: {
solid: 'border-transparent bg-blue-500 text-white hover:bg-blue-600 active:bg-blue-700',
outline: 'border-blue-500 text-blue-500 hover:bg-blue-50 active:bg-blue-100',
}
}
})
</script>
In the variants
key, you could also include more keys like size
with sm
and md
variants.
Next, extract the variants for use in our component props:
<script lang="ts">
// ...
type ButtonVariantProps = VariantProps<typeof button>
export interface ButtonProps {
label?: string
variant?: ButtonVariantProps['variant']
}
</script>
Then open a new script
tag with the setup
attribute:
<script setup lang="ts">
withDefaults(defineProps<ButtonProps>(), {
variant: 'solid',
})
</script>
We split the script
tag into two parts to enable named exports. The ButtonProps
interface is used in the defineProps
function and is exported so developers can extend it to create a new component. This detail is crucial for building a robust component library.
Add the component's template:
<template>
<button :class="button({ variant })">
<slot>
{{ label }}
</slot>
</button>
</template>
Our component is ready to use:
<Button variant="outline" label="Click me" />
However, it's not usable until we build our component library.
Building the Component Library
Next, we'll build the component library. Start by creating a vite.config.ts
file in the huchet-vue
package root:
touch vite.config.ts
This configuration instructs Vite how to transpile our Vue.js components into JavaScript that can be used in other projects. By default, Vite caters to web applications, not libraries, so we'll adjust it for our needs.
Install the necessary plugin:
pnpm add -D @vitejs/plugin-vue
Edit the vite.config.ts
file with the following configuration:
import Vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [Vue()],
build: {
lib: {
formats: ['es'],
name: 'huchet-vue',
fileName: (_, name) => `${name}.mjs`,
entry: {
index: resolve(__dirname, 'src/index.ts'),
},
},
rollupOptions: {
external: ['vue', 'tailwind-variants'],
},
},
})
We choose not to include Vue.js and Tailwind Variants in the final bundle for flexibility, allowing users to use their versions. This also avoid duplication in the final bundle if the user already has these dependencies.
Add them as peer dependencies:
pnpm add --save-peer vue tailwind-variants
Note
A peer dependency isn't installed by the package but should be present in the user's project. This flexibility is especially useful when a package may operate in varied contexts.
Create the src/index.ts
file, as referenced in vite.config.ts
:
touch src/index.ts
This file exports all library components:
export * from './Button'
Create a new index.ts
at the component level:
touch src/Button/index.ts
This file serves as the public API, exposing selected elements to the user while keeping the internal structure hidden:
export {
type ButtonProps,
default as Button
} from './Button.vue'
Finally, build the library:
pnpm run build
The library compiles under the dist
directory as index.mjs
.
Exposing the Components
Our component library is built, but to be usable as an npm package, we need to define some fields in the package.json
file. These fields provide crucial information to Node about how our package should be loaded:
{
"exports": {
".": {
"import": "./dist/index.mjs"
}
},
"main": "./dist/index.mjs"
}
Simple, right? The exports
field informs Node about the available imports for our package. In our case, we have only one import—the root of our package—and it is the index.mjs
file in the dist
folder. The main
field specifies the entry point of our package. Here, the entry point is the index.mjs
file located in the dist
folder.
Note
Since we have only one entry point, we could rely solely on the main
field. However, it is a good practice to define the exports
field to ensure compatibility with future versions of Node and to prepare for the support of multiple entry points. The exports
field is more modern and powerful than main
.
In the next article, we will add type definitions to our component library to provide a better developer experience.
Thanks for reading! My name is Estéban, and I love to write about web development.
I've been coding for several years now, and I'm still learning new things every day. I enjoy sharing my knowledge with others, as I would have appreciated having access to such clear and complete resources when I first started learning programming.
If you have any questions or want to chat, feel free to comment below or reach out to me on Bluesky, X, and LinkedIn.
I hope you enjoyed this article and learned something new. Please consider sharing it with your friends or on social media, and feel free to leave a comment or a reaction below—it would mean a lot to me! If you'd like to support my work, you can sponsor me on GitHub!