
Mockup Creation
- 27 installs
- 14 repo stars
- Updated January 23, 2026
- dauquangthanh/hanoi-rainbow
mockup-creation is a Hanoi Rainbow agent skill that scaffolds Nuxt or Next.js UI prototypes so developers can demo interfaces with TypeScript and TailwindCSS v4.
About
The mockup-creation skill builds polished, interactive UI mockups using NuxtJS 4 with Vue or Next.js with React, TypeScript, and TailwindCSS v4. It documents project initialization, styling setup, routing, and component patterns for rapid frontend demonstrations. Reach for it when you need production-quality prototypes for landing pages, dashboards, or admin UIs and want agent-guided scaffolding instead of static design files alone.
- Supports NuxtJS 4 Vue and Next.js React paths with TypeScript defaults
- Uses TailwindCSS v4 with documented Vite or Turbopack setup steps
- Targets landing pages, dashboards, admin panels, and interactive demos
- Requires Node.js 20.x or newer per skill compatibility metadata
Mockup Creation by the numbers
- 27 all-time installs (skills.sh)
- Ranked #1,480 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/dauquangthanh/hanoi-rainbow --skill mockup-creationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 27 |
|---|---|
| repo stars | ★ 14 |
| Last updated | January 23, 2026 |
| Repository | dauquangthanh/hanoi-rainbow ↗ |
How do you quickly turn a UI idea into an interactive web prototype with modern Vue or React tooling?
Scaffolds interactive Nuxt 4 or Next.js mockups with TypeScript, Tailwind CSS v4, and component-driven pages for rapid UI demos.
Who is it for?
Frontend developers prototyping web mockups who can run Node.js 20+ and want Nuxt 4 or Next.js starter flows from the skill.
Skip if: Native mobile app UI work or backend-only services with no interactive interface to prototype.
When should I use this skill?
Users ask to create a mockup, build a prototype, interactive demo, or UI prototype with Nuxt or Next.js.
What you get
A runnable mockup project structure with Tailwind v4 configuration and component-oriented pages per the skill quick start.
Files
Mockup Creation
Create polished, interactive UI mockups and prototypes using NuxtJS 4 (Vue) or Next.js (React) with TypeScript and TailwindCSS v4.
Overview
Rapid creation of production-quality mockups with:
NuxtJS 4 (Vue):
- Vue 3 Composition API with TypeScript
- Vite bundler (built-in)
- Auto-imports for components and composables
- Zero-config TypeScript with auto-generated types
- File-based routing from pages/ directory
Next.js (React):
- React 19 with TypeScript v5.1.0+
- Turbopack bundler (default, faster than Webpack)
- App Router with Server/Client Components
- Built-in TypeScript with zero configuration
- File-based routing from app/ directory
Common Features:
- TailwindCSS v4 with modern tooling
- Component-driven architecture
- Server-side rendering (SSR) or static site generation (SSG)
- Interactive reactivity
- Production-ready optimizations
Quick Start
Option A: NuxtJS 4 (Vue-based)
Option A: NuxtJS 4 (Vue-based)
1. Initialize Project
# Create NuxtJS 4 project (TypeScript enabled by default)
npx nuxi@latest init my-mockup
cd my-mockup
npm install2. Install and Configure TailwindCSS
Install TailwindCSS v4 with Vite plugin:
npm install -D tailwindcss @tailwindcss/viteConfigure nuxt.config.ts:
// https://nuxt.com/docs/4.x/api/configuration/nuxt-config
import tailwindcss from '@tailwindcss/vite'
export default defineNuxtConfig({
devtools: { enabled: true },
typescript: {
strict: true,
typeCheck: true
},
// Auto-import components and composables
components: [
{
path: '~/components',
pathPrefix: false,
},
],
// App configuration
app: {
head: {
charset: 'utf-8',
viewport: 'width=device-width, initial-scale=1',
title: 'My Mockup',
meta: [
{ name: 'description', content: 'My mockup description' }
],
}
},
// Vite configuration with TailwindCSS v4
vite: {
plugins: [
tailwindcss()
],
css: {
devSourcemap: true
}
}
})Create CSS file and import TailwindCSS (e.g., assets/css/main.css):
@import "tailwindcss";Import CSS in app.vue or nuxt.config.ts:
// In nuxt.config.ts, add to the config:
export default defineNuxtConfig({
css: ['~/assets/css/main.css'],
// ... rest of config
})3. Start Development
npm run dev
# Opens http://localhost:3000Option B: Next.js (React-based)
1. Initialize Project
# Create Next.js project (uses recommended defaults with TypeScript and TailwindCSS)
npx create-next-app@latest my-mockup --yes
cd my-mockupOr with custom options:
npx create-next-app@latest my-mockup
# Choose: TypeScript: Yes, TailwindCSS: Yes, App Router: Yes2. Verify TailwindCSS v4 Setup
Next.js includes TailwindCSS by default. To upgrade to v4:
npm install -D tailwindcss@next @tailwindcss/postcss@nextUpdate tailwind.config.ts:
import type { Config } from 'tailwindcss'
export default {
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
],
} satisfies ConfigUpdate postcss.config.mjs:
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}Update app/globals.css:
@import "tailwindcss";3. Start Development
npm run dev
# Opens http://localhost:3000Workflow
1. Define Structure
Identify mockup requirements:
- Layout type (single page, dashboard, multi-page)
- Sections needed (header, hero, features, footer, sidebar)
- Responsive breakpoints (mobile, tablet, desktop)
- Interactive elements (forms, modals, dropdowns)
2. Create Component Architecture
NuxtJS (Vue) auto-imports from:
my-mockup/
├── components/
│ ├── layout/ # Header, Footer, Sidebar (auto-imported)
│ ├── ui/ # Button, Card, Modal, Input (auto-imported)
│ └── sections/ # Hero, Features, Testimonials (auto-imported)
├── pages/ # File-based routing (auto-routed)
├── composables/ # Shared logic (auto-imported)
├── layouts/ # Layout templates (default.vue, dashboard.vue)
└── types/ # TypeScript interfacesNext.js (React) structure:
my-mockup/
├── app/
│ ├── layout.tsx # Root layout (required)
│ ├── page.tsx # Home page
│ ├── dashboard/
│ │ └── page.tsx # /dashboard route
│ └── globals.css # TailwindCSS imports
├── components/
│ ├── layout/ # Header, Footer, Sidebar
│ ├── ui/ # Button, Card, Modal, Input
│ └── sections/ # Hero, Features, Testimonials
├── lib/ # Utilities and shared logic
└── types/ # TypeScript interfaces3. Build Components
NuxtJS (Vue) Example: Type-safe Button Component (components/ui/Button.vue)
<script setup lang="ts">
// No need to import 'computed' - auto-imported by Nuxt
interface Props {
variant?: 'primary' | 'secondary' | 'outline'
size?: 'sm' | 'md' | 'lg'
}
const props = withDefaults(defineProps<Props>(), {
variant: 'primary',
size: 'md'
})
const buttonClasses = computed(() => {
const variants = {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-600 text-white hover:bg-gray-700',
outline: 'border-2 border-blue-600 text-blue-600 hover:bg-blue-50'
}
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg'
}
return `font-semibold rounded-lg transition ${variants[props.variant]} ${sizes[props.size]}`
})
</script>
<template>
<button :class="buttonClasses">
<slot />
</button>
</template>Usage in pages/index.vue:
<template>
<div>
<!-- Auto-imported as UiButton from components/ui/Button.vue -->
<UiButton variant="primary" size="lg">
Get Started
</UiButton>
</div>
</template>Next.js (React) Example: Type-safe Button Component (components/ui/Button.tsx)
import { ButtonHTMLAttributes } from 'react'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline'
size?: 'sm' | 'md' | 'lg'
children: React.ReactNode
}
export function Button({
variant = 'primary',
size = 'md',
children,
className = '',
...props
}: ButtonProps) {
const variants = {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-600 text-white hover:bg-gray-700',
outline: 'border-2 border-blue-600 text-blue-600 hover:bg-blue-50'
}
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg'
}
const buttonClasses = `font-semibold rounded-lg transition ${variants[variant]} ${sizes[size]} ${className}`
return (
<button className={buttonClasses} {...props}>
{children}
</button>
)
}Usage in app/page.tsx:
import { Button } from '@/components/ui/Button'
export default function Home() {
return (
<div>
<Button variant="primary" size="lg">
Get Started
</Button>
</div>
)
}4. Apply Responsive Design
Use TailwindCSS breakpoints:
sm:(640px),md:(768px),lg:(1024px),xl:(1280px),2xl:(1536px)
NuxtJS (Vue) Responsive Grid Example:
<template>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Auto-imported as UiCard from components/ui/Card.vue -->
<UiCard v-for="item in items" :key="item.id" :data="item" />
</div>
</template>
<script setup lang="ts">
const items = ref([...])
</script>Next.js (React) Responsive Grid Example:
import { Card } from '@/components/ui/Card'
export default function FeaturesSection() {
const items = [
{ id: 1, title: 'Feature 1', description: '...' },
// ...
]
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{items.map((item) => (
<Card key={item.id} data={item} />
))}
</div>
)
}5. Add Interactivity
NuxtJS (Vue) Composable Pattern (composables/useModal.ts):
// Auto-imported by Nuxt - no need to import 'ref'
export function useModal() {
const isOpen = ref(false)
const open = () => { isOpen.value = true }
const close = () => { isOpen.value = false }
return { isOpen, open, close }
}Usage in any Vue component:
<script setup lang="ts">
// Auto-imported - no import statement needed
const { isOpen, open, close } = useModal()
</script>Next.js (React) Custom Hook Pattern (lib/hooks/useModal.ts):
import { useState } from 'react'
export function useModal() {
const [isOpen, setIsOpen] = useState(false)
const open = () => setIsOpen(true)
const close = () => setIsOpen(false)
return { isOpen, open, close }
}Usage in any React component:
'use client' // Mark as Client Component for interactivity
import { useModal } from '@/lib/hooks/useModal'
export default function MyComponent() {
const { isOpen, open, close } = useModal()
return (
<div>
<button onClick={open}>Open Modal</button>
{isOpen && <Modal onClose={close} />}
</div>
)
}6. Build for Production
NuxtJS:
npm run build # Build for production (.output/)
npm run preview # Preview production build
npm run generate # Generate static site (SSG)Next.js:
npm run build # Build for production (.next/)
npm run start # Start production server (SSR)
# For static export (SSG), add to next.config.js:
# output: 'export'Common Patterns
Landing Page (NuxtJS)
Create layout (layouts/default.vue):
<template>
<div class="min-h-screen flex flex-col">
<LayoutHeader />
<main class="flex-1">
<slot />
</main>
<LayoutFooter />
</div>
</template>Create page (pages/index.vue):
<template>
<div>
<SectionsHero />
<SectionsFeatures />
</div>
</template>Landing Page (Next.js)
Create layout (app/layout.tsx):
import { Header } from '@/components/layout/Header'
import { Footer } from '@/components/layout/Footer'
import './globals.css'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">{children}</main>
<Footer />
</body>
</html>
)
}Create page (app/page.tsx):
import { Hero } from '@/components/sections/Hero'
import { Features } from '@/components/sections/Features'
export default function Home() {
return (
<div>
<Hero />
<Features />
</div>
)
}Dashboard Layout (NuxtJS)
Create dashboard layout (layouts/dashboard.vue):
<template>
<div class="flex h-screen bg-gray-100">
<LayoutSidebar class="w-64 bg-white shadow-lg" />
<div class="flex-1 flex flex-col">
<LayoutTopBar class="bg-white shadow-sm" />
<main class="flex-1 overflow-y-auto p-6">
<slot />
</main>
</div>
</div>
</template>Use in page (pages/dashboard/index.vue):
<script setup lang="ts">
definePageMeta({
layout: 'dashboard'
})
</script>
<template>
<div>
<!-- Dashboard content -->
</div>
</template>Dashboard Layout (Next.js)
Create dashboard layout (app/dashboard/layout.tsx):
import { Sidebar } from '@/components/layout/Sidebar'
import { TopBar } from '@/components/layout/TopBar'
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="flex h-screen bg-gray-100">
<Sidebar className="w-64 bg-white shadow-lg" />
<div className="flex-1 flex flex-col">
<TopBar className="bg-white shadow-sm" />
<main className="flex-1 overflow-y-auto p-6">
{children}
</main>
</div>
</div>
)
}Use in page (app/dashboard/page.tsx):
export default function DashboardPage() {
return (
<div>
{/* Dashboard content */}
</div>
)
}Design System
Colors
- Primary:
blue-600, Secondary:gray-600 - Success:
green-600, Warning:yellow-600, Error:red-600 - Neutral:
gray-100togray-900
Spacing
Use consistent scale: p-1 (4px), p-2 (8px), p-4 (16px), p-6 (24px), p-8 (32px)
Typography
- Headings:
text-4xl,text-3xl,text-2xl,text-xl - Body:
text-base(16px) - Weights:
font-normal,font-medium,font-semibold,font-bold
Advanced Guides
For detailed implementations:
- [Component Library](references/component-library.md) - Complete reusable Vue components with TypeScript
- [TailwindCSS v4 Patterns](references/tailwind-patterns.md) - Advanced styling (works with both frameworks)
- [NuxtJS Composables](references/composition-api.md) - State management and auto-imports (Vue-specific)
- [React Hooks](references/react-hooks.md) - Custom hooks and state management (React-specific)
- [Animations](references/animations.md) - Vue transitions and TailwindCSS animations
- [Examples](references/examples.md) - Complete Vue/NuxtJS mockup examples
- [Deployment](references/deployment.md) - Build and hosting guides for both frameworks
Scripts
Helper scripts available in scripts/ (Bash and PowerShell versions):
create-component.sh / .ps1 - Generate components with TypeScript boilerplate
# Linux/macOS - Vue component
./scripts/create-component.sh ComponentName ui vue
# Linux/macOS - React component
./scripts/create-component.sh ComponentName ui react
# Windows - Vue component
.\scripts\create-component.ps1 -ComponentName ComponentName -Type ui -Framework vue
# Windows - React component
.\scripts\create-component.ps1 -ComponentName ComponentName -Type ui -Framework reactbuild-deploy.sh / .ps1 - Build and prepare for deployment
# Linux/macOS
./scripts/build-deploy.sh
# Windows
.\scripts\build-deploy.ps1NuxtJS CLI commands:
npx nuxi add component ComponentName # Add new component
npx nuxi add page pageName # Add new page
npx nuxi add layout layoutName # Add new layoutNext.js CLI commands:
# No built-in CLI for components, use scripts above
# Or manually create files in app/ or components/Troubleshooting
NuxtJS (Vue)
TailwindCSS v4 not working:
- Restart dev server after nuxt.config.ts changes
- Verify
@import "tailwindcss";in your CSS file - Ensure
@tailwindcss/viteplugin is in vite.plugins array - Check CSS file is imported in nuxt.config.ts or app.vue
- Run
npx nuxi prepareto regenerate types
TypeScript errors:
- Install Volar extension (not Vetur)
- Run
npx nuxi prepareto generate types - Restart TypeScript server in VS Code
Auto-imports not working:
rm -rf .nuxt
npx nuxi prepare
npm run devHMR issues:
rm -rf .nuxt node_modules/.cache
npm run devLarge bundle size:
- Use lazy loading:
defineAsyncComponent(() => import('./Component.vue')) - Analyze with:
npx nuxi analyze
Next.js (React)
TailwindCSS not working:
- Check
tailwind.config.tshas correct content paths - Verify
@import "tailwindcss";in app/globals.css - Restart dev server after config changes
- Clear
.nextfolder:rm -rf .next && npm run dev
TypeScript errors:
- Ensure tsconfig.json is properly configured
- Run
npm run buildto see full type checking - Restart TypeScript server in VS Code
Server vs Client Components:
- Use
'use client'directive at top of file for interactive components - Server Components (default) cannot use hooks or event handlers
- Client Components can use useState, useEffect, onClick, etc.
Build errors:
rm -rf .next node_modules/.cache
npm install
npm run buildLarge bundle size:
- Use dynamic imports:
const Component = dynamic(() => import('./Component')) - Analyze with:
npm run build(shows bundle sizes)
Best Practices
Common (Both Frameworks)
1. Component Design: Small, single-purpose, reusable 2. TypeScript: Clear interfaces for props and component APIs 3. TailwindCSS: Prefer utilities over custom CSS 4. Responsive: Mobile-first approach 5. Performance: Use SSR/SSG when appropriate, lazy load large components 6. Accessibility: ARIA labels and keyboard navigation 7. Code Organization: Group related components, consistent naming
NuxtJS (Vue) Specific
8. Composables: Extract shared logic (auto-imported) 9. Auto-imports: Leverage Nuxt's auto-import system for components and composables 10. File-based Routing: Use pages/ directory for automatic routing 11. Layouts: Create reusable layouts for consistent UI structure 12. SEO: Use useHead() and useSeoMeta() for meta tags
Next.js (React) Specific
8. Server Components: Default to Server Components, use Client Components only when needed 9. Data Fetching: Use async Server Components for data fetching 10. Metadata API: Use generateMetadata() for dynamic SEO tags 11. Image Optimization: Use Next.js <Image> component for automatic optimization 12. Route Handlers: Use route.ts for API endpoints in app/api/
Animations & Transitions Guide
Advanced animation patterns for NuxtJS 4 mockups using TailwindCSS v4 and native CSS.
Note: All Vue APIs (ref, computed, etc.) are auto-imported by Nuxt - no manual imports needed.
Table of Contents
- CSS Transitions
- Vue Transitions
- Tailwind Animations
- Custom Animations
- Loading States
- Micro-interactions
- Page Transitions
---
CSS Transitions
Basic Transition Properties
<template>
<!-- Transition all properties -->
<button class="transition-all duration-300 hover:scale-110">
Hover Me
</button>
<!-- Transition specific properties -->
<div class="transition-colors duration-200 hover:bg-blue-600">
Color Transition
</div>
<!-- Multiple properties -->
<div class="transition-[background-color,transform] duration-300 ease-in-out">
Multiple Properties
</div>
</template>Timing Functions
<template>
<!-- Linear -->
<div class="transition-all duration-300 ease-linear">Linear</div>
<!-- Ease (default) -->
<div class="transition-all duration-300 ease-in-out">Ease In Out</div>
<!-- Custom cubic-bezier -->
<div class="transition-all duration-300" style="transition-timing-function: cubic-bezier(0.68, -0.55, 0.265, 1.55)">
Bounce
</div>
</template>---
Vue Transitions
Basic Vue Transition
<script setup lang="ts">
// No imports needed - ref auto-imported by Nuxt
const show = ref(true)
</script>
<template>
<button @click="show = !show">Toggle</button>
<Transition name="fade">
<div v-if="show" class="p-4 bg-blue-100 rounded">
Fade transition content
</div>
</Transition>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>Transition with TailwindCSS Classes
<template>
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 scale-90"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-90"
>
<div v-if="show" class="p-4 bg-white rounded-lg shadow-lg">
Content with scale and fade
</div>
</Transition>
</template>Slide Transitions
<template>
<!-- Slide from right -->
<Transition
enter-active-class="transition-transform duration-300"
enter-from-class="translate-x-full"
enter-to-class="translate-x-0"
leave-active-class="transition-transform duration-300"
leave-from-class="translate-x-0"
leave-to-class="translate-x-full"
>
<div v-if="show" class="fixed right-0 top-0 h-full w-80 bg-white shadow-xl p-6">
Sidebar content
</div>
</Transition>
<!-- Slide from top -->
<Transition
enter-active-class="transition-transform duration-300"
enter-from-class="-translate-y-full"
enter-to-class="translate-y-0"
leave-active-class="transition-transform duration-300"
leave-from-class="translate-y-0"
leave-to-class="-translate-y-full"
>
<div v-if="show">Dropdown content</div>
</Transition>
</template>TransitionGroup
<script setup lang="ts">
// No imports needed - ref auto-imported by Nuxt
interface Item {
id: number
text: string
}
const items = ref<Item[]>([
{ id: 1, text: 'Item 1' },
{ id: 2, text: 'Item 2' },
{ id: 3, text: 'Item 3' }
])
const addItem = () => {
items.value.push({
id: Date.now(),
text: `Item ${items.value.length + 1}`
})
}
const removeItem = (id: number) => {
items.value = items.value.filter(item => item.id !== id)
}
</script>
<template>
<button @click="addItem" class="mb-4 px-4 py-2 bg-blue-600 text-white rounded">
Add Item
</button>
<TransitionGroup
name="list"
tag="div"
class="space-y-2"
>
<div
v-for="item in items"
:key="item.id"
class="p-4 bg-white rounded-lg shadow flex items-center justify-between"
>
<span>{{ item.text }}</span>
<button @click="removeItem(item.id)" class="text-red-600">Remove</button>
</div>
</TransitionGroup>
</template>
<style scoped>
.list-enter-active,
.list-leave-active {
transition: all 0.5s ease;
}
.list-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.list-leave-to {
opacity: 0;
transform: translateX(30px);
}
/* Move animation */
.list-move {
transition: transform 0.5s ease;
}
</style>---
Tailwind Animations
Built-in Animations
<template>
<!-- Spin -->
<div class="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
<!-- Ping -->
<div class="relative">
<span class="absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75 animate-ping" />
<span class="relative inline-flex rounded-full h-3 w-3 bg-blue-600" />
</div>
<!-- Pulse -->
<div class="w-4 h-4 bg-blue-600 rounded-full animate-pulse" />
<!-- Bounce -->
<div class="animate-bounce">↓</div>
</template>Custom Animation Utilities
// tailwind.config.js
export default {
theme: {
extend: {
animation: {
'fade-in': 'fadeIn 0.5s ease-in',
'slide-in': 'slideIn 0.3s ease-out',
'scale-in': 'scaleIn 0.3s ease-out',
'shake': 'shake 0.5s ease-in-out',
'wiggle': 'wiggle 1s ease-in-out infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' }
},
slideIn: {
'0%': { transform: 'translateY(-10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' }
},
scaleIn: {
'0%': { transform: 'scale(0.9)', opacity: '0' },
'100%': { transform: 'scale(1)', opacity: '1' }
},
shake: {
'0%, 100%': { transform: 'translateX(0)' },
'10%, 30%, 50%, 70%, 90%': { transform: 'translateX(-10px)' },
'20%, 40%, 60%, 80%': { transform: 'translateX(10px)' }
},
wiggle: {
'0%, 100%': { transform: 'rotate(-3deg)' },
'50%': { transform: 'rotate(3deg)' }
}
}
}
}
}Usage:
<template>
<div class="animate-fade-in">Fades in</div>
<div class="animate-slide-in">Slides in</div>
<div class="animate-scale-in">Scales in</div>
<div class="animate-shake">Shakes</div>
<div class="animate-wiggle">Wiggles</div>
</template>---
Loading States
Skeleton Loader
<script setup lang="ts">
// No imports needed - ref, onMounted auto-imported by Nuxt
const loading = ref(true)
onMounted(() => {
setTimeout(() => {
loading.value = false
}, 2000)
})
</script>
<template>
<div v-if="loading" class="space-y-4">
<!-- Skeleton card -->
<div class="bg-white rounded-lg p-6 shadow-md">
<div class="animate-pulse space-y-4">
<!-- Header -->
<div class="flex items-center space-x-4">
<div class="w-12 h-12 bg-gray-300 rounded-full" />
<div class="flex-1 space-y-2">
<div class="h-4 bg-gray-300 rounded w-1/4" />
<div class="h-3 bg-gray-300 rounded w-1/3" />
</div>
</div>
<!-- Body -->
<div class="space-y-2">
<div class="h-4 bg-gray-300 rounded" />
<div class="h-4 bg-gray-300 rounded w-5/6" />
<div class="h-4 bg-gray-300 rounded w-4/6" />
</div>
</div>
</div>
</div>
<div v-else>
<!-- Actual content -->
</div>
</template>Spinner Component
<script setup lang="ts">
interface Props {
size?: 'sm' | 'md' | 'lg'
color?: string
}
const props = withDefaults(defineProps<Props>(), {
size: 'md',
color: 'blue'
})
const sizeClasses = {
sm: 'w-4 h-4 border-2',
md: 'w-8 h-8 border-4',
lg: 'w-12 h-12 border-4'
}
</script>
<template>
<div
:class="[
'rounded-full animate-spin',
sizeClasses[size],
`border-${color}-600 border-t-transparent`
]"
/>
</template>Progress Bar
<script setup lang="ts">
// No imports needed - ref, computed auto-imported by Nuxt
interface Props {
value: number
max?: number
showLabel?: boolean
animated?: boolean
}
const props = withDefaults(defineProps<Props>(), {
max: 100,
showLabel: true,
animated: true
})
const percentage = computed(() => {
return Math.min(100, (props.value / props.max) * 100)
})
</script>
<template>
<div class="w-full">
<div v-if="showLabel" class="flex justify-between mb-1 text-sm">
<span>Progress</span>
<span>{{ Math.round(percentage) }}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="bg-blue-600 h-2 rounded-full transition-all duration-500"
:class="{ 'animate-pulse': animated }"
:style="{ width: `${percentage}%` }"
/>
</div>
</div>
</template>---
Micro-interactions
Button Hover Effects
<template>
<!-- Scale on hover -->
<button class="px-4 py-2 bg-blue-600 text-white rounded-lg transition-transform hover:scale-105">
Scale
</button>
<!-- Lift effect -->
<button class="px-4 py-2 bg-blue-600 text-white rounded-lg transition-all hover:-translate-y-1 hover:shadow-lg">
Lift
</button>
<!-- Glow effect -->
<button class="px-4 py-2 bg-blue-600 text-white rounded-lg transition-shadow hover:shadow-[0_0_20px_rgba(59,130,246,0.5)]">
Glow
</button>
<!-- Ripple effect -->
<button class="relative overflow-hidden px-4 py-2 bg-blue-600 text-white rounded-lg group">
<span class="relative z-10">Ripple</span>
<span class="absolute inset-0 bg-white opacity-0 group-hover:opacity-20 transition-opacity" />
</button>
</template>Card Hover Effects
<template>
<!-- Lift with shadow -->
<div class="bg-white rounded-lg p-6 shadow-md transition-all hover:-translate-y-2 hover:shadow-xl">
Card content
</div>
<!-- Border highlight -->
<div class="bg-white rounded-lg p-6 border-2 border-transparent transition-colors hover:border-blue-600">
Card content
</div>
<!-- Background gradient -->
<div class="bg-white rounded-lg p-6 transition-all hover:bg-gradient-to-br hover:from-blue-50 hover:to-purple-50">
Card content
</div>
</template>Input Focus Effects
<template>
<!-- Expanding border -->
<input
class="w-full px-4 py-2 border-2 border-gray-300 rounded-lg focus:outline-none focus:border-blue-600 focus:scale-105 transition-all"
/>
<!-- Glow ring -->
<input
class="w-full px-4 py-2 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-4 focus:ring-blue-200 focus:border-blue-600 transition-all"
/>
<!-- Bottom border animation -->
<div class="relative">
<input
class="w-full px-4 py-2 border-b-2 border-gray-300 focus:outline-none peer"
/>
<div class="absolute bottom-0 left-0 w-0 h-0.5 bg-blue-600 peer-focus:w-full transition-all duration-300" />
</div>
</template>---
Page Transitions
Route Transitions with Vue Router
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
// Your routes
],
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition
}
return { top: 0 }
}
})
export default router<!-- App.vue or Layout -->
<script setup lang="ts">
// No imports needed - useRoute, computed auto-imported by Nuxt
const route = useRoute()
const transitionName = computed(() => {
// Customize based on route
return 'fade'
})
</script>
<template>
<Transition
:name="transitionName"
mode="out-in"
>
<router-view :key="route.path" />
</Transition>
</template>
<style>
/* Fade transition */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* Slide transition */
.slide-enter-active,
.slide-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.slide-enter-from {
transform: translateX(20px);
opacity: 0;
}
.slide-leave-to {
transform: translateX(-20px);
opacity: 0;
}
</style>Modal Transitions
<script setup lang="ts">
// No imports needed - ref auto-imported by Nuxt
const show = ref(false)
</script>
<template>
<button @click="show = true">Open Modal</button>
<Teleport to="body">
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
@click="show = false"
>
<div
class="bg-white rounded-lg p-6 max-w-md w-full mx-4"
@click.stop
>
<h2 class="text-xl font-bold mb-4">Modal Title</h2>
<p>Modal content</p>
<button @click="show = false" class="mt-4 px-4 py-2 bg-blue-600 text-white rounded">
Close
</button>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-active > div,
.modal-leave-active > div {
transition: transform 0.3s ease;
}
.modal-enter-from > div {
transform: scale(0.9) translateY(-20px);
}
.modal-leave-to > div {
transform: scale(0.9) translateY(-20px);
}
</style>These animation patterns create engaging, performant user experiences while maintaining accessibility and code quality.
Component Library Reference
Complete collection of production-ready NuxtJS 4 + TypeScript + TailwindCSS v4 components for mockup creation.
Note: All Vue APIs (ref, computed, watch, etc.) are auto-imported by Nuxt - no manual imports needed.
Table of Contents
- UI Components
- Button
- Card
- Modal
- Input
- Select
- Checkbox
- Radio
- Tabs
- Accordion
- Badge
- Alert
- Toast
- Layout Components
- Container
- Grid
- Section Components
- Hero
- Features
---
UI Components
Button
Flexible button component with multiple variants and sizes.
File: components/ui/Button.vue
<script setup lang="ts">
// No imports needed - auto-imported by Nuxt
interface Props {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
disabled?: boolean
loading?: boolean
fullWidth?: boolean
icon?: string
}
const props = withDefaults(defineProps<Props>(), {
variant: 'primary',
size: 'md',
disabled: false,
loading: false,
fullWidth: false
})
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
const buttonClasses = computed(() => {
const base = 'inline-flex items-center justify-center font-semibold rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2'
const variants = {
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
secondary: 'bg-gray-600 text-white hover:bg-gray-700 focus:ring-gray-500',
outline: 'border-2 border-blue-600 text-blue-600 hover:bg-blue-50 focus:ring-blue-500',
ghost: 'text-blue-600 hover:bg-blue-50 focus:ring-blue-500',
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500'
}
const sizes = {
xs: 'px-2.5 py-1.5 text-xs',
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
xl: 'px-8 py-4 text-xl'
}
const width = props.fullWidth ? 'w-full' : ''
const opacity = props.disabled || props.loading ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'
return `${base} ${variants[props.variant]} ${sizes[props.size]} ${width} ${opacity}`
})
</script>
<template>
<button
:class="buttonClasses"
:disabled="disabled || loading"
@click="emit('click', $event)"
>
<span v-if="loading" class="mr-2 animate-spin">⚪</span>
<span v-if="icon && !loading" class="mr-2">{{ icon }}</span>
<slot />
</button>
</template>Usage:
<template>
<!-- Auto-imported as UiButton from components/ui/Button.vue -->
<UiButton variant="primary" size="md" @click="handleClick">
Click Me
</UiButton>
<UiButton variant="outline" size="lg" :loading="true">
Loading...
</UiButton>
<UiButton variant="danger" icon="🗑️" @click="deleteItem">
Delete
</UiButton>
</template>---
Card
Flexible card component for content containers.
File: components/ui/Card.vue
<script setup lang="ts">
// No imports needed - auto-imported by Nuxt
interface Props {
hoverable?: boolean
clickable?: boolean
padding?: 'none' | 'sm' | 'md' | 'lg'
shadow?: 'none' | 'sm' | 'md' | 'lg' | 'xl'
}
const props = withDefaults(defineProps<Props>(), {
hoverable: false,
clickable: false,
padding: 'md',
shadow: 'md'
})
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
const cardClasses = computed(() => {
const base = 'bg-white rounded-lg transition-all duration-200'
const paddings = {
none: '',
sm: 'p-3',
md: 'p-4',
lg: 'p-6'
}
const shadows = {
none: '',
sm: 'shadow-sm',
md: 'shadow-md',
lg: 'shadow-lg',
xl: 'shadow-xl'
}
const hover = props.hoverable ? 'hover:shadow-xl hover:-translate-y-1' : ''
const cursor = props.clickable ? 'cursor-pointer' : ''
return `${base} ${paddings[props.padding]} ${shadows[props.shadow]} ${hover} ${cursor}`
})
</script>
<template>
<div :class="cardClasses" @click="clickable && emit('click', $event)">
<slot />
</div>
</template>Usage:
<template>
<!-- Auto-imported as UiCard from components/ui/Card.vue -->
<UiCard hoverable clickable @click="handleCardClick">
<h3 class="text-xl font-bold">Card Title</h3>
<p class="text-gray-600 mt-2">Card content goes here</p>
</UiCard>
</template>---
Modal
Full-featured modal dialog with backdrop.
File: components/ui/Modal.vue
<script setup lang="ts">
// No imports needed - onMounted, onUnmounted auto-imported by Nuxt
interface Props {
modelValue: boolean
title?: string
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
closeOnBackdrop?: boolean
showClose?: boolean
}
const props = withDefaults(defineProps<Props>(), {
size: 'md',
closeOnBackdrop: true,
showClose: true
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
close: []
}>()
const close = () => {
emit('update:modelValue', false)
emit('close')
}
const handleBackdropClick = () => {
if (props.closeOnBackdrop) {
close()
}
}
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && props.modelValue) {
close()
}
}
onMounted(() => {
document.addEventListener('keydown', handleEscape)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleEscape)
})
const sizeClasses = {
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
full: 'max-w-full mx-4'
}
</script>
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="modelValue"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black bg-opacity-50"
@click.self="handleBackdropClick"
>
<div
:class="['bg-white rounded-lg shadow-xl w-full', sizeClasses[size]]"
@click.stop
>
<!-- Header -->
<div v-if="title || showClose" class="flex items-center justify-between p-4 border-b">
<h3 v-if="title" class="text-xl font-semibold">{{ title }}</h3>
<button
v-if="showClose"
@click="close"
class="text-gray-400 hover:text-gray-600 transition-colors"
>
✕
</button>
</div>
<!-- Body -->
<div class="p-6">
<slot />
</div>
<!-- Footer -->
<div v-if="$slots.footer" class="p-4 border-t bg-gray-50">
<slot name="footer" />
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-active .bg-white,
.modal-leave-active .bg-white {
transition: transform 0.3s ease;
}
.modal-enter-from .bg-white,
.modal-leave-to .bg-white {
transform: scale(0.9);
}
</style>Usage:
<script setup lang="ts">
// No imports needed - ref auto-imported by Nuxt
const showModal = ref(false)
</script>
<template>
<UiButton @click="showModal = true">Open Modal</UiButton>
<UiModal v-model="showModal" title="Confirmation" size="md">
<p>Are you sure you want to proceed?</p>
<template #footer>
<div class="flex justify-end gap-2">
<UiButton variant="ghost" @click="showModal = false">Cancel</UiButton>
<UiButton variant="primary" @click="handleConfirm">Confirm</UiButton>
</div>
</template>
</UiModal>
</template>---
Input
Text input with validation support.
<script setup lang="ts">
// No imports needed - computed auto-imported by Nuxt
interface Props {
modelValue: string
type?: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url'
label?: string
placeholder?: string
error?: string
disabled?: boolean
required?: boolean
icon?: string
}
const props = withDefaults(defineProps<Props>(), {
type: 'text',
disabled: false,
required: false
})
const emit = defineEmits<{
'update:modelValue': [value: string]
blur: [event: FocusEvent]
focus: [event: FocusEvent]
}>()
const inputClasses = computed(() => {
const base = 'w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 transition-all duration-200'
const state = props.error
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:ring-blue-500 focus:border-blue-500'
const disabled = props.disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'
const withIcon = props.icon ? 'pl-10' : ''
return `${base} ${state} ${disabled} ${withIcon}`
})
</script>
<template>
<div class="w-full">
<label v-if="label" class="block text-sm font-medium text-gray-700 mb-1">
{{ label }}
<span v-if="required" class="text-red-500">*</span>
</label>
<div class="relative">
<span v-if="icon" class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
{{ icon }}
</span>
<input
:type="type"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:class="inputClasses"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
@blur="emit('blur', $event)"
@focus="emit('focus', $event)"
/>
</div>
<p v-if="error" class="mt-1 text-sm text-red-600">
{{ error }}
</p>
</div>
</template>Usage:
<Input
v-model="email"
type="email"
label="Email Address"
placeholder="you@example.com"
icon="📧"
:error="emailError"
required
/>---
Select
Dropdown select component.
<script setup lang="ts">
// No imports needed - computed auto-imported by Nuxt
interface Option {
value: string | number
label: string
disabled?: boolean
}
interface Props {
modelValue: string | number
options: Option[]
label?: string
placeholder?: string
error?: string
disabled?: boolean
required?: boolean
}
const props = withDefaults(defineProps<Props>(), {
placeholder: 'Select an option',
disabled: false,
required: false
})
const emit = defineEmits<{
'update:modelValue': [value: string | number]
change: [value: string | number]
}>()
const selectClasses = computed(() => {
const base = 'w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 transition-all duration-200'
const state = props.error
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:ring-blue-500 focus:border-blue-500'
const disabled = props.disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'
return `${base} ${state} ${disabled}`
})
const handleChange = (event: Event) => {
const value = (event.target as HTMLSelectElement).value
emit('update:modelValue', value)
emit('change', value)
}
</script>
<template>
<div class="w-full">
<label v-if="label" class="block text-sm font-medium text-gray-700 mb-1">
{{ label }}
<span v-if="required" class="text-red-500">*</span>
</label>
<select
:value="modelValue"
:disabled="disabled"
:class="selectClasses"
@change="handleChange"
>
<option value="" disabled>{{ placeholder }}</option>
<option
v-for="option in options"
:key="option.value"
:value="option.value"
:disabled="option.disabled"
>
{{ option.label }}
</option>
</select>
<p v-if="error" class="mt-1 text-sm text-red-600">
{{ error }}
</p>
</div>
</template>---
Tabs
Tab navigation component.
<script setup lang="ts">
// No imports needed - ref, provide auto-imported by Nuxt
interface Props {
modelValue?: string | number
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: string | number]
}>()
const activeTab = ref(props.modelValue)
const setActiveTab = (value: string | number) => {
activeTab.value = value
emit('update:modelValue', value)
}
provide('activeTab', activeTab)
provide('setActiveTab', setActiveTab)
</script>
<template>
<div class="w-full">
<div class="border-b border-gray-200">
<nav class="flex space-x-8">
<slot name="tabs" />
</nav>
</div>
<div class="mt-4">
<slot />
</div>
</div>
</template>Tab Item:
<script setup lang="ts">
// No imports needed - inject, computed auto-imported by Nuxt
interface Props {
value: string | number
label: string
disabled?: boolean
}
const props = defineProps<Props>()
const activeTab = inject<Ref<string | number>>('activeTab')
const setActiveTab = inject<(value: string | number) => void>('setActiveTab')
const isActive = computed(() => activeTab?.value === props.value)
const tabClasses = computed(() => {
const base = 'py-2 px-1 border-b-2 font-medium text-sm transition-colors duration-200'
const active = isActive.value
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
const disabled = props.disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'
return `${base} ${active} ${disabled}`
})
</script>
<template>
<button
:class="tabClasses"
:disabled="disabled"
@click="setActiveTab?.(value)"
>
{{ label }}
</button>
</template>Tab Panel:
<script setup lang="ts">
// No imports needed - inject, computed auto-imported by Nuxt
interface Props {
value: string | number
}
const props = defineProps<Props>()
const activeTab = inject<Ref<string | number>>('activeTab')
const isActive = computed(() => activeTab?.value === props.value)
</script>
<template>
<div v-if="isActive">
<slot />
</div>
</template>Usage:
<Tabs v-model="activeTab">
<template #tabs>
<TabItem value="profile" label="Profile" />
<TabItem value="settings" label="Settings" />
<TabItem value="security" label="Security" />
</template>
<TabPanel value="profile">
<h3>Profile Content</h3>
</TabPanel>
<TabPanel value="settings">
<h3>Settings Content</h3>
</TabPanel>
<TabPanel value="security">
<h3>Security Content</h3>
</TabPanel>
</Tabs>---
Layout Components
Container
Responsive container with max-width.
<script setup lang="ts">
interface Props {
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
padding?: boolean
}
const props = withDefaults(defineProps<Props>(), {
size: 'lg',
padding: true
})
const sizeClasses = {
sm: 'max-w-2xl',
md: 'max-w-4xl',
lg: 'max-w-6xl',
xl: 'max-w-7xl',
full: 'max-w-full'
}
</script>
<template>
<div :class="['mx-auto', sizeClasses[size], padding && 'px-4 sm:px-6 lg:px-8']">
<slot />
</div>
</template>---
Grid
Responsive grid layout.
<script setup lang="ts">
interface Props {
cols?: 1 | 2 | 3 | 4 | 6 | 12
gap?: 0 | 2 | 4 | 6 | 8
responsive?: boolean
}
const props = withDefaults(defineProps<Props>(), {
cols: 3,
gap: 4,
responsive: true
})
const gridClasses = computed(() => {
const base = 'grid'
const gaps = `gap-${props.gap}`
if (props.responsive) {
return `${base} grid-cols-1 md:grid-cols-2 lg:grid-cols-${props.cols} ${gaps}`
}
return `${base} grid-cols-${props.cols} ${gaps}`
})
</script>
<template>
<div :class="gridClasses">
<slot />
</div>
</template>---
Section Components
Hero
Full-width hero section.
<script setup lang="ts">
interface Props {
title: string
subtitle?: string
image?: string
alignment?: 'left' | 'center' | 'right'
overlay?: boolean
}
const props = withDefaults(defineProps<Props>(), {
alignment: 'center',
overlay: false
})
</script>
<template>
<section
class="relative py-20 md:py-32"
:style="image ? `background-image: url(${image}); background-size: cover; background-position: center;` : ''"
>
<div v-if="overlay && image" class="absolute inset-0 bg-black bg-opacity-50" />
<Container>
<div
:class="[
'relative z-10',
alignment === 'center' && 'text-center',
alignment === 'right' && 'text-right'
]"
>
<h1
:class="[
'text-4xl md:text-5xl lg:text-6xl font-bold',
image ? 'text-white' : 'text-gray-900'
]"
>
{{ title }}
</h1>
<p
v-if="subtitle"
:class="[
'mt-6 text-xl md:text-2xl',
image ? 'text-gray-200' : 'text-gray-600'
]"
>
{{ subtitle }}
</p>
<div class="mt-10">
<slot />
</div>
</div>
</Container>
</section>
</template>Usage:
<template>
<SectionsHero
title="Welcome to Our Platform"
subtitle="Build amazing things with Vue.js"
image="/hero-bg.jpg"
:overlay="true"
>
<UiButton variant="primary" size="lg">Get Started</UiButton>
<UiButton variant="outline" size="lg" class="ml-4">Learn More</UiButton>
</SectionsHero>
</template>---
Features
Feature grid section.
<script setup lang="ts">
interface Feature {
icon: string
title: string
description: string
}
interface Props {
title?: string
subtitle?: string
features: Feature[]
columns?: 2 | 3 | 4
}
const props = withDefaults(defineProps<Props>(), {
columns: 3
})
</script>
<template>
<section class="py-16 bg-gray-50">
<Container>
<div v-if="title || subtitle" class="text-center mb-12">
<h2 v-if="title" class="text-3xl md:text-4xl font-bold text-gray-900">
{{ title }}
</h2>
<p v-if="subtitle" class="mt-4 text-xl text-gray-600">
{{ subtitle }}
</p>
</div>
<Grid :cols="columns" :gap="8">
<Card
v-for="(feature, index) in features"
:key="index"
hoverable
padding="lg"
>
<div class="text-4xl mb-4">{{ feature.icon }}</div>
<h3 class="text-xl font-semibold text-gray-900 mb-2">
{{ feature.title }}
</h3>
<p class="text-gray-600">
{{ feature.description }}
</p>
</Card>
</Grid>
</Container>
</section>
</template>This component library provides a solid foundation for creating production-quality mockups. Each component is fully typed, accessible, and follows TailwindCSS best practices.
NuxtJS 4 Composables Patterns
Advanced patterns for NuxtJS 4 composables with TypeScript in mockup development.
Note: All Vue APIs and composables are auto-imported by Nuxt - no manual imports needed.
Table of Contents
- Composables
- State Management
- Lifecycle Hooks
- Reactive Data
- Computed Properties
- Watchers
- Event Handling
- Template Refs
- Provide/Inject
- Async Data
---
Composables
Basic Composable Pattern
File: composables/useCounter.ts
// No imports needed - ref, computed auto-imported by Nuxt
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const increment = () => {
count.value++
}
const decrement = () => {
count.value--
}
const reset = () => {
count.value = initialValue
}
const doubleCount = computed(() => count.value * 2)
return {
count,
increment,
decrement,
reset,
doubleCount
}
}Usage:
<script setup lang="ts">
// Auto-imported by Nuxt - no import statement needed
const { count, increment, decrement, doubleCount } = useCounter(10)
</script>
<template>
<div>
<p>Count: {{ count }}</p>
<p>Double: {{ doubleCount }}</p>
<button @click="increment">+</button>
<button @click="decrement">-</button>
</div>
</template>useToggle
File: composables/useToggle.ts
// No imports needed - ref auto-imported by Nuxt
export function useToggle(initialState = false) {
const state = ref(initialState)
const toggle = () => {
state.value = !state.value
}
const setTrue = () => {
state.value = true
}
const setFalse = () => {
state.value = false
}
return {
state,
toggle,
setTrue,
setFalse
}
}useLocalStorage
// composables/useLocalStorage.ts
// No imports needed - ref, watch auto-imported by Nuxt
export function useLocalStorage<T>(key: string, defaultValue: T) {
const storedValue = localStorage.getItem(key)
const value = ref<T>(
storedValue ? JSON.parse(storedValue) : defaultValue
)
watch(value, (newValue) => {
localStorage.setItem(key, JSON.stringify(newValue))
}, { deep: true })
return value
}Usage:
<script setup lang="ts">
import { useLocalStorage } from '@/composables/useLocalStorage'
interface User {
name: string
email: string
}
const user = useLocalStorage<User>('user', {
name: '',
email: ''
})
</script>useDebounce
// composables/useDebounce.ts
// No imports needed - ref, watch auto-imported by Nuxt
export function useDebounce<T>(value: Ref<T>, delay = 300) {
const debouncedValue = ref<T>(value.value)
let timeout: ReturnType<typeof setTimeout>
watch(value, (newValue) => {
clearTimeout(timeout)
timeout = setTimeout(() => {
debouncedValue.value = newValue
}, delay)
})
return debouncedValue
}Usage:
<script setup lang="ts">
// No imports needed - ref auto-imported by Nuxt
import { useDebounce } from '@/composables/useDebounce'
const searchQuery = ref('')
const debouncedQuery = useDebounce(searchQuery, 500)
watch(debouncedQuery, (query) => {
// API call with debounced query
console.log('Searching for:', query)
})
</script>
<template>
<input v-model="searchQuery" placeholder="Search..." />
</template>useClickOutside
// composables/useClickOutside.ts
// No imports needed - onMounted, onUnmounted auto-imported by Nuxt
import type { Ref } from 'vue'
export function useClickOutside(
elementRef: Ref<HTMLElement | null>,
callback: () => void
) {
const handleClick = (event: MouseEvent) => {
if (elementRef.value && !elementRef.value.contains(event.target as Node)) {
callback()
}
}
onMounted(() => {
document.addEventListener('click', handleClick)
})
onUnmounted(() => {
document.removeEventListener('click', handleClick)
})
}Usage:
<script setup lang="ts">
// No imports needed - ref auto-imported by Nuxt
import { useClickOutside } from '@/composables/useClickOutside'
const dropdownRef = ref<HTMLElement | null>(null)
const isOpen = ref(false)
useClickOutside(dropdownRef, () => {
isOpen.value = false
})
</script>
<template>
<div ref="dropdownRef" class="relative">
<button @click="isOpen = !isOpen">Toggle</button>
<div v-if="isOpen" class="absolute">
Dropdown content
</div>
</div>
</template>---
State Management
Simple Reactive Store
// stores/useStore.ts
// No imports needed - reactive, readonly auto-imported by Nuxt
interface State {
user: User | null
theme: 'light' | 'dark'
notifications: Notification[]
}
const state = reactive<State>({
user: null,
theme: 'light',
notifications: []
})
const setUser = (user: User | null) => {
state.user = user
}
const setTheme = (theme: 'light' | 'dark') => {
state.theme = theme
}
const addNotification = (notification: Notification) => {
state.notifications.push(notification)
}
const removeNotification = (id: string) => {
const index = state.notifications.findIndex(n => n.id === id)
if (index !== -1) {
state.notifications.splice(index, 1)
}
}
export function useStore() {
return {
state: readonly(state),
setUser,
setTheme,
addNotification,
removeNotification
}
}Usage:
<script setup lang="ts">
import { useStore } from '@/stores/useStore'
const { state, setTheme } = useStore()
</script>
<template>
<div>
<p>Current theme: {{ state.theme }}</p>
<button @click="setTheme('dark')">Dark Mode</button>
</div>
</template>Pinia Store (Recommended)
// stores/user.ts
import { defineStore } from 'pinia'
interface User {
id: string
name: string
email: string
}
export const useUserStore = defineStore('user', {
state: () => ({
user: null as User | null,
isAuthenticated: false
}),
getters: {
userName: (state) => state.user?.name ?? 'Guest',
userEmail: (state) => state.user?.email ?? ''
},
actions: {
setUser(user: User) {
this.user = user
this.isAuthenticated = true
},
logout() {
this.user = null
this.isAuthenticated = false
}
}
})Setup:
// main.ts
import { createPinia } from 'pinia'
// No imports needed - createApp auto-imported by Nuxt
import App from './App.vue'
const pinia = createPinia()
const app = createApp(App)
app.use(pinia)
app.mount('#app')Usage:
<script setup lang="ts">
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
</script>
<template>
<div>
<p v-if="userStore.isAuthenticated">
Welcome, {{ userStore.userName }}!
</p>
<button @click="userStore.logout">Logout</button>
</div>
</template>---
Lifecycle Hooks
Hook Usage Patterns
<script setup lang="ts">
import {
onBeforeMount,
onMounted,
onBeforeUpdate,
onUpdated,
onBeforeUnmount,
onUnmounted
} from 'vue'
// Before component is mounted
onBeforeMount(() => {
console.log('Before mount')
})
// After component is mounted
onMounted(() => {
console.log('Mounted')
// Fetch data, set up event listeners
})
// Before component updates
onBeforeUpdate(() => {
console.log('Before update')
})
// After component updates
onUpdated(() => {
console.log('Updated')
})
// Before component is unmounted
onBeforeUnmount(() => {
console.log('Before unmount')
// Clean up event listeners, timers
})
// After component is unmounted
onUnmounted(() => {
console.log('Unmounted')
})
</script>Fetch Data on Mount
<script setup lang="ts">
// No imports needed - ref, onMounted auto-imported by Nuxt
interface Product {
id: string
name: string
price: number
}
const products = ref<Product[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
onMounted(async () => {
loading.value = true
try {
const response = await fetch('/api/products')
products.value = await response.json()
} catch (e) {
error.value = 'Failed to load products'
} finally {
loading.value = false
}
})
</script>
<template>
<div>
<div v-if="loading">Loading...</div>
<div v-else-if="error">{{ error }}</div>
<div v-else>
<div v-for="product in products" :key="product.id">
{{ product.name }} - ${{ product.price }}
</div>
</div>
</div>
</template>---
Reactive Data
ref vs reactive
<script setup lang="ts">
// No imports needed - ref, reactive auto-imported by Nuxt
// ref - for primitives and single values
const count = ref(0)
const name = ref('John')
// Access with .value
count.value++
console.log(name.value)
// reactive - for objects
const state = reactive({
count: 0,
name: 'John',
user: {
id: 1,
email: 'john@example.com'
}
})
// Direct access (no .value)
state.count++
console.log(state.name)
// Nested reactivity
state.user.email = 'new@example.com'
</script>toRefs
<script setup lang="ts">
// No imports needed - reactive, toRefs auto-imported by Nuxt
const state = reactive({
count: 0,
name: 'John'
})
// Destructure while maintaining reactivity
const { count, name } = toRefs(state)
// Now these are refs
count.value++
console.log(name.value)
</script>shallowRef & shallowReactive
<script setup lang="ts">
// No imports needed - shallowRef, shallowReactive auto-imported by Nuxt
// Only top level is reactive
const state = shallowReactive({
count: 0,
nested: {
value: 1 // Not reactive
}
})
// Triggers reactivity
state.count++
// Does NOT trigger reactivity
state.nested.value++
// Must replace entire nested object
state.nested = { value: 2 } // Triggers reactivity
</script>---
Computed Properties
Basic Computed
<script setup lang="ts">
// No imports needed - ref, computed auto-imported by Nuxt
const firstName = ref('John')
const lastName = ref('Doe')
const fullName = computed(() => {
return `${firstName.value} ${lastName.value}`
})
</script>
<template>
<p>{{ fullName }}</p>
</template>Writable Computed
<script setup lang="ts">
// No imports needed - ref, computed auto-imported by Nuxt
const firstName = ref('John')
const lastName = ref('Doe')
const fullName = computed({
get() {
return `${firstName.value} ${lastName.value}`
},
set(value: string) {
[firstName.value, lastName.value] = value.split(' ')
}
})
</script>
<template>
<input v-model="fullName" />
</template>Computed with Complex Logic
<script setup lang="ts">
// No imports needed - ref, computed auto-imported by Nuxt
interface Product {
id: string
name: string
price: number
category: string
}
const products = ref<Product[]>([])
const searchQuery = ref('')
const selectedCategory = ref('')
const sortBy = ref<'name' | 'price'>('name')
const filteredProducts = computed(() => {
let result = products.value
// Filter by search query
if (searchQuery.value) {
result = result.filter(p =>
p.name.toLowerCase().includes(searchQuery.value.toLowerCase())
)
}
// Filter by category
if (selectedCategory.value) {
result = result.filter(p => p.category === selectedCategory.value)
}
// Sort
result.sort((a, b) => {
if (sortBy.value === 'name') {
return a.name.localeCompare(b.name)
}
return a.price - b.price
})
return result
})
</script>---
Watchers
Basic Watch
<script setup lang="ts">
// No imports needed - ref, watch auto-imported by Nuxt
const count = ref(0)
watch(count, (newValue, oldValue) => {
console.log(`Count changed from ${oldValue} to ${newValue}`)
})
</script>Watch Multiple Sources
<script setup lang="ts">
// No imports needed - ref, watch auto-imported by Nuxt
const firstName = ref('John')
const lastName = ref('Doe')
watch([firstName, lastName], ([newFirst, newLast], [oldFirst, oldLast]) => {
console.log(`Name changed from ${oldFirst} ${oldLast} to ${newFirst} ${newLast}`)
})
</script>Deep Watch
<script setup lang="ts">
// No imports needed - reactive, watch auto-imported by Nuxt
const state = reactive({
user: {
name: 'John',
settings: {
theme: 'light'
}
}
})
watch(
() => state.user,
(newUser) => {
console.log('User changed:', newUser)
},
{ deep: true }
)
</script>Immediate Watch
<script setup lang="ts">
// No imports needed - ref, watch auto-imported by Nuxt
const count = ref(0)
watch(
count,
(value) => {
console.log('Count:', value)
},
{ immediate: true } // Runs immediately with current value
)
</script>watchEffect
<script setup lang="ts">
// No imports needed - ref, watchEffect auto-imported by Nuxt
const count = ref(0)
const doubled = ref(0)
// Automatically tracks dependencies
watchEffect(() => {
doubled.value = count.value * 2
console.log(`Count: ${count.value}, Doubled: ${doubled.value}`)
})
</script>---
Event Handling
Type-Safe Events
<script setup lang="ts">
// No imports needed - ref auto-imported by Nuxt
const handleClick = (event: MouseEvent) => {
console.log('Clicked at:', event.clientX, event.clientY)
}
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement
console.log('Input value:', target.value)
}
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Enter') {
console.log('Enter pressed')
}
}
</script>
<template>
<button @click="handleClick">Click Me</button>
<input @input="handleInput" @keydown="handleKeydown" />
</template>Event Modifiers
<template>
<!-- Prevent default -->
<form @submit.prevent="handleSubmit">
<button type="submit">Submit</button>
</form>
<!-- Stop propagation -->
<div @click="handleParent">
<button @click.stop="handleChild">Click</button>
</div>
<!-- Once -->
<button @click.once="handleOnce">Click Once</button>
<!-- Key modifiers -->
<input @keyup.enter="handleEnter" />
<input @keyup.ctrl.s="handleSave" />
<!-- Mouse modifiers -->
<button @click.left="handleLeft">Left Click</button>
<button @click.right.prevent="handleRight">Right Click</button>
</template>---
Template Refs
Basic Template Ref
<script setup lang="ts">
// No imports needed - ref, onMounted auto-imported by Nuxt
const inputRef = ref<HTMLInputElement | null>(null)
onMounted(() => {
inputRef.value?.focus()
})
</script>
<template>
<input ref="inputRef" />
</template>Component Ref
<script setup lang="ts">
// No imports needed - ref auto-imported by Nuxt
import ChildComponent from './ChildComponent.vue'
const childRef = ref<InstanceType<typeof ChildComponent> | null>(null)
const callChildMethod = () => {
childRef.value?.someMethod()
}
</script>
<template>
<ChildComponent ref="childRef" />
<button @click="callChildMethod">Call Child Method</button>
</template>---
Provide/Inject
Provide Data
<script setup lang="ts">
// No imports needed - provide, ref auto-imported by Nuxt
const theme = ref('light')
provide('theme', theme)
</script>Inject Data
<script setup lang="ts">
// No imports needed - inject auto-imported by Nuxt
import type { Ref } from 'vue'
const theme = inject<Ref<string>>('theme')
</script>
<template>
<div :class="theme">Content</div>
</template>Type-Safe Provide/Inject
// keys.ts
import type { InjectionKey, Ref } from 'vue'
export const ThemeKey: InjectionKey<Ref<string>> = Symbol('theme')<script setup lang="ts">
// No imports needed - provide, ref auto-imported by Nuxt
import { ThemeKey } from './keys'
const theme = ref('light')
provide(ThemeKey, theme)
</script><script setup lang="ts">
// No imports needed - inject auto-imported by Nuxt
import { ThemeKey } from './keys'
const theme = inject(ThemeKey)
// theme is typed as Ref<string> | undefined
</script>---
Async Data
useFetch Composable
// composables/useFetch.ts
// No imports needed - ref auto-imported by Nuxt
export function useFetch<T>(url: string) {
const data = ref<T | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const fetch = async () => {
loading.value = true
error.value = null
try {
const response = await window.fetch(url)
if (!response.ok) throw new Error('Failed to fetch')
data.value = await response.json()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Unknown error'
} finally {
loading.value = false
}
}
return {
data,
loading,
error,
fetch
}
}Usage:
<script setup lang="ts">
// No imports needed - onMounted auto-imported by Nuxt
import { useFetch } from '@/composables/useFetch'
interface User {
id: number
name: string
}
const { data: user, loading, error, fetch } = useFetch<User>('/api/user')
onMounted(() => {
fetch()
})
</script>
<template>
<div v-if="loading">Loading...</div>
<div v-else-if="error">{{ error }}</div>
<div v-else-if="user">{{ user.name }}</div>
</template>---
Form Validation Patterns
Complete Form with Validation
<script setup lang="ts">
// No imports needed - ref, computed auto-imported by Nuxt
interface FormData {
email: string
password: string
confirmPassword: string
}
const form = ref<FormData>({
email: '',
password: '',
confirmPassword: ''
})
const errors = ref<Partial<Record<keyof FormData, string>>>({})
const touched = ref<Partial<Record<keyof FormData, boolean>>>({})
const isValid = computed(() => {
return form.value.email.length > 0 &&
form.value.password.length >= 8 &&
form.value.password === form.value.confirmPassword
})
const validateField = (field: keyof FormData) => {
touched.value[field] = true
switch (field) {
case 'email':
if (!form.value.email) {
errors.value.email = 'Email is required'
} else if (!form.value.email.includes('@')) {
errors.value.email = 'Invalid email address'
} else {
delete errors.value.email
}
break
case 'password':
if (!form.value.password) {
errors.value.password = 'Password is required'
} else if (form.value.password.length < 8) {
errors.value.password = 'Password must be at least 8 characters'
} else {
delete errors.value.password
}
break
case 'confirmPassword':
if (form.value.password !== form.value.confirmPassword) {
errors.value.confirmPassword = 'Passwords do not match'
} else {
delete errors.value.confirmPassword
}
break
}
}
const validateAll = () => {
(Object.keys(form.value) as Array<keyof FormData>).forEach(validateField)
return Object.keys(errors.value).length === 0
}
const handleSubmit = () => {
if (validateAll()) {
console.log('Form submitted:', form.value)
}
}
</script>
<template>
<form @submit.prevent="handleSubmit" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700">Email</label>
<input
v-model="form.email"
@blur="validateField('email')"
type="email"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
:class="{ 'border-red-500': touched.email && errors.email }"
/>
<p v-if="touched.email && errors.email" class="mt-1 text-sm text-red-600">
{{ errors.email }}
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Password</label>
<input
v-model="form.password"
@blur="validateField('password')"
type="password"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
:class="{ 'border-red-500': touched.password && errors.password }"
/>
<p v-if="touched.password && errors.password" class="mt-1 text-sm text-red-600">
{{ errors.password }}
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Confirm Password</label>
<input
v-model="form.confirmPassword"
@blur="validateField('confirmPassword')"
type="password"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
:class="{ 'border-red-500': touched.confirmPassword && errors.confirmPassword }"
/>
<p v-if="touched.confirmPassword && errors.confirmPassword" class="mt-1 text-sm text-red-600">
{{ errors.confirmPassword }}
</p>
</div>
<button
type="submit"
:disabled="!isValid"
class="w-full bg-blue-600 text-white py-2 rounded-md disabled:bg-gray-400 disabled:cursor-not-allowed"
>
Submit
</button>
</form>
</template>Reusable useForm Composable
// composables/useForm.ts
// No imports needed - ref, computed auto-imported by Nuxt
type ValidationRule<T> = (value: T) => string | undefined
interface FieldConfig<T> {
initialValue: T
rules?: ValidationRule<T>[]
}
export function useForm<T extends Record<string, any>>(
config: Record<keyof T, FieldConfig<any>>
) {
const form = ref<T>(
Object.keys(config).reduce((acc, key) => {
acc[key as keyof T] = config[key as keyof T].initialValue
return acc
}, {} as T)
)
const errors = ref<Partial<Record<keyof T, string>>>({})
const touched = ref<Partial<Record<keyof T, boolean>>>({})
const validateField = (field: keyof T) => {
touched.value[field] = true
const rules = config[field].rules || []
for (const rule of rules) {
const error = rule(form.value[field])
if (error) {
errors.value[field] = error
return false
}
}
delete errors.value[field]
return true
}
const validateAll = () => {
let isValid = true
for (const field of Object.keys(config) as Array<keyof T>) {
if (!validateField(field)) {
isValid = false
}
}
return isValid
}
const reset = () => {
form.value = Object.keys(config).reduce((acc, key) => {
acc[key as keyof T] = config[key as keyof T].initialValue
return acc
}, {} as T)
errors.value = {}
touched.value = {}
}
const isValid = computed(() => Object.keys(errors.value).length === 0)
return {
form,
errors,
touched,
validateField,
validateAll,
reset,
isValid
}
}
// Validation helpers
export const required = (message = 'This field is required') =>
(value: any) => value ? undefined : message
export const minLength = (min: number, message?: string) =>
(value: string) => value.length >= min ? undefined : message || `Minimum ${min} characters`
export const email = (message = 'Invalid email address') =>
(value: string) => value.includes('@') ? undefined : message
export const match = (otherField: any, message = 'Fields do not match') =>
(value: any) => value === otherField.value ? undefined : messageUsage:
<script setup lang="ts">
import { useForm, required, minLength, email } from '@/composables/useForm'
const { form, errors, touched, validateField, validateAll, isValid } = useForm({
email: {
initialValue: '',
rules: [required(), email()]
},
password: {
initialValue: '',
rules: [required(), minLength(8)]
}
})
const handleSubmit = () => {
if (validateAll()) {
console.log('Form submitted:', form.value)
}
}
</script>---
These patterns provide a solid foundation for building complex Vue 3 applications with TypeScript and the Composition API.
Deployment Guide
Comprehensive guide for deploying NuxtJS 4 (Vue) and Next.js (React) mockups with TailwindCSS v4 to various hosting platforms.
Note: Both frameworks support SSR (Server-Side Rendering) and SSG (Static Site Generation). Choose based on your needs.
Table of Contents
- Build Configuration
- NuxtJS Configuration
- Next.js Configuration
- Netlify Deployment
- Vercel Deployment
- GitHub Pages
- AWS S3 + CloudFront
- Docker Deployment
- Environment Variables
- Performance Optimization
---
Build Configuration
NuxtJS Configuration
nuxt.config.ts for Production
// https://nuxt.com/docs/4.x/api/configuration/nuxt-config
import tailwindcss from '@tailwindcss/vite'
export default defineNuxtConfig({
// Rendering mode
ssr: true, // Set to false for SPA mode
// Nitro (server) configuration
nitro: {
compressPublicAssets: true,
prerender: {
crawlLinks: true,
routes: ['/'] // Add your routes for SSG
}
},
// Runtime config (for environment variables)
runtimeConfig: {
// Private keys (server-side only)
apiSecret: '',
// Public keys (exposed to client)
public: {
apiBase: process.env.API_BASE_URL || ''
}
},
// App configuration
app: {
head: {
title: 'My Mockup',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ name: 'description', content: 'My mockup description' }
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
]
}
},
// TypeScript
typescript: {
strict: true,
typeCheck: true
},
// Vite optimization with TailwindCSS v4
vite: {
plugins: [
tailwindcss()
],
build: {
cssCodeSplit: true,
rollupOptions: {
output: {
manualChunks: {
// Split vendor chunks for better caching
}
}
}
}
},
// CSS files
css: ['~/assets/css/main.css']
})Build Commands (NuxtJS)
{
"scripts": {
"dev": "nuxt dev",
"build": "nuxt build",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"typecheck": "nuxi typecheck",
"analyze": "nuxi analyze"
}
}Build Types:
npm run build- SSR build (.output/ directory)npm run generate- Static site (SSG) build (.output/public/ directory)npm run preview- Preview production build locally
Next.js Configuration
next.config.js for Production
/** @type {import('next').NextConfig} */
const nextConfig = {
// Rendering mode
// output: 'export', // Uncomment for static export (SSG)
// Performance optimizations
poweredByHeader: false,
compress: true,
// Image optimization
images: {
// For static export, use unoptimized
// unoptimized: true,
remotePatterns: [
{
protocol: 'https',
hostname: 'example.com',
},
],
},
// TypeScript and linting
typescript: {
ignoreBuildErrors: false,
},
eslint: {
ignoreDuringBuilds: false,
},
// Environment variables (public vars must start with NEXT_PUBLIC_)
env: {
CUSTOM_KEY: process.env.CUSTOM_KEY,
},
// Headers for security
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
],
},
]
},
}
export default nextConfigBuild Commands (Next.js)
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"lint:fix": "eslint --fix ."
}
}Build Types:
npm run build- Production build (SSR, default)npm run buildwithoutput: 'export'in config - Static export (SSG)npm run start- Start production server (SSR only)
---
Netlify Deployment
NuxtJS on Netlify
SSG (Static Site) Deployment
1. Create `netlify.toml`:
[build]
command = "npm run generate"
publish = ".output/public"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
[build.environment]
NODE_VERSION = "20"
[[headers]]
for = "/*"
[headers.values]
X-Frame-Options = "DENY"
X-XSS-Protection = "1; mode=block"
X-Content-Type-Options = "nosniff"
Referrer-Policy = "strict-origin-when-cross-origin"
[[headers]]
for = "/_nuxt/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"SSR Deployment (using Nitro)
Install Netlify adapter:
npm install -D @netlify/functionsUpdate nuxt.config.ts:
export default defineNuxtConfig({
nitro: {
preset: 'netlify'
}
})Build and deploy:
npm run build
# Output is in .output/Next.js on Netlify
SSR Deployment (Default)
1. Create `netlify.toml`:
[build]
command = "npm run build"
publish = ".next"
[build.environment]
NODE_VERSION = "20"
[[plugins]]
package = "@netlify/plugin-nextjs"
[[headers]]
for = "/*"
[headers.values]
X-Frame-Options = "DENY"
X-Content-Type-Options = "nosniff"
Referrer-Policy = "strict-origin-when-cross-origin"Install Next.js plugin:
npm install -D @netlify/plugin-nextjsSSG (Static Export) Deployment
Update next.config.js:
const nextConfig = {
output: 'export',
images: {
unoptimized: true, // Required for static export
},
}Update `netlify.toml`:
[build]
command = "npm run build"
publish = "out"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
[build.environment]
NODE_VERSION = "20"Deployment Methods (Both Frameworks)
Method 1: Git-based Deployment
- Go to <https://app.netlify.com>
- Click "New site from Git"
- Select your repository
- Build settings are auto-detected from
netlify.toml - Click "Deploy site"
Method 2: Netlify CLI
# Install Netlify CLI
npm install -g netlify-cli
# Login to Netlify
netlify login
# Initialize Netlify site
netlify init
# Deploy
netlify deploy --prodCustom Domain Setup
# Add custom domain
netlify domains:add yourdomain.com
# Configure DNS (use Netlify DNS or add CNAME record)
# CNAME record: www -> your-site-name.netlify.app---
Vercel Deployment
Both NuxtJS and Next.js have first-class Vercel support - zero configuration needed!
NuxtJS on Vercel
Method 1: Git Integration (Recommended)
1. No vercel.json needed - Vercel auto-detects NuxtJS
2. Deploy:
- Go to <https://vercel.com>
- Click "New Project"
- Import your Git repository
- Vercel automatically detects Nuxt and configures everything
- Click "Deploy"
Method 2: Vercel CLI
# Install Vercel CLI
npm install -g vercel
# Login
vercel login
# Deploy
vercel --prodSSR vs SSG on Vercel (NuxtJS)
SSR (default): Runs Nuxt server on Vercel Edge Functions
SSG: Update nuxt.config.ts:
export default defineNuxtConfig({
nitro: {
preset: 'vercel-static'
}
})Then run npm run generate for static deployment.
Next.js on Vercel (Recommended Platform)
Vercel is the creator and recommended platform for Next.js
Method 1: Git Integration (Easiest)
1. Zero configuration needed - Vercel auto-detects Next.js
2. Deploy:
- Go to <https://vercel.com>
- Click "New Project"
- Import your Git repository
- Vercel automatically configures everything
- Click "Deploy"
Method 2: Vercel CLI
# Install Vercel CLI
npm install -g vercel
# Login
vercel login
# Deploy
vercel --prodAutomatic Features
- SSR by default: Server-Side Rendering with Edge Functions
- ISR: Incremental Static Regeneration automatically enabled
- Image Optimization: Built-in with Next.js Image component
- Analytics: Optional Vercel Analytics integration
- Edge Middleware: Runs on Vercel Edge Network
Environment Variables
Set in Vercel Dashboard:
1. Go to Project Settings → Environment Variables 2. Add variables (e.g., NEXT_PUBLIC_API_URL) 3. Redeploy to apply changes
---
GitHub Pages
Note: GitHub Pages only supports static sites (SSG)
NuxtJS on GitHub Pages
Using GitHub Actions
1. Update nuxt.config.ts for GitHub Pages:
export default defineNuxtConfig({
app: {
baseURL: '/your-repo-name/', // Replace with your repo name
buildAssetsDir: 'assets',
},
// Use SSG
ssr: false
})2. Create `.github/workflows/deploy.yml`:
name: Deploy to GitHub Pages
on:
push:
branches:
- main
permissions:
contents: read
pages: write
id-token: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run generate
env:
NODE_ENV: production
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: .output/public
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v41. Enable GitHub Pages:
- Go to repository Settings
- Navigate to Pages
- Source: GitHub Actions
---
AWS S3 + CloudFront
Setup Script
#!/bin/bash
# Configuration
BUCKET_NAME="your-mockup-bucket"
CLOUDFRONT_ID="your-distribution-id"
REGION="us-east-1"
# Build project
echo "Building project..."
npm run build
# Create S3 bucket
echo "Creating S3 bucket..."
aws s3 mb s3://$BUCKET_NAME --region $REGION
# Configure bucket for static website hosting
aws s3 website s3://$BUCKET_NAME \
--index-document index.html \
--error-document index.html
# Upload files
echo "Uploading files..."
aws s3 sync dist/ s3://$BUCKET_NAME \
--delete \
--cache-control "public, max-age=31536000, immutable" \
--exclude "index.html"
# Upload index.html separately with no-cache
aws s3 cp dist/index.html s3://$BUCKET_NAME/index.html \
--cache-control "no-cache, no-store, must-revalidate"
# Create CloudFront invalidation
echo "Creating CloudFront invalidation..."
aws cloudfront create-invalidation \
--distribution-id $CLOUDFRONT_ID \
--paths "/*"
echo "Deployment complete!"CloudFront Configuration
{
"Origins": [
{
"Id": "S3-mockup-bucket",
"DomainName": "your-mockup-bucket.s3.amazonaws.com",
"S3OriginConfig": {
"OriginAccessIdentity": ""
}
}
],
"DefaultCacheBehavior": {
"TargetOriginId": "S3-mockup-bucket",
"ViewerProtocolPolicy": "redirect-to-https",
"Compress": true,
"CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6"
},
"CustomErrorResponses": [
{
"ErrorCode": 404,
"ResponseCode": 200,
"ResponsePagePath": "/index.html"
},
{
"ErrorCode": 403,
"ResponseCode": 200,
"ResponsePagePath": "/index.html"
}
]
}---
Docker Deployment
Dockerfile
# Build stage
FROM node:18-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source files
COPY . .
# Build application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built files
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]nginx.conf
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Enable gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/json;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
}Docker Commands
# Build image
docker build -t mockup-app .
# Run container
docker run -d -p 8080:80 --name mockup mockup-app
# Stop container
docker stop mockup
# Remove container
docker rm mockupdocker-compose.yml
version: '3.8'
services:
web:
build: .
ports:
- "8080:80"
restart: unless-stopped
environment:
- NODE_ENV=production---
Environment Variables
.env Files
# .env.development
VITE_API_URL=http://localhost:3000/api
VITE_APP_TITLE=Mockup Dev
# .env.production
VITE_API_URL=https://api.production.com
VITE_APP_TITLE=MockupUsing Environment Variables
// src/config/index.ts
export const config = {
apiUrl: import.meta.env.VITE_API_URL,
appTitle: import.meta.env.VITE_APP_TITLE,
isDev: import.meta.env.DEV,
isProd: import.meta.env.PROD
}<script setup lang="ts">
import { config } from '@/config'
console.log(config.apiUrl)
</script>---
Performance Optimization
Code Splitting
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
component: () => import('@/views/Home.vue') // Lazy loaded
},
{
path: '/dashboard',
component: () => import('@/views/Dashboard.vue')
}
]
})Image Optimization
# Install image optimization plugin
npm install -D vite-plugin-imagemin// vite.config.ts
import imagemin from 'vite-plugin-imagemin'
export default defineConfig({
plugins: [
vue(),
imagemin({
gifsicle: { optimizationLevel: 7 },
optipng: { optimizationLevel: 7 },
mozjpeg: { quality: 80 },
svgo: {
plugins: [
{ name: 'removeViewBox', active: false },
{ name: 'removeEmptyAttrs', active: true }
]
}
})
]
})Bundle Analysis
# Install plugin
npm install -D rollup-plugin-visualizer// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
plugins: [
vue(),
visualizer({
open: true,
gzipSize: true,
brotliSize: true
})
]
})Preload/Prefetch
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks(id) {
// Split vendor chunks
if (id.includes('node_modules')) {
return 'vendor'
}
}
}
}
}
})Service Worker (PWA)
# Install PWA plugin
npm install -D vite-plugin-pwa// vite.config.ts
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
vue(),
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'Mockup App',
short_name: 'Mockup',
theme_color: '#ffffff',
icons: [
{
src: '/icon-192.png',
sizes: '192x192',
type: 'image/png'
}
]
}
})
]
})---
Deployment Checklist
- [ ] Run type checking:
npm run type-check - [ ] Build project:
npm run build - [ ] Test production build locally:
npm run preview - [ ] Verify environment variables are set correctly
- [ ] Check bundle size and optimize if needed
- [ ] Test on multiple browsers
- [ ] Verify mobile responsiveness
- [ ] Check accessibility (WCAG compliance)
- [ ] Set up analytics (Google Analytics, etc.)
- [ ] Configure error tracking (Sentry, etc.)
- [ ] Set up monitoring and uptime checks
- [ ] Configure SSL certificate
- [ ] Set up custom domain (if applicable)
- [ ] Create backup/rollback strategy
- [ ] Document deployment process
- [ ] Test deployed site functionality
This guide covers the most common deployment scenarios. Choose the platform that best fits your project requirements and team expertise.
Complete Mockup Examples
Production-ready mockup examples using NuxtJS 4, TypeScript, and TailwindCSS v4.
Note: All composables and Vue APIs are auto-imported by Nuxt - no manual imports needed.
Table of Contents
---
E-commerce Product Page
Complete product page with gallery, reviews, and cart functionality.
ProductPage.vue
<script setup lang="ts">
// No imports needed - ref, computed, useCart auto-imported by Nuxt
interface Product {
id: string
name: string
price: number
originalPrice?: number
rating: number
reviews: number
images: string[]
description: string
features: string[]
sizes: string[]
colors: { name: string; hex: string }[]
}
const product = ref<Product>({
id: '1',
name: 'Premium Cotton T-Shirt',
price: 29.99,
originalPrice: 49.99,
rating: 4.5,
reviews: 128,
images: [
'/product-1.jpg',
'/product-2.jpg',
'/product-3.jpg',
'/product-4.jpg'
],
description: 'Made from 100% organic cotton, this premium t-shirt offers exceptional comfort and durability. Perfect for everyday wear.',
features: [
'100% Organic Cotton',
'Machine Washable',
'Breathable Fabric',
'Eco-Friendly Dyes',
'Regular Fit'
],
sizes: ['XS', 'S', 'M', 'L', 'XL', 'XXL'],
colors: [
{ name: 'Black', hex: '#000000' },
{ name: 'White', hex: '#FFFFFF' },
{ name: 'Navy', hex: '#1e3a8a' },
{ name: 'Gray', hex: '#6b7280' }
]
})
const selectedImage = ref(0)
const selectedSize = ref('')
const selectedColor = ref('')
const quantity = ref(1)
const { addToCart } = useCart()
const discount = computed(() => {
if (product.value.originalPrice) {
return Math.round(
((product.value.originalPrice - product.value.price) / product.value.originalPrice) * 100
)
}
return 0
})
const handleAddToCart = () => {
if (!selectedSize.value || !selectedColor.value) {
alert('Please select size and color')
return
}
addToCart({
productId: product.value.id,
name: product.value.name,
price: product.value.price,
quantity: quantity.value,
size: selectedSize.value,
color: selectedColor.value,
image: product.value.images[0]
})
}
</script>
<template>
<div class="min-h-screen bg-gray-50">
<Container class="py-8">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12">
<!-- Image Gallery -->
<div>
<!-- Main Image -->
<div class="bg-white rounded-lg overflow-hidden shadow-lg mb-4">
<img
:src="product.images[selectedImage]"
:alt="product.name"
class="w-full aspect-square object-cover"
/>
</div>
<!-- Thumbnail Gallery -->
<div class="grid grid-cols-4 gap-4">
<button
v-for="(image, index) in product.images"
:key="index"
@click="selectedImage = index"
:class="[
'relative overflow-hidden rounded-lg border-2 transition-all',
selectedImage === index
? 'border-blue-600'
: 'border-gray-200 hover:border-gray-300'
]"
>
<img :src="image" :alt="`${product.name} ${index + 1}`" class="w-full aspect-square object-cover" />
</button>
</div>
</div>
<!-- Product Info -->
<div>
<h1 class="text-3xl font-bold text-gray-900 mb-2">
{{ product.name }}
</h1>
<!-- Rating -->
<div class="flex items-center gap-2 mb-4">
<div class="flex">
<span v-for="i in 5" :key="i" class="text-yellow-400">
{{ i <= product.rating ? '★' : '☆' }}
</span>
</div>
<span class="text-gray-600">{{ product.rating }} ({{ product.reviews }} reviews)</span>
</div>
<!-- Price -->
<div class="flex items-baseline gap-3 mb-6">
<span class="text-4xl font-bold text-gray-900">
${{ product.price }}
</span>
<span v-if="product.originalPrice" class="text-2xl text-gray-500 line-through">
${{ product.originalPrice }}
</span>
<span v-if="discount" class="px-3 py-1 bg-red-100 text-red-700 rounded-full text-sm font-semibold">
-{{ discount }}%
</span>
</div>
<!-- Description -->
<p class="text-gray-600 mb-6">
{{ product.description }}
</p>
<!-- Features -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-3">Features</h3>
<ul class="space-y-2">
<li v-for="feature in product.features" :key="feature" class="flex items-center text-gray-600">
<span class="text-green-500 mr-2">✓</span>
{{ feature }}
</li>
</ul>
</div>
<!-- Color Selection -->
<div class="mb-6">
<h3 class="text-sm font-semibold mb-3">Color</h3>
<div class="flex gap-3">
<button
v-for="color in product.colors"
:key="color.name"
@click="selectedColor = color.name"
:class="[
'w-10 h-10 rounded-full border-2 transition-all',
selectedColor === color.name
? 'border-blue-600 scale-110'
: 'border-gray-300 hover:border-gray-400'
]"
:style="{ backgroundColor: color.hex }"
:title="color.name"
/>
</div>
</div>
<!-- Size Selection -->
<div class="mb-6">
<h3 class="text-sm font-semibold mb-3">Size</h3>
<div class="flex gap-2">
<button
v-for="size in product.sizes"
:key="size"
@click="selectedSize = size"
:class="[
'px-4 py-2 border-2 rounded-lg font-medium transition-all',
selectedSize === size
? 'border-blue-600 bg-blue-50 text-blue-600'
: 'border-gray-300 hover:border-gray-400'
]"
>
{{ size }}
</button>
</div>
</div>
<!-- Quantity -->
<div class="mb-6">
<h3 class="text-sm font-semibold mb-3">Quantity</h3>
<div class="flex items-center gap-3">
<button
@click="quantity = Math.max(1, quantity - 1)"
class="w-10 h-10 border-2 border-gray-300 rounded-lg hover:border-gray-400"
>
-
</button>
<span class="text-xl font-semibold w-12 text-center">{{ quantity }}</span>
<button
@click="quantity++"
class="w-10 h-10 border-2 border-gray-300 rounded-lg hover:border-gray-400"
>
+
</button>
</div>
</div>
<!-- Actions -->
<div class="flex gap-4">
<button
@click="handleAddToCart"
class="flex-1 bg-blue-600 text-white py-4 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
>
Add to Cart
</button>
<button class="px-6 py-4 border-2 border-gray-300 rounded-lg hover:border-gray-400 transition-colors">
♡
</button>
</div>
</div>
</div>
</Container>
</div>
</template>---
SaaS Dashboard
Full-featured dashboard with metrics, charts, and data tables.
Dashboard.vue
<script setup lang="ts">
// No imports needed - ref, computed auto-imported by Nuxt
interface Metric {
label: string
value: string
change: number
icon: string
}
const metrics = ref<Metric[]>([
{ label: 'Total Revenue', value: '$45,231', change: 12.5, icon: '💰' },
{ label: 'Active Users', value: '2,345', change: 8.2, icon: '👥' },
{ label: 'Conversion Rate', value: '3.24%', change: -2.4, icon: '📊' },
{ label: 'Avg. Order Value', value: '$124', change: 5.1, icon: '🛒' }
])
interface Activity {
id: string
user: string
action: string
time: string
avatar: string
}
const recentActivity = ref<Activity[]>([
{ id: '1', user: 'John Doe', action: 'Made a purchase of $299', time: '2 minutes ago', avatar: '👨' },
{ id: '2', user: 'Jane Smith', action: 'Signed up for premium plan', time: '15 minutes ago', avatar: '👩' },
{ id: '3', user: 'Mike Johnson', action: 'Updated profile information', time: '1 hour ago', avatar: '👨💼' },
{ id: '4', user: 'Sarah Williams', action: 'Left a 5-star review', time: '2 hours ago', avatar: '👩💼' }
])
const chartData = ref([
{ month: 'Jan', value: 4000 },
{ month: 'Feb', value: 3000 },
{ month: 'Mar', value: 5000 },
{ month: 'Apr', value: 4500 },
{ month: 'May', value: 6000 },
{ month: 'Jun', value: 5500 }
])
const maxValue = computed(() => Math.max(...chartData.value.map(d => d.value)))
</script>
<template>
<div class="min-h-screen bg-gray-50">
<!-- Header -->
<header class="bg-white border-b border-gray-200 sticky top-0 z-10">
<Container>
<div class="flex items-center justify-between h-16">
<h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
<div class="flex items-center gap-4">
<button class="relative p-2 text-gray-600 hover:text-gray-900">
🔔
<span class="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
</button>
<div class="flex items-center gap-3">
<div class="text-right">
<p class="text-sm font-medium text-gray-900">Admin User</p>
<p class="text-xs text-gray-500">admin@example.com</p>
</div>
<div class="w-10 h-10 bg-blue-600 rounded-full flex items-center justify-center text-white">
👤
</div>
</div>
</div>
</div>
</Container>
</header>
<!-- Main Content -->
<Container class="py-8">
<!-- Metrics Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div
v-for="metric in metrics"
:key="metric.label"
class="bg-white rounded-lg shadow-sm p-6 hover:shadow-md transition-shadow"
>
<div class="flex items-center justify-between mb-4">
<span class="text-3xl">{{ metric.icon }}</span>
<span
:class="[
'text-sm font-semibold px-2 py-1 rounded',
metric.change > 0
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'
]"
>
{{ metric.change > 0 ? '+' : '' }}{{ metric.change }}%
</span>
</div>
<p class="text-sm text-gray-600 mb-1">{{ metric.label }}</p>
<p class="text-3xl font-bold text-gray-900">{{ metric.value }}</p>
</div>
</div>
<!-- Charts and Activity -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Revenue Chart -->
<div class="lg:col-span-2 bg-white rounded-lg shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-6">Revenue Overview</h2>
<div class="flex items-end justify-between h-64 gap-4">
<div
v-for="data in chartData"
:key="data.month"
class="flex-1 flex flex-col items-center gap-2"
>
<div class="w-full bg-gray-200 rounded-t-lg relative" style="height: 100%">
<div
class="absolute bottom-0 w-full bg-blue-600 rounded-t-lg transition-all duration-500"
:style="{ height: `${(data.value / maxValue) * 100}%` }"
/>
</div>
<span class="text-sm text-gray-600">{{ data.month }}</span>
</div>
</div>
</div>
<!-- Recent Activity -->
<div class="bg-white rounded-lg shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-6">Recent Activity</h2>
<div class="space-y-4">
<div
v-for="activity in recentActivity"
:key="activity.id"
class="flex gap-3"
>
<div class="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center flex-shrink-0">
{{ activity.avatar }}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900">{{ activity.user }}</p>
<p class="text-sm text-gray-600 truncate">{{ activity.action }}</p>
<p class="text-xs text-gray-500 mt-1">{{ activity.time }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Data Table -->
<div class="mt-8 bg-white rounded-lg shadow-sm overflow-hidden">
<div class="p-6 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">Recent Orders</h2>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Order ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Customer</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Amount</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Date</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr v-for="i in 5" :key="i" class="hover:bg-gray-50">
<td class="px-6 py-4 text-sm font-medium text-gray-900">#{{ 1000 + i }}</td>
<td class="px-6 py-4 text-sm text-gray-600">Customer {{ i }}</td>
<td class="px-6 py-4">
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-700">
Completed
</span>
</td>
<td class="px-6 py-4 text-sm text-gray-900">${{ (Math.random() * 500 + 50).toFixed(2) }}</td>
<td class="px-6 py-4 text-sm text-gray-600">{{ new Date().toLocaleDateString() }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</Container>
</div>
</template>---
Portfolio Landing Page
Modern portfolio landing page with hero, projects, and contact sections.
Portfolio.vue
<script setup lang="ts">
// No imports needed - ref auto-imported by Nuxt
interface Project {
id: string
title: string
description: string
image: string
tags: string[]
link: string
}
const projects = ref<Project[]>([
{
id: '1',
title: 'E-Commerce Platform',
description: 'Full-stack e-commerce solution with payment integration',
image: '/project-1.jpg',
tags: ['Vue.js', 'Node.js', 'MongoDB'],
link: '#'
},
{
id: '2',
title: 'Task Management App',
description: 'Collaborative task management with real-time updates',
image: '/project-2.jpg',
tags: ['React', 'Firebase', 'TailwindCSS'],
link: '#'
},
{
id: '3',
title: 'Analytics Dashboard',
description: 'Data visualization dashboard with advanced analytics',
image: '/project-3.jpg',
tags: ['Vue.js', 'D3.js', 'TypeScript'],
link: '#'
}
])
const skills = ref([
'Vue.js', 'React', 'TypeScript', 'Node.js', 'TailwindCSS',
'MongoDB', 'PostgreSQL', 'AWS', 'Docker', 'Git'
])
</script>
<template>
<div class="min-h-screen">
<!-- Header -->
<header class="fixed top-0 w-full bg-white/80 backdrop-blur-md z-50 border-b border-gray-200">
<Container>
<nav class="flex items-center justify-between h-16">
<a href="#" class="text-xl font-bold text-gray-900">Portfolio</a>
<div class="hidden md:flex items-center gap-8">
<a href="#about" class="text-gray-600 hover:text-gray-900 transition-colors">About</a>
<a href="#projects" class="text-gray-600 hover:text-gray-900 transition-colors">Projects</a>
<a href="#skills" class="text-gray-600 hover:text-gray-900 transition-colors">Skills</a>
<a href="#contact" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
Contact
</a>
</div>
</nav>
</Container>
</header>
<!-- Hero Section -->
<section class="pt-32 pb-20 bg-gradient-to-br from-blue-50 to-purple-50">
<Container>
<div class="text-center max-w-3xl mx-auto">
<h1 class="text-5xl md:text-6xl font-bold text-gray-900 mb-6">
Hi, I'm <span class="text-blue-600">John Doe</span>
</h1>
<p class="text-xl md:text-2xl text-gray-600 mb-8">
Full-Stack Developer specializing in modern web applications
</p>
<div class="flex justify-center gap-4">
<a
href="#projects"
class="px-8 py-3 bg-blue-600 text-white rounded-lg font-semibold hover:bg-blue-700 transition-colors"
>
View My Work
</a>
<a
href="#contact"
class="px-8 py-3 border-2 border-gray-900 text-gray-900 rounded-lg font-semibold hover:bg-gray-900 hover:text-white transition-colors"
>
Get in Touch
</a>
</div>
</div>
</Container>
</section>
<!-- Projects Section -->
<section id="projects" class="py-20">
<Container>
<h2 class="text-4xl font-bold text-center text-gray-900 mb-12">Featured Projects</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<div
v-for="project in projects"
:key="project.id"
class="bg-white rounded-lg shadow-lg overflow-hidden hover:shadow-xl transition-shadow"
>
<div class="h-48 bg-gray-200">
<img :src="project.image" :alt="project.title" class="w-full h-full object-cover" />
</div>
<div class="p-6">
<h3 class="text-xl font-semibold text-gray-900 mb-2">{{ project.title }}</h3>
<p class="text-gray-600 mb-4">{{ project.description }}</p>
<div class="flex flex-wrap gap-2 mb-4">
<span
v-for="tag in project.tags"
:key="tag"
class="px-3 py-1 bg-blue-100 text-blue-700 rounded-full text-sm"
>
{{ tag }}
</span>
</div>
<a
:href="project.link"
class="inline-flex items-center text-blue-600 hover:text-blue-700 font-semibold"
>
View Project →
</a>
</div>
</div>
</div>
</Container>
</section>
<!-- Skills Section -->
<section id="skills" class="py-20 bg-gray-50">
<Container>
<h2 class="text-4xl font-bold text-center text-gray-900 mb-12">Skills & Technologies</h2>
<div class="flex flex-wrap justify-center gap-4">
<span
v-for="skill in skills"
:key="skill"
class="px-6 py-3 bg-white text-gray-900 rounded-lg shadow-md font-medium hover:shadow-lg transition-shadow"
>
{{ skill }}
</span>
</div>
</Container>
</section>
<!-- Contact Section -->
<section id="contact" class="py-20">
<Container>
<div class="max-w-2xl mx-auto text-center">
<h2 class="text-4xl font-bold text-gray-900 mb-6">Let's Work Together</h2>
<p class="text-xl text-gray-600 mb-8">
Have a project in mind? Get in touch and let's create something amazing.
</p>
<form class="space-y-4">
<input
type="text"
placeholder="Your Name"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<input
type="email"
placeholder="Your Email"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<textarea
placeholder="Your Message"
rows="5"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
class="w-full px-8 py-3 bg-blue-600 text-white rounded-lg font-semibold hover:bg-blue-700 transition-colors"
>
Send Message
</button>
</form>
</div>
</Container>
</section>
<!-- Footer -->
<footer class="bg-gray-900 text-white py-8">
<Container>
<div class="text-center">
<p>© 2026 John Doe. All rights reserved.</p>
</div>
</Container>
</footer>
</div>
</template>These examples demonstrate production-ready mockups with real-world patterns and best practices. Each can be customized and extended based on specific project requirements.
React Hooks for Mockups
Custom React hooks for state management and reusable logic in Next.js mockups.
Basic State Management
useToggle
Toggle boolean state with actions.
// lib/hooks/useToggle.ts
import { useState, useCallback } from 'react'
export function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue)
const toggle = useCallback(() => setValue(v => !v), [])
const setTrue = useCallback(() => setValue(true), [])
const setFalse = useCallback(() => setValue(false), [])
return { value, toggle, setTrue, setFalse }
}Usage:
'use client'
import { useToggle } from '@/lib/hooks/useToggle'
export default function Sidebar() {
const { value: isOpen, toggle, setFalse } = useToggle(false)
return (
<>
<button onClick={toggle}>Toggle Sidebar</button>
{isOpen && <aside>Sidebar content</aside>}
</>
)
}useCounter
Counter state with increment/decrement.
// lib/hooks/useCounter.ts
import { useState, useCallback } from 'react'
export function useCounter(initialValue = 0, step = 1) {
const [count, setCount] = useState(initialValue)
const increment = useCallback(() => setCount(c => c + step), [step])
const decrement = useCallback(() => setCount(c => c - step), [step])
const reset = useCallback(() => setCount(initialValue), [initialValue])
const set = useCallback((value: number) => setCount(value), [])
return { count, increment, decrement, reset, set }
}Usage:
'use client'
import { useCounter } from '@/lib/hooks/useCounter'
export default function CounterDemo() {
const { count, increment, decrement, reset } = useCounter(0, 1)
return (
<div className="space-y-4">
<div className="text-4xl font-bold">{count}</div>
<div className="flex gap-2">
<button onClick={decrement} className="btn">-</button>
<button onClick={increment} className="btn">+</button>
<button onClick={reset} className="btn">Reset</button>
</div>
</div>
)
}Modal Management
useModal
Manage modal open/close state.
// lib/hooks/useModal.ts
import { useState, useCallback } from 'react'
export function useModal() {
const [isOpen, setIsOpen] = useState(false)
const open = useCallback(() => setIsOpen(true), [])
const close = useCallback(() => setIsOpen(false), [])
const toggle = useCallback(() => setIsOpen(v => !v), [])
return { isOpen, open, close, toggle }
}Usage:
'use client'
import { useModal } from '@/lib/hooks/useModal'
import { Modal } from '@/components/ui/Modal'
export default function ModalDemo() {
const { isOpen, open, close } = useModal()
return (
<>
<button onClick={open} className="btn">Open Modal</button>
<Modal isOpen={isOpen} onClose={close}>
<h2>Modal Content</h2>
<p>This is a modal dialog.</p>
</Modal>
</>
)
}Form Handling
useForm
Simple form state management with validation.
// lib/hooks/useForm.ts
import { useState, useCallback, FormEvent } from 'react'
interface UseFormOptions<T> {
initialValues: T
onSubmit: (values: T) => void | Promise<void>
validate?: (values: T) => Partial<Record<keyof T, string>>
}
export function useForm<T extends Record<string, any>>({
initialValues,
onSubmit,
validate
}: UseFormOptions<T>) {
const [values, setValues] = useState<T>(initialValues)
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const handleChange = useCallback((name: keyof T, value: any) => {
setValues(prev => ({ ...prev, [name]: value }))
// Clear error when user types
setErrors(prev => ({ ...prev, [name]: undefined }))
}, [])
const handleSubmit = useCallback(async (e: FormEvent) => {
e.preventDefault()
// Validate if validator provided
if (validate) {
const validationErrors = validate(values)
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors)
return
}
}
setIsSubmitting(true)
try {
await onSubmit(values)
} finally {
setIsSubmitting(false)
}
}, [values, validate, onSubmit])
const reset = useCallback(() => {
setValues(initialValues)
setErrors({})
}, [initialValues])
return {
values,
errors,
isSubmitting,
handleChange,
handleSubmit,
reset
}
}Usage:
'use client'
import { useForm } from '@/lib/hooks/useForm'
interface LoginForm {
email: string
password: string
}
export default function LoginForm() {
const { values, errors, isSubmitting, handleChange, handleSubmit } = useForm<LoginForm>({
initialValues: { email: '', password: '' },
onSubmit: async (values) => {
console.log('Submitting:', values)
// API call here
},
validate: (values) => {
const errors: Partial<Record<keyof LoginForm, string>> = {}
if (!values.email) errors.email = 'Email is required'
if (!values.password) errors.password = 'Password is required'
return errors
}
})
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={values.email}
onChange={(e) => handleChange('email', e.target.value)}
className="input"
/>
{errors.email && <span className="text-red-600">{errors.email}</span>}
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={values.password}
onChange={(e) => handleChange('password', e.target.value)}
className="input"
/>
{errors.password && <span className="text-red-600">{errors.password}</span>}
</div>
<button type="submit" disabled={isSubmitting} className="btn">
{isSubmitting ? 'Submitting...' : 'Login'}
</button>
</form>
)
}Data Fetching
useFetch
Client-side data fetching with loading and error states.
// lib/hooks/useFetch.ts
import { useState, useEffect } from 'react'
interface UseFetchOptions {
skip?: boolean
}
export function useFetch<T>(url: string, options?: UseFetchOptions) {
const [data, setData] = useState<T | null>(null)
const [error, setError] = useState<Error | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
if (options?.skip) {
setIsLoading(false)
return
}
let cancelled = false
async function fetchData() {
try {
setIsLoading(true)
const response = await fetch(url)
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
const json = await response.json()
if (!cancelled) {
setData(json)
setError(null)
}
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e : new Error('Unknown error'))
setData(null)
}
} finally {
if (!cancelled) {
setIsLoading(false)
}
}
}
fetchData()
return () => {
cancelled = true
}
}, [url, options?.skip])
return { data, error, isLoading }
}Usage:
'use client'
import { useFetch } from '@/lib/hooks/useFetch'
interface User {
id: number
name: string
email: string
}
export default function UserList() {
const { data, error, isLoading } = useFetch<User[]>('/api/users')
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
if (!data) return <div>No data</div>
return (
<ul>
{data.map(user => (
<li key={user.id}>{user.name} - {user.email}</li>
))}
</ul>
)
}Note: For production, prefer Next.js Server Components with async/await for data fetching, or use libraries like SWR or React Query for client-side fetching.
Local Storage
useLocalStorage
Persist state in localStorage with TypeScript support.
// lib/hooks/useLocalStorage.ts
import { useState, useEffect } from 'react'
export function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(initialValue)
useEffect(() => {
// Only run on client
try {
const item = window.localStorage.getItem(key)
if (item) {
setStoredValue(JSON.parse(item))
}
} catch (error) {
console.error(`Error loading ${key} from localStorage:`, error)
}
}, [key])
const setValue = (value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value
setStoredValue(valueToStore)
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(valueToStore))
}
} catch (error) {
console.error(`Error saving ${key} to localStorage:`, error)
}
}
return [storedValue, setValue] as const
}Usage:
'use client'
import { useLocalStorage } from '@/lib/hooks/useLocalStorage'
export default function ThemeToggle() {
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light')
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Current theme: {theme}
</button>
)
}Media Queries
useMediaQuery
Respond to CSS media queries in React.
// lib/hooks/useMediaQuery.ts
import { useState, useEffect } from 'react'
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false)
useEffect(() => {
const media = window.matchMedia(query)
// Set initial value
setMatches(media.matches)
// Create listener
const listener = (e: MediaQueryListEvent) => setMatches(e.matches)
// Add listener
media.addEventListener('change', listener)
// Cleanup
return () => media.removeEventListener('change', listener)
}, [query])
return matches
}Usage:
'use client'
import { useMediaQuery } from '@/lib/hooks/useMediaQuery'
export default function ResponsiveComponent() {
const isMobile = useMediaQuery('(max-width: 768px)')
const isDesktop = useMediaQuery('(min-width: 1024px)')
return (
<div>
{isMobile && <div>Mobile view</div>}
{isDesktop && <div>Desktop view</div>}
{!isMobile && !isDesktop && <div>Tablet view</div>}
</div>
)
}Debounce
useDebounce
Debounce rapidly changing values.
// lib/hooks/useDebounce.ts
import { useState, useEffect } from 'react'
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => {
clearTimeout(handler)
}
}, [value, delay])
return debouncedValue
}Usage:
'use client'
import { useState } from 'react'
import { useDebounce } from '@/lib/hooks/useDebounce'
export default function SearchInput() {
const [searchTerm, setSearchTerm] = useState('')
const debouncedSearchTerm = useDebounce(searchTerm, 500)
// Effect runs only when debouncedSearchTerm changes
useEffect(() => {
if (debouncedSearchTerm) {
console.log('Searching for:', debouncedSearchTerm)
// API call here
}
}, [debouncedSearchTerm])
return (
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search..."
className="input"
/>
)
}Best Practices
1. Custom Hooks: Extract reusable logic into custom hooks 2. TypeScript: Always type your hooks with generics for reusability 3. useCallback: Memoize functions to prevent unnecessary re-renders 4. Cleanup: Always cleanup effects (event listeners, timers, etc.) 5. Server vs Client: Remember Next.js Server Components don't support hooks 6. Dependencies: Be careful with effect dependencies to avoid infinite loops 7. Testing: Write unit tests for custom hooks using React Testing Library
See Also
- Component Library - Reusable UI components (Vue & React)
- Animations - Animation patterns
- Examples - Complete mockup examples
# Create Component Script (PowerShell) for NuxtJS 4
# Generates a new Nuxt component with TypeScript and TailwindCSS v4 boilerplate
# Components are auto-imported by Nuxt
# Platform: Windows (PowerShell 5.1+), optional: Linux/macOS (PowerShell Core 7+)
[CmdletBinding()]
param(
[Parameter(Mandatory=$true, Position=0)]
[string]$ComponentName,
[Parameter(Mandatory=$false, Position=1)]
[ValidateSet('ui', 'layout', 'section')]
[string]$Type = 'ui'
)
# Set error action preference
$ErrorActionPreference = "Stop"
# Function for colored output
function Write-ColorOutput {
param(
[string]$Message,
[string]$Color = "White"
)
Write-Host $Message -ForegroundColor $Color
}
try {
# Validate component name (PascalCase)
if ($ComponentName -notmatch '^[A-Z][a-zA-Z0-9]*$') {
Write-ColorOutput "Error: Component name must be in PascalCase (e.g., MyButton)" "Red"
exit 1
}
# Determine directory based on type
$dir = switch ($Type) {
'ui' { 'components\ui' }
'layout' { 'components\layout' }
'section' { 'components\sections' }
}
# Create directory if it doesn't exist
if (-not (Test-Path $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
$filePath = Join-Path $dir "$ComponentName.vue"
# Check if component already exists
if (Test-Path $filePath) {
Write-ColorOutput "Warning: Component $ComponentName already exists" "Yellow"
$response = Read-Host "Overwrite? (y/n)"
if ($response -ne 'y') {
exit 1
}
}
# Convert PascalCase to kebab-case for CSS class
$kebabCase = $ComponentName -creplace '([A-Z])', '-$1' -replace '^-', '' | ForEach-Object { $_.ToLower() }
# Create component file
$componentContent = @"
<script setup lang="ts">
// No imports needed - auto-imported by Nuxt
interface Props {
// Add your props here
}
const props = withDefaults(defineProps<Props>(), {
// Add default values here
})
const emit = defineEmits<{
// Add your events here
// example: [event: MouseEvent]
}>()
// Component logic here
</script>
<template>
<div class="$kebabCase">
<!-- Add your template here -->
<slot />
</div>
</template>
<style scoped>
/* Add component-specific styles if needed */
/* Prefer TailwindCSS utility classes */
</style>
"@
Set-Content -Path $filePath -Value $componentContent -Encoding UTF8
Write-ColorOutput "✓ Component created successfully!" "Green"
Write-Host " Location: $filePath"
Write-Host ""
Write-ColorOutput "Next steps:" "Yellow"
Write-Host " 1. Open $filePath"
Write-Host " 2. Define your props interface"
Write-Host " 3. Add component logic"
Write-Host " 4. Build your template with TailwindCSS"
Write-Host ""
Write-ColorOutput "Usage (auto-imported):" "Yellow"
Write-Host " <template>"
if ($Type -eq 'ui') {
Write-Host " <!-- Auto-imported as Ui$ComponentName -->"
Write-Host " <Ui$ComponentName />"
} elseif ($Type -eq 'layout') {
Write-Host " <!-- Auto-imported as Layout$ComponentName -->"
Write-Host " <Layout$ComponentName />"
} else {
Write-Host " <!-- Auto-imported as Sections$ComponentName -->"
Write-Host " <Sections$ComponentName />"
}
Write-Host " </template>"
}
catch {
Write-ColorOutput "Error: $_" "Red"
exit 1
}
Related skills
FAQ
Which frameworks are supported?
The skill supports NuxtJS 4 with Vue and Next.js with React, choosing based on user preference.
What Node version is required?
Compatibility metadata requires Node.js 20.x or newer.
When should Next.js be chosen?
The description says to prefer Nuxt for Vue projects and use Next.js when users mention ReactJS.