Created
March 20, 2026 04:51
-
-
Save mkaz/a7e278697370573e9299f6caccfa5884 to your computer and use it in GitHub Desktop.
Gradient Studio
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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