refactor: simplify export to PNG-only, improve controls UI

- Replace html2canvas with modern-screenshot for better performance
- Remove ExportModal and CSS export functionality
- Simplify export to single PNG download
- Improve controls layout with better spacing and text shadows
- Increase glass-panel opacity for better readability
- Remove unused ExportData interface and CSS generation utilities
This commit is contained in:
Ryan Walters
2025-11-06 23:40:43 -06:00
parent 6834aa308f
commit f5090b9c10
10 changed files with 110 additions and 327 deletions

View File

@@ -1,29 +1,32 @@
![Grain Project - Banner Image][grain-banner]
[![Grain Project - Banner Image][grain-banner]][grain-website]
A small experiment on creating beautiful, dynamic backgrounds with
colorful gradients & film grain. Built in <b>React</b> & <b>Vite</b> with <b>SVGs</b> and layers of <b>Radial Gradients</b>
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE)
[![Node Version](https://img.shields.io/badge/node-v22-brightgreen.svg)](https://nodejs.org/)
[![pnpm](https://img.shields.io/badge/maintained%20with-pnpm-cc00ff.svg)](https://pnpm.io/)
### Dependencies Used
Create beautiful, dynamic backgrounds with colorful gradients and film grain. Built with modern web technologies for smooth performance and stunning visuals.
- Hero Icons
- React
- Typescript
- Vite
- Sass
- **Customizable gradients & noise** via SVG filters
- **Preset palettes** for quick styling
- **PNG export** with a single click
### Installation
Built with [Preact](https://preactjs.com/), [Vite](https://vite.dev/), [Tailwind CSS](https://tailwindcss.com/), [Radix UI](https://www.radix-ui.com/), and [Lucide Icons](https://lucide.dev/).
- Built on Node v16, packages managed with Yarn.
## Usage
```bash
npm install --global yarn # If you don't have yarn installed
yarn # Run inside root directory to install all dependencies.
git clone https://github.com/Xevion/grain.git # Clone the repository
cd grain # Navigate to the repository
npm install --global pnpm # Install pnpm if needed
pnpm install # Install dependencies
pnpm dev # Start development server
pnpm build # Build for production
pnpm preview # Preview the production build
```
### Development
## License
```bash
yarn dev # Starts a development server with Hot Module Replacement
```
Licensed under the [GNU General Public License v3.0](LICENSE).
[grain-banner]: ./.media/banner.jpeg
[grain-website]: https://grain.xevion.dev/

View File

@@ -23,8 +23,8 @@
"@tailwindcss/vite": "^4.1.17",
"cssnano": "^7.1.0",
"framer-motion": "^12.23.24",
"html2canvas": "^1.4.1",
"lucide-preact": "^0.468.0",
"modern-screenshot": "^4.6.6",
"preact": "^10.27.2",
"preact-iso": "^2.11.0",
"preact-render-to-string": "^6.6.3",

47
pnpm-lock.yaml generated
View File

@@ -41,12 +41,12 @@ importers:
framer-motion:
specifier: ^12.23.24
version: 12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
html2canvas:
specifier: ^1.4.1
version: 1.4.1
lucide-preact:
specifier: ^0.468.0
version: 0.468.0(preact@10.27.2)
modern-screenshot:
specifier: ^4.6.6
version: 4.6.6
preact:
specifier: ^10.27.2
version: 10.27.2
@@ -1297,10 +1297,6 @@ packages:
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
base64-arraybuffer@1.0.2:
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
engines: {node: '>= 0.6.0'}
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
@@ -1384,9 +1380,6 @@ packages:
peerDependencies:
postcss: ^8.0.9
css-line-break@2.1.0:
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
css-select@5.2.2:
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
@@ -1831,10 +1824,6 @@ packages:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
html2canvas@1.4.1:
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
engines: {node: '>=8.0.0'}
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@@ -2159,6 +2148,9 @@ packages:
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
modern-screenshot@4.6.6:
resolution: {integrity: sha512-8tF0xEpe7yx37mK95UcIghSCWYeu628K2hLJl+ZNY2ANmRzYLlRLpquPHAQcL8keF6BoeEzTEw4GrgmUpGuZ8w==}
motion-dom@12.23.23:
resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==}
@@ -2734,9 +2726,6 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
text-segmentation@1.0.3:
resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -2829,9 +2818,6 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
utrie@1.0.2:
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
vite-prerender-plugin@0.5.11:
resolution: {integrity: sha512-xWOhb8Ef2zoJIiinYVunIf3omRfUbEXcPEvrkQcrDpJ2yjDokxhvQ26eSJbkthRhymntWx6816jpATrJphh+ug==}
peerDependencies:
@@ -4004,8 +3990,6 @@ snapshots:
balanced-match@1.0.2: {}
base64-arraybuffer@1.0.2: {}
boolbase@1.0.0: {}
brace-expansion@1.1.12:
@@ -4095,10 +4079,6 @@ snapshots:
dependencies:
postcss: 8.5.6
css-line-break@2.1.0:
dependencies:
utrie: 1.0.2
css-select@5.2.2:
dependencies:
boolbase: 1.0.0
@@ -4711,11 +4691,6 @@ snapshots:
he@1.2.0: {}
html2canvas@1.4.1:
dependencies:
css-line-break: 2.1.0
text-segmentation: 1.0.3
ignore@5.3.2: {}
immutable@5.1.4:
@@ -5007,6 +4982,8 @@ snapshots:
dependencies:
brace-expansion: 1.1.12
modern-screenshot@4.6.6: {}
motion-dom@12.23.23:
dependencies:
motion-utils: 12.23.6
@@ -5626,10 +5603,6 @@ snapshots:
tapable@2.3.0: {}
text-segmentation@1.0.3:
dependencies:
utrie: 1.0.2
tinybench@2.9.0: {}
tinyexec@0.3.2: {}
@@ -5723,10 +5696,6 @@ snapshots:
util-deprecate@1.0.2: {}
utrie@1.0.2:
dependencies:
base64-arraybuffer: 1.0.2
vite-prerender-plugin@0.5.11(vite@7.2.1(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(yaml@2.8.1)):
dependencies:
kolorist: 1.8.0

View File

@@ -1,116 +0,0 @@
import { useState } from "preact/hooks";
import * as Dialog from "@radix-ui/react-dialog";
import { X, Download, Copy, Check } from "lucide-preact";
import { GlassButton } from "./GlassButton";
import { exportAsPNG, generateCSS, copyCSSToClipboard, downloadCSS, type ExportData } from "../utils/exportGradient";
interface ExportModalProps {
open: boolean;
onClose: () => void;
exportData: ExportData;
}
export function ExportModal({ open, onClose, exportData }: ExportModalProps) {
const [copied, setCopied] = useState(false);
const [exporting, setExporting] = useState(false);
const cssCode = generateCSS(exportData);
const handleCopyCSS = async () => {
try {
await copyCSSToClipboard(cssCode);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error("Failed to copy:", error);
}
};
const handleDownloadCSS = () => {
downloadCSS(cssCode);
};
const handleExportPNG = async () => {
setExporting(true);
try {
await exportAsPNG("gradient-container");
} catch (error) {
console.error("Failed to export PNG:", error);
} finally {
setExporting(false);
}
};
return (
<Dialog.Root open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50" />
<Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-full max-w-lg">
<div className="glass-panel p-6 m-4 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<Dialog.Title className="font-space-grotesk text-2xl font-bold text-zinc-900">
Export Gradient
</Dialog.Title>
<Dialog.Close asChild>
<button
className="glass-button rounded-lg p-2 text-zinc-900"
aria-label="Close"
>
<X size={20} />
</button>
</Dialog.Close>
</div>
{/* PNG Export Section */}
<div className="space-y-3">
<h3 className="font-space-grotesk text-sm font-semibold uppercase tracking-wide text-zinc-900">
Export as Image
</h3>
<GlassButton
onClick={handleExportPNG}
disabled={exporting}
className="w-full flex items-center justify-center gap-2"
>
<Download size={18} />
<span>{exporting ? "Exporting..." : "Download PNG"}</span>
</GlassButton>
</div>
{/* CSS Export Section */}
<div className="space-y-3">
<h3 className="font-space-grotesk text-sm font-semibold uppercase tracking-wide text-zinc-900">
Export as CSS
</h3>
{/* CSS Code Preview */}
<div className="glass-input rounded-lg p-4 overflow-x-auto max-h-48 overflow-y-auto">
<pre className="font-mono text-xs text-zinc-900 leading-relaxed">
{cssCode}
</pre>
</div>
{/* CSS Actions */}
<div className="flex gap-2">
<GlassButton
onClick={handleCopyCSS}
className="flex-1 flex items-center justify-center gap-2"
>
{copied ? <Check size={18} /> : <Copy size={18} />}
<span>{copied ? "Copied!" : "Copy CSS"}</span>
</GlassButton>
<GlassButton
onClick={handleDownloadCSS}
className="flex-1 flex items-center justify-center gap-2"
>
<Download size={18} />
<span>Download CSS</span>
</GlassButton>
</div>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -28,23 +28,23 @@ export function GradientControls({
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="p-6">
<h1 className="font-space-grotesk text-4xl font-bold tracking-tight text-zinc-900">
<div className="pl-4 pt-3 pb-2">
<h1 className="font-space-grotesk text-4xl font-bold tracking-tight text-zinc-50 text-shadow-md">
Grain
</h1>
</div>
<Separator.Root className="h-px bg-white/20 mx-6" />
<Separator.Root className="h-px bg-white/20" />
{/* Controls */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Palette Selector */}
<PaletteSelector
className="px-6 py-4"
selectedPaletteId={paletteId}
onSelect={onPaletteChange}
/>
<Separator.Root className="h-px bg-white/10" />
<Separator.Root className="h-px bg-white/20" />
{/* Gradient Count Slider */}
<SliderControl
@@ -54,6 +54,7 @@ export function GradientControls({
min={2}
max={8}
step={1}
className="px-6 py-4"
/>
{/* Noise Intensity Slider */}
@@ -65,12 +66,13 @@ export function GradientControls({
max={1.5}
step={0.1}
formatValue={(v) => `${Math.round(v * 100)}%`}
className="px-6 py-4"
/>
<Separator.Root className="h-px bg-white/10" />
<Separator.Root className="h-px bg-white/20" />
{/* Action Buttons */}
<div className="space-y-3">
<div className="flex flex-col gap-2 mx-6 my-4">
<GlassButton
onClick={onRegenerate}
className="w-full flex items-center justify-center gap-2"
@@ -92,6 +94,5 @@ export function GradientControls({
</GlassButton>
</div>
</div>
</div>
);
}

View File

@@ -4,12 +4,17 @@ import { motion } from "framer-motion";
interface PaletteSelectorProps {
selectedPaletteId: string;
onSelect: (paletteId: string) => void;
className?: string;
}
export function PaletteSelector({ selectedPaletteId, onSelect }: PaletteSelectorProps) {
export function PaletteSelector({
selectedPaletteId,
onSelect,
className,
}: PaletteSelectorProps) {
return (
<div className="space-y-2">
<label className="font-space-grotesk text-sm font-semibold uppercase tracking-wide text-zinc-900">
<div className={`space-y-2 ${className}`}>
<label className="font-space-grotesk text-sm font-semibold uppercase tracking-wide text-zinc-50 text-shadow-md">
Color Palette
</label>
<div className="grid grid-cols-2 gap-2">

View File

@@ -8,6 +8,7 @@ interface SliderControlProps {
max: number;
step?: number;
formatValue?: (value: number) => string;
className?: string;
}
export function SliderControl({
@@ -17,15 +18,16 @@ export function SliderControl({
min,
max,
step = 1,
formatValue = (v) => v.toString()
formatValue = (v) => v.toString(),
className,
}: SliderControlProps) {
return (
<div className="space-y-2">
<div className={`space-y ${className}`}>
<div className="flex justify-between items-center">
<label className="font-space-grotesk text-sm font-semibold uppercase tracking-wide text-zinc-900">
<label className="font-space-grotesk text-sm font-semibold uppercase tracking-wide text-zinc-50 text-shadow-md">
{label}
</label>
<span className="font-inter text-sm text-zinc-800">
<span className="font-inter text-sm text-zinc-100 text-shadow-md">
{formatValue(value)}
</span>
</div>
@@ -37,7 +39,7 @@ export function SliderControl({
max={max}
step={step}
>
<Slider.Track className="glass-input relative flex-grow rounded-full h-3">
<Slider.Track className="glass-input relative grow rounded-full h-3">
<Slider.Range className="absolute bg-white/40 rounded-full h-full" />
</Slider.Track>
<Slider.Thumb

View File

@@ -65,7 +65,7 @@ pre.inline {
/* Glassmorphism utilities */
.glass-panel {
background: rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.35);
backdrop-filter: blur(50px) saturate(100%);
/* -webkit-backdrop-filter: blur(10px) saturate(80%); */
border: 1px solid rgba(255, 255, 255, 0.3);

View File

@@ -7,9 +7,8 @@ import { useViewportSize } from "./utils/useViewportSize";
import useBackground from "./utils/useBackground";
import { GlassPanel } from "./components/GlassPanel";
import { GradientControls } from "./components/GradientControls";
import { ExportModal } from "./components/ExportModal";
import { getPaletteById } from "./utils/palettes";
import type { ExportData } from "./utils/exportGradient";
import { exportGradientAsPNG } from "./utils/exportGradient";
export function App() {
const { width, height } = useViewportSize();
@@ -18,7 +17,6 @@ export function App() {
const [paletteId, setPaletteId] = useState("classic");
const [gradientCount, setGradientCount] = useState(5);
const [noiseIntensity, setNoiseIntensity] = useState(0.9);
const [exportModalOpen, setExportModalOpen] = useState(false);
const palette = getPaletteById(paletteId);
@@ -37,12 +35,6 @@ export function App() {
};
}, [svg, backgrounds]);
const exportData: ExportData = useMemo(() => ({
backgrounds,
svg,
noiseIntensity,
}), [backgrounds, svg, noiseIntensity]);
// Keyboard shortcuts
useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
@@ -51,7 +43,7 @@ export function App() {
regenerate();
} else if (e.key === "e" || e.key === "E") {
e.preventDefault();
setExportModalOpen(true);
exportGradientAsPNG();
}
};
@@ -81,17 +73,10 @@ export function App() {
noiseIntensity={noiseIntensity}
onNoiseIntensityChange={setNoiseIntensity}
onRegenerate={regenerate}
onExport={() => setExportModalOpen(true)}
onExport={() => exportGradientAsPNG()}
/>
</GlassPanel>
</div>
{/* Export modal */}
<ExportModal
open={exportModalOpen}
onClose={() => setExportModalOpen(false)}
exportData={exportData}
/>
</>
);
}

View File

@@ -1,86 +1,20 @@
import html2canvas from "html2canvas";
export interface ExportData {
backgrounds: string[];
svg: string;
noiseIntensity: number;
}
import { domToPng } from "modern-screenshot";
/**
* Export the current gradient as a PNG image
* Export the gradient as a PNG screenshot
*/
export async function exportAsPNG(elementId: string = "gradient-container"): Promise<void> {
const element = document.getElementById(elementId);
export async function exportGradientAsPNG(
elementId: string = "gradient-container"
): Promise<void> {
const element = document.querySelector(`#${elementId}`);
if (!element) {
throw new Error("Gradient element not found");
}
try {
const canvas = await html2canvas(element, {
backgroundColor: null,
scale: 2, // Higher quality
logging: false,
});
const dataUrl = await domToPng(element as HTMLElement);
canvas.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `grain-gradient-${Date.now()}.png`;
link.href = dataUrl;
link.click();
URL.revokeObjectURL(url);
});
} catch (error) {
console.error("Failed to export PNG:", error);
throw error;
}
}
/**
* Generate CSS code for the current gradient
*/
export function generateCSS(data: ExportData): string {
const { backgrounds, svg } = data;
const backgroundLayers = [`url("${svg}")`, ...backgrounds].join(",\n ");
return `/* Grain Gradient CSS */
.gradient-background {
background: ${backgroundLayers};
filter: contrast(150%) brightness(90%);
background-blend-mode: overlay;
}
/* Additional overlay (optional) */
.gradient-overlay {
background: rgba(40, 40, 40, 0.5);
background-blend-mode: overlay;
}`;
}
/**
* Copy CSS code to clipboard
*/
export async function copyCSSToClipboard(css: string): Promise<void> {
try {
await navigator.clipboard.writeText(css);
} catch (error) {
console.error("Failed to copy to clipboard:", error);
throw error;
}
}
/**
* Download CSS code as a file
*/
export function downloadCSS(css: string): void {
const blob = new Blob([css], { type: "text/css" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `grain-gradient-${Date.now()}.css`;
link.click();
URL.revokeObjectURL(url);
}