Animated c Generator app
How and Why I build one
Tags: Gradient Generator, CSS, Animation, Tool, Nextjs
Hello and welcome to new blog
Today's story is about building an Animated Gradient Generator. Well, the idea came into mind because of gettemplate.website premium-templates, most premium-templates for our PRO clients of gettemplate need catchy and animated websites, including animated backgrounds
And every time I've to provide the same prompt to the cursor AI agent or probably use other websites and that's where I thought why can't I make one and release it for others to use.
Check demo: https://www.gettemplate.website/tool/gradient-generator
I usually make tools to automate my own work, and if I find a good tool, I launch it, giving a good UI.
A few examples can be found on iHateReading jobs portals, Universo, github trending repositories and roadmap templates. These are the features or products that I needed and then launched for others to use.
This one trick is my go-to approach for any new products. If I want and can make something good, I can launch it for the people as well.
This gradient generator provides background CSS that can be directly copied and pasted for use, and it is also available for download for other purposes. Since it includes animation, I've used ffmpeg to create videos in MP4 and GIF formats for people to download.
I do need this tool to create some background animations for YouTube videos quickly, banner images as well, and the reason I made it is that I am done opening figma and canva and logging in every time just for a small gradient generator.
These apps are good, but sometimes we need small apps that load quickly and work well for small or the smallest tasks. This is another reason why I am not building this app further or adding any more features; its whole purpose is to provide background gradients, nothing else.
Let's begin with how I made this tool
It's simple because I've experience, that's why I can say that, but it is simple in general in 2025 with the use of AI.
I am a big fan of the code file component as a product. What it means is, I write code in one single file to create the entire MVP of the product and avoid reuse and any other concepts. One single file is the MVP, and in general, I stick to the plan of writing almost 5k lines of code for every MVP
Our gradient generator, as shown below, has 3k lines of code
import React, {
useState,
useRef,
useCallback,
useEffect,
useMemo,
} from "react";
import {
Copy,
Plus,
Trash2,
X,
ChevronDown,
Download,
InfoIcon,
} from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
// Gradient Presets - 20 presets with 2 color stops each
const gradientPresets = [
{
id: 1,
name: "Sunset",
type: "linear",
angle: 45,
stops: [
{ id: 1, color: "#FF6B6B", position: { x: 0, y: 0 } },
{ id: 2, color: "#FFE66D", position: { x: 100, y: 100 } },
],
},
{
id: 2,
name: "Ocean",
type: "linear",
angle: 135,
stops: [
{ id: 1, color: "#4ECDC4", position: { x: 0, y: 0 } },
{ id: 2, color: "#44A08D", position: { x: 100, y: 100 } },
],
},
{
id: 3,
name: "Purple Dream",
type: "linear",
angle: 90,
stops: [
{ id: 1, color: "#667EEA", position: { x: 0, y: 0 } },
{ id: 2, color: "#764BA2", position: { x: 100, y: 100 } },
],
},
{
id: 4,
name: "Forest",
type: "linear",
angle: 45,
stops: [
{ id: 1, color: "#11998E", position: { x: 0, y: 0 } },
{ id: 2, color: "#38EF7D", position: { x: 100, y: 100 } },
],
},
{
id: 5,
name: "Coral",
type: "linear",
angle: 120,
stops: [
{ id: 1, color: "#FF9A9E", position: { x: 0, y: 0 } },
{ id: 2, color: "#FECFEF", position: { x: 100, y: 100 } },
],
},
{
id: 6,
name: "Blue Sky",
type: "linear",
angle: 180,
stops: [
{ id: 1, color: "#3494E6", position: { x: 0, y: 0 } },
{ id: 2, color: "#EC6EAD", position: { x: 100, y: 100 } },
],
},
{
id: 7,
name: "Peach",
type: "linear",
angle: 45,
stops: [
{ id: 1, color: "#FFECD2", position: { x: 0, y: 0 } },
{ id: 2, color: "#FCB69F", position: { x: 100, y: 100 } },
],
},
{
id: 8,
name: "Midnight",
type: "linear",
angle: 135,
stops: [
{ id: 1, color: "#0F2027", position: { x: 0, y: 0 } },
{ id: 2, color: "#203A43", position: { x: 100, y: 100 } },
],
},
{
id: 9,
name: "Rose Gold",
type: "linear",
angle: 90,
stops: [
{ id: 1, color: "#F093FB", position: { x: 0, y: 0 } },
{ id: 2, color: "#F5576C", position: { x: 100, y: 100 } },
],
},
{
id: 10,
name: "Mint",
type: "linear",
angle: 45,
stops: [
{ id: 1, color: "#A8EDEA", position: { x: 0, y: 0 } },
{ id: 2, color: "#FED6E3", position: { x: 100, y: 100 } },
],
},
{
id: 11,
name: "Fire",
type: "linear",
angle: 45,
stops: [
{ id: 1, color: "#FF416C", position: { x: 0, y: 0 } },
{ id: 2, color: "#FF4B2B", position: { x: 100, y: 100 } },
],
},
{
id: 12,
name: "Lavender",
type: "linear",
angle: 135,
stops: [
{ id: 1, color: "#E0C3FC", position: { x: 0, y: 0 } },
{ id: 2, color: "#8EC5FC", position: { x: 100, y: 100 } },
],
},
{
id: 13,
name: "zinc Tea",
type: "linear",
angle: 90,
stops: [
{ id: 1, color: "#D4FC79", position: { x: 0, y: 0 } },
{ id: 2, color: "#96E6A1", position: { x: 100, y: 100 } },
],
},
{
id: 14,
name: "Cherry",
type: "linear",
angle: 45,
stops: [
{ id: 1, color: "#EB3349", position: { x: 0, y: 0 } },
{ id: 2, color: "#F45C43", position: { x: 100, y: 100 } },
],
},
{
id: 15,
name: "Sky Blue",
type: "linear",
angle: 180,
stops: [
{ id: 1, color: "#89F7FE", position: { x: 0, y: 0 } },
{ id: 2, color: "#66A6FF", position: { x: 100, y: 100 } },
],
},
{
id: 16,
name: "Orange",
type: "linear",
angle: 120,
stops: [
{ id: 1, color: "#FFB347", position: { x: 0, y: 0 } },
{ id: 2, color: "#FFCC33", position: { x: 100, y: 100 } },
],
},
{
id: 17,
name: "Violet",
type: "linear",
angle: 135,
stops: [
{ id: 1, color: "#8360C3", position: { x: 0, y: 0 } },
{ id: 2, color: "#2EBF91", position: { x: 100, y: 100 } },
],
},
{
id: 18,
name: "Pink",
type: "linear",
angle: 45,
stops: [
{ id: 1, color: "#FF6B95", position: { x: 0, y: 0 } },
{ id: 2, color: "#FFC796", position: { x: 100, y: 100 } },
],
},
{
id: 19,
name: "Cyan",
type: "linear",
angle: 90,
stops: [
{ id: 1, color: "#00F5FF", position: { x: 0, y: 0 } },
{ id: 2, color: "#00D4FF", position: { x: 100, y: 100 } },
],
},
{
id: 20,
name: "Golden",
type: "linear",
angle: 45,
stops: [
{ id: 1, color: "#F6D365", position: { x: 0, y: 0 } },
{ id: 2, color: "#FDA085", position: { x: 100, y: 100 } },
],
},
{
id: 21,
name: "Dark Night",
type: "linear",
angle: 135,
stops: [
{ id: 1, color: "#1a1a2e", position: { x: 0, y: 0 } },
{ id: 2, color: "#16213e", position: { x: 100, y: 100 } },
],
},
{
id: 22,
name: "Deep Purple",
type: "linear",
angle: 90,
stops: [
{ id: 1, color: "#2d1b69", position: { x: 0, y: 0 } },
{ id: 2, color: "#11998e", position: { x: 100, y: 100 } },
],
},
{
id: 23,
name: "Charcoal",
type: "linear",
angle: 45,
stops: [
{ id: 1, color: "#2c3e50", position: { x: 0, y: 0 } },
{ id: 2, color: "#34495e", position: { x: 100, y: 100 } },
],
},
{
id: 24,
name: "Dark Blue",
type: "linear",
angle: 180,
stops: [
{ id: 1, color: "#0c0c0c", position: { x: 0, y: 0 } },
{ id: 2, color: "#1a237e", position: { x: 100, y: 100 } },
],
},
{
id: 25,
name: "Dark Forest",
type: "linear",
angle: 135,
stops: [
{ id: 1, color: "#0f2027", position: { x: 0, y: 0 } },
{ id: 2, color: "#203a43", position: { x: 100, y: 100 } },
],
},
{
id: 26,
name: "Ebony",
type: "linear",
angle: 90,
stops: [
{ id: 1, color: "#232526", position: { x: 0, y: 0 } },
{ id: 2, color: "#414345", position: { x: 100, y: 100 } },
],
},
{
id: 27,
name: "Dark Red",
type: "linear",
angle: 45,
stops: [
{ id: 1, color: "#1a0000", position: { x: 0, y: 0 } },
{ id: 2, color: "#4a0000", position: { x: 100, y: 100 } },
],
},
{
id: 28,
name: "Midnight Blue",
type: "linear",
angle: 120,
stops: [
{ id: 1, color: "#0f0c29", position: { x: 0, y: 0 } },
{ id: 2, color: "#302b63", position: { x: 100, y: 100 } },
],
},
{
id: 29,
name: "Dark Teal",
type: "linear",
angle: 135,
stops: [
{ id: 1, color: "#134e5e", position: { x: 0, y: 0 } },
{ id: 2, color: "#71b280", position: { x: 100, y: 100 } },
],
},
{
id: 30,
name: "Shadow",
type: "linear",
angle: 45,
stops: [
{ id: 1, color: "#1e3c72", position: { x: 0, y: 0 } },
{ id: 2, color: "#2a5298", position: { x: 100, y: 100 } },
],
},
{
id: 31,
name: "Dark Violet",
type: "linear",
angle: 90,
stops: [
{ id: 1, color: "#4b0082", position: { x: 0, y: 0 } },
{ id: 2, color: "#6a0dad", position: { x: 100, y: 100 } },
],
},
{
id: 32,
name: "Obsidian",
type: "linear",
angle: 180,
stops: [
{ id: 1, color: "#000000", position: { x: 0, y: 0 } },
{ id: 2, color: "#1a1a1a", position: { x: 100, y: 100 } },
],
},
{
id: 33,
name: "Dark Emerald",
type: "linear",
angle: 135,
stops: [
{ id: 1, color: "#0d2818", position: { x: 0, y: 0 } },
{ id: 2, color: "#1a5f3f", position: { x: 100, y: 100 } },
],
},
{
id: 34,
name: "Deep Space",
type: "linear",
angle: 45,
stops: [
{ id: 1, color: "#000000", position: { x: 0, y: 0 } },
{ id: 2, color: "#1a1a2e", position: { x: 100, y: 100 } },
],
},
{
id: 35,
name: "Dark Orange",
type: "linear",
angle: 120,
stops: [
{ id: 1, color: "#2c1810", position: { x: 0, y: 0 } },
{ id: 2, color: "#8b4513", position: { x: 100, y: 100 } },
],
},
];
// Background Pattern Presets
const backgroundPatternPresets = [
{
id: 1,
name: "None",
css: "",
preview: "transparent",
},
{
id: 2,
name: "Grid Squares",
css: `linear-gradient(rgba(0, 0, 0, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 0, 0, 0.1) 1px, transparent 1px)`,
backgroundSize: "20px 20px",
preview: `linear-gradient(rgba(0, 0, 0, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 0, 0, 0.1) 1px, transparent 1px)`,
},
{
id: 3,
name: "Vertical Lines",
css: `repeating-linear-gradient(90deg, rgba(0, 0, 0, 0.1) 0px, rgba(0, 0, 0, 0.1) 1px, transparent 1px, transparent 20px)`,
backgroundSize: "20px 20px",
preview: `repeating-linear-gradient(90deg, rgba(0, 0, 0, 0.1) 0px, rgba(0, 0, 0, 0.1) 1px, transparent 1px, transparent 20px)`,
},
{
id: 4,
name: "Horizontal Lines",
css: `repeating-linear-gradient(0deg, rgba(0, 0, 0, 0.1) 0px, rgba(0, 0, 0, 0.1) 1px, transparent 1px, transparent 20px)`,
backgroundSize: "20px 20px",
preview: `repeating-linear-gradient(0deg, rgba(0, 0, 0, 0.1) 0px, rgba(0, 0, 0, 0.1) 1px, transparent 1px, transparent 20px)`,
},
{
id: 5,
name: "Diagonal Lines",
css: `repeating-linear-gradient(45deg, rgba(0, 0, 0, 0.1) 0px, rgba(0, 0, 0, 0.1) 1px, transparent 1px, transparent 20px)`,
backgroundSize: "20px 20px",
preview: `repeating-linear-gradient(45deg, rgba(0, 0, 0, 0.1) 0px, rgba(0, 0, 0, 0.1) 1px, transparent 1px, transparent 20px)`,
},
{
id: 6,
name: "Dots Pattern",
css: `radial-gradient(circle, rgba(0, 0, 0, 0.15) 1px, transparent 1px)`,
backgroundSize: "20px 20px",
preview: `radial-gradient(circle, rgba(0, 0, 0, 0.15) 1px, transparent 1px)`,
},
{
id: 7,
name: "Large Circle",
css: `radial-gradient(circle at center, rgba(0, 0, 0, 0.1) 0%, transparent 50%)`,
backgroundSize: "100% 100%",
preview: `radial-gradient(circle at center, rgba(0, 0, 0, 0.1) 0%, transparent 50%)`,
},
{
id: 8,
name: "Ellipse Center",
css: `radial-gradient(ellipse at center, rgba(0, 0, 0, 0.1) 0%, transparent 70%)`,
backgroundSize: "100% 100%",
preview: `radial-gradient(ellipse at center, rgba(0, 0, 0, 0.1) 0%, transparent 70%)`,
},
{
id: 9,
name: "Cross Pattern",
css: `linear-gradient(rgba(0, 0, 0, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 0, 0, 0.1) 1px, transparent 1px)`,
backgroundSize: "30px 30px",
preview: `linear-gradient(rgba(0, 0, 0, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 0, 0, 0.1) 1px, transparent 1px)`,
},
{
id: 10,
name: "Hexagon Pattern",
css: `repeating-linear-gradient(60deg, rgba(0, 0, 0, 0.1) 0px, rgba(0, 0, 0, 0.1) 1px, transparent 1px, transparent 30px),
repeating-linear-gradient(120deg, rgba(0, 0, 0, 0.1) 0px, rgba(0, 0, 0, 0.1) 1px, transparent 1px, transparent 30px),
repeating-linear-gradient(0deg, rgba(0, 0, 0, 0.1) 0px, rgba(0, 0, 0, 0.1) 1px, transparent 1px, transparent 30px)`,
backgroundSize: "30px 30px",
preview: `repeating-linear-gradient(60deg, rgba(0, 0, 0, 0.1) 0px, rgba(0, 0, 0, 0.1) 1px, transparent 1px, transparent 30px),
repeating-linear-gradient(120deg, rgba(0, 0, 0, 0.1) 0px, rgba(0, 0, 0, 0.1) 1px, transparent 1px, transparent 30px),
repeating-linear-gradient(0deg, rgba(0, 0, 0, 0.1) 0px, rgba(0, 0, 0, 0.1) 1px, transparent 1px, transparent 30px)`,
},
{
id: 11,
name: "Wavy Lines",
css: `repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0, 0, 0, 0.1) 2px, rgba(0, 0, 0, 0.1) 4px)`,
backgroundSize: "20px 20px",
preview: `repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0, 0, 0, 0.1) 2px, rgba(0, 0, 0, 0.1) 4px)`,
},
{
id: 12,
name: "Checkerboard",
css: `linear-gradient(45deg, rgba(0, 0, 0, 0.1) 25%, transparent 25%),
linear-gradient(-45deg, rgba(0, 0, 0, 0.1) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgba(0, 0, 0, 0.1) 75%),
linear-gradient(-45deg, transparent 75%, rgba(0, 0, 0, 0.1) 75%)`,
backgroundSize: "20px 20px",
backgroundPosition: "0 0, 0 10px, 10px -10px, -10px 0px",
preview: `linear-gradient(45deg, rgba(0, 0, 0, 0.1) 25%, transparent 25%),
linear-gradient(-45deg, rgba(0, 0, 0, 0.1) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgba(0, 0, 0, 0.1) 75%),
linear-gradient(-45deg, transparent 75%, rgba(0, 0, 0, 0.1) 75%)`,
},
];
// Helper function to generate gradient CSS from preset
const generatePresetGradientCSS = (preset) => {
if (preset.type === "linear") {
return `linear-gradient(${preset.angle || 45}deg, ${preset.stops[0].color}, ${preset.stops[1].color})`;
} else if (preset.type === "radial") {
return `radial-gradient(circle, ${preset.stops[0].color}, ${preset.stops[1].color})`;
} else if (preset.type === "conic") {
return `conic-gradient(${preset.stops[0].color}, ${preset.stops[1].color})`;
}
return `linear-gradient(45deg, ${preset.stops[0].color}, ${preset.stops[1].color})`;
};
// Custom Dropdown Component
const Dropdown = ({
value,
onChange,
options,
placeholder,
className = "",
}) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef(null);
const handleClickOutside = useCallback((event) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setIsOpen(false);
}
}, []);
useEffect(() => {
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [handleClickOutside]);
const selectedOption = options.find((option) => option.value === value);
return (
<div ref={dropdownRef} className={`relative ${className}`}>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="w-full h-8 px-2 text-xs border border-zinc-200 bg-white rounded-xl focus:outline-none focus:ring-2 focus:ring-zinc-400 focus:border-zinc-400 transition-colors flex items-center justify-between"
>
<span className="text-left">
{selectedOption?.label || placeholder}
</span>
<ChevronDown
className={`w-3 h-3 transition-transform ${
isOpen ? "rotate-180" : ""
}`}
/>
</button>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.15 }}
className="absolute z-50 w-full mt-1 bg-white border border-zinc-200 rounded-xl shadow-lg overflow-hidden"
>
{options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
onChange(option.value);
setIsOpen(false);
}}
className={`w-full px-2 py-1.5 text-xs text-left hover:bg-zinc-50 transition-colors ${
value === option.value
? "bg-zinc-100 text-zinc-900"
: "text-zinc-700"
}`}
>
{option.label}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
};
const GradientGenerator = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [gradient, setGradient] = useState({
type: "linear",
angle: 45,
stops: [
{ id: 1, color: "#FF8D00", position: { x: 0, y: 0 } },
{ id: 2, color: "#FFB870", position: { x: 50, y: 30 } },
],
noise: {
enabled: true,
intensity: 0.3,
},
animation: {
enabled: true,
type: "rotate",
duration: 3,
easing: "ease-in-out",
direction: "normal",
},
backgroundAnimation: {
enabled: true,
type: "slide",
direction: "right",
speed: 5,
easing: "linear",
},
dimensions: {
width: 1080,
height: 1920,
},
backgroundPattern: {
id: 1,
name: "None",
},
});
const [isPlaying, setIsPlaying] = useState(true);
const [copied, setCopied] = useState("");
const [selectedStop, setSelectedStop] = useState(null);
const [isDragging, setIsDragging] = useState(false);
const [downloadDimension, setDownloadDimension] = useState("desktop");
const [previewFrameSize, setPreviewFrameSize] = useState("desktop");
const [isDownloadDropdownOpen, setIsDownloadDropdownOpen] = useState(false);
const [isPanelDownloadDropdownOpen, setIsPanelDownloadDropdownOpen] =
useState(false);
const [isGeneratingMP4, setIsGeneratingMP4] = useState(false);
const [mp4Progress, setMp4Progress] = useState(0);
const [isGradientPresetsOpen, setIsGradientPresetsOpen] = useState(false);
const [isBackgroundPatternsOpen, setIsBackgroundPatternsOpen] =
useState(false);
const gradientPresetsRef = useRef(null);
const backgroundPatternsRef = useRef(null);
const previewRef = useRef(null);
const modalPreviewRef = useRef(null);
const downloadDropdownRef = useRef(null);
const panelDownloadDropdownRef = useRef(null);
const dimensionPresets = {
mobile: { width: 1080, height: 1920, label: "Mobile (1080×1920)" },
desktop: { width: 1920, height: 1080, label: "Desktop (1920×1080)" },
};
const previewFramePresets = {
mobile: { width: 1080, height: 1920, label: "Mobile", icon: "📱" },
tablet: { width: 1024, height: 1366, label: "Tablet", icon: "📱" },
desktop: { width: 1920, height: 1080, label: "Desktop", icon: "🖥️" },
laptop: { width: 1366, height: 768, label: "Laptop", icon: "💻" },
ultrawide: { width: 2560, height: 1080, label: "Ultrawide", icon: "🖥️" },
custom: { width: 1080, height: 1080, label: "Square", icon: "⬜" },
};
const addColorStop = () => {
const newStop = {
id: Date.now(),
color: "#10b981",
position: {
x: Math.random() * 80 + 10,
y: Math.random() * 80 + 10,
},
};
setGradient((prev) => ({
...prev,
stops: [...prev.stops, newStop],
}));
};
const removeColorStop = (id) => {
setGradient((prev) => ({
...prev,
stops: prev.stops.filter((stop) => stop.id !== id),
}));
};
const updateColorStop = (id, property, value) => {
setGradient((prev) => ({
...prev,
stops: prev.stops.map((stop) =>
stop.id === id ? { ...stop, [property]: value } : stop
),
}));
};
const handleMouseDown = useCallback((e, stopId) => {
e.preventDefault();
setIsDragging(true);
setSelectedStop(stopId);
const previewRect = previewRef.current.getBoundingClientRect();
const handleMouseMove = (e) => {
const x = ((e.clientX - previewRect.left) / previewRect.width) * 100;
const y = ((e.clientY - previewRect.top) / previewRect.height) * 100;
const clampedX = Math.max(0, Math.min(100, x));
const clampedY = Math.max(0, Math.min(100, y));
updateColorStop(stopId, "position", { x: clampedX, y: clampedY });
};
const handleMouseUp = () => {
setIsDragging(false);
setSelectedStop(null);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}, []);
const handleKeyDown = useCallback(
(e, stopId) => {
if (!selectedStop || selectedStop !== stopId) return;
const step = e.shiftKey ? 10 : 1;
const stop = gradient.stops.find((s) => s.id === stopId);
if (!stop) return;
let newPosition = { ...stop.position };
switch (e.key) {
case "ArrowLeft":
newPosition.x = Math.max(0, stop.position.x - step);
break;
case "ArrowRight":
newPosition.x = Math.min(100, stop.position.x + step);
break;
case "ArrowUp":
newPosition.y = Math.max(0, stop.position.y - step);
break;
case "ArrowDown":
newPosition.y = Math.min(100, stop.position.y + step);
break;
case "Delete":
case "Backspace":
removeColorStop(stopId);
return;
default:
return;
}
e.preventDefault();
updateColorStop(stopId, "position", newPosition);
},
[selectedStop, gradient.stops]
);
const generateGradientCSS = () => {
// Handle empty case - return white background when no stops
if (gradient.stops.length === 0) {
return "#ffffff";
}
const sortedStops = [...gradient.stops].sort((a, b) => {
const distA = Math.sqrt(
a.position.x * a.position.x + a.position.y * a.position.y
);
const distB = Math.sqrt(
b.position.x * b.position.x + b.position.y * b.position.y
);
return distA - distB;
});
if (gradient.type === "linear") {
const firstStop = sortedStops[0];
const lastStop = sortedStops[sortedStops.length - 1];
const angle =
Math.atan2(
lastStop.position.y - firstStop.position.y,
lastStop.position.x - firstStop.position.x
) *
(180 / Math.PI);
const stopsString = sortedStops
.map((stop) => `${stop.color} ${Math.round(stop.position.x)}%`)
.join(", ");
return `linear-gradient(${Math.round(angle)}deg, ${stopsString})`;
}
if (gradient.type === "radial") {
const stopsString = sortedStops
.map(
(stop) =>
`${stop.color} ${Math.round(
Math.sqrt(
Math.pow(stop.position.x - 50, 2) +
Math.pow(stop.position.y - 50, 2)
)
)}%`
)
.join(", ");
return `radial-gradient(circle at 50% 50%, ${stopsString})`;
}
if (gradient.type === "conic") {
const stopsString = sortedStops
.map((stop) => `${stop.color} ${Math.round(stop.position.x)}%`)
.join(", ");
return `conic-gradient(from 0deg, ${stopsString})`;
}
if (gradient.type === "mesh") {
// Mesh gradient using multiple radial gradients as background layers
const meshGradients = sortedStops
.map(
(stop) =>
`radial-gradient(circle at ${stop.position.x}% ${stop.position.y}%, ${stop.color} 0%, transparent 50%)`
)
.join(", ");
return meshGradients;
}
if (gradient.type === "grid") {
// Grid pattern using repeating linear gradients
const firstStop = sortedStops[0];
const lastStop = sortedStops[sortedStops.length - 1];
const angle =
Math.atan2(
lastStop.position.y - firstStop.position.y,
lastStop.position.x - firstStop.position.x
) *
(180 / Math.PI);
const stopsString = sortedStops
.map((stop) => `${stop.color} ${Math.round(stop.position.x)}%`)
.join(", ");
return `repeating-linear-gradient(${Math.round(angle)}deg, ${stopsString})`;
}
if (gradient.type === "sharp") {
// Sharp transitions - linear gradient with hard stops
const firstStop = sortedStops[0];
const lastStop = sortedStops[sortedStops.length - 1];
const angle =
Math.atan2(
lastStop.position.y - firstStop.position.y,
lastStop.position.x - firstStop.position.x
) *
(180 / Math.PI);
// Create sharp transitions by duplicating colors at same position
const sharpStops = sortedStops
.flatMap((stop, index) => {
if (index === sortedStops.length - 1) {
return [`${stop.color} ${Math.round(stop.position.x)}%`];
}
const nextStop = sortedStops[index + 1];
const midPoint = (stop.position.x + nextStop.position.x) / 2;
return [
`${stop.color} ${Math.round(stop.position.x)}%`,
`${stop.color} ${Math.round(midPoint)}%`,
`${nextStop.color} ${Math.round(midPoint)}%`,
];
})
.join(", ");
return `linear-gradient(${Math.round(angle)}deg, ${sharpStops})`;
}
if (gradient.type === "soft") {
// Soft transitions - linear gradient with extended color stops
const firstStop = sortedStops[0];
const lastStop = sortedStops[sortedStops.length - 1];
const angle =
Math.atan2(
lastStop.position.y - firstStop.position.y,
lastStop.position.x - firstStop.position.x
) *
(180 / Math.PI);
// Create soft transitions by extending color stops
const softStops = sortedStops
.map((stop, index) => {
if (index === 0) {
return `${stop.color} ${Math.max(0, Math.round(stop.position.x) - 10)}%`;
}
if (index === sortedStops.length - 1) {
return `${stop.color} ${Math.min(100, Math.round(stop.position.x) + 10)}%`;
}
const prevStop = sortedStops[index - 1];
const nextStop = sortedStops[index + 1];
const startPos = Math.max(
prevStop.position.x,
Math.round(stop.position.x) - 15
);
const endPos = Math.min(
nextStop.position.x,
Math.round(stop.position.x) + 15
);
return `${stop.color} ${Math.round(startPos)}% ${Math.round(endPos)}%`;
})
.join(", ");
return `linear-gradient(${Math.round(angle)}deg, ${softStops})`;
}
return "";
};
// Generate background pattern CSS
const generateBackgroundPatternCSS = () => {
const pattern = backgroundPatternPresets.find(
(p) => p.id === gradient.backgroundPattern.id
);
if (!pattern || !pattern.css) return "";
let css = `background-image: ${pattern.css};`;
if (pattern.backgroundSize) {
css += `\nbackground-size: ${pattern.backgroundSize};`;
}
if (pattern.backgroundPosition) {
css += `\nbackground-position: ${pattern.backgroundPosition};`;
}
return css;
};
// Get background pattern style object for inline styles
const getBackgroundPatternStyle = () => {
const gradientCSS = generateGradientCSS();
const pattern = backgroundPatternPresets.find(
(p) => p.id === gradient.backgroundPattern.id
);
// If no pattern or pattern is "None", just return gradient
if (!pattern || !pattern.css) {
return {
background: gradientCSS,
};
}
// Combine gradient and pattern in background-image
const combinedBackground = `${gradientCSS}, ${pattern.css}`;
const style = {
backgroundImage: combinedBackground,
};
if (pattern.backgroundSize) {
// Combine background sizes: gradient uses auto, pattern uses its size
style.backgroundSize = `auto, ${pattern.backgroundSize}`;
}
if (pattern.backgroundPosition) {
// Combine background positions
style.backgroundPosition = `0 0, ${pattern.backgroundPosition}`;
}
return style;
};
const generateAnimationCSS = () => {
const {
type: bgType,
direction: bgDirection,
speed,
easing: bgEasing,
repeat,
} = gradient.backgroundAnimation;
let backgroundAnimation = "";
if (gradient.backgroundAnimation.enabled) {
const infinite = repeat ? "infinite" : "1";
switch (bgType) {
case "slide":
if (bgDirection === "right") {
backgroundAnimation = `slideRight ${speed}s ${bgEasing} ${infinite}`;
} else if (bgDirection === "left") {
backgroundAnimation = `slideLeft ${speed}s ${bgEasing} ${infinite}`;
} else if (bgDirection === "up") {
backgroundAnimation = `slideUp ${speed}s ${bgEasing} ${infinite}`;
} else {
backgroundAnimation = `slideDown ${speed}s ${bgEasing} ${infinite}`;
}
break;
case "wave":
backgroundAnimation = `wave ${speed}s ${bgEasing} ${infinite}`;
break;
}
}
return backgroundAnimation;
};
const copyToClipboard = async (text, type) => {
try {
await navigator.clipboard.writeText(text);
setCopied(type);
setTimeout(() => setCopied(""), 2000);
} catch (err) {
console.error("Failed to copy: ", err);
}
};
// Simple SVG download
const downloadSVG = (dimensionType = downloadDimension) => {
const dimensions =
previewFramePresets[dimensionType] ||
dimensionPresets[dimensionType] ||
dimensionPresets.mobile;
const width = dimensions.width;
const height = dimensions.height;
// Handle empty case - return white background SVG
if (gradient.stops.length === 0) {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<rect width="100%" height="100%" fill="#ffffff" />
</svg>`;
const blob = new Blob([svg], { type: "image/svg+xml" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `gradient-${dimensionType}-${width}x${height}-${Date.now()}.svg`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
return;
}
const sortedStops = [...gradient.stops].sort((a, b) => {
const distA = Math.sqrt(
a.position.x * a.position.x + a.position.y * a.position.y
);
const distB = Math.sqrt(
b.position.x * b.position.x + b.position.y * b.position.y
);
return distA - distB;
});
let gradientElement = "";
if (gradient.type === "linear") {
const firstStop = sortedStops[0];
const lastStop = sortedStops[sortedStops.length - 1];
const angle =
Math.atan2(
lastStop.position.y - firstStop.position.y,
lastStop.position.x - firstStop.position.x
) *
(180 / Math.PI);
const radians = (angle * Math.PI) / 180;
const x1 = 50 - 50 * Math.cos(radians);
const y1 = 50 - 50 * Math.sin(radians);
const x2 = 50 + 50 * Math.cos(radians);
const y2 = 50 + 50 * Math.sin(radians);
gradientElement = `
<linearGradient id="gradientFill" x1="${x1}%" y1="${y1}%" x2="${x2}%" y2="${y2}%">
${sortedStops
.map(
(stop) =>
`<stop offset="${stop.position.x}%" stop-color="${stop.color}" />`
)
.join("\n ")}
</linearGradient>`;
} else if (gradient.type === "radial") {
gradientElement = `
<radialGradient id="gradientFill" cx="50%" cy="50%" r="50%">
${sortedStops
.map((stop) => {
const distance = Math.sqrt(
Math.pow(stop.position.x - 50, 2) +
Math.pow(stop.position.y - 50, 2)
);
return `<stop offset="${distance}%" stop-color="${stop.color}" />`;
})
.join("\n ")}
</radialGradient>`;
} else if (gradient.type === "conic") {
gradientElement = `
<linearGradient id="gradientFill" x1="50%" y1="0%" x2="50%" y2="100%">
${sortedStops
.map(
(stop) =>
`<stop offset="${stop.position.x}%" stop-color="${stop.color}" />`
)
.join("\n ")}
</linearGradient>`;
} else if (gradient.type === "mesh") {
// For mesh, use multiple radial gradients
const meshGradients = sortedStops
.map(
(stop, index) => `
<radialGradient id="meshGradient${index}" cx="${stop.position.x}%" cy="${stop.position.y}%" r="50%">
<stop offset="0%" stop-color="${stop.color}" stop-opacity="1" />
<stop offset="50%" stop-color="${stop.color}" stop-opacity="0.5" />
<stop offset="100%" stop-color="${stop.color}" stop-opacity="0" />
</radialGradient>`
)
.join("");
gradientElement = meshGradients;
} else if (gradient.type === "grid") {
const firstStop = sortedStops[0];
const lastStop = sortedStops[sortedStops.length - 1];
const angle =
Math.atan2(
lastStop.position.y - firstStop.position.y,
lastStop.position.x - firstStop.position.x
) *
(180 / Math.PI);
const radians = (angle * Math.PI) / 180;
const x1 = 50 - 50 * Math.cos(radians);
const y1 = 50 - 50 * Math.sin(radians);
const x2 = 50 + 50 * Math.cos(radians);
const y2 = 50 + 50 * Math.sin(radians);
gradientElement = `
<linearGradient id="gradientFill" x1="${x1}%" y1="${y1}%" x2="${x2}%" y2="${y2}%" gradientUnits="userSpaceOnUse">
${sortedStops
.map(
(stop) =>
`<stop offset="${stop.position.x}%" stop-color="${stop.color}" />`
)
.join("\n ")}
</linearGradient>`;
} else if (gradient.type === "sharp") {
const firstStop = sortedStops[0];
const lastStop = sortedStops[sortedStops.length - 1];
const angle =
Math.atan2(
lastStop.position.y - firstStop.position.y,
lastStop.position.x - firstStop.position.x
) *
(180 / Math.PI);
const radians = (angle * Math.PI) / 180;
const x1 = 50 - 50 * Math.cos(radians);
const y1 = 50 - 50 * Math.sin(radians);
const x2 = 50 + 50 * Math.cos(radians);
const y2 = 50 + 50 * Math.sin(radians);
const sharpStops = sortedStops.flatMap((stop, index) => {
if (index === sortedStops.length - 1) {
return [
`<stop offset="${stop.position.x}%" stop-color="${stop.color}" />`,
];
}
const nextStop = sortedStops[index + 1];
const midPoint = (stop.position.x + nextStop.position.x) / 2;
return [
`<stop offset="${stop.position.x}%" stop-color="${stop.color}" />`,
`<stop offset="${midPoint}%" stop-color="${stop.color}" />`,
`<stop offset="${midPoint}%" stop-color="${nextStop.color}" />`,
];
});
gradientElement = `
<linearGradient id="gradientFill" x1="${x1}%" y1="${y1}%" x2="${x2}%" y2="${y2}%">
${sharpStops.join("\n ")}
</linearGradient>`;
} else if (gradient.type === "soft") {
const firstStop = sortedStops[0];
const lastStop = sortedStops[sortedStops.length - 1];
const angle =
Math.atan2(
lastStop.position.y - firstStop.position.y,
lastStop.position.x - firstStop.position.x
) *
(180 / Math.PI);
const radians = (angle * Math.PI) / 180;
const x1 = 50 - 50 * Math.cos(radians);
const y1 = 50 - 50 * Math.sin(radians);
const x2 = 50 + 50 * Math.cos(radians);
const y2 = 50 + 50 * Math.sin(radians);
const softStops = sortedStops.map((stop, index) => {
if (index === 0) {
return `<stop offset="${Math.max(0, Math.round(stop.position.x) - 10)}%" stop-color="${stop.color}" />`;
}
if (index === sortedStops.length - 1) {
return `<stop offset="${Math.min(100, Math.round(stop.position.x) + 10)}%" stop-color="${stop.color}" />`;
}
const prevStop = sortedStops[index - 1];
const nextStop = sortedStops[index + 1];
const startPos = Math.max(
prevStop.position.x,
Math.round(stop.position.x) - 15
);
const endPos = Math.min(
nextStop.position.x,
Math.round(stop.position.x) + 15
);
return `<stop offset="${Math.round(startPos)}%" stop-color="${stop.color}" />
<stop offset="${Math.round(endPos)}%" stop-color="${stop.color}" />`;
});
gradientElement = `
<linearGradient id="gradientFill" x1="${x1}%" y1="${y1}%" x2="${x2}%" y2="${y2}%">
${softStops.join("\n ")}
</linearGradient>`;
} else {
// Default to radial
gradientElement = `
<radialGradient id="gradientFill" cx="50%" cy="50%" r="50%">
${sortedStops
.map((stop) => {
const distance = Math.sqrt(
Math.pow(stop.position.x - 50, 2) +
Math.pow(stop.position.y - 50, 2)
);
return `<stop offset="${distance}%" stop-color="${stop.color}" />`;
})
.join("\n ")}
</radialGradient>`;
}
// For mesh gradients, we need multiple rects
let svgContent = "";
if (gradient.type === "mesh") {
svgContent = sortedStops
.map(
(stop, index) => `
<rect width="100%" height="100%" fill="url(#meshGradient${index})" />`
)
.join("");
} else {
svgContent = `<rect width="100%" height="100%" fill="url(#gradientFill)" />`;
}
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<defs>${gradientElement}
</defs>${svgContent}
</svg>`;
const blob = new Blob([svg], { type: "image/svg+xml" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `gradient-${dimensionType}-${width}x${height}-${Date.now()}.svg`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
// Canvas-based download for PNG/JPEG
const downloadRaster = (
format = "png",
dimensionType = downloadDimension
) => {
const dimensions =
previewFramePresets[dimensionType] ||
dimensionPresets[dimensionType] ||
dimensionPresets.mobile;
const canvas = document.createElement("canvas");
const width = dimensions.width;
const height = dimensions.height;
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
// Handle empty case - fill with white background
if (gradient.stops.length === 0) {
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, width, height);
canvas.toBlob(
(blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `gradient-${dimensionType}-${width}x${height}-${Date.now()}.${format}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
},
format === "jpeg" ? "image/jpeg" : "image/png",
format === "jpeg" ? 0.92 : undefined
);
return;
}
let gradientObj;
const sortedStops = [...gradient.stops].sort((a, b) => {
const distA = Math.sqrt(
a.position.x * a.position.x + a.position.y * a.position.y
);
const distB = Math.sqrt(
b.position.x * b.position.x + b.position.y * b.position.y
);
return distA - distB;
});
if (gradient.type === "linear") {
const firstStop = sortedStops[0];
const lastStop = sortedStops[sortedStops.length - 1];
const angle =
Math.atan2(
lastStop.position.y - firstStop.position.y,
lastStop.position.x - firstStop.position.x
) *
(180 / Math.PI);
const radians = (angle * Math.PI) / 180;
const x1 = width / 2 - (width / 2) * Math.cos(radians);
const y1 = height / 2 - (width / 2) * Math.sin(radians);
const x2 = width / 2 + (width / 2) * Math.cos(radians);
const y2 = height / 2 + (width / 2) * Math.sin(radians);
gradientObj = ctx.createLinearGradient(x1, y1, x2, y2);
sortedStops.forEach((stop) => {
const position = Math.round(stop.position.x) / 100;
gradientObj.addColorStop(position, stop.color);
});
} else if (gradient.type === "radial") {
gradientObj = ctx.createRadialGradient(
width / 2,
height / 2,
0,
width / 2,
height / 2,
Math.max(width, height) / 2
);
sortedStops.forEach((stop) => {
const distance = Math.sqrt(
Math.pow(stop.position.x - 50, 2) + Math.pow(stop.position.y - 50, 2)
);
const position = Math.round(distance) / 100;
gradientObj.addColorStop(position, stop.color);
});
} else if (gradient.type === "conic") {
// Conic gradients are approximated with linear gradient
gradientObj = ctx.createLinearGradient(width / 2, 0, width / 2, height);
sortedStops.forEach((stop) => {
const position = Math.round(stop.position.x) / 100;
gradientObj.addColorStop(position, stop.color);
});
} else if (gradient.type === "mesh") {
// For mesh, draw multiple radial gradients
ctx.globalCompositeOperation = "multiply";
sortedStops.forEach((stop) => {
const meshGradient = ctx.createRadialGradient(
(stop.position.x / 100) * width,
(stop.position.y / 100) * height,
0,
(stop.position.x / 100) * width,
(stop.position.y / 100) * height,
Math.max(width, height) / 2
);
meshGradient.addColorStop(0, stop.color);
meshGradient.addColorStop(0.5, stop.color);
meshGradient.addColorStop(1, "transparent");
ctx.fillStyle = meshGradient;
ctx.fillRect(0, 0, width, height);
});
ctx.globalCompositeOperation = "source-over";
return; // Early return for mesh
} else if (gradient.type === "grid") {
const firstStop = sortedStops[0];
const lastStop = sortedStops[sortedStops.length - 1];
const angle =
Math.atan2(
lastStop.position.y - firstStop.position.y,
lastStop.position.x - firstStop.position.x
) *
(180 / Math.PI);
const radians = (angle * Math.PI) / 180;
const x1 = width / 2 - (width / 2) * Math.cos(radians);
const y1 = height / 2 - (width / 2) * Math.sin(radians);
const x2 = width / 2 + (width / 2) * Math.cos(radians);
const y2 = height / 2 + (width / 2) * Math.sin(radians);
// Create pattern for repeating gradient
const patternCanvas = document.createElement("canvas");
patternCanvas.width = width;
patternCanvas.height = height;
const patternCtx = patternCanvas.getContext("2d");
const patternGradient = patternCtx.createLinearGradient(x1, y1, x2, y2);
sortedStops.forEach((stop) => {
const position = Math.round(stop.position.x) / 100;
patternGradient.addColorStop(position, stop.color);
});
patternCtx.fillStyle = patternGradient;
patternCtx.fillRect(0, 0, width, height);
const pattern = ctx.createPattern(patternCanvas, "repeat");
ctx.fillStyle = pattern;
} else if (gradient.type === "sharp") {
const firstStop = sortedStops[0];
const lastStop = sortedStops[sortedStops.length - 1];
const angle =
Math.atan2(
lastStop.position.y - firstStop.position.y,
lastStop.position.x - firstStop.position.x
) *
(180 / Math.PI);
const radians = (angle * Math.PI) / 180;
const x1 = width / 2 - (width / 2) * Math.cos(radians);
const y1 = height / 2 - (width / 2) * Math.sin(radians);
const x2 = width / 2 + (width / 2) * Math.cos(radians);
const y2 = height / 2 + (width / 2) * Math.sin(radians);
gradientObj = ctx.createLinearGradient(x1, y1, x2, y2);
sortedStops.forEach((stop, index) => {
const position = Math.round(stop.position.x) / 100;
gradientObj.addColorStop(position, stop.color);
if (index < sortedStops.length - 1) {
const nextStop = sortedStops[index + 1];
const midPoint = (stop.position.x + nextStop.position.x) / 200;
gradientObj.addColorStop(midPoint, stop.color);
gradientObj.addColorStop(midPoint, nextStop.color);
}
});
} else if (gradient.type === "soft") {
const firstStop = sortedStops[0];
const lastStop = sortedStops[sortedStops.length - 1];
const angle =
Math.atan2(
lastStop.position.y - firstStop.position.y,
lastStop.position.x - firstStop.position.x
) *
(180 / Math.PI);
const radians = (angle * Math.PI) / 180;
const x1 = width / 2 - (width / 2) * Math.cos(radians);
const y1 = height / 2 - (width / 2) * Math.sin(radians);
const x2 = width / 2 + (width / 2) * Math.cos(radians);
const y2 = height / 2 + (width / 2) * Math.sin(radians);
gradientObj = ctx.createLinearGradient(x1, y1, x2, y2);
sortedStops.forEach((stop, index) => {
if (index === 0) {
gradientObj.addColorStop(
Math.max(0, (Math.round(stop.position.x) - 10) / 100),
stop.color
);
} else if (index === sortedStops.length - 1) {
gradientObj.addColorStop(
Math.min(1, (Math.round(stop.position.x) + 10) / 100),
stop.color
);
} else {
const prevStop = sortedStops[index - 1];
const nextStop = sortedStops[index + 1];
const startPos = Math.max(
prevStop.position.x / 100,
(Math.round(stop.position.x) - 15) / 100
);
const endPos = Math.min(
nextStop.position.x / 100,
(Math.round(stop.position.x) + 15) / 100
);
gradientObj.addColorStop(startPos, stop.color);
gradientObj.addColorStop(endPos, stop.color);
}
});
} else {
// Default to radial
gradientObj = ctx.createRadialGradient(
width / 2,
height / 2,
0,
width / 2,
height / 2,
Math.max(width, height) / 2
);
sortedStops.forEach((stop) => {
const distance = Math.sqrt(
Math.pow(stop.position.x - 50, 2) + Math.pow(stop.position.y - 50, 2)
);
const position = Math.round(distance) / 100;
gradientObj.addColorStop(position, stop.color);
});
}
if (gradientObj) {
ctx.fillStyle = gradientObj;
ctx.fillRect(0, 0, width, height);
}
const mimeType = format === "jpeg" ? "image/jpeg" : "image/png";
const quality = format === "jpeg" ? 0.92 : undefined;
canvas.toBlob(
(blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `gradient-${dimensionType}-${width}x${height}-${Date.now()}.${format}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
},
mimeType,
quality
);
};
// GIF download function
const downloadGIF = async (dimensionType = downloadDimension) => {
alert(
"GIF export is being processed. This feature will capture animated frames of your gradient."
);
};
// MP4 download function
const downloadMP4 = async (dimensionType = downloadDimension) => {
if (typeof window === "undefined") return;
// Check if animation is enabled - if not, download as PNG instead
if (!gradient.backgroundAnimation.enabled) {
// Download as PNG since there's no animation
downloadRaster("png", dimensionType);
return;
}
setIsGeneratingMP4(true);
setMp4Progress(0);
try {
const dimensions =
previewFramePresets[dimensionType] ||
dimensionPresets[dimensionType] ||
dimensionPresets.mobile;
const width = dimensions.width;
const height = dimensions.height;
setMp4Progress(10);
// Create a canvas for recording
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
canvas.style.position = "fixed";
canvas.style.top = "-9999px";
canvas.style.left = "-9999px";
document.body.appendChild(canvas);
const ctx = canvas.getContext("2d");
// Get canvas stream for MediaRecorder
const stream = canvas.captureStream(30); // 30 FPS
const mediaRecorder = new MediaRecorder(stream, {
mimeType: "video/webm;codecs=vp9",
});
const chunks = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunks.push(event.data);
}
};
mediaRecorder.onstop = () => {
const blob = new Blob(chunks, { type: "video/webm" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `gradient-${dimensionType}-${width}x${height}-${Date.now()}.webm`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
document.body.removeChild(canvas);
setIsGeneratingMP4(false);
setMp4Progress(0);
};
// Calculate animation parameters
const animationSpeed = gradient.backgroundAnimation.speed;
const animationType = gradient.backgroundAnimation.type;
const animationDirection = gradient.backgroundAnimation.direction;
const duration = 5; // 5 seconds of video
const fps = 30;
const totalFrames = duration * fps;
const drawGradient = (progress = 0) => {
// Clear canvas
ctx.clearRect(0, 0, width, height);
// Handle empty case - fill with white background
if (gradient.stops.length === 0) {
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, width, height);
return;
}
// Calculate background position based on animation
let bgX = 0;
let bgY = 0;
const bgSize = 200; // 200% for animation
if (animationType === "slide") {
if (animationDirection === "right") {
bgX = progress * bgSize;
} else if (animationDirection === "left") {
bgX = (1 - progress) * bgSize;
} else if (animationDirection === "up") {
bgY = (1 - progress) * bgSize;
} else if (animationDirection === "down") {
bgY = progress * bgSize;
}
} else if (animationType === "wave") {
bgX = Math.sin(progress * Math.PI * 2) * bgSize;
bgY = Math.cos(progress * Math.PI * 2) * bgSize;
}
// Save context for background pattern
ctx.save();
// Draw gradient
let gradientObj;
const sortedStops = [...gradient.stops].sort((a, b) => {
const distA = Math.sqrt(
a.position.x * a.position.x + a.position.y * a.position.y
);
const distB = Math.sqrt(
b.position.x * b.position.x + b.position.y * b.position.y
);
return distA - distB;
});
if (gradient.type === "linear") {
const firstStop = sortedStops[0];
const lastStop = sortedStops[sortedStops.length - 1];
const angle =
Math.atan2(
lastStop.position.y - firstStop.position.y,
lastStop.position.x - firstStop.position.x
) *
(180 / Math.PI);
const radians = (angle * Math.PI) / 180;
const x1 = width / 2 - (width / 2) * Math.cos(radians);
const y1 = height / 2 - (width / 2) * Math.sin(radians);
const x2 = width / 2 + (width / 2) * Math.cos(radians);
const y2 = height / 2 + (width / 2) * Math.sin(radians);
gradientObj = ctx.createLinearGradient(x1, y1, x2, y2);
sortedStops.forEach((stop) => {
const position = Math.round(stop.position.x) / 100;
gradientObj.addColorStop(position, stop.color);
});
} else if (gradient.type === "radial") {
gradientObj = ctx.createRadialGradient(
width / 2,
height / 2,
0,
width / 2,
height / 2,
Math.max(width, height) / 2
);
sortedStops.forEach((stop) => {
const distance = Math.sqrt(
Math.pow(stop.position.x - 50, 2) +
Math.pow(stop.position.y - 50, 2)
);
const position = Math.round(distance) / 100;
gradientObj.addColorStop(position, stop.color);
});
} else if (gradient.type === "conic") {
gradientObj = ctx.createLinearGradient(
width / 2,
0,
width / 2,
height
);
sortedStops.forEach((stop) => {
const position = Math.round(stop.position.x) / 100;
gradientObj.addColorStop(position, stop.color);
});
} else if (gradient.type === "mesh") {
ctx.globalCompositeOperation = "multiply";
sortedStops.forEach((stop) => {
const meshGradient = ctx.createRadialGradient(
(stop.position.x / 100) * width,
(stop.position.y / 100) * height,
0,
(stop.position.x / 100) * width,
(stop.position.y / 100) * height,
Math.max(width, height) / 2
);
meshGradient.addColorStop(0, stop.color);
meshGradient.addColorStop(0.5, stop.color);
meshGradient.addColorStop(1, "transparent");
ctx.fillStyle = meshGradient;
ctx.fillRect(0, 0, width, height);
});
ctx.globalCompositeOperation = "source-over";
ctx.restore();
return;
} else if (gradient.type === "grid") {
const firstStop = sortedStops[0];
const lastStop = sortedStops[sortedStops.length - 1];
const angle =
Math.atan2(
lastStop.position.y - firstStop.position.y,
lastStop.position.x - firstStop.position.x
) *
(180 / Math.PI);
const radians = (angle * Math.PI) / 180;
const x1 = width / 2 - (width / 2) * Math.cos(radians);
const y1 = height / 2 - (width / 2) * Math.sin(radians);
const x2 = width / 2 + (width / 2) * Math.cos(radians);
const y2 = height / 2 + (width / 2) * Math.sin(radians);
const patternCanvas = document.createElement("canvas");
patternCanvas.width = width;
patternCanvas.height = height;
const patternCtx = patternCanvas.getContext("2d");
const patternGradient = patternCtx.createLinearGradient(
x1,
y1,
x2,
y2
);
sortedStops.forEach((stop) => {
const position = Math.round(stop.position.x) / 100;
patternGradient.addColorStop(position, stop.color);
});
patternCtx.fillStyle = patternGradient;
patternCtx.fillRect(0, 0, width, height);
const pattern = ctx.createPattern(patternCanvas, "repeat");
ctx.fillStyle = pattern;
} else if (gradient.type === "sharp") {
const firstStop = sortedStops[0];
const lastStop = sortedStops[sortedStops.length - 1];
const angle =
Math.atan2(
lastStop.position.y - firstStop.position.y,
lastStop.position.x - firstStop.position.x
) *
(180 / Math.PI);
const radians = (angle * Math.PI) / 180;
const x1 = width / 2 - (width / 2) * Math.cos(radians);
const y1 = height / 2 - (width / 2) * Math.sin(radians);
const x2 = width / 2 + (width / 2) * Math.cos(radians);
const y2 = height / 2 + (width / 2) * Math.sin(radians);
gradientObj = ctx.createLinearGradient(x1, y1, x2, y2);
sortedStops.forEach((stop, index) => {
const position = Math.round(stop.position.x) / 100;
gradientObj.addColorStop(position, stop.color);
if (index < sortedStops.length - 1) {
const nextStop = sortedStops[index + 1];
const midPoint = (stop.position.x + nextStop.position.x) / 200;
gradientObj.addColorStop(midPoint, stop.color);
gradientObj.addColorStop(midPoint, nextStop.color);
}
});
} else if (gradient.type === "soft") {
const firstStop = sortedStops[0];
const lastStop = sortedStops[sortedStops.length - 1];
const angle =
Math.atan2(
lastStop.position.y - firstStop.position.y,
lastStop.position.x - firstStop.position.x
) *
(180 / Math.PI);
const radians = (angle * Math.PI) / 180;
const x1 = width / 2 - (width / 2) * Math.cos(radians);
const y1 = height / 2 - (width / 2) * Math.sin(radians);
const x2 = width / 2 + (width / 2) * Math.cos(radians);
const y2 = height / 2 + (width / 2) * Math.sin(radians);
gradientObj = ctx.createLinearGradient(x1, y1, x2, y2);
sortedStops.forEach((stop, index) => {
if (index === 0) {
gradientObj.addColorStop(
Math.max(0, (Math.round(stop.position.x) - 10) / 100),
stop.color
);
} else if (index === sortedStops.length - 1) {
gradientObj.addColorStop(
Math.min(1, (Math.round(stop.position.x) + 10) / 100),
stop.color
);
} else {
const prevStop = sortedStops[index - 1];
const nextStop = sortedStops[index + 1];
const startPos = Math.max(
prevStop.position.x / 100,
(Math.round(stop.position.x) - 15) / 100
);
const endPos = Math.min(
nextStop.position.x / 100,
(Math.round(stop.position.x) + 15) / 100
);
gradientObj.addColorStop(startPos, stop.color);
gradientObj.addColorStop(endPos, stop.color);
}
});
} else {
gradientObj = ctx.createRadialGradient(
width / 2,
height / 2,
0,
width / 2,
height / 2,
Math.max(width, height) / 2
);
sortedStops.forEach((stop) => {
const distance = Math.sqrt(
Math.pow(stop.position.x - 50, 2) +
Math.pow(stop.position.y - 50, 2)
);
const position = Math.round(distance) / 100;
gradientObj.addColorStop(position, stop.color);
});
}
if (gradientObj) {
// Apply background size for animation
ctx.save();
ctx.translate(-bgX, -bgY);
ctx.scale(2, 2); // 200% size
ctx.fillStyle = gradientObj;
ctx.fillRect(bgX / 2, bgY / 2, width, height);
ctx.restore();
}
// Draw background pattern if enabled
const pattern = backgroundPatternPresets.find(
(p) => p.id === gradient.backgroundPattern.id
);
if (pattern && pattern.css) {
// For patterns, we'll draw them on top
// This is a simplified version - full pattern rendering would be more complex
ctx.restore();
} else {
ctx.restore();
}
};
setMp4Progress(20);
// Start recording
mediaRecorder.start();
// Record frames
for (let frame = 0; frame < totalFrames; frame++) {
const progress = (frame / totalFrames) * (duration / animationSpeed);
const normalizedProgress = progress % 1; // Loop animation
drawGradient(normalizedProgress);
// Update progress
const frameProgress = 20 + (frame / totalFrames) * 70;
setMp4Progress(Math.min(95, frameProgress));
// Wait for next frame
await new Promise((resolve) => setTimeout(resolve, 1000 / fps));
}
setMp4Progress(95);
// Stop recording
mediaRecorder.stop();
} catch (error) {
console.error("Error generating MP4:", error);
setIsGeneratingMP4(false);
setMp4Progress(0);
alert(
"Error generating video. Your browser may not support video recording. Please try downloading as PNG instead."
);
}
};
// Handle click outside gradient presets dropdown
const handleGradientPresetsClickOutside = useCallback((event) => {
if (
gradientPresetsRef.current &&
!gradientPresetsRef.current.contains(event.target)
) {
setIsGradientPresetsOpen(false);
}
}, []);
useEffect(() => {
if (isGradientPresetsOpen) {
document.addEventListener("mousedown", handleGradientPresetsClickOutside);
return () =>
document.removeEventListener(
"mousedown",
handleGradientPresetsClickOutside
);
}
}, [isGradientPresetsOpen, handleGradientPresetsClickOutside]);
// Handle click outside background patterns dropdown
const handleBackgroundPatternsClickOutside = useCallback((event) => {
if (
backgroundPatternsRef.current &&
!backgroundPatternsRef.current.contains(event.target)
) {
setIsBackgroundPatternsOpen(false);
}
}, []);
useEffect(() => {
if (isBackgroundPatternsOpen) {
document.addEventListener(
"mousedown",
handleBackgroundPatternsClickOutside
);
return () =>
document.removeEventListener(
"mousedown",
handleBackgroundPatternsClickOutside
);
}
}, [isBackgroundPatternsOpen, handleBackgroundPatternsClickOutside]);
// Handle click outside download dropdown
const handleDownloadDropdownClickOutside = useCallback((event) => {
if (
downloadDropdownRef.current &&
!downloadDropdownRef.current.contains(event.target)
) {
setIsDownloadDropdownOpen(false);
}
}, []);
useEffect(() => {
if (isDownloadDropdownOpen) {
document.addEventListener(
"mousedown",
handleDownloadDropdownClickOutside
);
return () =>
document.removeEventListener(
"mousedown",
handleDownloadDropdownClickOutside
);
}
}, [isDownloadDropdownOpen, handleDownloadDropdownClickOutside]);
// Handle click outside panel download dropdown
const handlePanelDownloadDropdownClickOutside = useCallback((event) => {
if (
panelDownloadDropdownRef.current &&
!panelDownloadDropdownRef.current.contains(event.target)
) {
setIsPanelDownloadDropdownOpen(false);
}
}, []);
useEffect(() => {
if (isPanelDownloadDropdownOpen) {
document.addEventListener(
"mousedown",
handlePanelDownloadDropdownClickOutside
);
return () =>
document.removeEventListener(
"mousedown",
handlePanelDownloadDropdownClickOutside
);
}
}, [isPanelDownloadDropdownOpen, handlePanelDownloadDropdownClickOutside]);
const backgroundAnimation = useMemo(
() => generateAnimationCSS(),
[gradient.backgroundAnimation]
);
// Calculate modal preview dimensions
const modalDimensions = useMemo(() => {
const { width: frameWidth, height: frameHeight } = gradient.dimensions;
const aspectRatio = frameWidth / frameHeight;
if (aspectRatio >= 1) {
return {
width: "90vw",
maxWidth: "90vw",
maxHeight: "90vh",
};
} else {
return {
height: "90vh",
maxWidth: "90vw",
maxHeight: "90vh",
};
}
}, [gradient.dimensions, previewFrameSize]);
// Find active gradient preset
const activePreset = useMemo(() => {
return gradientPresets.find((preset) => {
// Check if type and angle match
if (preset.type !== gradient.type) return false;
if (preset.type === "linear" && preset.angle !== gradient.angle) {
return false;
}
// Check if stops match (compare colors and positions)
if (preset.stops.length !== gradient.stops.length) return false;
// Sort stops by position for comparison
const sortedPresetStops = [...preset.stops].sort((a, b) => {
const distA = Math.sqrt(
a.position.x * a.position.x + a.position.y * a.position.y
);
const distB = Math.sqrt(
b.position.x * b.position.x + b.position.y * b.position.y
);
return distA - distB;
});
const sortedGradientStops = [...gradient.stops].sort((a, b) => {
const distA = Math.sqrt(
a.position.x * a.position.x + a.position.y * a.position.y
);
const distB = Math.sqrt(
b.position.x * b.position.x + b.position.y * b.position.y
);
return distA - distB;
});
// Compare each stop's color (allow small position differences)
return sortedPresetStops.every((presetStop, index) => {
const gradientStop = sortedGradientStops[index];
return (
presetStop.color.toLowerCase() === gradientStop.color.toLowerCase()
);
});
});
}, [gradient.type, gradient.angle, gradient.stops]);
// Get current preset index
const currentPresetIndex = useMemo(() => {
if (!activePreset) return -1;
return gradientPresets.findIndex((p) => p.id === activePreset.id);
}, [activePreset]);
// Navigate to next preset
const navigateToNextPreset = useCallback(() => {
const currentIndex = currentPresetIndex >= 0 ? currentPresetIndex : 0;
const nextIndex = (currentIndex + 1) % gradientPresets.length;
const nextPreset = gradientPresets[nextIndex];
setGradient((prev) => ({
...prev,
type: nextPreset.type,
angle: nextPreset.angle,
stops: nextPreset.stops.map((stop) => ({
...stop,
})),
}));
}, [currentPresetIndex]);
// Navigate to previous preset
const navigateToPreviousPreset = useCallback(() => {
const currentIndex = currentPresetIndex >= 0 ? currentPresetIndex : 0;
const prevIndex =
currentIndex === 0 ? gradientPresets.length - 1 : currentIndex - 1;
const prevPreset = gradientPresets[prevIndex];
setGradient((prev) => ({
...prev,
type: prevPreset.type,
angle: prevPreset.angle,
stops: prevPreset.stops.map((stop) => ({
...stop,
})),
}));
}, [currentPresetIndex]);
// Keyboard navigation for presets
useEffect(() => {
const handleKeyPress = (e) => {
// Only trigger if not typing in an input/textarea
if (
e.target.tagName === "INPUT" ||
e.target.tagName === "TEXTAREA" ||
e.target.isContentEditable
) {
return;
}
// Arrow Right, Period (.), or Spacebar for next preset
if (e.key === "ArrowRight" || e.key === "." || e.key === " ") {
e.preventDefault();
navigateToNextPreset();
}
// Arrow Left or Comma (,) for previous preset
else if (e.key === "ArrowLeft" || e.key === ",") {
e.preventDefault();
navigateToPreviousPreset();
}
};
window.addEventListener("keydown", handleKeyPress);
return () => window.removeEventListener("keydown", handleKeyPress);
}, [navigateToNextPreset, navigateToPreviousPreset]);
return (
<div className="min-h-screen bg-stone-100 p-6 canvas-dots-bg">
<style jsx>{`
.slider::-webkit-slider-thumb {
appearance: none;
height: 16px;
width: 16px;
border-radius: 50%;
background: #71717a;
cursor: pointer;
border: 2px solid #ffffff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.slider::-moz-range-thumb {
height: 16px;
width: 16px;
border-radius: 50%;
background: #71717a;
cursor: pointer;
border: 2px solid #ffffff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
@keyframes slideRight {
0% {
background-position: 0% 0%;
}
100% {
background-position: 100% 0%;
}
}
@keyframes slideLeft {
0% {
background-position: 100% 0%;
}
100% {
background-position: 0% 0%;
}
}
@keyframes slideUp {
0% {
background-position: 0% 100%;
}
100% {
background-position: 0% 0%;
}
}
@keyframes slideDown {
0% {
background-position: 0% 0%;
}
100% {
background-position: 0% 100%;
}
}
@keyframes wave {
0% {
background-position: 0% 0%;
}
25% {
background-position: 100% 0%;
}
50% {
background-position: 100% 100%;
}
75% {
background-position: 0% 100%;
}
100% {
background-position: 0% 0%;
}
}
@keyframes meshGradient {
0% {
background-position: 0% 0%;
}
50% {
background-position: 100% 100%;
}
100% {
background-position: 0% 0%;
}
}
.canvas-dots-bg {
background-image: radial-gradient(
circle,
rgba(0, 0, 0, 0.15) 1px,
transparent 1px
);
background-size: 20px 20px;
background-position: 0 0;
}
.noise-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
opacity: var(--noise-opacity);
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
background-size: 200px 200px;
mix-blend-mode: overlay;
}
`}</style>
<div className="max-w-7xl mx-auto">
{/* Preview Header */}
<div className="flex items-center justify-end gap-2 mb-4 flex-wrap">
{/* Frame Size Selector */}
<div className="flex ">
<Dropdown
value={previewFrameSize}
onChange={(value) => {
setPreviewFrameSize(value);
const frame = previewFramePresets[value];
if (frame) {
setGradient((prev) => ({
...prev,
dimensions: {
width: frame.width,
height: frame.height,
},
}));
}
}}
options={Object.entries(previewFramePresets).map(
([key, preset]) => ({
value: key,
label: `${preset.icon} ${preset.label} (${preset.width}×${preset.height})`,
})
)}
placeholder="Select frame size"
className="min-w-[180px]"
/>
</div>
{/* <button
onClick={() => setIsModalOpen(true)}
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-zinc-100 hover:bg-zinc-200 rounded-xl transition-colors h-8"
>
<Maximize2 className="w-4 h-4" />
Preview
</button> */}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 w-full">
{/* Preview Section */}
<div className="space-y-6 col-span-2">
<div className="bg-white rounded-xl shadow-lg">
{/* Interactive Gradient Preview */}
<div
className="flex justify-center items-center w-full"
style={{
minHeight:
gradient.dimensions.height > gradient.dimensions.width
? "600px"
: "400px",
}}
>
<div
ref={previewRef}
className="relative rounded-xl overflow-hidden cursor-crosshair "
style={{
aspectRatio: `${gradient.dimensions.width} / ${gradient.dimensions.height}`,
width: "100%",
maxWidth:
gradient.dimensions.width > gradient.dimensions.height
? `${Math.min(gradient.dimensions.width * 0.6, 1200)}px`
: "100%",
maxHeight:
gradient.dimensions.height > gradient.dimensions.width
? "90vh"
: `${Math.min(gradient.dimensions.height * 0.6, 600)}px`,
...getBackgroundPatternStyle(),
...(isPlaying &&
gradient.backgroundAnimation.enabled && {
backgroundSize: (() => {
const patternStyle = getBackgroundPatternStyle();
if (patternStyle.backgroundSize) {
return patternStyle.backgroundSize.replace(
"auto",
"200% 200%"
);
}
return "200% 200%";
})(),
}),
...(isPlaying &&
backgroundAnimation && {
animation: backgroundAnimation,
}),
}}
>
{/* Noise Overlay */}
{gradient.noise.enabled && (
<div
className="noise-overlay"
style={{
"--noise-opacity": gradient.noise.intensity,
}}
/>
)}
{/* Color Stop Handles */}
{gradient.stops.map((stop) => (
<div
key={stop.id}
className={`absolute w-6 h-6 flex items-center justify-center cursor-pointer group transition-transform ${
selectedStop === stop.id
? "scale-125"
: "hover:scale-110"
}`}
style={{
left: `calc(${stop.position.x}% - 12px)`,
top: `calc(${stop.position.y}% - 12px)`,
}}
onMouseDown={(e) => handleMouseDown(e, stop.id)}
onKeyDown={(e) => handleKeyDown(e, stop.id)}
tabIndex={0}
role="button"
aria-label={`Color stop at ${Math.round(
stop.position.x
)}%, ${Math.round(stop.position.y)}%`}
>
<div
className="w-6 h-6 rounded-full border-2 border-white shadow-lg hover:shadow-xl transition-shadow"
style={{ backgroundColor: stop.color }}
/>
<div className="absolute -bottom-8 left-1/2 transform -translate-x-1/2 text-xs text-white bg-black bg-opacity-75 px-2 py-1 rounded whitespace-nowrap">
{Math.round(stop.position.x)}%,{" "}
{Math.round(stop.position.y)}%
</div>
</div>
))}
</div>
</div>
</div>
</div>
{/* Control Panel */}
<div className="space-y-6 bg-white cols-span-1 ml-auto max-h-[80vh] max-w-xs overflow-y-auto hidescrollbar shadow-xl border border-zinc-100 rounded-xl">
{/* Color Stops */}
{/* Gradient Type & Controls */}
<div className="bg-white rounded-xl shadow-lg p-4">
<div className="flex items-center justify-between">
<h3 className="text-sm">Colors</h3>
<button
onClick={addColorStop}
className="flex items-center px-2 py-1 rounded-xl hover:bg-zinc-100 transition-colors text-xs h-8"
>
<Plus className="w-3 h-3" />
</button>
</div>
<div className="mb-4">
{gradient.stops.map((stop) => (
<div
key={stop.id}
className="flex items-center gap-2 p-1 hover:bg-gray-50 rounded-xl"
>
<input
type="color"
value={stop.color}
onChange={(e) =>
updateColorStop(stop.id, "color", e.target.value)
}
className="w-6 h-6 rounded border border-gray-300 cursor-pointer"
aria-label={`Color for stop at ${Math.round(
stop.position.x
)}%, ${Math.round(stop.position.y)}%`}
/>
<div className="flex-1 flex justify-between">
<div className="text-xs text-gray-600 mb-0.5">
Position: {Math.round(stop.position.x)}%,{" "}
{Math.round(stop.position.y)}%
</div>
<div className="text-[10px] text-gray-500 font-mono">
{stop.color}
</div>
</div>
<button
onClick={() => removeColorStop(stop.id)}
className="p-1.5 text-red-500 hover:bg-red-50 rounded"
aria-label={`Remove color stop`}
>
<Trash2 className="w-3 h-3" />
</button>
</div>
))}
</div>
<div className="space-y-3">
<div>
<label className="flex justify-between items-center text-xs font-medium text-gray-700 mb-1.5">
Gradient Templates
<div className="relative group flex items-center gap-1">
<span className="w-fit border border-gray-200 group-hover:opacity-100 opacity-0 transition-all duration-75 ease-in invisible group-hover:visible bg-white z-50 p-1 rounded-xl text-[10px] text-gray-500">
Use {"<, >"} arrow keys to shuffle
</span>
<InfoIcon className="w-3 h-3 text-gray-500 cursor-pointer" />
</div>
</label>
<div ref={gradientPresetsRef} className="relative">
<button
type="button"
onClick={() =>
setIsGradientPresetsOpen(!isGradientPresetsOpen)
}
className="w-full h-8 px-2 text-xs border border-zinc-200 bg-white rounded-xl focus:outline-none focus:ring-2 focus:ring-zinc-400 focus:border-zinc-400 transition-colors flex items-center justify-between"
>
<span className="text-left">
{activePreset ? activePreset.name : "Choose Gradient"}
</span>
<ChevronDown
className={`w-3 h-3 transition-transform ${
isGradientPresetsOpen ? "rotate-180" : ""
}`}
/>
</button>
<AnimatePresence>
{isGradientPresetsOpen && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.15 }}
className="absolute z-50 w-full mt-1 bg-white border border-zinc-200 rounded-xl shadow-lg overflow-hidden"
style={{ maxHeight: "400px", overflowY: "auto" }}
>
<div className="p-3">
<div className="grid grid-cols-1 gap-2">
{gradientPresets.map((preset) => (
<button
key={preset.id}
type="button"
onClick={() => {
setGradient((prev) => ({
...prev,
type: preset.type,
angle: preset.angle,
stops: preset.stops.map((stop) => ({
...stop,
})),
}));
setIsGradientPresetsOpen(false);
}}
className="h-10 rounded-xl overflow-hidden hover:border-zinc-400 transition-colors focus:outline-none focus:ring-2 focus:ring-zinc-400"
style={{
background:
generatePresetGradientCSS(preset),
}}
title={preset.name}
>
<div className="w-full h-full flex items-center justify-center p-1">
<span className="text-xs text-white px-1.5 py-0.5 rounded text-left truncate w-full">
{preset.name}
</span>
</div>
</button>
))}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* <div>
<label className="block text-xs font-medium text-gray-700 mb-1.5">
Background Patterns
</label>
<div ref={backgroundPatternsRef} className="relative">
<button
type="button"
onClick={() =>
setIsBackgroundPatternsOpen(!isBackgroundPatternsOpen)
}
className="w-full h-8 px-2 text-xs border border-zinc-200 bg-white rounded-xl focus:outline-none focus:ring-2 focus:ring-zinc-400 focus:border-zinc-400 transition-colors flex items-center justify-between"
>
<span className="text-left">
{gradient.backgroundPattern.name || "Choose Pattern"}
</span>
<ChevronDown
className={`w-3 h-3 transition-transform ${
isBackgroundPatternsOpen ? "rotate-180" : ""
}`}
/>
</button>
<AnimatePresence>
{isBackgroundPatternsOpen && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.15 }}
className="absolute z-50 w-full mt-1 bg-white border border-zinc-200 rounded-xl shadow-lg overflow-hidden"
style={{ maxHeight: "400px", overflowY: "auto" }}
>
<div className="p-3">
<div className="grid grid-cols-1 gap-2">
{backgroundPatternPresets.map((pattern) => (
<button
key={pattern.id}
type="button"
onClick={() => {
setGradient((prev) => ({
...prev,
backgroundPattern: {
id: pattern.id,
name: pattern.name,
},
}));
setIsBackgroundPatternsOpen(false);
}}
className={`h-10 rounded-xl overflow-hidden hover:border-zinc-400 transition-colors focus:outline-none focus:ring-2 focus:ring-zinc-400 border ${
gradient.backgroundPattern.id === pattern.id
? "border-zinc-400"
: "border-transparent"
}`}
style={{
background:
pattern.preview || "transparent",
backgroundSize:
pattern.backgroundSize || "auto",
backgroundPosition:
pattern.backgroundPosition || "0 0",
}}
title={pattern.name}
>
<div className="w-full h-full flex items-center justify-center p-1 bg-white bg-opacity-50">
<span className="text-xs text-gray-900 px-1.5 py-0.5 rounded text-left truncate w-full font-medium">
{pattern.name}
</span>
</div>
</button>
))}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div> */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1.5">
Gradient Type
</label>
<Dropdown
value={gradient.type}
onChange={(value) =>
setGradient((prev) => ({ ...prev, type: value }))
}
options={[
{ value: "linear", label: "Linear" },
{ value: "radial", label: "Radial" },
{ value: "conic", label: "Conic" },
{ value: "mesh", label: "Mesh" },
{ value: "grid", label: "Grid" },
{ value: "sharp", label: "Sharp" },
{ value: "soft", label: "Soft" },
]}
placeholder="Select gradient type"
/>
</div>
{/* Noise Controls */}
<div className="border-t pt-3">
<h3 className="text-base font-semibold mb-3">Noise</h3>
<div className="space-y-3">
<div>
<label className="flex items-center space-x-2 text-xs font-medium cursor-pointer group mb-3">
<div className="relative">
<input
type="checkbox"
checked={gradient.noise.enabled}
onChange={(e) =>
setGradient((prev) => ({
...prev,
noise: {
...prev.noise,
enabled: e.target.checked,
},
}))
}
className="sr-only"
/>
<div
className={`w-4 h-4 rounded border-2 transition-all duration-200 flex items-center justify-center ${
gradient.noise.enabled
? "bg-zinc-600 border-zinc-600"
: "bg-white border-zinc-300 group-hover:border-zinc-400"
}`}
>
{gradient.noise.enabled && (
<svg
className="w-2.5 h-2.5 text-white"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M5 13l4 4L19 7"></path>
</svg>
)}
</div>
</div>
<span className="select-none">Enable Noise</span>
</label>
</div>
{gradient.noise.enabled && (
<div>
<label className="block text-xs font-medium text-gray-700 mb-1.5">
Intensity:{" "}
{Math.round(gradient.noise.intensity * 100)}%
</label>
<input
type="range"
min="0"
max="1"
step="0.01"
value={gradient.noise.intensity}
onChange={(e) =>
setGradient((prev) => ({
...prev,
noise: {
...prev.noise,
intensity: parseFloat(e.target.value),
},
}))
}
className="w-full h-1.5 bg-zinc-200 rounded-xl appearance-none cursor-pointer slider"
/>
</div>
)}
</div>
</div>
{/* Background Animation Controls */}
<div className="border-t pt-3">
<h3 className="text-base font-semibold mb-3">
Background Animation
</h3>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1.5">
Animation Type
</label>
<Dropdown
value={gradient.backgroundAnimation.type}
onChange={(value) =>
setGradient((prev) => ({
...prev,
backgroundAnimation: {
...prev.backgroundAnimation,
type: value,
},
}))
}
options={[
{ value: "slide", label: "Slide" },
{ value: "wave", label: "Wave" },
]}
placeholder="Select animation type"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1.5">
Direction
</label>
<Dropdown
value={gradient.backgroundAnimation.direction}
onChange={(value) =>
setGradient((prev) => ({
...prev,
backgroundAnimation: {
...prev.backgroundAnimation,
direction: value,
},
}))
}
options={[
{ value: "right", label: "Right" },
{ value: "left", label: "Left" },
{ value: "up", label: "Up" },
{ value: "down", label: "Down" },
]}
placeholder="Select direction"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1.5">
Speed: {gradient.backgroundAnimation.speed}s
</label>
<input
type="range"
min="1"
max="20"
step="1"
value={gradient.backgroundAnimation.speed}
onChange={(e) =>
setGradient((prev) => ({
...prev,
backgroundAnimation: {
...prev.backgroundAnimation,
speed: parseInt(e.target.value),
},
}))
}
className="w-full h-1.5 bg-zinc-200 rounded-xl appearance-none cursor-pointer slider"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1.5">
Easing
</label>
<Dropdown
value={gradient.backgroundAnimation.easing}
onChange={(value) =>
setGradient((prev) => ({
...prev,
backgroundAnimation: {
...prev.backgroundAnimation,
easing: value,
},
}))
}
options={[
{ value: "linear", label: "Linear" },
{ value: "ease", label: "Ease" },
{ value: "ease-in", label: "Ease In" },
{ value: "ease-out", label: "Ease Out" },
{ value: "ease-in-out", label: "Ease In Out" },
]}
placeholder="Select easing"
/>
</div>
<div>
<label className="flex items-center space-x-2 text-xs font-medium cursor-pointer group">
<div className="relative">
<input
type="checkbox"
checked={gradient.backgroundAnimation.enabled}
onChange={(e) =>
setGradient((prev) => ({
...prev,
backgroundAnimation: {
...prev.backgroundAnimation,
enabled: e.target.checked,
},
}))
}
className="sr-only"
/>
<div
className={`w-4 h-4 rounded border-2 transition-all duration-200 flex items-center justify-center ${
gradient.backgroundAnimation.enabled
? "bg-zinc-600 border-zinc-600"
: "bg-white border-zinc-300 group-hover:border-zinc-400"
}`}
>
{gradient.backgroundAnimation.enabled && (
<svg
className="w-2.5 h-2.5 text-white"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M5 13l4 4L19 7"></path>
</svg>
)}
</div>
</div>
<span className="select-none">Enable Animation</span>
</label>
</div>
<div>
<label className="flex items-center space-x-2 text-xs font-medium cursor-pointer group">
<div className="relative">
<input
type="checkbox"
checked={
gradient.backgroundAnimation.repeat || false
}
onChange={(e) =>
setGradient((prev) => ({
...prev,
backgroundAnimation: {
...prev.backgroundAnimation,
repeat: e.target.checked,
},
}))
}
className="sr-only"
/>
<div
className={`w-4 h-4 rounded border-2 transition-all duration-200 flex items-center justify-center ${
gradient.backgroundAnimation.repeat
? "bg-zinc-600 border-zinc-600"
: "bg-white border-zinc-300 group-hover:border-zinc-400"
}`}
>
{gradient.backgroundAnimation.repeat && (
<svg
className="w-2.5 h-2.5 text-white"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M5 13l4 4L19 7"></path>
</svg>
)}
</div>
</div>
<span className="select-none">
Repeat Animation (infinite)
</span>
</label>
</div>
{/* Background CSS Output */}
<div className="border-t pt-3">
<div className="flex items-center justify-between mb-1.5">
<h4 className="text-xs font-medium">Background CSS</h4>
<button
onClick={() => {
const patternCSS = generateBackgroundPatternCSS();
const css = `background: ${generateGradientCSS()};${
patternCSS ? `\n${patternCSS}` : ""
}${
backgroundAnimation
? `\nbackground-size: 200% 200%;\nanimation: ${backgroundAnimation};`
: ""
}`;
copyToClipboard(css, "background-css");
}}
className="flex items-center gap-1 px-1.5 py-0.5 text-[10px] bg-zinc-100 hover:bg-zinc-200 rounded transition-colors"
>
<Copy className="w-2.5 h-2.5" />
{copied === "background-css" ? "Copied!" : "Copy CSS"}
</button>
</div>
<pre className="bg-gray-900 text-zinc-400 p-2 rounded text-[10px] overflow-x-auto max-h-28 overflow-y-auto mb-3">
<code>{`background: ${generateGradientCSS()};${
generateBackgroundPatternCSS()
? `\n${generateBackgroundPatternCSS()}`
: ""
}${
backgroundAnimation
? `\nbackground-size: 200% 200%;\nanimation: ${backgroundAnimation};`
: ""
}`}</code>
</pre>
{/* Download Button */}
<div ref={panelDownloadDropdownRef} className="relative">
<button
onClick={() =>
setIsPanelDownloadDropdownOpen(
!isPanelDownloadDropdownOpen
)
}
className="w-full flex items-center justify-center gap-2 px-3 py-2 text-xs bg-zinc-800 hover:bg-black hover:shadow-zinc-200 hover:shadow-2xl text-white rounded-xl transition-all duration-75 ease-in hover:scale-105"
>
<Download className="w-3 h-3" />
Download
</button>
<AnimatePresence>
{isPanelDownloadDropdownOpen && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.15 }}
className="absolute bottom-full left-0 mb-2 w-full bg-white border border-zinc-200 rounded-xl shadow-lg overflow-hidden z-50"
>
<div className="flex flex-col">
<button
type="button"
onClick={() => {
downloadSVG(previewFrameSize);
setIsPanelDownloadDropdownOpen(false);
}}
className="w-full px-3 py-2 text-xs text-left hover:bg-zinc-50 transition-colors flex items-center gap-2"
>
<Download className="w-3 h-3" />
<span>
SVG -{" "}
{previewFramePresets[previewFrameSize]
?.label || "Current Frame"}
</span>
</button>
<button
type="button"
onClick={() => {
downloadRaster("png", previewFrameSize);
setIsPanelDownloadDropdownOpen(false);
}}
className="w-full px-3 py-2 text-xs text-left hover:bg-zinc-50 transition-colors flex items-center gap-2 border-t border-zinc-100"
>
<Download className="w-3 h-3" />
<span>
PNG -{" "}
{previewFramePresets[previewFrameSize]
?.label || "Current Frame"}
</span>
</button>
<button
type="button"
onClick={() => {
downloadRaster("jpeg", previewFrameSize);
setIsPanelDownloadDropdownOpen(false);
}}
className="w-full px-3 py-2 text-xs text-left hover:bg-zinc-50 transition-colors flex items-center gap-2 border-t border-zinc-100"
>
<Download className="w-3 h-3" />
<span>
JPEG -{" "}
{previewFramePresets[previewFrameSize]
?.label || "Current Frame"}
</span>
</button>
{gradient.backgroundAnimation.enabled && (
<button
type="button"
onClick={() => {
downloadGIF(previewFrameSize);
setIsPanelDownloadDropdownOpen(false);
}}
className="w-full px-3 py-2 text-xs text-left hover:bg-zinc-50 transition-colors flex items-center gap-2 border-t border-zinc-100"
>
<Download className="w-3 h-3" />
<span>
GIF -{" "}
{previewFramePresets[previewFrameSize]
?.label || "Current Frame"}
</span>
</button>
)}
<button
type="button"
onClick={() => {
downloadMP4(previewFrameSize);
setIsPanelDownloadDropdownOpen(false);
}}
className="w-full px-3 py-2 text-xs text-left hover:bg-zinc-50 transition-colors flex items-center gap-2 border-t border-zinc-100"
>
<Download className="w-3 h-3" />
<span>
MP4 -{" "}
{previewFramePresets[previewFrameSize]
?.label || "Current Frame"}
</span>
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Modal */}
<AnimatePresence>
{isModalOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black bg-opacity-80 flex items-center justify-center z-50"
onClick={() => setIsModalOpen(false)}
>
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.8, opacity: 0 }}
className="relative flex items-center justify-center w-full h-full"
onClick={(e) => e.stopPropagation()}
>
{/* Close Button, Download, and Frame Size Selector */}
<div className="absolute top-4 right-4 flex items-center gap-2 z-50">
{/* Frame Size Selector */}
<div className="flex items-center">
<Dropdown
value={previewFrameSize}
onChange={(value) => {
setPreviewFrameSize(value);
const frame = previewFramePresets[value];
if (frame) {
setGradient((prev) => ({
...prev,
dimensions: {
width: frame.width,
height: frame.height,
},
}));
}
}}
options={Object.entries(previewFramePresets).map(
([key, preset]) => ({
value: key,
label: `${preset.icon} ${preset.label} (${preset.width}×${preset.height})`,
})
)}
placeholder="Select frame size"
className="min-w-[180px]"
/>
</div>
{/* Download Button with Dropdown */}
<div ref={downloadDropdownRef} className="relative">
<button
onClick={() =>
setIsDownloadDropdownOpen(!isDownloadDropdownOpen)
}
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-white hover:bg-gray-100 rounded-xl transition-colors border border-gray-200 shadow-lg h-10"
>
<Download className="w-4 h-4" />
Download
</button>
<AnimatePresence>
{isDownloadDropdownOpen && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.15 }}
className="absolute right-0 mt-2 bg-white border border-gray-200 rounded-xl shadow-lg overflow-hidden z-50 min-w-[200px]"
>
<div className="flex flex-col">
<button
type="button"
onClick={() => {
downloadSVG(previewFrameSize);
setIsDownloadDropdownOpen(false);
}}
className="w-full px-4 py-3 text-sm text-left hover:bg-gray-50 transition-colors flex items-center gap-2"
>
<Download className="w-4 h-4" />
<span>
Download as SVG -{" "}
{previewFramePresets[previewFrameSize]?.label ||
"Current Frame"}
</span>
</button>
<button
type="button"
onClick={() => {
downloadRaster("png", previewFrameSize);
setIsDownloadDropdownOpen(false);
}}
className="w-full px-4 py-3 text-sm text-left hover:bg-gray-50 transition-colors flex items-center gap-2 border-t border-gray-100"
>
<Download className="w-4 h-4" />
<span>
Download as PNG -{" "}
{previewFramePresets[previewFrameSize]?.label ||
"Current Frame"}
</span>
</button>
<button
type="button"
onClick={() => {
downloadRaster("jpeg", previewFrameSize);
setIsDownloadDropdownOpen(false);
}}
className="w-full px-4 py-3 text-sm text-left hover:bg-gray-50 transition-colors flex items-center gap-2 border-t border-gray-100"
>
<Download className="w-4 h-4" />
<span>
Download as JPEG -{" "}
{previewFramePresets[previewFrameSize]?.label ||
"Current Frame"}
</span>
</button>
{/* GIF Download - Only show if background animation is enabled */}
{gradient.backgroundAnimation.enabled && (
<button
type="button"
onClick={() => {
downloadGIF(previewFrameSize);
setIsDownloadDropdownOpen(false);
}}
className="w-full px-4 py-3 text-sm text-left hover:bg-gray-50 transition-colors flex items-center gap-2 border-t border-gray-100"
>
<Download className="w-4 h-4" />
<span>
Download as GIF -{" "}
{previewFramePresets[previewFrameSize]?.label ||
"Current Frame"}
</span>
</button>
)}
{/* MP4 Download */}
<button
type="button"
onClick={() => {
downloadMP4(previewFrameSize);
setIsDownloadDropdownOpen(false);
}}
className="w-full px-4 py-3 text-sm text-left hover:bg-gray-50 transition-colors flex items-center gap-2 border-t border-gray-100"
>
<Download className="w-4 h-4" />
<span>
Download as MP4 -{" "}
{previewFramePresets[previewFrameSize]?.label ||
"Current Frame"}
</span>
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Close Button */}
<button
onClick={() => setIsModalOpen(false)}
className="w-10 h-10 bg-white hover:bg-gray-100 rounded-full flex items-center justify-center shadow-lg transition-colors border border-gray-200"
>
<X className="w-5 h-5 text-gray-900" />
</button>
</div>
{/* Modal Preview Container */}
<div
ref={modalPreviewRef}
className="relative rounded-xl overflow-hidden shadow-2xl"
style={{
aspectRatio: `${gradient.dimensions.width} / ${gradient.dimensions.height}`,
...modalDimensions,
background: generateGradientCSS(),
...(isPlaying &&
gradient.backgroundAnimation.enabled && {
backgroundSize: "200% 200%",
}),
...(isPlaying &&
backgroundAnimation && {
animation: backgroundAnimation,
}),
}}
/>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* MP4 Generation Loading Modal */}
<AnimatePresence>
{isGeneratingMP4 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black bg-opacity-80 flex items-center justify-center z-50"
>
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.8, opacity: 0 }}
className="bg-white rounded-xl p-8 max-w-md w-full mx-4"
>
<div className="text-center">
<div className="mb-4">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-purple-600"></div>
</div>
<h3 className="text-xl font-semibold mb-2">
Generating MP4 Video
</h3>
<p className="text-gray-600 mb-4">
{mp4Progress < 20
? "Initializing..."
: mp4Progress < 70
? "Recording frames..."
: mp4Progress < 90
? "Converting to MP4..."
: "Finalizing..."}
</p>
<div className="w-full bg-gray-200 rounded-full h-2.5 mb-2">
<div
className="bg-purple-600 h-2.5 rounded-full transition-all duration-300"
style={{ width: `${mp4Progress}%` }}
></div>
</div>
<p className="text-sm text-gray-500">
{Math.round(mp4Progress)}%
</p>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
export default GradientGenerator;
The Tech stack I am using is as follows
- Nextjs
- Reactjs
- Tailwind CSS
- Lucide React for icons
- Framer motion for animation
The whole idea is to quickly make something that I can use and launch it as a URL, so I didn't pay attention to user authentication, CRUD operations by user, etc, and only one feature is added is to download the output.
One can look, I've added gradient presets to further allow people to quickly iterate over examples because I hate doing manual work every time, and templates save time. I'll add more gradients, almost 100,+ but I hope that won't confuse the user, so currently it only has 10/20 presets as examples.
That's the story for today, see you in the next one
Shrey
Top comments (0)