Skip to content

Instantly share code, notes, and snippets.

@mkaz
Created March 20, 2026 04:51
Show Gist options
  • Select an option

  • Save mkaz/a7e278697370573e9299f6caccfa5884 to your computer and use it in GitHub Desktop.

Select an option

Save mkaz/a7e278697370573e9299f6caccfa5884 to your computer and use it in GitHub Desktop.
Gradient Studio
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gradient Studio</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Fraunces:ital,opsz,wght@0,9..144,300;0,9..144,600;1,9..144,300&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--ink: #0e0e0e;
--paper: #f5f2ed;
--muted: #a09a90;
--accent: #c8440a;
--panel-bg: rgba(245, 242, 237, 0.92);
--border: rgba(14,14,14,0.12);
--radius: 6px;
}
body {
font-family: 'DM Mono', monospace;
background: #111;
overflow: hidden;
height: 100vh;
width: 100vw;
color: var(--ink);
}
/* ── PREVIEW ── */
#preview {
position: fixed;
inset: 0;
z-index: 0;
transition: all 0.3s ease;
}
/* ── PANEL ── */
#panel {
position: fixed;
top: 0; right: 0;
height: 100vh;
width: 340px;
background: var(--panel-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-left: 1px solid var(--border);
z-index: 10;
display: flex;
flex-direction: column;
overflow: hidden;
transition: transform 0.35s cubic-bezier(.16,1,.3,1);
}
#panel.hidden { transform: translateX(100%); }
.panel-header {
padding: 20px 22px 14px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: baseline;
gap: 10px;
flex-shrink: 0;
}
.panel-header h1 {
font-family: 'Fraunces', serif;
font-size: 22px;
font-weight: 600;
letter-spacing: -0.5px;
line-height: 1;
}
.panel-header span {
font-size: 10px;
color: var(--muted);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.panel-scroll {
flex: 1;
overflow-y: auto;
padding: 18px 22px;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
/* ── SECTIONS ── */
.section {
margin-bottom: 24px;
}
.section-label {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--muted);
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 8px;
}
.section-label::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
/* ── GRADIENT TYPE ── */
.type-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 6px;
}
.type-btn {
aspect-ratio: 1;
border: 1.5px solid var(--border);
border-radius: var(--radius);
background: var(--paper);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
transition: all 0.15s;
flex-direction: column;
gap: 4px;
padding: 6px 4px;
}
.type-btn svg { width: 18px; height: 18px; }
.type-btn:hover { border-color: var(--ink); color: var(--ink); }
.type-btn.active { border-color: var(--accent); color: var(--accent); background: rgba(200,68,10,0.06); }
/* ── COLOR STOPS ── */
.stops-list { display: flex; flex-direction: column; gap: 8px; }
.stop-row {
display: flex;
align-items: center;
gap: 8px;
background: white;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 8px 10px;
position: relative;
}
.stop-swatch {
width: 28px;
height: 28px;
border-radius: 4px;
border: 1px solid var(--border);
cursor: pointer;
flex-shrink: 0;
position: relative;
overflow: hidden;
}
.stop-swatch input[type=color] {
position: absolute;
inset: -4px;
width: calc(100% + 8px);
height: calc(100% + 8px);
border: none;
padding: 0;
cursor: pointer;
opacity: 0;
}
.stop-hex {
font-size: 11px;
color: var(--ink);
width: 64px;
letter-spacing: 0.02em;
}
.stop-pos-wrap {
flex: 1;
display: flex;
align-items: center;
gap: 6px;
}
.stop-pos-num {
font-size: 10px;
color: var(--muted);
width: 26px;
text-align: right;
}
.stop-remove {
width: 18px;
height: 18px;
border: none;
background: none;
cursor: pointer;
color: var(--muted);
font-size: 14px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
border-radius: 3px;
transition: all 0.1s;
}
.stop-remove:hover { color: var(--accent); background: rgba(200,68,10,0.08); }
.add-stop-btn {
width: 100%;
padding: 8px;
border: 1.5px dashed var(--border);
border-radius: var(--radius);
background: transparent;
cursor: pointer;
font-family: 'DM Mono', monospace;
font-size: 10px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
transition: all 0.15s;
}
.add-stop-btn:hover { border-color: var(--ink); color: var(--ink); }
/* ── SLIDERS ── */
.slider-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.slider-label {
font-size: 10px;
color: var(--muted);
width: 64px;
flex-shrink: 0;
}
.slider-val {
font-size: 10px;
color: var(--ink);
width: 36px;
text-align: right;
flex-shrink: 0;
}
input[type=range] {
flex: 1;
-webkit-appearance: none;
height: 3px;
background: var(--border);
border-radius: 2px;
outline: none;
cursor: pointer;
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--ink);
border: 2px solid var(--paper);
box-shadow: 0 0 0 1px var(--ink);
transition: transform 0.1s;
}
input[type=range]:hover::-webkit-slider-thumb { transform: scale(1.2); }
input[type=range].stop-slider::-webkit-slider-thumb {
background: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
}
/* ── GRADIENT BAR ── */
#gradient-bar {
height: 28px;
border-radius: var(--radius);
border: 1px solid var(--border);
margin-bottom: 14px;
position: relative;
overflow: hidden;
}
/* ── OVERLAY CONTROLS ── */
.overlay-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 12px;
}
.overlay-btn {
padding: 8px 10px;
border: 1.5px solid var(--border);
border-radius: var(--radius);
background: var(--paper);
cursor: pointer;
font-family: 'DM Mono', monospace;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
transition: all 0.15s;
}
.overlay-btn:hover { border-color: var(--ink); color: var(--ink); }
.overlay-btn.active { border-color: var(--accent); color: var(--accent); background: rgba(200,68,10,0.06); }
/* ── ACTIONS ── */
.actions {
padding: 14px 22px;
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 8px;
flex-shrink: 0;
}
.btn-primary {
width: 100%;
padding: 11px;
background: var(--ink);
color: var(--paper);
border: none;
border-radius: var(--radius);
font-family: 'DM Mono', monospace;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.1em;
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.btn-primary:hover { background: var(--accent); }
.btn-secondary {
width: 100%;
padding: 9px;
background: transparent;
color: var(--muted);
border: 1.5px solid var(--border);
border-radius: var(--radius);
font-family: 'DM Mono', monospace;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.1em;
cursor: pointer;
transition: all 0.15s;
}
.btn-secondary:hover { border-color: var(--ink); color: var(--ink); }
/* ── CSS OUTPUT ── */
.css-output {
font-size: 9px;
color: var(--muted);
background: white;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 12px;
line-height: 1.6;
word-break: break-all;
cursor: pointer;
transition: all 0.15s;
position: relative;
}
.css-output:hover { border-color: var(--ink); color: var(--ink); }
.css-output .copy-hint {
position: absolute;
top: 6px; right: 8px;
font-size: 8px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
opacity: 0;
transition: opacity 0.15s;
}
.css-output:hover .copy-hint { opacity: 1; }
/* ── TOGGLE BUTTON ── */
#toggle-panel {
position: fixed;
top: 20px;
right: 356px;
z-index: 20;
width: 36px;
height: 36px;
background: var(--panel-bg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid var(--border);
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
transition: all 0.35s cubic-bezier(.16,1,.3,1);
color: var(--ink);
}
#toggle-panel.panel-hidden { right: 20px; }
#toggle-panel:hover { background: var(--paper); }
/* ── FULLSCREEN HINT ── */
#hint {
position: fixed;
bottom: 20px;
left: 20px;
z-index: 20;
font-family: 'DM Mono', monospace;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: rgba(255,255,255,0.4);
display: flex;
align-items: center;
gap: 8px;
}
.key-badge {
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 3px;
padding: 2px 6px;
font-size: 9px;
}
/* ── NOISE OVERLAY (optional) ── */
#noise {
position: fixed;
inset: 0;
z-index: 1;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s;
}
/* ── VIGNETTE OVERLAY (optional) ── */
#vignette {
position: fixed;
inset: 0;
z-index: 1;
pointer-events: none;
opacity: 0;
background: radial-gradient(ellipse at center, transparent 40%, rgba(0,0,0,0.7) 100%);
transition: opacity 0.3s;
}
/* ── TOAST ── */
#toast {
position: fixed;
bottom: 60px;
left: 50%;
transform: translateX(-50%) translateY(10px);
background: var(--ink);
color: var(--paper);
font-family: 'DM Mono', monospace;
font-size: 10px;
letter-spacing: 0.08em;
padding: 8px 16px;
border-radius: 20px;
opacity: 0;
transition: all 0.3s;
pointer-events: none;
z-index: 100;
white-space: nowrap;
}
#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
/* ── NUMBER INPUT ── */
.num-input {
width: 48px;
border: 1px solid var(--border);
border-radius: 4px;
background: white;
font-family: 'DM Mono', monospace;
font-size: 10px;
color: var(--ink);
padding: 4px 6px;
text-align: center;
outline: none;
}
.num-input:focus { border-color: var(--ink); }
</style>
</head>
<body>
<div id="preview"></div>
<canvas id="noise" width="256" height="256"></canvas>
<div id="vignette"></div>
<div id="panel">
<div class="panel-header">
<h1>Gradient Studio</h1>
<span>Background Builder</span>
</div>
<div class="panel-scroll">
<!-- TYPE -->
<div class="section">
<div class="section-label">Type</div>
<div class="type-grid">
<button class="type-btn active" data-type="linear" title="Linear">
<svg viewBox="0 0 20 20"><defs><linearGradient id="lg1" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stop-color="#666"/><stop offset="100%" stop-color="#eee"/></linearGradient></defs><rect width="20" height="20" rx="2" fill="url(#lg1)"/></svg>
Lin
</button>
<button class="type-btn" data-type="radial" title="Radial">
<svg viewBox="0 0 20 20"><defs><radialGradient id="rg1" cx="50%" cy="50%" r="50%"><stop offset="0%" stop-color="#eee"/><stop offset="100%" stop-color="#444"/></radialGradient></defs><rect width="20" height="20" rx="2" fill="url(#rg1)"/></svg>
Rad
</button>
<button class="type-btn" data-type="conic" title="Conic">
<svg viewBox="0 0 20 20"><defs><linearGradient id="cg1" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#555"/><stop offset="50%" stop-color="#eee"/><stop offset="100%" stop-color="#555"/></linearGradient></defs><rect width="20" height="20" rx="2" fill="url(#cg1)"/></svg>
Con
</button>
<button class="type-btn" data-type="mesh" title="Mesh (multi-layer)">
<svg viewBox="0 0 20 20"><defs><radialGradient id="mg1" cx="30%" cy="30%" r="60%"><stop offset="0%" stop-color="#aaa"/><stop offset="100%" stop-color="transparent"/></radialGradient><radialGradient id="mg2" cx="70%" cy="70%" r="60%"><stop offset="0%" stop-color="#666"/><stop offset="100%" stop-color="transparent"/></radialGradient></defs><rect width="20" height="20" rx="2" fill="#222"/><rect width="20" height="20" rx="2" fill="url(#mg1)"/><rect width="20" height="20" rx="2" fill="url(#mg2)"/></svg>
Mesh
</button>
<button class="type-btn" data-type="stripe" title="Stripe">
<svg viewBox="0 0 20 20"><defs><linearGradient id="sg1" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#aaa"/><stop offset="100%" stop-color="#333"/></linearGradient></defs><rect width="20" height="20" rx="2" fill="url(#sg1)"/><line x1="0" y1="10" x2="20" y2="10" stroke="rgba(255,255,255,0.2)" stroke-width="2"/></svg>
Str
</button>
</div>
</div>
<!-- GRADIENT BAR -->
<div id="gradient-bar"></div>
<!-- COLOR STOPS -->
<div class="section">
<div class="section-label">Color Stops</div>
<div class="stops-list" id="stops-list"></div>
<button class="add-stop-btn" id="add-stop-btn">+ Add Stop</button>
</div>
<!-- DIRECTION / ANGLE (linear) -->
<div class="section" id="section-angle">
<div class="section-label">Angle</div>
<div class="slider-row">
<span class="slider-label">Direction</span>
<input type="range" id="angle" min="0" max="360" value="135" step="1">
<span class="slider-val" id="angle-val">135°</span>
</div>
<!-- Quick preset buttons -->
<div style="display:flex;gap:6px;flex-wrap:wrap;">
<button class="overlay-btn" style="flex:1;min-width:40px;" onclick="setAngle(0)">↑</button>
<button class="overlay-btn" style="flex:1;min-width:40px;" onclick="setAngle(45)">↗</button>
<button class="overlay-btn" style="flex:1;min-width:40px;" onclick="setAngle(90)">→</button>
<button class="overlay-btn" style="flex:1;min-width:40px;" onclick="setAngle(135)">↘</button>
<button class="overlay-btn" style="flex:1;min-width:40px;" onclick="setAngle(180)">↓</button>
<button class="overlay-btn" style="flex:1;min-width:40px;" onclick="setAngle(225)">↙</button>
<button class="overlay-btn" style="flex:1;min-width:40px;" onclick="setAngle(270)">←</button>
<button class="overlay-btn" style="flex:1;min-width:40px;" onclick="setAngle(315)">↖</button>
</div>
</div>
<!-- RADIAL / CONIC CONTROLS -->
<div class="section" id="section-radial" style="display:none;">
<div class="section-label">Origin</div>
<div class="slider-row">
<span class="slider-label">Center X</span>
<input type="range" id="cx" min="0" max="100" value="50" step="1">
<span class="slider-val" id="cx-val">50%</span>
</div>
<div class="slider-row">
<span class="slider-label">Center Y</span>
<input type="range" id="cy" min="0" max="100" value="50" step="1">
<span class="slider-val" id="cy-val">50%</span>
</div>
<div class="slider-row" id="row-size">
<span class="slider-label">Size</span>
<input type="range" id="rsize" min="20" max="200" value="100" step="1">
<span class="slider-val" id="rsize-val">100%</span>
</div>
</div>
<!-- CONIC ROTATION -->
<div class="section" id="section-conic" style="display:none;">
<div class="section-label">Conic Rotation</div>
<div class="slider-row">
<span class="slider-label">From</span>
<input type="range" id="conic-from" min="0" max="360" value="0" step="1">
<span class="slider-val" id="conic-from-val">0°</span>
</div>
</div>
<!-- MESH CONTROLS -->
<div class="section" id="section-mesh" style="display:none;">
<div class="section-label">Mesh Layers</div>
<div class="slider-row">
<span class="slider-label">Blend</span>
<input type="range" id="mesh-blend" min="0" max="100" value="50" step="1">
<span class="slider-val" id="mesh-blend-val">50%</span>
</div>
<div class="slider-row">
<span class="slider-label">Spread</span>
<input type="range" id="mesh-spread" min="20" max="150" value="80" step="1">
<span class="slider-val" id="mesh-spread-val">80%</span>
</div>
</div>
<!-- GLOBAL CONTROLS -->
<div class="section">
<div class="section-label">Adjust</div>
<div class="slider-row">
<span class="slider-label">Opacity</span>
<input type="range" id="opacity" min="0" max="100" value="100" step="1">
<span class="slider-val" id="opacity-val">100%</span>
</div>
<div class="slider-row">
<span class="slider-label">Saturation</span>
<input type="range" id="saturate" min="0" max="200" value="100" step="1">
<span class="slider-val" id="saturate-val">100%</span>
</div>
<div class="slider-row">
<span class="slider-label">Brightness</span>
<input type="range" id="brightness" min="20" max="200" value="100" step="1">
<span class="slider-val" id="brightness-val">100%</span>
</div>
<div class="slider-row">
<span class="slider-label">Blur</span>
<input type="range" id="blur" min="0" max="60" value="0" step="1">
<span class="slider-val" id="blur-val">0px</span>
</div>
<div class="slider-row">
<span class="slider-label">Scale</span>
<input type="range" id="scale" min="80" max="200" value="100" step="1">
<span class="slider-val" id="scale-val">100%</span>
</div>
</div>
<!-- OVERLAYS -->
<div class="section">
<div class="section-label">Overlays</div>
<div class="overlay-grid">
<button class="overlay-btn" id="btn-noise" onclick="toggleOverlay('noise')">Grain</button>
<button class="overlay-btn" id="btn-vignette" onclick="toggleOverlay('vignette')">Vignette</button>
</div>
<div class="slider-row" id="noise-row" style="display:none;">
<span class="slider-label">Intensity</span>
<input type="range" id="noise-amount" min="0" max="100" value="30" step="1">
<span class="slider-val" id="noise-amount-val">30%</span>
</div>
</div>
<!-- CSS OUTPUT -->
<div class="section">
<div class="section-label">CSS</div>
<div class="css-output" id="css-output" onclick="copyCss()">
<span class="copy-hint">click to copy</span>
<span id="css-text"></span>
</div>
</div>
</div>
<!-- ACTIONS -->
<div class="actions">
<button class="btn-primary" onclick="downloadImage()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Export PNG
</button>
<button class="btn-secondary" onclick="randomize()">Randomize</button>
</div>
</div>
<button id="toggle-panel" onclick="togglePanel()">⚙</button>
<div id="hint">
<span class="key-badge">F</span> Fullscreen
<span class="key-badge" style="margin-left:8px;">H</span> Hide panel
</div>
<div id="toast"></div>
<script>
// ── STATE ──
const state = {
type: 'linear',
stops: [
{ color: '#ff6b6b', pos: 0 },
{ color: '#4ecdc4', pos: 50 },
{ color: '#45b7d1', pos: 100 }
],
angle: 135,
cx: 50, cy: 50, rsize: 100,
conicFrom: 0,
meshBlend: 50, meshSpread: 80,
opacity: 100, saturate: 100, brightness: 100, blur: 0, scale: 100,
noiseOn: false, vignetteOn: false, noiseAmount: 30
};
// ── NOISE CANVAS ──
(function initNoise() {
const canvas = document.getElementById('noise');
const ctx = canvas.getContext('2d');
const img = ctx.createImageData(256, 256);
for (let i = 0; i < img.data.length; i += 4) {
const v = Math.random() * 255;
img.data[i] = img.data[i+1] = img.data[i+2] = v;
img.data[i+3] = 255;
}
ctx.putImageData(img, 0, 0);
canvas.style.backgroundImage = `url(${canvas.toDataURL()})`;
canvas.style.backgroundRepeat = 'repeat';
canvas.style.backgroundSize = '256px';
canvas.style.mixBlendMode = 'overlay';
})();
// ── BUILD GRADIENT CSS ──
function buildGradientCSS() {
const stops = [...state.stops].sort((a,b) => a.pos - b.pos);
const stopsStr = stops.map(s => `${s.color} ${s.pos}%`).join(', ');
let grad = '';
switch(state.type) {
case 'linear':
grad = `linear-gradient(${state.angle}deg, ${stopsStr})`;
break;
case 'radial': {
const shape = `ellipse ${state.rsize}% ${state.rsize}% at ${state.cx}% ${state.cy}%`;
grad = `radial-gradient(${shape}, ${stopsStr})`;
break;
}
case 'conic':
grad = `conic-gradient(from ${state.conicFrom}deg at ${state.cx}% ${state.cy}%, ${stopsStr})`;
break;
case 'mesh': {
const blend = state.meshBlend / 100;
const spread = state.meshSpread;
const layers = stops.map((s, i) => {
const ox = 15 + (i * 37 % 70);
const oy = 20 + (i * 53 % 60);
return `radial-gradient(ellipse ${spread}% ${spread}% at ${ox}% ${oy}%, ${s.color}${Math.round(blend*255).toString(16).padStart(2,'0')}, transparent)`;
});
grad = layers.join(', ');
break;
}
case 'stripe': {
const stripeStops = stops.flatMap((s, i) => {
if (i === 0) return [`${s.color} 0%`, `${s.color} ${s.pos + 2}%`];
const prev = stops[i-1];
return [`${prev.color} ${s.pos - 2}%`, `${s.color} ${s.pos}%`, `${s.color} ${s.pos + 2}%`];
});
stripeStops.push(`${stops[stops.length-1].color} 100%`);
grad = `linear-gradient(${state.angle}deg, ${stripeStops.join(', ')})`;
break;
}
}
return grad;
}
// ── APPLY ──
function apply() {
const grad = buildGradientCSS();
const preview = document.getElementById('preview');
const filters = [
state.saturate !== 100 ? `saturate(${state.saturate}%)` : '',
state.brightness !== 100 ? `brightness(${state.brightness}%)` : '',
state.blur > 0 ? `blur(${state.blur}px)` : ''
].filter(Boolean).join(' ');
preview.style.background = grad;
preview.style.opacity = state.opacity / 100;
preview.style.filter = filters || 'none';
preview.style.transform = state.scale !== 100 ? `scale(${state.scale / 100})` : 'none';
// Noise
const noiseEl = document.getElementById('noise');
noiseEl.style.opacity = state.noiseOn ? (state.noiseAmount / 100) * 0.4 : 0;
// Vignette
document.getElementById('vignette').style.opacity = state.vignetteOn ? 1 : 0;
// Gradient bar
const sorted = [...state.stops].sort((a,b) => a.pos - b.pos);
const barStops = sorted.map(s => `${s.color} ${s.pos}%`).join(', ');
document.getElementById('gradient-bar').style.background = `linear-gradient(90deg, ${barStops})`;
// CSS output
const full = `background: ${grad};${filters ? '\nfilter: '+filters+';' : ''}${state.opacity < 100 ? '\nopacity: '+state.opacity/100+';' : ''}`;
document.getElementById('css-text').textContent = full;
}
// ── RENDER STOPS ──
function renderStops() {
const list = document.getElementById('stops-list');
list.innerHTML = '';
state.stops.forEach((stop, i) => {
const row = document.createElement('div');
row.className = 'stop-row';
row.innerHTML = `
<div class="stop-swatch" style="background:${stop.color}">
<input type="color" value="${stop.color}" data-i="${i}" class="color-picker">
</div>
<span class="stop-hex">${stop.color.toUpperCase()}</span>
<div class="stop-pos-wrap">
<input type="range" class="stop-slider" min="0" max="100" value="${stop.pos}" step="1" data-i="${i}">
<span class="stop-pos-num">${stop.pos}%</span>
</div>
${state.stops.length > 2 ? `<button class="stop-remove" data-i="${i}" title="Remove stop">×</button>` : ''}
`;
list.appendChild(row);
});
// Events
list.querySelectorAll('.color-picker').forEach(el => {
el.addEventListener('input', e => {
const i = +e.target.dataset.i;
state.stops[i].color = e.target.value;
e.target.closest('.stop-swatch').style.background = e.target.value;
e.target.closest('.stop-row').querySelector('.stop-hex').textContent = e.target.value.toUpperCase();
apply();
});
});
list.querySelectorAll('.stop-slider').forEach(el => {
el.addEventListener('input', e => {
const i = +e.target.dataset.i;
state.stops[i].pos = +e.target.value;
e.target.closest('.stop-pos-wrap').querySelector('.stop-pos-num').textContent = e.target.value + '%';
apply();
});
});
list.querySelectorAll('.stop-remove').forEach(el => {
el.addEventListener('click', e => {
const i = +e.target.dataset.i;
state.stops.splice(i, 1);
renderStops();
apply();
});
});
}
// ── CONTROLS ──
function wire(id, key, suffix, transform) {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener('input', () => {
state[key] = +el.value;
document.getElementById(id+'-val').textContent = el.value + suffix;
apply();
});
}
wire('angle', 'angle', '°');
wire('cx', 'cx', '%');
wire('cy', 'cy', '%');
wire('rsize', 'rsize', '%');
wire('conic-from', 'conicFrom', '°');
wire('mesh-blend', 'meshBlend', '%');
wire('mesh-spread', 'meshSpread', '%');
wire('opacity', 'opacity', '%');
wire('saturate', 'saturate', '%');
wire('brightness', 'brightness', '%');
wire('blur', 'blur', 'px');
wire('scale', 'scale', '%');
document.getElementById('noise-amount').addEventListener('input', e => {
state.noiseAmount = +e.target.value;
document.getElementById('noise-amount-val').textContent = e.target.value + '%';
apply();
});
// Type buttons
document.querySelectorAll('.type-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.type-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
state.type = btn.dataset.type;
document.getElementById('section-angle').style.display = (state.type === 'linear' || state.type === 'stripe') ? '' : 'none';
document.getElementById('section-radial').style.display = (state.type === 'radial' || state.type === 'conic') ? '' : 'none';
document.getElementById('section-conic').style.display = state.type === 'conic' ? '' : 'none';
document.getElementById('section-mesh').style.display = state.type === 'mesh' ? '' : 'none';
document.getElementById('row-size').style.display = state.type === 'radial' ? '' : 'none';
apply();
});
});
// Add stop
document.getElementById('add-stop-btn').addEventListener('click', () => {
const hue = Math.round(Math.random() * 360);
const mid = Math.round(Math.random() * 60 + 20);
const color = `hsl(${hue}, 70%, 60%)`;
// convert hsl to hex
const tmp = document.createElement('div');
tmp.style.color = color;
document.body.appendChild(tmp);
const c = getComputedStyle(tmp).color;
document.body.removeChild(tmp);
const m = c.match(/(\d+),\s*(\d+),\s*(\d+)/);
let hex = '#';
if (m) hex = '#' + [m[1],m[2],m[3]].map(x=>parseInt(x).toString(16).padStart(2,'0')).join('');
state.stops.push({ color: hex, pos: mid });
renderStops();
apply();
});
// ── TOGGLE OVERLAY ──
function toggleOverlay(type) {
if (type === 'noise') {
state.noiseOn = !state.noiseOn;
document.getElementById('btn-noise').classList.toggle('active', state.noiseOn);
document.getElementById('noise-row').style.display = state.noiseOn ? 'flex' : 'none';
} else {
state.vignetteOn = !state.vignetteOn;
document.getElementById('btn-vignette').classList.toggle('active', state.vignetteOn);
}
apply();
}
// ── ANGLE PRESETS ──
function setAngle(deg) {
state.angle = deg;
document.getElementById('angle').value = deg;
document.getElementById('angle-val').textContent = deg + '°';
apply();
}
// ── PANEL TOGGLE ──
let panelVisible = true;
function togglePanel() {
panelVisible = !panelVisible;
document.getElementById('panel').classList.toggle('hidden', !panelVisible);
const btn = document.getElementById('toggle-panel');
btn.classList.toggle('panel-hidden', !panelVisible);
btn.textContent = panelVisible ? '⚙' : '⚙';
}
// ── COPY CSS ──
function copyCss() {
const text = document.getElementById('css-text').textContent;
navigator.clipboard.writeText(text).then(() => toast('CSS copied!')).catch(() => toast('Copy failed'));
}
// ── DOWNLOAD PNG ──
function downloadImage() {
const grad = buildGradientCSS();
const w = window.screen.width * window.devicePixelRatio;
const h = window.screen.height * window.devicePixelRatio;
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
const img = new Image();
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}">
<defs>
<style>
rect { background: ${grad}; }
</style>
</defs>
<foreignObject width="100%" height="100%">
<div xmlns="http://www.w3.org/1999/xhtml" style="width:${w}px;height:${h}px;background:${grad};filter:${document.getElementById('preview').style.filter || 'none'}"></div>
</foreignObject>
</svg>`;
// Use canvas approach for reliability
renderGradientToCanvas(canvas, ctx, w, h, grad);
}
function renderGradientToCanvas(canvas, ctx, w, h, gradCSS) {
// Parse and draw stops on canvas
const stops = [...state.stops].sort((a,b) => a.pos - b.pos);
let grd;
if (state.type === 'linear' || state.type === 'stripe') {
const rad = state.angle * Math.PI / 180;
const cx = w/2, cy = h/2;
const len = Math.max(w,h);
grd = ctx.createLinearGradient(
cx - Math.cos(rad)*len/2, cy - Math.sin(rad)*len/2,
cx + Math.cos(rad)*len/2, cy + Math.sin(rad)*len/2
);
} else if (state.type === 'radial' || state.type === 'mesh') {
const r = Math.max(w,h) * (state.rsize/100);
grd = ctx.createRadialGradient(w*state.cx/100, h*state.cy/100, 0, w*state.cx/100, h*state.cy/100, r);
} else { // conic — approximate
grd = ctx.createConicGradient(state.conicFrom * Math.PI / 180, w*state.cx/100, h*state.cy/100);
}
stops.forEach(s => grd.addColorStop(s.pos/100, s.color));
if (state.brightness !== 100 || state.saturate !== 100 || state.blur > 0) {
ctx.filter = document.getElementById('preview').style.filter;
}
ctx.fillStyle = grd;
ctx.fillRect(0, 0, w, h);
// Noise
if (state.noiseOn) {
const noiseCanvas = document.getElementById('noise');
ctx.globalAlpha = state.noiseAmount / 100 * 0.4;
ctx.globalCompositeOperation = 'overlay';
for (let x = 0; x < w; x += 256) for (let y = 0; y < h; y += 256) ctx.drawImage(noiseCanvas, x, y);
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
}
// Vignette
if (state.vignetteOn) {
const vgrd = ctx.createRadialGradient(w/2, h/2, 0, w/2, h/2, Math.max(w,h)*0.7);
vgrd.addColorStop(0, 'transparent');
vgrd.addColorStop(1, 'rgba(0,0,0,0.7)');
ctx.fillStyle = vgrd;
ctx.fillRect(0, 0, w, h);
}
canvas.toBlob(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `gradient-${Date.now()}.png`;
a.click();
URL.revokeObjectURL(url);
toast('PNG exported!');
});
}
// ── RANDOMIZE ──
function randomize() {
const palettes = [
['#ff6b6b','#feca57','#ff9ff3'],
['#48dbfb','#ff9f43','#ee5a24'],
['#6c5ce7','#a29bfe','#fd79a8'],
['#00b894','#00cec9','#0984e3'],
['#e17055','#fdcb6e','#6c5ce7'],
['#2d3436','#636e72','#b2bec3'],
['#ff0099','#493240','#ff6600'],
['#12c2e9','#f64f59','#c471ed'],
];
const pal = palettes[Math.floor(Math.random() * palettes.length)];
state.stops = pal.map((c, i) => ({ color: c, pos: Math.round(i / (pal.length - 1) * 100) }));
state.angle = Math.floor(Math.random() * 360);
document.getElementById('angle').value = state.angle;
document.getElementById('angle-val').textContent = state.angle + '°';
renderStops();
apply();
toast('New palette!');
}
// ── TOAST ──
function toast(msg) {
const t = document.getElementById('toast');
t.textContent = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2000);
}
// ── KEYBOARD SHORTCUTS ──
document.addEventListener('keydown', e => {
if (e.key === 'f' || e.key === 'F') {
if (!document.fullscreenElement) document.documentElement.requestFullscreen();
else document.exitFullscreen();
}
if (e.key === 'h' || e.key === 'H') togglePanel();
if (e.key === 'r' || e.key === 'R') randomize();
if (e.key === 's' || e.key === 'S') downloadImage();
});
// ── INIT ──
renderStops();
apply();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment