Introducing Nuvyx UI v1.0.0

Morphing Blob

Interactive blob elements that change shape dynamically. Works as background visuals, buttons, or section transitions.

Morphing Blob Components

Interactive, customizable fluid elements that add dynamic motion to your interface

Basic Components

Simple, lightweight blobs with various themes and complexities

Primary

Secondary

Accent

Advanced Options

Enhanced blobs with custom complexity, speed and glow effects

AI Powered

Quantum

Feature Showcase

Feature Highlight

Create engaging, dynamic UI elements that respond to user interaction

Custom Colors

Define your own gradient color schemes to match your brand identity

Responsive Design

Blobs adapt seamlessly to different screen sizes and device types

Installation Guide

1

Install Dependencies

Utility Functions

npm install clsx tailwind-merge
2

Setup Configuration

Create file: /lib/utils.ts
/lib/utils.ts
Configuration
1import { clsx, type ClassValue } from "clsx";
2import { twMerge } from "tailwind-merge";
3
4  export function cn(...inputs: ClassValue[]) {
5      return twMerge(clsx(inputs));
6  }
3

Copy Component Code

Morphing Blob.tsx
TypeScript
1"use client";
2import type React from "react";
3import { useRef, useEffect, useState, useMemo, useCallback } from "react";
4import { cn } from "@/lib/utils";
5
6export interface MorphingBlobProps
7  extends React.HTMLAttributes<HTMLDivElement> {
8  theme?:
9    | "primary"
10    | "secondary"
11    | "accent"
12    | "success"
13    | "warning"
14    | "danger"
15    | "custom";
16  customColors?: {
17    from: string;
18    via?: string;
19    to: string;
20  };
21  size?: "sm" | "md" | "lg" | "xl" | "full";
22  complexity?: 1 | 2 | 3 | 4 | 5;
23  speed?: 1 | 2 | 3 | 4 | 5;
24  hoverEffect?: boolean;
25  clickEffect?: boolean;
26  pulse?: boolean;
27  glow?: boolean;
28  glowIntensity?: 1 | 2 | 3 | 4 | 5;
29  opacity?: number;
30  smooth?: boolean;
31  effect3D?: boolean;
32  children?: React.ReactNode;
33}
34
35export function MorphingBlob({
36  theme = "primary",
37  customColors,
38  size = "md",
39  complexity = 3,
40  speed = 3,
41  hoverEffect = true,
42  clickEffect = true,
43  pulse = false,
44  glow = true,
45  glowIntensity = 3,
46  opacity = 100,
47  smooth = true,
48  effect3D = false,
49  className,
50  children,
51  ...props
52}: MorphingBlobProps) {
53  const blobRef = useRef<HTMLDivElement>(null);
54  const [isHovered, setIsHovered] = useState(false);
55  const [isClicked, setIsClicked] = useState(false);
56  const [blobPath, setBlobPath] = useState("");
57  const [prevBlobPath, setPrevBlobPath] = useState("");
58  const [rotation, setRotation] = useState(0);
59  const requestRef = useRef<number | null>(null);
60  const previousTimeRef = useRef<number | null>(null);
61  const animationProgress = useRef(0);
62
63  const themeColors = useMemo(
64    () => ({
65      primary: {
66        gradient: "from-blue-400 via-blue-600 to-indigo-700",
67        glow: "shadow-blue-500/60",
68        filter: "drop-shadow(0 0 8px rgba(59, 130, 246, 0.5))",
69      },
70      secondary: {
71        gradient: "from-purple-400 via-purple-600 to-violet-800",
72        glow: "shadow-purple-500/60",
73        filter: "drop-shadow(0 0 8px rgba(147, 51, 234, 0.5))",
74      },
75      accent: {
76        gradient: "from-teal-300 via-teal-500 to-emerald-700",
77        glow: "shadow-teal-500/60",
78        filter: "drop-shadow(0 0 8px rgba(20, 184, 166, 0.5))",
79      },
80      success: {
81        gradient: "from-green-300 via-green-500 to-emerald-700",
82        glow: "shadow-green-500/60",
83        filter: "drop-shadow(0 0 8px rgba(34, 197, 94, 0.5))",
84      },
85      warning: {
86        gradient: "from-yellow-300 via-amber-500 to-orange-600",
87        glow: "shadow-amber-500/60",
88        filter: "drop-shadow(0 0 8px rgba(245, 158, 11, 0.5))",
89      },
90      danger: {
91        gradient: "from-red-300 via-red-500 to-rose-700",
92        glow: "shadow-red-500/60",
93        filter: "drop-shadow(0 0 8px rgba(239, 68, 68, 0.5))",
94      },
95      custom: {
96        gradient: customColors
97          ? `from-[${customColors.from}] ${
98              customColors.via ? `via-[${customColors.via}]` : ""
99            } to-[${customColors.to}]`
100          : "",
101        glow: customColors
102          ? `shadow-[${customColors.from}]/60`
103          : "shadow-blue-500/60",
104        filter: `drop-shadow(0 0 8px ${
105          customColors?.from || "rgba(59, 130, 246, 0.5)"
106        })`,
107      },
108    }),
109    [customColors]
110  );
111
112  const currentTheme = themeColors[theme];
113
114  const sizeClasses = {
115    sm: "w-32 h-32 md:w-40 md:h-40",
116    md: "w-40 h-40 md:w-56 md:h-56",
117    lg: "w-56 h-56 md:w-72 md:h-72",
118    xl: "w-64 h-64 md:w-96 md:h-96",
119    full: "w-full h-full",
120  };
121
122  const complexityFactors = useMemo(
123    () => ({
124      1: { points: 6, variance: 0.15, tension: 0.2 },
125      2: { points: 8, variance: 0.25, tension: 0.3 },
126      3: { points: 10, variance: 0.35, tension: 0.4 },
127      4: { points: 12, variance: 0.45, tension: 0.5 },
128      5: { points: 16, variance: 0.55, tension: 0.6 },
129    }),
130    []
131  );
132
133  const speedFactors = useMemo(
134    () => ({
135      1: 12000,
136      2: 9000,
137      3: 6000,
138      4: 4000,
139      5: 2000,
140    }),
141    []
142  );
143
144  const glowIntensityClasses = {
145    1: "shadow-md",
146    2: "shadow-lg",
147    3: "shadow-xl",
148    4: "shadow-2xl",
149    5: "shadow-2xl shadow-xl",
150  };
151
152  const isValidNumber = useCallback((num: number) => {
153    return typeof num === "number" && !isNaN(num) && isFinite(num);
154  }, []);
155
156  const generateBlobPath = useCallback(
157    (
158      factor: { points: number; variance: number; tension: number },
159      hover = false,
160      click = false
161    ) => {
162      const { points, variance, tension } = factor;
163      const centerX = 50;
164      const centerY = 50;
165      const baseRadius = hover ? 42 : click ? 38 : 40;
166      const angleStep = (Math.PI * 2) / points;
167
168      const blobPoints = [];
169      for (let i = 0; i < points; i++) {
170        const angle = i * angleStep;
171        const waveVariation = Math.sin(i * 3) * 0.15;
172        const randomVariance =
173          1 - variance + Math.random() * variance * 2 + waveVariation;
174        const radius = Math.max(baseRadius * randomVariance, 5);
175
176        const x = centerX + Math.cos(angle) * radius;
177        const y = centerY + Math.sin(angle) * radius;
178        blobPoints.push({ x, y });
179      }
180
181      let path = `M${blobPoints[0].x},${blobPoints[0].y}`;
182
183      for (let i = 0; i < points; i++) {
184        const current = blobPoints[i];
185        const next = blobPoints[(i + 1) % points];
186
187        const cp1x =
188          current.x +
189          (next.x - blobPoints[(i - 1 + points) % points].x) * tension;
190        const cp1y =
191          current.y +
192          (next.y - blobPoints[(i - 1 + points) % points].y) * tension;
193        const cp2x =
194          next.x - (blobPoints[(i + 2) % points].x - current.x) * tension;
195        const cp2y =
196          next.y - (blobPoints[(i + 2) % points].y - current.y) * tension;
197
198        const validCp1x = isValidNumber(cp1x) ? cp1x : current.x;
199        const validCp1y = isValidNumber(cp1y) ? cp1y : current.y;
200        const validCp2x = isValidNumber(cp2x) ? cp2x : next.x;
201        const validCp2y = isValidNumber(cp2y) ? cp2y : next.y;
202
203        path += ` C${validCp1x},${validCp1y} ${validCp2x},${validCp2y} ${next.x},${next.y}`;
204      }
205
206      path += " Z";
207      return path;
208    },
209    [isValidNumber]
210  );
211
212  const interpolatePaths = useCallback(
213    (path1: string, path2: string, progress: number) => {
214      if (!path1 || !path2) return path2 || path1 || "";
215
216      const extractPoints = (path: string) => {
217        const regex = /([MC]) ?([^MC]+)/g;
218        const matches = [...path.matchAll(regex)];
219
220        const points: number[][] = [];
221
222        for (const match of matches) {
223          const [, command, coordStr] = match;
224          const coords = coordStr
225            .trim()
226            .split(/[ ,]/)
227            .filter(Boolean)
228            .map(parseFloat);
229
230          if (command === "M" && coords.length >= 2) {
231            const x = coords[0];
232            const y = coords[1];
233            if (isValidNumber(x) && isValidNumber(y)) {
234              points.push([x, y]);
235            }
236          } else if (command === "C" && coords.length >= 6) {
237            const endX = coords[4];
238            const endY = coords[5];
239            if (isValidNumber(endX) && isValidNumber(endY)) {
240              points.push([endX, endY]);
241            }
242          }
243        }
244
245        return points;
246      };
247
248      const points1 = extractPoints(path1);
249      const points2 = extractPoints(path2);
250
251      if (points1.length !== points2.length || points1.length === 0) {
252        return path2;
253      }
254
255      const interpolatedPoints = points1.map((point, i) => {
256        if (i < points2.length) {
257          const x = point[0] + (points2[i][0] - point[0]) * progress;
258          const y = point[1] + (points2[i][1] - point[1]) * progress;
259
260          return [
261            isValidNumber(x) ? x : point[0],
262            isValidNumber(y) ? y : point[1],
263          ];
264        }
265        return point;
266      });
267
268      let newPath = `M${interpolatedPoints[0][0]},${interpolatedPoints[0][1]}`;
269
270      for (let i = 1; i < interpolatedPoints.length; i++) {
271        const prev = interpolatedPoints[i - 1];
272        const curr = interpolatedPoints[i];
273
274        const tension = 0.4;
275        const dx = curr[0] - prev[0];
276        const dy = curr[1] - prev[1];
277
278        const cpx1 = prev[0] + dx * tension;
279        const cpy1 = prev[1] + dy * tension;
280        const cpx2 = curr[0] - dx * tension;
281        const cpy2 = curr[1] - dy * tension;
282
283        const validCpx1 = isValidNumber(cpx1) ? cpx1 : prev[0];
284        const validCpy1 = isValidNumber(cpy1) ? cpy1 : prev[1];
285        const validCpx2 = isValidNumber(cpx2) ? cpx2 : curr[0];
286        const validCpy2 = isValidNumber(cpy2) ? cpy2 : curr[1];
287
288        newPath += ` C${validCpx1},${validCpy1} ${validCpx2},${validCpy2} ${curr[0]},${curr[1]}`;
289      }
290
291      newPath += " Z";
292      return newPath;
293    },
294    [isValidNumber]
295  );
296
297  const animate = useCallback(
298    (time: number) => {
299      if (previousTimeRef.current === null) {
300        previousTimeRef.current = time;
301      }
302
303      const deltaTime = time - (previousTimeRef.current || 0);
304      const factor = complexityFactors[complexity];
305      const duration = speedFactors[speed];
306
307      animationProgress.current += deltaTime / duration;
308      if (animationProgress.current >= 1) {
309        animationProgress.current = 0;
310        setPrevBlobPath(blobPath);
311        setBlobPath(generateBlobPath(factor, isHovered, isClicked));
312        setRotation((prev) => (prev + 30) % 360);
313      }
314
315      previousTimeRef.current = time;
316      requestRef.current = requestAnimationFrame(animate);
317    },
318    [
319      blobPath,
320      complexity,
321      speed,
322      isHovered,
323      isClicked,
324      complexityFactors,
325      speedFactors,
326      generateBlobPath,
327    ]
328  );
329
330  useEffect(() => {
331    const factor = complexityFactors[complexity];
332
333    if (!blobPath) {
334      const initialPath = generateBlobPath(factor, isHovered, isClicked);
335      setBlobPath(initialPath);
336      setPrevBlobPath(initialPath);
337    }
338
339    if (smooth) {
340      requestRef.current = requestAnimationFrame(animate);
341      return () => {
342        if (requestRef.current !== null) {
343          cancelAnimationFrame(requestRef.current);
344        }
345      };
346    } else {
347      const interval = setInterval(() => {
348        setPrevBlobPath(blobPath);
349        setBlobPath(generateBlobPath(factor, isHovered, isClicked));
350        setRotation((prev) => (prev + 30) % 360);
351      }, speedFactors[speed] / 6);
352
353      return () => clearInterval(interval);
354    }
355  }, [
356    complexity,
357    speed,
358    isHovered,
359    isClicked,
360    smooth,
361    animate,
362    blobPath,
363    complexityFactors,
364    speedFactors,
365    generateBlobPath,
366  ]);
367
368  const handleMouseEnter = () => {
369    if (hoverEffect) {
370      setIsHovered(true);
371    }
372  };
373
374  const handleMouseLeave = () => {
375    if (hoverEffect) {
376      setIsHovered(false);
377    }
378    if (clickEffect) {
379      setIsClicked(false);
380    }
381  };
382
383  const handleMouseDown = () => {
384    if (clickEffect) {
385      setIsClicked(true);
386    }
387  };
388
389  const handleMouseUp = () => {
390    if (clickEffect) {
391      setIsClicked(false);
392    }
393  };
394
395  const displayPath =
396    smooth && prevBlobPath
397      ? interpolatePaths(prevBlobPath, blobPath, animationProgress.current)
398      : blobPath;
399
400  return (
401    <div
402      ref={blobRef}
403      className={cn(
404        "relative flex items-center justify-center transition-all duration-500",
405        sizeClasses[size],
406        glow && glowIntensityClasses[glowIntensity],
407        glow && currentTheme.glow,
408        pulse && "animate-pulse",
409        className
410      )}
411      onMouseEnter={handleMouseEnter}
412      onMouseLeave={handleMouseLeave}
413      onMouseDown={handleMouseDown}
414      onMouseUp={handleMouseUp}
415      style={{
416        opacity: opacity / 100,
417      }}
418      {...props}
419    >
420      <svg
421        viewBox="0 0 100 100"
422        className="absolute inset-0 w-full h-full"
423        style={{
424          transform: `rotate(${rotation}deg)`,
425          transition: "transform 8s ease-in-out",
426          filter: glow ? currentTheme.filter : "none",
427        }}
428      >
429        {effect3D && (
430          <path
431            d={displayPath}
432            className="transition-all duration-300"
433            fill="rgba(0,0,0,0.2)"
434            transform="translate(3,3)"
435          />
436        )}
437
438        <path
439          d={displayPath}
440          className={cn(
441            "transition-all duration-300 bg-gradient-to-br",
442            effect3D ? "backdrop-blur-lg" : ""
443          )}
444          fill="url(#blob-gradient)"
445        />
446
447        {effect3D && (
448          <path
449            d={displayPath}
450            className="transition-all duration-300"
451            fill="rgba(255,255,255,0.1)"
452            transform="translate(-1.5,-1.5) scale(0.98)"
453          />
454        )}
455
456        <defs>
457          <linearGradient
458            id="blob-gradient"
459            x1="0%"
460            y1="0%"
461            x2="100%"
462            y2="100%"
463          >
464            <stop offset="0%" className="stop-color-from" />
465            {theme === "custom" && customColors?.via && (
466              <stop offset="50%" className="stop-color-via" />
467            )}
468            <stop offset="100%" className="stop-color-to" />
469          </linearGradient>
470
471          <radialGradient
472            id="blob-radial"
473            cx="50%"
474            cy="50%"
475            r="50%"
476            fx="50%"
477            fy="50%"
478          >
479            <stop offset="0%" stopColor="rgba(255,255,255,0.15)" />
480            <stop offset="100%" stopColor="rgba(255,255,255,0)" />
481          </radialGradient>
482
483          <filter id="blur-filter" x="-50%" y="-50%" width="200%" height="200%">
484            <feGaussianBlur in="SourceGraphic" stdDeviation="5" />
485          </filter>
486        </defs>
487      </svg>
488
489      {children && (
490        <div className="relative z-10 flex items-center justify-center text-white transition-all duration-300">
491          {children}
492        </div>
493      )}
494    </div>
495  );
496}
497
4

Final Steps

Update Import Paths

Make sure to update the import paths in the component code to match your project structure. For example, change @/components/ui/button to match your UI components location.

Props

NameTypeDefaultDescription
themestring"primary"The color theme of the blob. Possible values: "primary", "secondary", "accent", "success", "warning", "danger", "custom".
customColorsobjectundefinedCustom colors for the blob when theme is set to "custom". The object should include properties: from, to, and optionally via.
sizestring"md"The size of the blob. Possible values: "sm", "md", "lg", "xl", "full".
complexitynumber3The complexity of the blob shape (1-5).
speednumber3The speed of the morphing animation (1-5).
hoverEffectbooleantrueWhether to enable the hover effect.
clickEffectbooleantrueWhether to enable the click effect.
pulsebooleanfalseWhether to enable the pulse animation.
glowbooleantrueWhether to show a glow effect.
glowIntensitynumber3The intensity of the glow effect (1-5).
opacitynumber100The opacity of the blob (percentage).
smoothbooleantrueWhether to enable smooth transitions in the morphing animation.
effect3DbooleanfalseWhether to enable a 3D effect on the blob.
classNamestring""Additional CSS classes to apply.
childrenReact.ReactNodeundefinedContent to be rendered inside the blob component.

Examples

Get Started

Beautiful animated backgrounds for modern UIs

Security

Enterprise-grade protection for your applications

Premium

Elevate your UI with custom gradient effects

AI Integration

Smart, adaptive components that learn and respond

Launch App