Themeable Portfolio Generator with Nuxt 4 + TypeScript + Tailwind

Build a themeable, statically pre-rendered portfolio generator using Nuxt 4, TypeScript, and Tailwind CSS. We’ll leverage CSS variables for design tokens, Tailwind for utility styling, and the latest @nuxtjs/color-mode module to switch between multiple named themes (not just light/dark). We’ll also prerender theme routes with Nitro for instant static previews.
Tags: Nuxt 4, TypeScript, Tailwind, Theming
Time to read: 9 min
Prerequisites
- Node.js 18.20+ (or 20+)
- Basic familiarity with Nuxt and Tailwind
- A package manager (npm, pnpm, or yarn)
1) Create the Nuxt 4 app
npx nuxi@latest init themeable-portfolio
cd themeable-portfolio
npm install
npm run dev
Open http://localhost:3000 to verify it boots.
2) Install Tailwind and Color Mode
We’ll use the official Nuxt Tailwind module and the updated @nuxtjs/color-mode (supports custom theme names and class-based switching).
npm i -D @nuxtjs/tailwindcss
npm i @nuxtjs/color-mode
3) Configure Nuxt (modules, css, prerender, color-mode)
Edit nuxt.config.ts:
// nuxt.config.ts
export default defineNuxtConfig({
modules: [
'@nuxtjs/tailwindcss',
'@nuxtjs/color-mode',
],
// Global CSS
css: [
'@/assets/css/tailwind.css',
'@/assets/css/themes.css',
],
// Color mode: we will attach theme classes like "theme-light", "theme-ocean"
colorMode: {
preference: 'light',
fallback: 'light',
classPrefix: 'theme-',
classSuffix: '',
storageKey: 'portfolio-theme',
},
// Prerender theme routes for static previews
nitro: {
prerender: {
routes: [
'/', // default
'/t/light',
'/t/dark',
'/t/ocean',
'/t/forest',
],
},
},
typescript: {
strict: true,
typeCheck: true,
},
})
Notes:
- colorMode adds the class theme-
to the html element (e.g., theme-ocean). - Nitro prerender will statically generate a page per theme route.
4) Tailwind setup with CSS variables
- Create
assets/css/tailwind.css:
/* assets/css/tailwind.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html, body { height: 100%; }
body {
background-color: rgb(var(--c-bg));
color: rgb(var(--c-fg));
}
}
- Create
tailwind.config.ts:
// tailwind.config.ts
import type { Config } from 'tailwindcss'
export default {
darkMode: ['class', '.theme-dark'],
content: [
'./components/**/*.{vue,ts}',
'./layouts/**/*.vue',
'./pages/**/*.vue',
'./composables/**/*.{ts,js}',
'./plugins/**/*.{ts,js}',
'./app.vue',
],
theme: {
extend: {
colors: {
primary: 'rgb(var(--c-primary) / <alpha-value>)',
bg: 'rgb(var(--c-bg) / <alpha-value>)',
fg: 'rgb(var(--c-fg) / <alpha-value>)',
muted: 'rgb(var(--c-muted) / <alpha-value>)',
card: 'rgb(var(--c-card) / <alpha-value>)',
ring: 'rgb(var(--c-ring) / <alpha-value>)',
},
boxShadow: {
card: '0 2px 12px rgba(0, 0, 0, 0.08)',
},
borderRadius: {
xl: '1rem',
},
},
},
plugins: [],
} satisfies Config
We use CSS variables so themes can swap palettes without changing classes.
5) Define theme tokens (CSS variables for each theme)
Create assets/css/themes.css. We’ll set a default palette plus named themes that map to colorMode classes.
/* assets/css/themes.css */
/* Default (light) tokens */
:root,
.theme-light {
--c-primary: 99 102 241; /* indigo-500 */
--c-bg: 255 255 255; /* white */
--c-fg: 15 23 42; /* slate-900 */
--c-muted: 100 116 139; /* slate-500 */
--c-card: 248 250 252; /* slate-50 */
--c-ring: 99 102 241; /* indigo-500 */
}
/* Dark theme */
.theme-dark {
--c-primary: 129 140 248; /* indigo-400 */
--c-bg: 2 6 23; /* slate-950 */
--c-fg: 226 232 240; /* slate-200 */
--c-muted: 148 163 184; /* slate-400 */
--c-card: 15 23 42; /* slate-900 */
--c-ring: 129 140 248; /* indigo-400 */
}
/* Ocean theme */
.theme-ocean {
--c-primary: 14 165 233; /* sky-500 */
--c-bg: 241 245 249; /* slate-100 */
--c-fg: 15 23 42; /* slate-900 */
--c-muted: 71 85 105; /* slate-600 */
--c-card: 255 255 255; /* white */
--c-ring: 2 132 199; /* sky-600 */
}
/* Forest theme */
.theme-forest {
--c-primary: 34 197 94; /* green-500 */
--c-bg: 253 253 251; /* off-white */
--c-fg: 17 24 39; /* gray-900 */
--c-muted: 75 85 99; /* gray-600 */
--c-card: 250 250 249; /* stone-50 */
--c-ring: 22 163 74; /* green-600 */
}
Tip: Add more themes by appending new .theme-<name> blocks with your color variables.
6) Define types and sample data
- Create
types/portfolio.ts:
// types/portfolio.ts
export type Link = {
label: string
href: string
}
export type Project = {
id: string
title: string
description: string
tags: string[]
links: Link[]
image?: string
}
export type Portfolio = {
name: string
role: string
about: string
location?: string
social: {
github?: string
twitter?: string
website?: string
email?: string
}
projects: Project[]
}
- Create
data/portfolio.ts:
// data/portfolio.ts
import type { Portfolio } from '@/types/portfolio'
const portfolio: Portfolio = {
name: 'Alex Dev',
role: 'Frontend Engineer',
about: 'I build delightful web experiences with Nuxt, TypeScript and 3D sprinkles.',
location: 'Remote / EU',
social: {
github: 'https://github.com/yourname',
twitter: 'https://x.com/yourhandle',
website: 'https://yourdomain.dev',
email: 'hello@yourdomain.dev'
},
projects: [
{
id: 'nuxt-visualizer',
title: 'Nuxt Visualizer',
description: 'Analyze and explore your Nuxt bundle in an interactive UI.',
tags: ['nuxt', 'vite', 'visualization'],
links: [
{ label: 'GitHub', href: 'https://github.com/yourname/nuxt-visualizer' },
{ label: 'Demo', href: 'https://visualizer.yourdomain.dev' }
]
},
{
id: 'mapbox-trails',
title: 'Mapbox Trails',
description: 'Hiking trail explorer with Mapbox GL, offline tiles and GPX support.',
tags: ['mapbox', 'gl', 'gpx'],
links: [
{ label: 'GitHub', href: 'https://github.com/yourname/mapbox-trails' }
],
image: '/images/trails.jpg'
},
{
id: 'pwa-camera',
title: 'PWA Camera',
description: 'Installable camera app with offline queue and background sync.',
tags: ['pwa', 'workbox', 'vite-pwa'],
links: [
{ label: 'Article', href: 'https://blog.yourdomain.dev/pwa-camera' }
]
}
]
}
export default portfolio
7) Composable to access portfolio data
- Create
composables/usePortfolio.ts:
// composables/usePortfolio.ts
import type { Portfolio } from '@/types/portfolio'
import portfolio from '@/data/portfolio'
export function usePortfolio() {
const data = reactive<Portfolio>(portfolio)
return { portfolio: data }
}
8) Theme switcher and cards
- Create
components/ThemeSwitcher.vue:
<script setup lang="ts">
const colorMode = useColorMode()
const themes = ['light', 'dark', 'ocean', 'forest'] as const
type ThemeName = typeof themes[number]
function setTheme(t: ThemeName) {
colorMode.preference = t
}
</script>
<template>
<div class="inline-flex items-center gap-2">
<span class="text-sm text-muted">Theme:</span>
<div class="flex gap-1 bg-card rounded-lg p-1 shadow-card border border-black/5">
<button
v-for="t in themes"
:key="t"
:aria-pressed="colorMode.preference === t"
@click="setTheme(t)"
class="px-3 py-1.5 rounded-md text-sm font-medium transition
hover:bg-primary/10 focus:outline-none focus:ring-2 focus:ring-ring"
:class="colorMode.preference === t ? 'bg-primary/15 text-primary' : 'text-muted'"
>
{{ t }}
</button>
</div>
</div>
</template>
- Create
components/ProjectCard.vue:
<script setup lang="ts">
import type { Project } from '@/types/portfolio'
defineProps<{
project: Project
}>()
</script>
<template>
<article class="rounded-xl bg-card shadow-card border border-black/5 overflow-hidden">
<div v-if="project.image" class="aspect-[16/9] overflow-hidden">
<img :src="project.image" :alt="project.title" class="w-full h-full object-cover" />
</div>
<div class="p-5">
<h3 class="text-lg font-semibold text-fg mb-1">{{ project.title }}</h3>
<p class="text-sm text-muted mb-3">{{ project.description }}</p>
<div class="flex flex-wrap gap-2 mb-4">
<span
v-for="tag in project.tags"
:key="tag"
class="px-2 py-0.5 rounded-full text-xs bg-primary/10 text-primary"
>
#{{ tag }}
</span>
</div>
<div class="flex flex-wrap gap-3">
<a
v-for="l in project.links"
:key="l.href"
:href="l.href"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-primary hover:underline"
>
{{ l.label }} →
</a>
</div>
</div>
</article>
</template>
- Create
components/PortfolioView.vue:
<script setup lang="ts">
const { portfolio } = usePortfolio()
</script>
<template>
<section class="container mx-auto max-w-5xl px-6 py-12">
<header class="flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
<div>
<h1 class="text-3xl font-bold text-fg">{{ portfolio.name }}</h1>
<p class="text-muted">{{ portfolio.role }} · {{ portfolio.location }}</p>
<p class="mt-3 max-w-2xl text-fg/90">{{ portfolio.about }}</p>
<nav class="mt-4 flex gap-4">
<a v-if="portfolio.social.website" :href="portfolio.social.website" class="text-primary hover:underline">Website</a>
<a v-if="portfolio.social.github" :href="portfolio.social.github" class="text-primary hover:underline">GitHub</a>
<a v-if="portfolio.social.twitter" :href="portfolio.social.twitter" class="text-primary hover:underline">Twitter</a>
<a v-if="portfolio.social.email" :href="`mailto:${portfolio.social.email}`" class="text-primary hover:underline">Email</a>
</nav>
</div>
<ThemeSwitcher />
</header>
<div class="grid md:grid-cols-2 gap-6 mt-8">
<ProjectCard v-for="p in portfolio.projects" :key="p.id" :project="p" />
</div>
</section>
</template>
9) Pages and layout
- Create a default layout with a sticky header:
layouts/default.vue
<template>
<div class="min-h-screen">
<header class="sticky top-0 z-10 backdrop-blur bg-bg/70 border-b border-black/5">
<div class="container mx-auto max-w-5xl px-6 py-3 flex items-center justify-between">
<NuxtLink to="/" class="font-semibold text-fg">Portfolio</NuxtLink>
<NuxtLink to="/t/ocean" class="text-sm text-primary hover:underline">Ocean Preview</NuxtLink>
</div>
</header>
<main>
<slot />
</main>
<footer class="border-t border-black/5 mt-12">
<div class="container mx-auto max-w-5xl px-6 py-8 text-sm text-muted">
Built with Nuxt 4 + Tailwind · Themed with CSS variables
</div>
</footer>
</div>
</template>
- Home page:
pages/index.vue
<script setup lang="ts">
useHead({
title: 'Themeable Portfolio',
meta: [
{ name: 'description', content: 'A themeable portfolio generator built with Nuxt 4 + Tailwind.' },
// Set a reasonable default theme color (light primary)
{ name: 'theme-color', content: '#6366F1' }, // indigo-500
],
})
</script>
<template>
<PortfolioView />
</template>
- Theme preview route:
pages/t/[theme].vue
<script setup lang="ts">
const route = useRoute()
const colorMode = useColorMode()
const theme = computed(() => String(route.params.theme || 'light'))
watchEffect(() => {
colorMode.preference = theme.value
})
useHead({
title: () => `Theme: ${theme.value} · Themeable Portfolio`,
})
</script>
<template>
<PortfolioView />
</template>
Visit:
- / → default theme (light)
- /t/dark → dark theme
- /t/ocean → ocean theme
- /t/forest → forest theme
10) Run and build
- Dev:
npm run dev
- Static build and preview:
npm run build
npm run preview
Check that Nitro prerendered the theme routes specified in nuxt.config.ts.
11) Add a new theme in 30 seconds
- Append variables to
assets/css/themes.css:
.theme-sunset {
--c-primary: 244 114 182; /* pink-400 */
--c-bg: 255 247 237; /* orange-50-ish */
--c-fg: 41 37 36; /* stone-800 */
--c-muted: 120 113 108; /* stone-500 */
--c-card: 255 255 255; /* white */
--c-ring: 236 72 153; /* pink-500 */
}
- Add "sunset" to the
themesarray inThemeSwitcher.vue. - Optionally prerender it by adding
/t/sunsettonuxt.config.tsundernitro.prerender.routes.
Why this is “new” and future-proof
- Nuxt 4 gives you stable modules, excellent TypeScript DX, and fast Nitro prerendering.
- The latest @nuxtjs/color-mode supports class-based custom theme names out of the box (beyond just light/dark), which we use to toggle CSS variable palettes.
- Tailwind + CSS variables provides a clean path to theme tokens that don’t require regenerating your CSS for each palette.
Wrap-up
You now have a themeable portfolio generator:
- Multiple named themes via color-mode class switching
- Design tokens with CSS variables
- Tailwind utilities mapped to your tokens
- Static theme previews with Nitro prerendered routes
Fork it, add your data and themes, and deploy anywhere that serves static files.





