- Published on
Modern Frontend Development with Vue 3, TypeScript, Tailwind CSS, and Styled Components
- Authors
- Name
- Muhamad Riyan
- @muhamad-riyan
Introduction
Modern frontend development has evolved to embrace type safety, component-based architecture, and flexible styling solutions. In this guide, we'll explore how to build robust applications using Vue 3 with TypeScript, while leveraging both Tailwind CSS and Styled Components for a powerful styling approach.
Project Setup
First, let's create a new Vue 3 project with TypeScript support:
npm create vite@latest my-vue-app -- --template vue-ts
cd my-vue-app
npm install
npm install tailwindcss postcss autoprefixer
npm install vue-styled-components
npx tailwindcss init -p
Configure Tailwind CSS
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#f8fafc',
100: '#f1f5f9',
// ... other shades
900: '#0f172a',
},
secondary: {
// Custom color palette
DEFAULT: '#7c3aed',
dark: '#5b21b6',
}
},
spacing: {
'18': '4.5rem',
'72': '18rem',
}
},
},
plugins: [],
}
TypeScript Configuration
Type Definitions
// src/types/index.ts
export interface User {
id: string;
name: string;
email: string;
preferences: UserPreferences;
}
export interface UserPreferences {
theme: 'light' | 'dark';
notifications: boolean;
language: string;
}
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost';
export interface ButtonProps {
variant: ButtonVariant;
size: 'sm' | 'md' | 'lg';
disabled?: boolean;
loading?: boolean;
}
Vue Components with TypeScript
Component Setup
// src/components/Button.vue
<script setup lang="ts">
import { computed } from 'vue';
import type { ButtonProps } from '@/types';
import styled from 'vue-styled-components';
const props = withDefaults(defineProps<ButtonProps>(), {
variant: 'primary',
size: 'md',
disabled: false,
loading: false
});
const StyledButton = styled('button', {
variant: String,
size: String
})`
${props => props.variant === 'primary' && `
background-color: ${props.theme.primary};
color: white;
&:hover {
background-color: ${props.theme.primaryDark};
}
`}
${props => props.size === 'lg' && `
padding: 1rem 2rem;
font-size: 1.125rem;
`}
`;
const buttonClasses = computed(() => ({
'opacity-50 cursor-not-allowed': props.disabled,
'relative overflow-hidden': props.loading
}));
</script>
<template>
<StyledButton
:variant="variant"
:size="size"
:class="buttonClasses"
:disabled="disabled || loading"
>
<slot v-if="!loading" />
<span v-else class="flex items-center justify-center">
<svg class="animate-spin h-5 w-5 mr-2" viewBox="0 0 24 24">
<!-- Loading spinner SVG -->
</svg>
Loading...
</span>
</StyledButton>
</template>
Composable Functions
// src/composables/useTheme.ts
import { ref, computed } from 'vue';
import type { Ref } from 'vue';
export function useTheme() {
const isDark: Ref<boolean> = ref(false);
const theme = computed(() => ({
primary: isDark.value ? '#1e293b' : '#3b82f6',
primaryDark: isDark.value ? '#0f172a' : '#2563eb',
background: isDark.value ? '#1a1a1a' : '#ffffff',
text: isDark.value ? '#ffffff' : '#1a1a1a',
}));
const toggleTheme = () => {
isDark.value = !isDark.value;
};
return {
isDark,
theme,
toggleTheme,
};
}
Combining Tailwind and Styled Components
Creating a Design System
// src/styles/theme.ts
export const theme = {
colors: {
primary: {
light: '#93c5fd',
DEFAULT: '#3b82f6',
dark: '#1d4ed8',
},
// ... other colors
},
spacing: {
xs: '0.25rem',
sm: '0.5rem',
md: '1rem',
lg: '1.5rem',
xl: '2rem',
},
breakpoints: {
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
},
};
// src/styles/styled.d.ts
import 'vue-styled-components';
declare module 'vue-styled-components' {
export interface Theme {
colors: typeof theme.colors;
spacing: typeof theme.spacing;
breakpoints: typeof theme.breakpoints;
}
}
Component Implementation
// src/components/Card.vue
<script setup lang="ts">
import styled from 'vue-styled-components';
interface CardProps {
elevated?: boolean;
padding?: 'sm' | 'md' | 'lg';
}
const props = withDefaults(defineProps<CardProps>(), {
elevated: false,
padding: 'md'
});
const StyledCard = styled('div', {
elevated: Boolean,
padding: String
})`
background-color: ${props => props.theme.colors.background};
border-radius: 0.5rem;
transition: all 0.2s ease-in-out;
${props => props.elevated && `
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
&:hover {
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
}
`}
${props => `padding: ${props.theme.spacing[props.padding]}`}
`;
</script>
<template>
<StyledCard
:elevated="elevated"
:padding="padding"
class="border border-gray-200 dark:border-gray-700"
>
<slot />
</StyledCard>
</template>
Form Handling with TypeScript
// src/components/Form.vue
<script setup lang="ts">
import { ref } from 'vue';
import type { User } from '@/types';
import styled from 'vue-styled-components';
const StyledForm = styled.form`
display: flex;
flex-direction: column;
gap: ${props => props.theme.spacing.md};
`;
const StyledInput = styled.input`
border: 1px solid ${props => props.theme.colors.gray[300]};
border-radius: 0.375rem;
padding: ${props => props.theme.spacing.sm} ${props => props.theme.spacing.md};
&:focus {
outline: none;
border-color: ${props => props.theme.colors.primary.DEFAULT};
box-shadow: 0 0 0 2px ${props => props.theme.colors.primary.light};
}
`;
interface FormData {
name: string;
email: string;
password: string;
}
const formData = ref<FormData>({
name: '',
email: '',
password: ''
});
const errors = ref<Partial<Record<keyof FormData, string>>>({});
const validateForm = (): boolean => {
errors.value = {};
let isValid = true;
if (!formData.value.name) {
errors.value.name = 'Name is required';
isValid = false;
}
if (!formData.value.email) {
errors.value.email = 'Email is required';
isValid = false;
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.value.email)) {
errors.value.email = 'Invalid email format';
isValid = false;
}
if (!formData.value.password) {
errors.value.password = 'Password is required';
isValid = false;
} else if (formData.value.password.length < 8) {
errors.value.password = 'Password must be at least 8 characters';
isValid = false;
}
return isValid;
};
const handleSubmit = async (event: Event) => {
event.preventDefault();
if (validateForm()) {
// Handle form submission
}
};
</script>
<template>
<StyledForm @submit="handleSubmit" class="max-w-md mx-auto">
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-700">
Name
</label>
<StyledInput
v-model="formData.name"
type="text"
:class="{'border-red-500': errors.name}"
/>
<p v-if="errors.name" class="text-red-500 text-sm">
{{ errors.name }}
</p>
</div>
<!-- Similar fields for email and password -->
<button
type="submit"
class="w-full bg-primary-600 text-white py-2 px-4 rounded-md hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
>
Submit
</button>
</StyledForm>
</template>
Responsive Design
Media Queries with Styled Components
// src/styles/mediaQueries.ts
export const media = {
sm: (styles: TemplateStringsArray) =>
`@media (min-width: 640px) { ${styles} }`,
md: (styles: TemplateStringsArray) =>
`@media (min-width: 768px) { ${styles} }`,
lg: (styles: TemplateStringsArray) =>
`@media (min-width: 1024px) { ${styles} }`,
xl: (styles: TemplateStringsArray) =>
`@media (min-width: 1280px) { ${styles} }`,
};
// Usage in components
const ResponsiveContainer = styled.div`
padding: 1rem;
${media.sm`
padding: 2rem;
`}
${media.lg`
padding: 3rem;
max-width: 1024px;
margin: 0 auto;
`}
`;
Performance Optimization
Dynamic Imports
// src/router/index.ts
import { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
path: '/',
component: () => import('@/views/Home.vue'),
},
{
path: '/dashboard',
component: () => import('@/views/Dashboard.vue'),
children: [
{
path: 'profile',
component: () => import('@/views/Profile.vue'),
},
{
path: 'settings',
component: () => import('@/views/Settings.vue'),
},
],
},
];
Component Optimization
// src/components/LazyImage.vue
<script setup lang="ts">
import { ref, onMounted } from 'vue';
interface Props {
src: string;
alt: string;
width?: number;
height?: number;
}
const props = defineProps<Props>();
const isLoaded = ref(false);
const observer = ref<IntersectionObserver | null>(null);
const imageRef = ref<HTMLImageElement | null>(null);
onMounted(() => {
observer.value = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
loadImage();
observer.value?.unobserve(entry.target);
}
});
});
if (imageRef.value) {
observer.value.observe(imageRef.value);
}
});
const loadImage = () => {
const img = new Image();
img.src = props.src;
img.onload = () => {
isLoaded.value = true;
};
};
</script>
<template>
<div
ref="imageRef"
class="relative overflow-hidden bg-gray-100"
:style="{
width: width ? `${width}px` : '100%',
height: height ? `${height}px` : 'auto'
}"
>
<img
v-if="isLoaded"
:src="src"
:alt="alt"
class="w-full h-full object-cover transition-opacity duration-300"
:class="{ 'opacity-100': isLoaded, 'opacity-0': !isLoaded }"
/>
<div
v-else
class="absolute inset-0 bg-gray-200 animate-pulse"
/>
</div>
</template>
Conclusion
Building modern frontend applications with Vue 3, TypeScript, Tailwind CSS, and Styled Components provides a powerful combination of:
Type Safety
- Strong typing with TypeScript
- Better developer experience
- Catch errors early
Styling Flexibility
- Utility-first approach with Tailwind
- Component-based styling with Styled Components
- Theme consistency
Component Architecture
- Reusable components
- Type-safe props
- Composable functions
Performance
- Optimized loading
- Code splitting
- Lazy loading
Remember to:
- Keep components focused and reusable
- Maintain consistent typing across the application
- Use appropriate styling solutions for different use cases
- Optimize for performance
- Follow Vue 3 best practices
Resources
Testing
Unit Testing with Vitest
// src/components/__tests__/Button.spec.ts
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import Button from '../Button.vue';
describe('Button Component', () => {
it('renders properly with default props', () => {
const wrapper = mount(Button, {
props: {
variant: 'primary',
size: 'md'
}
});
expect(wrapper.classes()).toContain('btn-primary');
expect(wrapper.classes()).toContain('btn-md');
});
it('shows loading state correctly', async () => {
const wrapper = mount(Button, {
props: {
loading: true,
variant: 'primary',
size: 'md'
}
});
expect(wrapper.find('.animate-spin').exists()).toBe(true);
expect(wrapper.text()).toContain('Loading');
});
it('handles disabled state properly', () => {
const wrapper = mount(Button, {
props: {
disabled: true,
variant: 'primary',
size: 'md'
}
});
expect(wrapper.attributes('disabled')).toBeDefined();
expect(wrapper.classes()).toContain('opacity-50');
});
});
Component Testing with Cypress
// cypress/components/Form.cy.ts
import Form from '../../src/components/Form.vue';
describe('Form Component', () => {
it('validates required fields', () => {
cy.mount(Form);
cy.get('button[type="submit"]').click();
cy.get('.text-red-500').should('have.length.at.least', 1);
cy.contains('Name is required').should('be.visible');
});
it('submits form with valid data', () => {
cy.mount(Form);
cy.get('input[name="name"]').type('John Doe');
cy.get('input[name="email"]').type('john@example.com');
cy.get('input[name="password"]').type('password123');
cy.get('button[type="submit"]').click();
// Verify form submission
cy.get('.text-red-500').should('not.exist');
});
});
State Management
Using Pinia with TypeScript
// src/stores/user.ts
import { defineStore } from 'pinia';
import type { User, UserPreferences } from '@/types';
interface UserState {
currentUser: User | null;
isAuthenticated: boolean;
loading: boolean;
}
export const useUserStore = defineStore('user', {
state: (): UserState => ({
currentUser: null,
isAuthenticated: false,
loading: false
}),
getters: {
userPreferences: (state): UserPreferences | undefined => {
return state.currentUser?.preferences;
},
displayName: (state): string => {
return state.currentUser?.name ?? 'Guest';
}
},
actions: {
async updatePreferences(preferences: Partial<UserPreferences>) {
if (!this.currentUser) return;
this.loading = true;
try {
const response = await fetch(`/api/users/${this.currentUser.id}/preferences`, {
method: 'PATCH',
body: JSON.stringify(preferences)
});
if (!response.ok) throw new Error('Failed to update preferences');
this.currentUser.preferences = {
...this.currentUser.preferences,
...preferences
};
} catch (error) {
console.error('Error updating preferences:', error);
throw error;
} finally {
this.loading = false;
}
}
}
});
Advanced Styling Patterns
Creating a Theme Provider
// src/components/ThemeProvider.vue
<script setup lang="ts">
import { provide, computed } from 'vue';
import { useTheme } from '@/composables/useTheme';
import type { Theme } from '@/styles/theme';
const { isDark, theme } = useTheme();
const themeValue = computed<Theme>(() => ({
colors: {
...theme.value,
background: isDark.value ? '#1a1a1a' : '#ffffff',
text: isDark.value ? '#ffffff' : '#1a1a1a',
},
spacing: {
xs: '0.25rem',
sm: '0.5rem',
md: '1rem',
lg: '1.5rem',
xl: '2rem',
},
breakpoints: {
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
},
}));
provide('theme', themeValue);
</script>
<template>
<div :class="{ 'dark': isDark }">
<slot />
</div>
</template>
Creating Reusable Styled Components
// src/components/styled/index.ts
import styled from 'vue-styled-components';
export const Flex = styled.div`
display: flex;
${props => props.direction && `flex-direction: ${props.direction};`}
${props => props.justify && `justify-content: ${props.justify};`}
${props => props.align && `align-items: ${props.align};`}
${props => props.gap && `gap: ${props.theme.spacing[props.gap]};`}
`;
export const Grid = styled.div`
display: grid;
${props => props.columns && `grid-template-columns: repeat(${props.columns}, 1fr);`}
${props => props.gap && `gap: ${props.theme.spacing[props.gap]};`}
`;
export const Text = styled.p`
color: ${props => props.theme.colors.text};
${props => props.size && `font-size: ${props.theme.typography[props.size]};`}
${props => props.weight && `font-weight: ${props.weight};`}
`;
Performance Monitoring
Setting up Performance Tracking
// src/plugins/performance.ts
import { onMounted, onUnmounted } from 'vue';
export function usePerformanceMonitoring() {
let observer: PerformanceObserver;
onMounted(() => {
observer = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
// Filter out non-interesting entries
if (entry.entryType === 'largest-contentful-paint') {
console.log('LCP:', entry.startTime);
}
if (entry.entryType === 'first-input') {
console.log('FID:', entry.processingStart - entry.startTime);
}
});
});
observer.observe({
entryTypes: ['largest-contentful-paint', 'first-input']
});
});
onUnmounted(() => {
observer.disconnect();
});
}
Best Practices and Common Patterns
Component Organization
- Keep components small and focused
- Use TypeScript interfaces for props
- Implement proper error boundaries
- Use composables for shared logic
Styling Guidelines
- Use Tailwind for common utilities
- Use Styled Components for complex, dynamic styles
- Maintain a consistent theming system
- Follow responsive design principles
Performance Optimization
- Implement lazy loading
- Use proper caching strategies
- Optimize bundle size
- Monitor performance metrics
Testing Strategy
- Write unit tests for business logic
- Use component testing for UI elements
- Implement end-to-end tests for critical paths
- Maintain good test coverage
Remember to continuously evaluate and improve your application's architecture and performance as it grows.
Happy coding!