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

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!





