December 5, 20259 min

Themeable Portfolio Generator with Nuxt 4 + TypeScript + Tailwind

Build a themeable, statically pre-rendered portfolio generator in Nuxt 4 using TypeScript, Tailwind CSS with CSS variables, and @nuxtjs/color-mode.

Themeable Portfolio Generator with Nuxt 4 + TypeScript + Tailwind

themeable-portfolio

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 themes array in ThemeSwitcher.vue.
  • Optionally prerender it by adding /t/sunset to nuxt.config.ts under nitro.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.