December 7, 20259 min

CSS Art Playground powered by Utility Classes: Nuxt 4 + TypeScript + Tailwind

Build an interactive CSS Art Playground that composes shapes, gradients, and effects using Tailwind utility classes in a Nuxt 4 + TypeScript app. Share states, copy markup, and explore creative CSS utilities.

CSS Art Playground powered by Utility Classes: Nuxt 4 + TypeScript + Tailwind

nuxt-tailwind-css-art

Create a zero-deps, interactive CSS Art Playground that composes shapes, gradients, and effects using Tailwind utility classes in a modern Nuxt 4 + TypeScript app. Share the state via URL, and copy the generated markup/classes with one click.

Tags: Nuxt 4, TypeScript, Tailwind, CSS Art, Utilities

Time to read: 9 min

Why this project?

  • Practice TypeScript + Vue 3 composition in Nuxt 4
  • Explore powerful Tailwind utilities and arbitrary values without writing custom CSS
  • Build a shareable, copy-paste-friendly playground for creative CSS art

Note: This tutorial uses stable Tailwind CSS v3.x via @nuxtjs/tailwindcss and Nuxt 4.

Prerequisites

  • Node.js 18+ (LTS recommended)
  • Basic familiarity with Vue/Nuxt and Tailwind
  • A package manager (npm, pnpm, or yarn)

1) Scaffold a Nuxt 4 app

npx nuxi@latest init css-art-playground
cd css-art-playground
npm install
npm run dev

Visit http://localhost:3000 to ensure it runs.

2) Add Tailwind to Nuxt 4

Install Tailwind and the Nuxt module:

npm i -D @nuxtjs/tailwindcss tailwindcss postcss autoprefixer

Update nuxt.config.ts:

// nuxt.config.ts
import { defineNuxtConfig } from 'nuxt/config'

export default defineNuxtConfig({
  modules: ['@nuxtjs/tailwindcss'],
  css: ['~/assets/css/tailwind.css'],
  typescript: { strict: true },
  tailwindcss: {
    viewer: false, // disable Tailwind viewer in prod
    exposeConfig: true,
  },
})

Create the Tailwind entry CSS:

/* assets/css/tailwind.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

Add a minimal Tailwind config with safelist patterns for dynamic utilities used in the playground:

// tailwind.config.ts
import type { Config } from 'tailwindcss'

export default <Partial<Config>>{
  content: [
    './components/**/*.{vue,js,ts}',
    './composables/**/*.{js,ts}',
    './layouts/**/*.vue',
    './pages/**/*.vue',
    './app.vue',
  ],
  theme: {
    extend: {
      colors: {
        brand: {
          500: '#6366f1',
          600: '#4f46e5',
        },
      },
    },
  },
  safelist: [
    // transforms, blur, rounded, ring/border options used by the playground
    { pattern: /^(rotate)-(0|45|90|180)$/ },
    { pattern: /^(blur)-(none|sm|md|lg|xl|2xl)$/ },
    { pattern: /^rounded-(none|md|lg|2xl|full)$/ },
    { pattern: /^shadow-(sm|md|lg|xl|2xl)$/ },
    { pattern: /^ring(-\d+)?$/ },
    { pattern: /^ring-(white|black)(\/\d+)?$/ },
    { pattern: /^ring-offset(-\d+)?$/ },
    { pattern: /^ring-offset-(white|black)(\/\d+)?$/ },
    'border',
    'border-white/20',
    // gradients used in presets
    'bg-gradient-to-tr',
    'bg-gradient-to-br',
    'bg-gradient-to-r',
    { pattern: /^(from|via|to)-(rose|orange|yellow|sky|cyan|emerald|fuchsia|lime)-(300|400|500)$/ },
    // misc
    'transform-gpu',
    'will-change-transform',
    'aspect-square',
  ],
  plugins: [],
}

Restart the dev server if it was running.

3) Core Playground State (TypeScript composable)

Define our playground state, a few gradients, some shapes (via clip-path), and helpers to copy/share.

// composables/usePlayground.ts
import { reactive, computed, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'

export type Shape = 'circle' | 'triangle' | 'diamond' | 'pentagon'

export interface Gradient {
  id: string
  name: string
  directionClass: string
  from: string
  via?: string
  to: string
}

export interface PlaygroundState {
  gradient: Gradient
  rounded: 'rounded-none' | 'rounded-md' | 'rounded-lg' | 'rounded-2xl' | 'rounded-full'
  rotate: 'rotate-0' | 'rotate-45' | 'rotate-90' | 'rotate-180'
  blur: 'blur-none' | 'blur-sm' | 'blur-md' | 'blur-lg' | 'blur-xl' | 'blur-2xl'
  shadow: 'shadow-sm' | 'shadow-md' | 'shadow-lg' | 'shadow-xl' | 'shadow-2xl'
  ring: boolean
  border: boolean
  scale: number
  shape: Shape
  shapePath: string
}

export const gradients: Gradient[] = [
  { id: 'sunset', name: 'Sunset', directionClass: 'bg-gradient-to-tr', from: 'from-rose-500', via: 'via-orange-400', to: 'to-yellow-300' },
  { id: 'ocean', name: 'Ocean', directionClass: 'bg-gradient-to-br', from: 'from-sky-500', via: 'via-cyan-400', to: 'to-emerald-300' },
  { id: 'neon', name: 'Neon', directionClass: 'bg-gradient-to-r', from: 'from-fuchsia-500', via: 'via-cyan-400', to: 'to-lime-300' },
]

const shapes: Record<Shape, string> = {
  circle: 'circle(50% at 50% 50%)',
  triangle: 'polygon(50% 0%, 0% 100%, 100% 100%)',
  diamond: 'polygon(50% 0%, 0% 50%, 50% 100%, 100% 50%)',
  pentagon: 'polygon(50% 0%, 0% 40%, 18% 100%, 82% 100%, 100% 40%)',
}

function findGradient(id: string): Gradient {
  return gradients.find(g => g.id === id) ?? gradients[0]
}

export function usePlayground() {
  const route = useRoute()
  const router = useRouter()

  const state = reactive<PlaygroundState>({
    gradient: gradients[0],
    rounded: 'rounded-2xl',
    rotate: 'rotate-0',
    blur: 'blur-none',
    shadow: 'shadow-xl',
    ring: true,
    border: false,
    scale: 1,
    shape: 'diamond',
    shapePath: shapes['diamond'],
  })

  function setShape(shape: Shape) {
    state.shape = shape
    state.shapePath = shapes[shape]
  }

  // Sync from URL query on mount
  onMounted(() => {
    const q = route.query
    if (typeof q.g === 'string') state.gradient = findGradient(q.g)
    if (typeof q.s === 'string' && q.s in shapes) setShape(q.s as Shape)
    if (typeof q.r === 'string') state.rounded = (q.r as PlaygroundState['rounded'])
    if (typeof q.rot === 'string') state.rotate = (q.rot as PlaygroundState['rotate'])
    if (typeof q.blur === 'string') state.blur = (q.blur as PlaygroundState['blur'])
    if (typeof q.sh === 'string') state.shadow = (q.sh as PlaygroundState['shadow'])
    if (typeof q.scale === 'string') {
      const v = parseFloat(q.scale)
      if (!Number.isNaN(v)) state.scale = Math.min(2, Math.max(0.5, v))
    }
    if (typeof q.ring === 'string') state.ring = q.ring === '1'
    if (typeof q.border === 'string') state.border = q.border === '1'
  })

  // Reflect state into URL for easy sharing
  watch(
    () => ({
      g: state.gradient.id,
      s: state.shape,
      r: state.rounded,
      rot: state.rotate,
      blur: state.blur,
      sh: state.shadow,
      ring: state.ring ? '1' : '0',
      border: state.border ? '1' : '0',
      scale: state.scale.toFixed(2),
    }),
    (q) => {
      router.replace({ query: q }).catch(() => {})
    },
    { deep: true }
  )

  const classes = computed(() => {
    return [
      state.gradient.directionClass,
      state.gradient.from,
      state.gradient.via,
      state.gradient.to,
      state.rounded,
      state.rotate,
      state.blur,
      state.shadow,
      state.ring ? 'ring-4 ring-white/30 ring-offset-2 ring-offset-black/20' : '',
      state.border ? 'border border-white/20' : '',
      '[clip-path:var(--shape)]',
      'scale-[var(--s)]',
      'transform-gpu',
      'will-change-transform',
      'aspect-square',
    ]
      .filter(Boolean)
      .join(' ')
  })

  const markup = computed(() => {
    return `<div class="${classes.value}" style="--shape: ${state.shapePath}; --s: ${state.scale}"></div>`
  })

  async function copy(text: string) {
    await navigator.clipboard.writeText(text)
  }

  return {
    state,
    gradients,
    setShape,
    classes,
    markup,
    copy,
  }
}

4) The Art Stage component

Draw the art with zero custom CSS, using Tailwind utilities and CSS variables. We’ll keep arbitrary values static by referencing variables (var(--shape), var(--s)).

<!-- components/ArtStage.vue -->
<template>
  <div class="relative w-full aspect-square grid place-items-center bg-neutral-950 text-white rounded-xl overflow-hidden p-4">
    <!-- subtle spotlight background -->
    <div class="absolute inset-0 opacity-40 pointer-events-none bg-[radial-gradient(1000px_500px_at_var(--mx,50%)_var(--my,50%),rgba(255,255,255,0.08),transparent)]"></div>

    <div class="relative w-[min(80vmin,26rem)] aspect-square transform-gpu origin-center transition-transform duration-200"
         :style="artVars">
      <div
        class="absolute inset-0 [clip-path:var(--shape)]"
        :class="[
          gradient.directionClass,
          gradient.from,
          gradient.via,
          gradient.to,
          rounded,
          rotate,
          blur,
          shadow,
          ring,
          border,
          'scale-[var(--s)]',
          'will-change-transform',
        ]"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import type { PlaygroundState } from '~/composables/usePlayground'

const props = defineProps<{
  state: PlaygroundState
}>()

const gradient = computed(() => props.state.gradient)
const rounded = computed(() => props.state.rounded)
const rotate = computed(() => props.state.rotate)
const blur = computed(() => props.state.blur)
const shadow = computed(() => props.state.shadow)
const ring = computed(() => (props.state.ring ? 'ring-4 ring-white/30 ring-offset-2 ring-offset-black/20' : ''))
const border = computed(() => (props.state.border ? 'border border-white/20' : ''))

const artVars = computed(() => {
  return {
    '--shape': props.state.shapePath,
    '--s': String(props.state.scale),
  } as Record<string, string>
})
</script>

5) The Playground page

Controls to tweak shape, gradient, rounding, rotation, blur, shadow, ring, border, and scale. Copy classes or full markup with one click.

<!-- pages/index.vue -->
<template>
  <main class="min-h-screen bg-neutral-950 text-neutral-100">
    <div class="mx-auto max-w-7xl px-6 py-10">
      <header class="mb-10">
        <h1 class="text-3xl font-bold tracking-tight">CSS Art Playground</h1>
        <p class="text-neutral-400 mt-2">Powered by Nuxt 4 + TypeScript + Tailwind utilities</p>
      </header>

      <div class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-start">
        <ArtStage :state="state" />

        <section class="bg-neutral-900/60 border border-white/10 rounded-xl p-5 space-y-6">
          <!-- Shape -->
          <div>
            <h2 class="text-sm font-semibold uppercase tracking-wider text-neutral-300">Shape</h2>
            <div class="mt-3 flex flex-wrap gap-2">
              <button
                v-for="opt in shapeOptions"
                :key="opt.value"
                class="px-3 py-1.5 rounded-md border border-white/10 bg-neutral-800/60 hover:bg-neutral-800 transition-colors"
                :class="state.shape === opt.value && 'ring-2 ring-white/30'"
                @click="setShape(opt.value)"
              >
                {{ opt.label }}
              </button>
            </div>
          </div>

          <!-- Gradient -->
          <div>
            <h2 class="text-sm font-semibold uppercase tracking-wider text-neutral-300">Gradient</h2>
            <div class="mt-3 grid grid-cols-3 gap-3">
              <button
                v-for="g in gradients"
                :key="g.id"
                class="h-14 rounded-lg border border-white/10 transition-transform hover:scale-[1.02]"
                :class="[g.directionClass, g.from, g.via, g.to, state.gradient.id === g.id && 'ring-2 ring-white/40']"
                @click="state.gradient = g"
                :aria-label="g.name"
                :title="g.name"
              />
            </div>
          </div>

          <!-- Rounding / Rotation -->
          <div class="grid grid-cols-2 gap-4">
            <div>
              <label class="block text-sm font-medium text-neutral-300">Rounded</label>
              <select v-model="state.rounded" class="mt-2 w-full bg-neutral-800 border border-white/10 rounded-md px-3 py-2">
                <option value="rounded-none">None</option>
                <option value="rounded-md">md</option>
                <option value="rounded-lg">lg</option>
                <option value="rounded-2xl">2xl</option>
                <option value="rounded-full">full</option>
              </select>
            </div>
            <div>
              <label class="block text-sm font-medium text-neutral-300">Rotate</label>
              <select v-model="state.rotate" class="mt-2 w-full bg-neutral-800 border border-white/10 rounded-md px-3 py-2">
                <option value="rotate-0">0°</option>
                <option value="rotate-45">45°</option>
                <option value="rotate-90">90°</option>
                <option value="rotate-180">180°</option>
              </select>
            </div>
          </div>

          <!-- Blur / Shadow -->
          <div class="grid grid-cols-2 gap-4">
            <div>
              <label class="block text-sm font-medium text-neutral-300">Blur</label>
              <select v-model="state.blur" class="mt-2 w-full bg-neutral-800 border border-white/10 rounded-md px-3 py-2">
                <option value="blur-none">none</option>
                <option value="blur-sm">sm</option>
                <option value="blur-md">md</option>
                <option value="blur-lg">lg</option>
                <option value="blur-xl">xl</option>
                <option value="blur-2xl">2xl</option>
              </select>
            </div>
            <div>
              <label class="block text-sm font-medium text-neutral-300">Shadow</label>
              <select v-model="state.shadow" class="mt-2 w-full bg-neutral-800 border border-white/10 rounded-md px-3 py-2">
                <option value="shadow-sm">sm</option>
                <option value="shadow-md">md</option>
                <option value="shadow-lg">lg</option>
                <option value="shadow-xl">xl</option>
                <option value="shadow-2xl">2xl</option>
              </select>
            </div>
          </div>

          <!-- Toggles -->
          <div class="flex items-center gap-6">
            <label class="inline-flex items-center gap-2">
              <input v-model="state.ring" type="checkbox" class="accent-brand-600" />
              <span>Ring</span>
            </label>
            <label class="inline-flex items-center gap-2">
              <input v-model="state.border" type="checkbox" class="accent-brand-600" />
              <span>Border</span>
            </label>
          </div>

          <!-- Scale -->
          <div>
            <label class="block text-sm font-medium text-neutral-300">Scale: {{ state.scale.toFixed(2) }}x</label>
            <input
              v-model.number="state.scale"
              type="range"
              min="0.5"
              max="2"
              step="0.01"
              class="mt-2 w-full"
            />
          </div>

          <!-- Output -->
          <div class="space-y-3">
            <div class="text-sm text-neutral-400">Class list (computed)</div>
            <pre class="text-xs bg-neutral-800/70 border border-white/10 rounded-lg p-3 overflow-x-auto">{{ classes }}</pre>

            <div class="flex gap-3">
              <button
                class="px-3 py-2 bg-brand-600 hover:bg-brand-500 rounded-md font-medium"
                @click="copy(classes)"
              >
                Copy classes
              </button>
              <button
                class="px-3 py-2 bg-neutral-800 hover:bg-neutral-700 rounded-md font-medium"
                @click="copy(markup)"
              >
                Copy markup
              </button>
              <NuxtLink
                class="px-3 py-2 bg-neutral-800 hover:bg-neutral-700 rounded-md font-medium"
                :to="{ query: $route.query }"
              >
                Share link
              </NuxtLink>
            </div>
          </div>
        </section>
      </div>

      <footer class="mt-12 text-sm text-neutral-500">
        Tip: Add your own gradient presets or shapes in composables/usePlayground.ts
      </footer>
    </div>
  </main>
</template>

<script setup lang="ts">
import ArtStage from '~/components/ArtStage.vue'
import { usePlayground, gradients } from '~/composables/usePlayground'
import { computed } from 'vue'

const { state, setShape, classes, markup, copy } = usePlayground()

const shapeOptions = [
  { label: 'Diamond', value: 'diamond' },
  { label: 'Circle', value: 'circle' },
  { label: 'Triangle', value: 'triangle' },
  { label: 'Pentagon', value: 'pentagon' },
] as const
</script>

Start your dev server and visit http://localhost:3000 — you should see the stage on the left and playground controls on the right. Tweak away and share your state using the URL!

6) How it works

  • Shapes: Created with clip-path via a stable Tailwind arbitrary property class clip-path:var(--shape) while the variable is set dynamically in style.
  • Scale: Similarly stabilized with scale-var(--s) and a dynamic --s variable.
  • Gradients: Composed via standard Tailwind gradient utilities (direction + from/via/to).
  • Effects: Tailwind utilities for rotate, blur, shadow, rounded, ring, and border.
  • Shareable State: We mirror reactive state into URL query params (watch + router.replace) and hydrate from the query on mount.
  • Copy to Clipboard: Simple navigator.clipboard API on button click.

7) Extending the playground

  • Add more shapes: Extend the shapes map in usePlayground.ts with more polygon() definitions.
  • Add gradient presets: Add new entries using Tailwind’s extended palettes or your own config.
  • Export as image: Use HTML-to-canvas libraries client-side to snapshot the art stage.
  • Keyboard shortcuts: Bind keys to cycle shapes, gradients, or effects.
  • Persist locally: Save last state with useCookie or localStorage.

8) Troubleshooting

  • Utilities not applying: Ensure the utility appears literally in the template or is accounted for by safelist. We stabilized arbitrary values with var(...) so Tailwind can generate the class at build time.
  • Clipboard permission: Some browsers block clipboard without a user gesture. Use a button click (as shown).
  • Query sharing not updating: Ensure router.replace calls aren’t throwing; this code safely ignores replace rejections from identical URLs.

Conclusion

You built a fully typed Nuxt 4 + Tailwind utility-powered CSS Art Playground:

  • Creative visuals with zero custom CSS files
  • Copy classes or full markup for instant reuse
  • Share any state right in the URL

Have fun extending the shapes and gradients, and happy shipping!