
Generative Abstract Shape Gradient Blur Background (Nuxt + Vue Clip-Path)
Build an animated, generative gradient blur background using CSS clip-path polygons with multi-anchor morphing, DX controls, and zero external libs.

Bo Cooper
Modern hero backgrounds often use a stack of blurred, softly animated organic shapes. Most implementations rely on pre‑baked SVGs or canvas shaders. This post shows a pure Vue + CSS approach: a component that generates and morphs polygon clip‑paths (with controlled anchors to preserve partial structure) over a gradient layer—plus a DX control surface to explore shapes and copy the resulting polygon value.
Core Ideas
- Use
clip-path: polygon(...)
on a large gradient div to sculpt an organic blob. - Generate polygon points with continuity: keep a small run of anchor points static each morph so the blob feels cohesive, not random noise.
- Morph by swapping the
clip-path
value; animate transitions with a CSStransition
on the container. - Offer DX controls: play / pause, next / prev, refresh, copy polygon, show index.
- Optional ring buffer ("bufferSize") for reusing / cycling polygons.
Component (Simplified)
Below is the full implementation (trimmed comments only where obvious). It exposes props to toggle generation strategy & animation smoothness, and emits no events—state is internal.
<template>
<div class="w-full h-full">
<div v-if="showControls" class="absolute inset-x-0 w-full h-full top-2 z-[20]">
<slot name="default" :generate-polygon="generatePolygon">
<section class="flex justify-center gap-2">
<button class="ctrl" @click.prevent.stop="refreshPolygon"><ArrowPathIcon class="ic" /></button>
<button class="ctrl" :class="{ 'opacity-50': indexAtBeginning }" :disabled="indexAtBeginning" @click.prevent.stop="goToPreviousPolygon"><BackwardIcon class="ic" /></button>
<button class="ctrl" :class="{ 'opacity-50': indexAtEnd }" :disabled="indexAtEnd" @click.prevent.stop="goToNextPolygon"><ForwardIcon class="ic" /></button>
<button class="ctrl" @click.prevent.stop="pauseGeneration"><PauseIcon class="ic" /></button>
<button class="ctrl" @click.prevent.stop="startGeneration"><PlayIcon class="ic" /></button>
<button class="ctrl" @click.prevent.stop="copyToClipboard"><ClipboardIcon class="ic" /></button>
</section>
<div class="flex justify-center w-full mt-2">
<div class="badge">{{ currentIndex + 1 }} / {{ polygonMap.size }}</div>
</div>
</slot>
</div>
<section
v-show="clipPath"
ref="polygonElement"
:class="['absolute inset-x-0 flex overflow-hidden -z-10 transform-gpu blur-2xl', positionClasses, opacityClasses]"
aria-hidden="true"
>
<div
:class="['flex-none transform transition-all', animateSmooth ? 'ease-linear' : 'ease-in-out', sizeClasses, rotateClass, marginClasses, gradientClasses]"
:style="{ clipPath: clipPath, transitionDuration: `${transitionDuration}ms` }"
/>
</section>
<div v-if="$slots.content" class="absolute inset-0 z-10 flex items-center justify-center">
<slot name="content" />
</div>
</div>
</template>
<script setup lang="ts">
import { ArrowPathIcon, PauseIcon, PlayIcon, BackwardIcon, ForwardIcon, ClipboardIcon } from '@heroicons/vue/24/outline'
import { ref, toRefs, computed, watch, onMounted, onUnmounted, defineProps } from 'vue'
import { DEFAULT_POLYGON_PATH } from '@/constants/generative'
import { useToast } from '@/composables/useToast'
const { addToast } = useToast()
const props = defineProps({
generate: { type: Boolean, default: false },
generateOnce: { type: Boolean, default: false },
showControls: { type: Boolean, default: false },
polygonPath: { type: String, default: DEFAULT_POLYGON_PATH },
positionClasses: { type: String, default: '-top-80 left-0 xl:justify-start' },
opacityClasses: { type: String, default: 'opacity-20' },
sizeClasses: { type: String, default: 'aspect-[1212/800] w-[80rem] origin-top-left' },
rotateClass: { type: String, default: 'rotate-[25deg]' },
marginClasses: { type: String, default: 'xl:ml-0 xl:mr-[calc(50%-10rem)] ml-[-20rem]' },
gradientClasses: { type: String, default: 'bg-gradient-to-bl from-mx-maroon/70 to-mx-orange-muted' },
transitionDuration: { type: Number, default: 500 },
interval: { type: Number, default: 2000 },
animateSmooth: { type: Boolean, default: false },
pauseAnimation: { type: Boolean, default: false },
bufferSize: { type: Number, default: 0 }
})
const { generate, generateOnce, showControls, polygonPath, interval, animateSmooth, pauseAnimation, bufferSize } = toRefs(props)
// State
const polygonElement = ref<HTMLElement | null>(null)
const generatedPolygonPath = ref('')
const intervalId = ref<NodeJS.Timeout | null>(null)
const tempTimerId = ref<NodeJS.Timeout | null>(null)
const polygonMap = ref(new Map<string, string>())
const currentIndex = ref(0)
const indexAtBeginning = computed(() => currentIndex.value === 0)
const indexAtEnd = computed(() => currentIndex.value === polygonMap.value.size - 1)
const buffer = computed(() => bufferSize.value > 0)
// Derived clip-path
const clipPath = computed(() => {
if (showControls.value) return generatedPolygonPath.value
return (generate.value || generateOnce.value || animateSmooth.value) ? generatedPolygonPath.value : polygonPath.value
})
// Polygon generation with multi-anchor stability
const NUM_POINTS = 16
const NUM_ANCHORS = 4
const anchorOffset = ref(0)
const parsePolygon = (poly: string) => poly.replace(/^polygon\(|\)$/g, '').split(',').map(p => p.trim())
const randomPoly = () => {
const pts: string[] = []
for (let i = 0; i < NUM_POINTS; i++) pts.push(`${Math.floor(Math.random() * 100)}% ${Math.floor(Math.random() * 100)}%`)
return `polygon(${pts.join(', ')})`
}
const anchoredPoly = () => {
if (!generatedPolygonPath.value) return randomPoly()
const oldPts = parsePolygon(generatedPolygonPath.value)
if (oldPts.length !== NUM_POINTS) return randomPoly()
const next: string[] = new Array(NUM_POINTS)
const start = anchorOffset.value
const end = start + (NUM_ANCHORS - 1)
for (let i = 0; i < NUM_POINTS; i++) {
const s = start % NUM_POINTS
const e = end % NUM_POINTS
const anchor = e < s ? (i >= s || i <= e) : (i >= s && i <= e)
next[i] = anchor ? oldPts[i] : `${Math.floor(Math.random() * 100)}% ${Math.floor(Math.random() * 100)}%`
}
anchorOffset.value = (anchorOffset.value + NUM_ANCHORS) % NUM_POINTS
return `polygon(${next.join(', ')})`
}
// Creation helpers
const createAndStore = (slotIndex: number) => { const p = anchoredPoly(); polygonMap.value.set(`polygon ${slotIndex}`, p); return p }
const bufferNext = () => {
if (!buffer.value) { const slotIndex = polygonMap.value.size; const p = createAndStore(slotIndex); currentIndex.value = slotIndex; return p }
if (bufferSize.value > 0) {
if (polygonMap.value.size < bufferSize.value) { const slotIndex = polygonMap.value.size; const p = createAndStore(slotIndex); currentIndex.value = slotIndex; return p }
const nextIndex = (currentIndex.value + 1) % bufferSize.value
const key = `polygon ${nextIndex}`
if (!polygonMap.value.has(key)) polygonMap.value.set(key, anchoredPoly())
currentIndex.value = nextIndex
return polygonMap.value.get(key) || ''
}
const slotIndex = polygonMap.value.size; const p = createAndStore(slotIndex); currentIndex.value = slotIndex; return p
}
const generatePolygon = () => { generatedPolygonPath.value = bufferNext() }
// Navigation
const goToIndex = (target: number) => {
pauseGeneration()
if (buffer.value) target = (target + bufferSize.value) % bufferSize.value
else target = (target + (polygonMap.value.size || 1)) % (polygonMap.value.size || 1)
const key = `polygon ${target}`
if (!polygonMap.value.has(key)) polygonMap.value.set(key, anchoredPoly())
currentIndex.value = target
generatedPolygonPath.value = polygonMap.value.get(key) || ''
}
const goToNextPolygon = () => goToIndex(currentIndex.value + 1)
const goToPreviousPolygon = () => goToIndex(currentIndex.value - 1)
const refreshPolygon = () => { currentIndex.value = polygonMap.value.size - 1; pauseGeneration(); generatePolygon() }
// Clipboard
const copyToClipboard = () => {
if (clipPath.value) navigator.clipboard.writeText(clipPath.value).then(() => addToast({ title: 'Copied polygon', message: clipPath.value, notificationType: 'success' })).catch(() => {})
}
// Interval logic
const pauseGeneration = () => { if (intervalId.value) { clearInterval(intervalId.value); intervalId.value = null } }
const pregenerate = () => { if (buffer.value) { const N = 5; const limit = bufferSize.value > 0 ? Math.min(bufferSize.value - polygonMap.value.size, N) : N; for (let i = 0; i < limit; i++) generatePolygon() } }
const startGeneration = () => { if (currentIndex.value) { currentIndex.value = polygonMap.value.size - 1; generatePolygon() }; intervalId.value = setInterval(generatePolygon, interval.value) }
// Smooth start sequence
const startContinuousGeneration = () => { pauseGeneration(); generatePolygon(); tempTimerId.value = setTimeout(() => generatePolygon(), 1000); pregenerate(); startGeneration() }
// Watchers
watch(clipPath, v => { polygonMap.value.set(`polygon ${currentIndex.value}`, v) })
watch(pauseAnimation, v => { v ? pauseGeneration() : startContinuousGeneration() })
// Lifecycle
onMounted(() => {
if ((generateOnce.value || showControls.value) && !animateSmooth.value) return generatePolygon()
if (generate.value && !animateSmooth.value) { generatePolygon(); intervalId.value = setInterval(generatePolygon, interval.value) }
if (animateSmooth.value) startContinuousGeneration()
})
onUnmounted(() => { if (intervalId.value) clearInterval(intervalId.value); if (tempTimerId.value) clearTimeout(tempTimerId.value) })
</script>
<style scoped>
.ctrl { @apply px-2 py-1 rounded hover:bg-mx-gray-300 dark:hover:bg-mx-green-700; }
.ic { @apply w-6 h-6 text-mx-green-800 dark:text-mx-gray-300; }
.badge { @apply px-2 py-1 rounded shadow-sm bg-mx-gray-300 dark:bg-white/10 text-mx-green-800 dark:text-mx-gray-300 backdrop-blur-lg; }
</style>
Multi-Anchor Morphing Explained
A naive random polygon causes harsh jumps. Multi‑anchor morphing:
- Keep
NUM_ANCHORS
consecutive points per tick. - Advance the anchor window each generation.
- Randomize only the other points. This yields a blob that flows while retaining partial recognizable geometry.
DX Features
Control | Purpose |
---|---|
Refresh | Forces a brand new polygon ignoring continuity window |
Previous / Next | Navigate existing buffer slots |
Pause / Play | Stop or resume interval generation |
Copy | Copies current clip-path polygon() for reuse |
Counter | Shows (index + 1) / size of map |
Buffer Size | If >0, cycles through fixed slots; else grows unbounded |
Performance
- Only DOM mutation is updating
style.clipPath
once per interval. - No canvas / WebGL overhead.
transition: clip-path
on large elements can trigger repaints—keep blur layers separate and composite withtransform-gpu
.- For ultra-smooth (60fps) morphing, reduce
NUM_POINTS
or move toPath2D
canvas; for most hero backgrounds, interval morphing (500ms–2s) is fine.
Accessibility
- Mark as decorative (
aria-hidden="true"
). - Avoid using color alone in this layer to convey meaning.
- Provide reduced motion: optionally gate continuous mode behind
!prefers-reduced-motion
.
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
// degrade: disable animateSmooth
}
Using in a Hero Section
<GenerativeGradientShape
:generate="true"
:animate-smooth="true"
:interval="2500"
:transition-duration="800"
gradient-classes="bg-gradient-to-r from-primary/60 to-secondary/60"
class="absolute inset-0"
/>
Add a second instance with a different gradient & slower interval for depth.
Copy Workflow
Designers / devs can open control mode (show-controls
) in a local build → iterate until they like a shape → copy polygon(...)
→ hard‑code it for production (or seed an initial path constant).
Extension Ideas
- Persist chosen polygons in
localStorage
. - Provide seeding for reproducible randoms.
- Add export to JSON for multiple shape sets + playback timelines.
- Interpolate using a tween library (Motion One) for frame-level morphs.
Wrap Up
This approach keeps the dependency surface zero, is deterministic enough to feel intentional, and hands you a DX panel for rapid shape exploration. Abstract the generation logic into a composable if you want to reuse across multiple gradient layers or even drive SVG masks.
If you want a packaged module or variant with seeding + reduced motion fallbacks, let me know.
Improving DX with Custom Dev Components
Build internal developer-facing Vue components (like an inline GA debug panel) to speed troubleshooting and reduce context switching.
Interactive SVG Game Boy Component (Vue + Filters + Parallax)
Building an accessible, filter-rich, interactive SVG Game Boy in Vue with glow feedback, CRT mask, animated gradient, button micro-interactions, and tiny parallax.