Building a First Component with Vue.js and Tailwind CSS

- Lire en français
Resources: huchet-vue

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:

bash
cd packages/huchet-vue && pnpm add -D vue vite

Then, create a src folder with the Button/Button.vue component:

bash
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:

vue
<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.

vue
<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:

vue
<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:

vue
<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:

vue
<template>
  <button :class="button({ variant })">
    <slot>
      {{ label }}
    </slot>
  </button>
</template>

Our component is ready to use:

html
<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:

bash
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:

bash
pnpm add -D @vitejs/plugin-vue

Edit the vite.config.ts file with the following configuration:

ts
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:

bash
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:

bash
touch src/index.ts

This file exports all library components:

ts
export * from './Button'

Create a new index.ts at the component level:

bash
touch src/Button/index.ts

This file serves as the public API, exposing selected elements to the user while keeping the internal structure hidden:

ts
export {
  type ButtonProps,
  default as Button
} from './Button.vue'

Finally, build the library:

bash
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:

json
{
  "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.

Profil Picture of Estéban

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!

Continue readingA Better Development Experience with TypeScript and TSConfig