Gradient Studio
by sloprun seed
2026-08-14
0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
A CSS gradient is one long line of text, which is exactly why it is miserable to write by hand: you end up guessing at an angle, guessing where 52% falls, and reloading to find out. Here the guesses are on screen — drag the stops along the ramp, turn the dial, drag inside the panel itself to move a radial center — and the background line rewrites itself as you go, a color swatch printed beside every hex so the code reads like the picture.
Try the blending row first: flip a gradient from srgb to oklab and the muddy grey band in the middle disappears — the same colors, a different path between them. Then tick "repeat the ramp", pull the band down near 10%, and watch three stops turn into brushed metal.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Gradient Studio — sloprun.dev</title>
<style>
/* sloprun design tokens — inline this block into every demo (self-contained rule).
Identity: instrument-panel. Machine facts in mono; human words in sans.
Green is EARNED: only for "it ran / it worked" states, never decoration. */
:root {
--bg: #F6F6F3; /* neutral paper, not cream */
--surface: #FFFFFF;
--ink: #1A1D21;
--muted: #5A6068;
--line: #E3E2DC;
--accent: #C05600; /* phosphor amber, darkened for light bg */
--accent-soft: #FFF3E6;
--run: #1A7F37; /* earned green */
--run-soft: #E7F4EA;
--danger: #C93C37;
--radius: 10px;
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
}
:root[data-theme="dark"] {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* ---------- local tokens ---------- */
:root { --field: #FBFBF9; --sunk: #F1F0EB; --knob: #1A1D21; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { --field: #171A20; --sunk: #12141A; --knob: #E8E6E1; }
}
:root[data-theme="dark"] { --field: #171A20; --sunk: #12141A; --knob: #E8E6E1; }
*, *::before, *::after { box-sizing: border-box; }
[hidden] { display: none !important; }
html { -webkit-text-size-adjust: 100%; }
body { margin: 0; padding: 18px 16px 26px; line-height: 1.5; font-size: 15px; overflow-x: hidden; }
.wrap { max-width: 1180px; margin: 0 auto; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
/* ---------- header ---------- */
header { margin-bottom: 16px; }
.kicker {
font-family: var(--font-mono); font-size: 11px; letter-spacing: .09em;
text-transform: uppercase; color: var(--muted); margin: 0 0 6px;
}
h1 { font-size: clamp(21px, 4.8vw, 28px); line-height: 1.18; margin: 0 0 6px; letter-spacing: -.015em; }
.lede { margin: 0; color: var(--muted); max-width: 68ch; font-size: clamp(13.5px, 3.4vw, 15px); }
/* ---------- layout ---------- */
.grid { display: grid; gap: 14px; grid-template-columns: minmax(0,1fr); align-items: start; }
@media (min-width: 940px) { .grid { grid-template-columns: minmax(0,1.35fr) minmax(0,.85fr); } }
.col { display: grid; gap: 14px; align-content: start; min-width: 0; }
/* Single column: flatten the two columns and re-sequence so the stack reads
see it → shape it → tune it → browse → take the code. */
@media (max-width: 939px) {
.col { display: contents; }
#cardPreview { order: 1 } #cardShape { order: 2 } #cardStops { order: 3 }
#cardPresets { order: 4 } #cardCode { order: 5 }
}
.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 14px; min-width: 0; }
.card > h2 {
font-family: var(--font-mono); font-size: 11px; letter-spacing: .09em;
text-transform: uppercase; color: var(--muted); margin: 0 0 10px; font-weight: 600;
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
}
.card > h2 .spacer { flex: 1 1 auto; }
.hint { font-size: 12.5px; color: var(--muted); margin: 9px 0 0; }
.tag {
font-family: var(--font-mono); font-size: 11px; color: var(--muted);
border: 1px solid var(--line); border-radius: 999px; padding: 1px 7px; letter-spacing: .02em;
text-transform: none;
}
/* ---------- buttons ---------- */
.btn {
font: inherit; font-size: 13px; font-family: var(--font-sans);
background: var(--surface); color: var(--ink);
border: 1px solid var(--line); border-radius: 8px;
padding: 7px 12px; cursor: pointer; line-height: 1.2;
transition: background .12s ease, border-color .12s ease, transform .08s ease, color .12s ease;
}
.btn:hover { border-color: var(--accent); }
.btn:active { transform: translateY(1px); }
.btn.tiny { font-size: 12px; padding: 5px 9px; font-family: var(--font-mono); }
.btn[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); font-weight: 600; }
.btn[disabled] { opacity: .45; cursor: not-allowed; }
.btn[disabled]:hover { border-color: var(--line); }
.btn.ok { background: var(--run-soft); border-color: var(--run); color: var(--run); font-weight: 600; }
@media (pointer: coarse) {
.btn { padding: 10px 14px; }
.btn.tiny { padding: 8px 11px; }
}
.row { display: flex; flex-wrap: wrap; gap: 7px; align-items: center; }
/* ---------- tabs ---------- */
.tabs { display: flex; gap: 4px; background: var(--sunk); border: 1px solid var(--line); border-radius: 9px; padding: 3px; }
.tab {
flex: 1 1 0; font: inherit; font-size: 13px; font-family: var(--font-mono);
background: transparent; color: var(--muted); border: 0; border-radius: 7px;
padding: 8px 6px; cursor: pointer; letter-spacing: .02em;
transition: background .12s ease, color .12s ease;
}
.tab:hover { color: var(--ink); }
.tab[aria-selected="true"] { background: var(--surface); color: var(--ink); font-weight: 600; box-shadow: 0 1px 2px rgba(0,0,0,.08); }
/* ---------- checkerboard (alpha) ---------- */
.checkerbg {
background-color: var(--surface);
background-image:
linear-gradient(45deg, var(--line) 25%, transparent 25%, transparent 75%, var(--line) 75%),
linear-gradient(45deg, var(--line) 25%, transparent 25%, transparent 75%, var(--line) 75%);
background-size: 16px 16px; background-position: 0 0, 8px 8px;
}
.checkersm { background-size: 8px 8px; background-position: 0 0, 4px 4px; }
/* ---------- preview ---------- */
.stage {
position: relative; border: 1px solid var(--line); border-radius: 9px; overflow: hidden;
height: clamp(190px, 38vh, 340px); touch-action: pan-y;
}
.stage.aim { cursor: crosshair; touch-action: none; }
.paint { position: absolute; inset: 0; }
.center-dot {
position: absolute; width: 26px; height: 26px; margin: -13px 0 0 -13px; padding: 0;
border-radius: 50%; border: 2px solid #fff; background: transparent; cursor: grab;
box-shadow: 0 0 0 1.5px rgba(0,0,0,.55), inset 0 0 0 1.5px rgba(0,0,0,.55);
touch-action: none;
}
.center-dot::after {
content: ""; position: absolute; inset: 9px; border-radius: 50%;
background: #fff; box-shadow: 0 0 0 1px rgba(0,0,0,.5);
}
.center-dot:active { cursor: grabbing; }
.stage-readout {
position: absolute; left: 8px; bottom: 8px; z-index: 3;
font-family: var(--font-mono); font-size: 11px; letter-spacing: .02em;
padding: 3px 7px; border-radius: 6px; pointer-events: none;
background: color-mix(in srgb, var(--surface) 86%, transparent);
border: 1px solid var(--line); color: var(--ink);
}
/* ---------- stop track ---------- */
.track {
position: relative; height: 42px; border: 1px solid var(--line); border-radius: 8px;
overflow: visible; margin: 4px 0 22px; touch-action: none; cursor: copy;
}
.track.full { cursor: not-allowed; }
.ramp { position: absolute; inset: 0; border-radius: 7px; }
.handles { position: absolute; inset: 0; pointer-events: none; }
.stop {
position: absolute; top: 50%; width: 22px; height: 30px; margin: -15px 0 0 -11px; padding: 2px;
border-radius: 6px; border: 1.5px solid var(--ink); background: var(--surface);
pointer-events: auto; cursor: grab; touch-action: none;
box-shadow: 0 1px 3px rgba(0,0,0,.28);
}
.stop::after {
content: ""; position: absolute; left: 50%; bottom: -7px; width: 2px; height: 6px;
margin-left: -1px; background: var(--ink);
}
.stop:active { cursor: grabbing; }
.stop.sel { border-color: var(--accent); border-width: 2.5px; transform: scale(1.08); z-index: 4; }
.stop-sw { display: block; width: 100%; height: 100%; border-radius: 3px; overflow: hidden; }
.stop-sw i { display: block; width: 100%; height: 100%; }
.ticks {
position: absolute; left: 0; right: 0; top: 100%; margin-top: 9px; height: 12px;
font-family: var(--font-mono); font-size: 10px; color: var(--muted);
pointer-events: none;
}
.ticks span { position: absolute; transform: translateX(-50%); white-space: nowrap; }
.ticks span:first-child { transform: none; }
.ticks span:last-child { transform: translateX(-100%); }
/* ---------- editor ---------- */
.editor { border-top: 1px dashed var(--line); margin-top: 14px; padding-top: 12px; }
.editor-head { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; margin-bottom: 10px; }
.big-sw {
width: 34px; height: 34px; border-radius: 8px; border: 1px solid var(--line);
flex: 0 0 auto; overflow: hidden;
}
.big-sw > i { display: block; width: 100%; height: 100%; }
.hexfield {
font: inherit; font-family: var(--font-mono); font-size: 13px; width: 10ch;
background: var(--field); color: var(--ink); border: 1px solid var(--line);
border-radius: 7px; padding: 6px 8px; text-transform: lowercase;
}
.hexfield.bad { border-color: var(--danger); }
.numfield {
font: inherit; font-family: var(--font-mono); font-size: 13px; width: 8ch;
background: var(--field); color: var(--ink); border: 1px solid var(--line);
border-radius: 7px; padding: 6px 8px;
}
.sliders { display: grid; gap: 7px; }
.sl { display: grid; grid-template-columns: 2.6ch 1fr 5ch; align-items: center; gap: 9px; }
.sl label { font-family: var(--font-mono); font-size: 11px; color: var(--muted); }
.sl output { font-family: var(--font-mono); font-size: 11.5px; color: var(--ink); text-align: right; }
input[type=range] {
-webkit-appearance: none; appearance: none; width: 100%; height: 20px;
background: transparent; cursor: pointer; margin: 0;
}
input[type=range]::-webkit-slider-runnable-track {
height: 12px; border-radius: 7px; border: 1px solid var(--line);
background-color: var(--field); background-image: var(--track, none);
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none; width: 16px; height: 16px; margin-top: -3px;
border-radius: 50%; background: var(--surface); border: 2px solid var(--knob);
box-shadow: 0 1px 2px rgba(0,0,0,.3);
}
input[type=range]::-moz-range-track {
height: 12px; border-radius: 7px; border: 1px solid var(--line);
background-color: var(--field); background-image: var(--track, none);
}
input[type=range]::-moz-range-thumb {
width: 14px; height: 14px; border-radius: 50%; background: var(--surface);
border: 2px solid var(--knob); box-shadow: 0 1px 2px rgba(0,0,0,.3);
}
/* The alpha slider is the one track whose left end means "see-through", so it
gets the checkerboard under the ramp instead of the flat field colour —
otherwise 0% alpha just reads as "white" (light) or "near-black" (dark). */
#sA::-webkit-slider-runnable-track {
background-color: var(--surface);
background-image: var(--track, none),
linear-gradient(45deg, var(--line) 25%, transparent 25%, transparent 75%, var(--line) 75%),
linear-gradient(45deg, var(--line) 25%, transparent 25%, transparent 75%, var(--line) 75%);
background-size: auto, 8px 8px, 8px 8px;
background-position: 0 0, 0 0, 4px 4px;
}
#sA::-moz-range-track {
background-color: var(--surface);
background-image: var(--track, none),
linear-gradient(45deg, var(--line) 25%, transparent 25%, transparent 75%, var(--line) 75%),
linear-gradient(45deg, var(--line) 25%, transparent 25%, transparent 75%, var(--line) 75%);
background-size: auto, 8px 8px, 8px 8px;
background-position: 0 0, 0 0, 4px 4px;
}
.chk { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; cursor: pointer; }
.chk input { width: 15px; height: 15px; accent-color: var(--accent); cursor: pointer; }
/* ---------- dial ---------- */
.dialwrap { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.dial { width: 118px; height: 118px; flex: 0 0 auto; touch-action: none; cursor: grab; border-radius: 50%; }
.dial:active { cursor: grabbing; }
.dial .face { fill: var(--field); stroke: var(--line); stroke-width: 1.5; }
.dial .tick { stroke: var(--line); stroke-width: 2; stroke-linecap: round; }
.dial .tick.major { stroke: var(--muted); }
.dial .needle { stroke: var(--accent); stroke-width: 3; stroke-linecap: round; }
.dial .knob { fill: var(--accent); stroke: var(--surface); stroke-width: 2; }
.dial .hub { fill: var(--muted); }
.dial:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.dialside { display: grid; gap: 8px; min-width: 0; flex: 1 1 176px; align-content: center; }
#angleChips { display: grid; grid-template-columns: repeat(3, auto); justify-content: start; gap: 5px; }
/* ---------- geometry field rows ---------- */
.field { display: grid; gap: 6px; margin-top: 11px; }
.field > .lbl {
font-family: var(--font-mono); font-size: 11px; letter-spacing: .07em;
text-transform: uppercase; color: var(--muted);
}
.seg { display: flex; flex-wrap: wrap; gap: 5px; }
.seg .btn { flex: 1 1 auto; }
/* ---------- presets ---------- */
.chips { display: grid; grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); gap: 8px; }
.chip {
display: grid; gap: 7px; padding: 7px; text-align: left; cursor: pointer;
background: var(--surface); border: 1px solid var(--line); border-radius: 9px;
font: inherit; font-size: 12.5px; color: var(--ink);
transition: border-color .12s ease, transform .08s ease;
}
.chip:hover { border-color: var(--accent); }
.chip:active { transform: translateY(1px); }
.chip .thumb {
height: 34px; border-radius: 6px; border: 1px solid var(--line); overflow: hidden;
}
.chip .thumb i { display: block; width: 100%; height: 100%; }
.chip .nm { line-height: 1.25; }
/* ---------- code ---------- */
.codebox {
background: var(--sunk); border: 1px solid var(--line); border-radius: 8px;
padding: 11px 12px; overflow-x: auto;
}
pre.code {
margin: 0; font-family: var(--font-mono); font-size: 12.5px; line-height: 1.65;
white-space: pre; color: var(--ink); tab-size: 2;
}
.t-prop { color: var(--muted); }
.t-fn { color: var(--accent); font-weight: 600; }
.t-num { color: var(--ink); }
.t-col { white-space: nowrap; }
.dot {
display: inline-block; width: 9px; height: 9px; border-radius: 2px; margin-right: 4px;
border: 1px solid var(--line); vertical-align: baseline; position: relative; top: 1px;
}
/* ---------- footer ---------- */
footer {
margin-top: 20px; padding-top: 12px; border-top: 1px solid var(--line);
font-family: var(--font-mono); font-size: 11px; color: var(--muted);
display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap;
}
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; animation: none !important; }
.btn:active, .chip:active { transform: none; }
.stop.sel { transform: none; }
}
@media (max-width: 380px) {
body { padding: 14px 11px 22px; }
.card { padding: 12px; }
.chips { grid-template-columns: repeat(auto-fill, minmax(112px, 1fr)); }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<p class="kicker">css · gradients</p>
<h1>Gradient Studio</h1>
<p class="lede">Drag the color stops, spin the dial, pick a shape — the panel repaints as you go and the
exact <span style="font-family:var(--font-mono)">background</span> line you would paste into a stylesheet
is written out below it.</p>
</header>
<div class="grid">
<!-- ============ LEFT ============ -->
<div class="col">
<section class="card" id="cardPreview">
<h2>Preview <span class="spacer"></span><span class="tag" id="typeTag">linear</span></h2>
<div class="stage checkerbg" id="stage">
<div class="paint" id="paint"></div>
<button class="center-dot" id="centerDot" type="button" hidden
aria-label="Gradient center position. Use the arrow keys to move it."></button>
<span class="stage-readout" id="stageOut">135deg</span>
</div>
<p class="hint" id="stageHint">Turn the dial to change the direction.</p>
</section>
<section class="card" id="cardStops">
<h2>Color stops <span class="tag" id="stopCount">3</span><span class="spacer"></span>
<span class="tag" id="trackTag">click the bar to add</span></h2>
<div class="track checkerbg" id="track">
<div class="ramp" id="ramp"></div>
<div class="handles" id="handles"></div>
<div class="ticks" aria-hidden="true"><span>0%</span><span style="left:50%">50%</span><span style="left:100%">100%</span></div>
</div>
<div class="row">
<button class="btn tiny" id="addStop">+ stop</button>
<button class="btn tiny" id="delStop">remove</button>
<button class="btn tiny" id="spread">even spacing</button>
<button class="btn tiny" id="reverse">reverse</button>
<button class="btn tiny" id="shuffle">surprise me</button>
</div>
<div class="editor">
<div class="editor-head">
<span class="big-sw checkerbg checkersm" aria-hidden="true"><i id="bigSw"></i></span>
<input class="hexfield" id="hex" type="text" spellcheck="false" autocomplete="off"
inputmode="text" aria-label="Hex color of the selected stop">
<label class="sr-only" for="posNum">Position of the selected stop, percent</label>
<input class="numfield" id="posNum" type="number" min="0" max="100" step="0.5">
<span class="tag">%</span>
<span class="tag" id="stopLabel">stop 1 / 3</span>
</div>
<div class="sliders">
<div class="sl"><label for="sH">H</label><input type="range" id="sH" min="0" max="360" step="1"><output id="oH">0</output></div>
<div class="sl"><label for="sS">S</label><input type="range" id="sS" min="0" max="100" step="1"><output id="oS">0</output></div>
<div class="sl"><label for="sL">L</label><input type="range" id="sL" min="0" max="100" step="1"><output id="oL">0</output></div>
<div class="sl"><label for="sA">A</label><input type="range" id="sA" min="0" max="100" step="1"><output id="oA">100</output></div>
</div>
</div>
</section>
<section class="card" id="cardCode">
<h2>Generated CSS <span class="spacer"></span>
<button class="btn tiny" id="copy">copy</button></h2>
<div class="codebox"><pre class="code" id="code"></pre></div>
<p class="hint">Paste this straight onto any element. Nothing else is needed.</p>
</section>
</div>
<!-- ============ RIGHT ============ -->
<div class="col">
<section class="card" id="cardShape">
<h2>Shape</h2>
<div class="tabs" role="tablist" aria-label="Gradient type">
<button class="tab" role="tab" id="tab-linear" data-type="linear" aria-selected="true" aria-controls="geo">linear</button>
<button class="tab" role="tab" id="tab-radial" data-type="radial" aria-selected="false" aria-controls="geo" tabindex="-1">radial</button>
<button class="tab" role="tab" id="tab-conic" data-type="conic" aria-selected="false" aria-controls="geo" tabindex="-1">conic</button>
</div>
<div id="geo" role="tabpanel" aria-labelledby="tab-linear">
<div class="field" id="angleField">
<span class="lbl" id="angleLbl">Direction</span>
<div class="dialwrap">
<svg class="dial" id="dial" viewBox="0 0 100 100" tabindex="0" role="slider"
aria-labelledby="angleLbl" aria-valuemin="0" aria-valuemax="359" aria-valuenow="135">
<circle class="face" cx="50" cy="50" r="40"></circle>
<g class="tick major"><line x1="50" y1="12" x2="50" y2="19"></line><line x1="88" y1="50" x2="81" y2="50"></line><line x1="50" y1="88" x2="50" y2="81"></line><line x1="12" y1="50" x2="19" y2="50"></line></g>
<g class="tick"><line x1="76.9" y1="23.1" x2="72.7" y2="27.3"></line><line x1="76.9" y1="76.9" x2="72.7" y2="72.7"></line><line x1="23.1" y1="76.9" x2="27.3" y2="72.7"></line><line x1="23.1" y1="23.1" x2="27.3" y2="27.3"></line></g>
<line class="needle" id="needle" x1="50" y1="50" x2="50" y2="18"></line>
<circle class="hub" cx="50" cy="50" r="3"></circle>
<circle class="knob" id="knob" cx="50" cy="18" r="6.5"></circle>
</svg>
<div class="dialside">
<div class="row">
<input class="numfield" id="angleNum" type="number" min="0" max="359" step="1" aria-label="Angle in degrees">
<span class="tag">deg</span>
</div>
<div class="row" id="angleChips"></div>
</div>
</div>
</div>
<div class="field" id="shapeField" hidden>
<span class="lbl">Shape</span>
<div class="seg" id="shapeSeg">
<button class="btn tiny" data-shape="circle" aria-pressed="true">circle</button>
<button class="btn tiny" data-shape="ellipse" aria-pressed="false">ellipse</button>
</div>
</div>
<div class="field" id="sizeField" hidden>
<span class="lbl">Reach</span>
<div class="seg" id="sizeSeg">
<button class="btn tiny" data-size="closest-side" aria-pressed="false">closest side</button>
<button class="btn tiny" data-size="closest-corner" aria-pressed="false">closest corner</button>
<button class="btn tiny" data-size="farthest-side" aria-pressed="false">farthest side</button>
<button class="btn tiny" data-size="farthest-corner" aria-pressed="true">farthest corner</button>
</div>
</div>
<div class="field" id="centerField" hidden>
<span class="lbl">Center</span>
<div class="row">
<input class="numfield" id="cxNum" type="number" min="0" max="100" step="1" aria-label="Center x, percent">
<span class="tag">x %</span>
<input class="numfield" id="cyNum" type="number" min="0" max="100" step="1" aria-label="Center y, percent">
<span class="tag">y %</span>
<button class="btn tiny" id="recenter">recenter</button>
</div>
</div>
<div class="field">
<span class="lbl">Tiling</span>
<div class="row">
<label class="chk"><input type="checkbox" id="repeat"> repeat the ramp</label>
</div>
<div class="sl" id="bandRow" hidden>
<label for="band" style="grid-column:1">band</label>
<input type="range" id="band" min="4" max="100" step="1">
<output id="oBand">25%</output>
</div>
</div>
<div class="field" id="interpField" hidden>
<span class="lbl">Blending</span>
<div class="seg" id="interpSeg">
<button class="btn tiny" data-interp="srgb" aria-pressed="true">srgb</button>
<button class="btn tiny" data-interp="oklab" aria-pressed="false">oklab</button>
<button class="btn tiny" data-interp="oklch" aria-pressed="false">oklch</button>
</div>
<p class="hint" id="interpHint">Plain sRGB blending — what a browser does by default.</p>
</div>
</div>
</section>
<section class="card" id="cardPresets">
<h2>Presets</h2>
<div class="chips" id="chips"></div>
</section>
</div>
</div>
<footer>
<span>demo · sloprun.dev</span>
<span id="engine">pure css output · no dependencies</span>
</footer>
</div>
<p class="sr-only" id="live" role="status" aria-live="polite"></p>
<script>
(function () {
"use strict";
/* ---------- platform theme bridge ---------- */
addEventListener("message", function (e) {
var d = e && e.data;
if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
document.documentElement.setAttribute("data-theme", d.theme);
}
});
var $ = function (id) { return document.getElementById(id); };
var clamp = function (v, a, b) { return v < a ? a : v > b ? b : v; };
var live = $("live");
var sayTimer = null;
function say(msg) {
clearTimeout(sayTimer);
sayTimer = setTimeout(function () { live.textContent = msg; }, 60);
}
/* ---------- color math ---------- */
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360; s = clamp(s, 0, 100) / 100; l = clamp(l, 0, 100) / 100;
var c = (1 - Math.abs(2 * l - 1)) * s;
var x = c * (1 - Math.abs((h / 60) % 2 - 1));
var m = l - c / 2, r = 0, g = 0, b = 0;
if (h < 60) { r = c; g = x; }
else if (h < 120) { r = x; g = c; }
else if (h < 180) { g = c; b = x; }
else if (h < 240) { g = x; b = c; }
else if (h < 300) { r = x; b = c; }
else { r = c; b = x; }
return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
}
function rgbToHsl(r, g, b) {
r /= 255; g /= 255; b /= 255;
var mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
var l = (mx + mn) / 2, h = 0, s = 0;
if (d) {
s = d / (1 - Math.abs(2 * l - 1));
if (mx === r) h = 60 * (((g - b) / d) % 6);
else if (mx === g) h = 60 * ((b - r) / d + 2);
else h = 60 * ((r - g) / d + 4);
}
return [((h % 360) + 360) % 360, clamp(s * 100, 0, 100), l * 100];
}
function hx(n) { n = clamp(Math.round(n), 0, 255); return (n < 16 ? "0" : "") + n.toString(16); }
function toHex(s) { var c = hslToRgb(s.h, s.s, s.l); return "#" + hx(c[0]) + hx(c[1]) + hx(c[2]); }
function parseHex(str) {
var m = String(str).trim().replace(/^#/, "");
if (/^[0-9a-fA-F]{3}$/.test(m)) m = m[0] + m[0] + m[1] + m[1] + m[2] + m[2];
if (!/^[0-9a-fA-F]{6}$/.test(m)) return null;
return [parseInt(m.slice(0, 2), 16), parseInt(m.slice(2, 4), 16), parseInt(m.slice(4, 6), 16)];
}
function cssColor(s) {
if (s.a >= 1) return toHex(s);
var c = hslToRgb(s.h, s.s, s.l);
return "rgb(" + c[0] + " " + c[1] + " " + c[2] + " / " + Math.round(s.a * 100) + "%)";
}
function fnum(n) { return String(Math.round(n * 10) / 10); }
/* ---------- state ---------- */
var MAX_STOPS = 12;
var uid = 0;
function mkStop(hexStr, pos, a) {
var rgb = parseHex(hexStr) || [128, 128, 128];
var h = rgbToHsl(rgb[0], rgb[1], rgb[2]);
return { id: "s" + (++uid), h: h[0], s: h[1], l: h[2], a: a == null ? 1 : a, pos: pos };
}
var state = {
type: "linear", angle: 135, from: 0,
shape: "circle", size: "farthest-corner", cx: 50, cy: 50,
repeat: false, band: 25, interp: "srgb",
stops: [], sel: null
};
/* ---------- presets ---------- */
var PRESETS = [
{ name: "Sunset Fade", type: "linear", angle: 160,
stops: [["#ffb347", 0], ["#e0567a", 52], ["#4b2c8f", 100]] },
{ name: "Deep Water", type: "linear", angle: 200,
stops: [["#023047", 0], ["#219ebc", 55], ["#8ecae6", 100]] },
{ name: "Peach Sorbet", type: "linear", angle: 135,
stops: [["#ffe6c9", 0], ["#ffb199", 48], ["#ff6f91", 100]] },
{ name: "Morning Mist", type: "linear", angle: 180,
stops: [["#f2f7f7", 0], ["#c9e4e7", 45], ["#8fb8c9", 100]] },
{ name: "Spotlight", type: "radial", shape: "circle", size: "closest-side", cx: 50, cy: 34,
stops: [["#fff6de", 0], ["#f0a202", 42], ["#25303f", 100]] },
{ name: "Color Wheel", type: "conic", from: 0, cx: 50, cy: 50,
stops: [["#ff4d4d", 0], ["#ffd93d", 17], ["#6bcB77", 33], ["#4d96ff", 50], ["#9b5de5", 67], ["#f15bb5", 83], ["#ff4d4d", 100]] },
{ name: "Brushed Steel", type: "linear", angle: 100, repeat: true, band: 9,
stops: [["#d9dde2", 0], ["#f4f6f8", 40], ["#aeb6c0", 100]] },
{ name: "Glass Fade", type: "linear", angle: 180,
stops: [["#ffffff", 0, 0.85], ["#ffffff", 100, 0]] }
];
function applyPreset(p) {
state.type = p.type;
state.angle = p.angle != null ? p.angle : 135;
state.from = p.from != null ? p.from : 0;
state.shape = p.shape || "circle";
state.size = p.size || "farthest-corner";
state.cx = p.cx != null ? p.cx : 50;
state.cy = p.cy != null ? p.cy : 50;
state.repeat = !!p.repeat;
state.band = p.band != null ? p.band : 25;
state.interp = p.interp || "srgb";
state.stops = p.stops.map(function (t) { return mkStop(t[0], t[1], t[2]); });
state.sel = state.stops[0].id;
syncControls();
render();
}
/* ---------- gradient strings ---------- */
function interpPart() { return state.interp === "srgb" ? "" : " in " + state.interp; }
function headParts() {
if (state.type === "linear") return fnum(state.angle) + "deg" + interpPart();
if (state.type === "radial") {
return state.shape + " " + state.size + " at " + fnum(state.cx) + "% " + fnum(state.cy) + "%" + interpPart();
}
return "from " + fnum(state.from) + "deg at " + fnum(state.cx) + "% " + fnum(state.cy) + "%" + interpPart();
}
function stopParts() {
var scale = state.repeat ? state.band / 100 : 1;
return state.stops.map(function (s) { return cssColor(s) + " " + fnum(s.pos * scale) + "%"; });
}
function fnName() { return (state.repeat ? "repeating-" : "") + state.type + "-gradient"; }
function gradientCss() { return fnName() + "(" + [headParts()].concat(stopParts()).join(", ") + ")"; }
function rampCss() {
var list = state.stops.map(function (s) { return cssColor(s) + " " + fnum(s.pos) + "%"; });
return "linear-gradient(90deg" + interpPart() + ", " + list.join(", ") + ")";
}
function codeText() {
var parts = [headParts()].concat(stopParts());
var one = "background: " + fnName() + "(" + parts.join(", ") + ");";
if (one.length <= 64) return one;
return "background: " + fnName() + "(\n " + parts.join(",\n ") + "\n);";
}
/* ---------- element refs ---------- */
var stage = $("stage"), paint = $("paint"), centerDot = $("centerDot"), stageOut = $("stageOut");
var track = $("track"), ramp = $("ramp"), handles = $("handles");
var code = $("code"), copyBtn = $("copy");
var hexIn = $("hex"), posNum = $("posNum"), bigSw = $("bigSw"), stopLabel = $("stopLabel"), stopCount = $("stopCount");
var sH = $("sH"), sS = $("sS"), sL = $("sL"), sA = $("sA");
var oH = $("oH"), oS = $("oS"), oL = $("oL"), oA = $("oA");
var dial = $("dial"), needle = $("needle"), knob = $("knob"), angleNum = $("angleNum");
var lastCode = "";
/* ---------- selection helpers ---------- */
function selIndex() {
for (var i = 0; i < state.stops.length; i++) if (state.stops[i].id === state.sel) return i;
return 0;
}
function selStop() { return state.stops[selIndex()]; }
function sortStops() {
state.stops.sort(function (a, b) { return a.pos - b.pos; });
}
/* ---------- stop handles ---------- */
var handleEls = {};
var dragId = null;
function posFromX(clientX) {
var r = ramp.getBoundingClientRect();
if (!r.width) return 0;
return clamp((clientX - r.left) / r.width * 100, 0, 100);
}
function setPos(id, pos, snap) {
var st = null;
for (var i = 0; i < state.stops.length; i++) if (state.stops[i].id === id) st = state.stops[i];
if (!st) return;
if (snap) pos = Math.round(pos / 5) * 5;
st.pos = clamp(Math.round(pos * 10) / 10, 0, 100);
sortStops();
render();
}
function bindHandle(el, id) {
el.addEventListener("pointerdown", function (e) {
e.preventDefault(); e.stopPropagation();
state.sel = id; dragId = id;
try { el.setPointerCapture(e.pointerId); } catch (_) {}
el.focus();
render();
});
el.addEventListener("pointermove", function (e) {
if (dragId !== id) return;
setPos(id, posFromX(e.clientX), e.shiftKey);
});
function end(e) {
if (dragId !== id) return;
dragId = null;
try { el.releasePointerCapture(e.pointerId); } catch (_) {}
}
el.addEventListener("pointerup", end);
el.addEventListener("pointercancel", end);
el.addEventListener("keydown", function (e) {
var st = null, i;
for (i = 0; i < state.stops.length; i++) if (state.stops[i].id === id) st = state.stops[i];
if (!st) return;
var step = e.shiftKey ? 10 : 1, k = e.key, done = true;
if (k === "ArrowLeft" || k === "ArrowDown") setPos(id, st.pos - step, false);
else if (k === "ArrowRight" || k === "ArrowUp") setPos(id, st.pos + step, false);
else if (k === "Home") setPos(id, 0, false);
else if (k === "End") setPos(id, 100, false);
else if (k === "Delete" || k === "Backspace") { e.preventDefault(); removeStop(id); return; }
else done = false;
if (done) { e.preventDefault(); state.sel = id; render(); }
});
el.addEventListener("focus", function () {
if (state.sel !== id) { state.sel = id; render(); }
});
}
function renderHandles() {
var seen = {};
state.stops.forEach(function (s, i) {
seen[s.id] = 1;
var el = handleEls[s.id];
if (!el) {
el = document.createElement("button");
el.className = "stop"; el.type = "button";
el.setAttribute("role", "slider");
el.setAttribute("aria-valuemin", "0");
el.setAttribute("aria-valuemax", "100");
var sw = document.createElement("span");
sw.className = "stop-sw checkerbg checkersm";
sw.appendChild(document.createElement("i"));
el.appendChild(sw);
handles.appendChild(el);
handleEls[s.id] = el;
bindHandle(el, s.id);
}
el.style.left = s.pos + "%";
el.firstChild.firstChild.style.background = cssColor(s);
el.setAttribute("aria-valuenow", String(Math.round(s.pos)));
el.setAttribute("aria-valuetext", Math.round(s.pos) + " percent, " + toHex(s));
el.setAttribute("aria-label", "Color stop " + (i + 1) + " of " + state.stops.length);
el.classList.toggle("sel", s.id === state.sel);
});
Object.keys(handleEls).forEach(function (k) {
if (!seen[k]) {
var el = handleEls[k];
if (el.parentNode) el.parentNode.removeChild(el);
delete handleEls[k];
}
});
}
function colorAt(pos) {
var st = state.stops;
if (!st.length) return { h: 0, s: 0, l: 50, a: 1 };
if (pos <= st[0].pos) return { h: st[0].h, s: st[0].s, l: st[0].l, a: st[0].a };
var lastS = st[st.length - 1];
if (pos >= lastS.pos) return { h: lastS.h, s: lastS.s, l: lastS.l, a: lastS.a };
for (var i = 0; i < st.length - 1; i++) {
var a = st[i], b = st[i + 1];
if (pos >= a.pos && pos <= b.pos) {
var span = b.pos - a.pos;
var t = span ? (pos - a.pos) / span : 0;
var ca = hslToRgb(a.h, a.s, a.l), cb = hslToRgb(b.h, b.s, b.l);
var mix = rgbToHsl(
ca[0] + (cb[0] - ca[0]) * t,
ca[1] + (cb[1] - ca[1]) * t,
ca[2] + (cb[2] - ca[2]) * t
);
return { h: mix[0], s: mix[1], l: mix[2], a: a.a + (b.a - a.a) * t };
}
}
return { h: st[0].h, s: st[0].s, l: st[0].l, a: st[0].a };
}
function addStopAt(pos) {
if (state.stops.length >= MAX_STOPS) {
say("That is the limit — " + MAX_STOPS + " stops. Remove one to add another.");
return null;
}
pos = clamp(Math.round(pos * 10) / 10, 0, 100);
var c = colorAt(pos);
var st = { id: "s" + (++uid), h: c.h, s: c.s, l: c.l, a: c.a, pos: pos };
state.stops.push(st);
sortStops();
state.sel = st.id;
render();
say("Stop added at " + Math.round(pos) + " percent. " + state.stops.length + " stops.");
return st;
}
function removeStop(id) {
if (state.stops.length <= 2) { say("Two stops is the minimum."); return; }
var i = -1, j;
for (j = 0; j < state.stops.length; j++) if (state.stops[j].id === id) i = j;
if (i < 0) return;
state.stops.splice(i, 1);
state.sel = state.stops[Math.min(i, state.stops.length - 1)].id;
render();
var el = handleEls[state.sel];
if (el) el.focus();
say("Stop removed. " + state.stops.length + " stops left.");
}
track.addEventListener("pointerdown", function (e) {
if (e.target.closest && e.target.closest(".stop")) return;
e.preventDefault();
var st = addStopAt(posFromX(e.clientX));
if (!st) return;
var el = handleEls[st.id];
if (el) {
el.focus();
dragId = st.id;
try { el.setPointerCapture(e.pointerId); } catch (_) {}
}
});
/* ---------- editor ---------- */
function setTrack(el, img) { el.style.setProperty("--track", img); }
function renderEditor() {
var s = selStop();
if (!s) return;
var hexStr = toHex(s);
bigSw.style.background = cssColor(s);
stopLabel.textContent = "stop " + (selIndex() + 1) + " / " + state.stops.length;
stopCount.textContent = String(state.stops.length);
if (document.activeElement !== hexIn) {
hexIn.value = hexStr;
hexIn.classList.remove("bad");
hexIn.removeAttribute("aria-invalid");
}
if (document.activeElement !== posNum) posNum.value = String(Math.round(s.pos * 10) / 10);
if (document.activeElement !== sH) sH.value = String(Math.round(s.h));
if (document.activeElement !== sS) sS.value = String(Math.round(s.s));
if (document.activeElement !== sL) sL.value = String(Math.round(s.l));
if (document.activeElement !== sA) sA.value = String(Math.round(s.a * 100));
oH.textContent = Math.round(s.h) + "°";
oS.textContent = Math.round(s.s) + "%";
oL.textContent = Math.round(s.l) + "%";
oA.textContent = Math.round(s.a * 100) + "%";
setTrack(sH, "linear-gradient(90deg,#ff0000,#ffff00,#00ff00,#00ffff,#0000ff,#ff00ff,#ff0000)");
setTrack(sS, "linear-gradient(90deg," + toHex({ h: s.h, s: 0, l: s.l }) + "," + toHex({ h: s.h, s: 100, l: s.l }) + ")");
setTrack(sL, "linear-gradient(90deg,#000000," + toHex({ h: s.h, s: s.s, l: 50 }) + ",#ffffff)");
setTrack(sA, "linear-gradient(90deg,rgb(255 255 255 / 0)," + toHex(s) + ")");
var full = state.stops.length >= MAX_STOPS;
$("delStop").disabled = state.stops.length <= 2;
$("addStop").disabled = full;
track.classList.toggle("full", full);
$("trackTag").textContent = full ? "limit " + MAX_STOPS + " stops" : "click the bar to add";
}
[["sH", "h", 1], ["sS", "s", 1], ["sL", "l", 1], ["sA", "a", 0.01]].forEach(function (t) {
$(t[0]).addEventListener("input", function () {
var s = selStop(); if (!s) return;
s[t[1]] = parseFloat(this.value) * t[2];
render();
});
});
hexIn.addEventListener("input", function () {
var rgb = parseHex(this.value);
if (!rgb) { this.classList.add("bad"); this.setAttribute("aria-invalid", "true"); return; }
this.classList.remove("bad");
this.removeAttribute("aria-invalid");
var s = selStop(); if (!s) return;
var h = rgbToHsl(rgb[0], rgb[1], rgb[2]);
s.h = h[0]; s.s = h[1]; s.l = h[2];
render();
});
hexIn.addEventListener("blur", function () {
this.classList.remove("bad");
this.removeAttribute("aria-invalid");
renderEditor();
});
posNum.addEventListener("input", function () {
var v = parseFloat(this.value);
if (isNaN(v)) return;
var s = selStop(); if (!s) return;
s.pos = clamp(v, 0, 100);
sortStops();
render();
});
/* Number fields are only written back while they are NOT focused, so a field
left holding an out-of-range or empty string (typed "500", cleared it) has
to be re-synced from state the moment focus leaves it. */
["posNum", "angleNum", "cxNum", "cyNum"].forEach(function (id) {
$(id).addEventListener("blur", function () { render(); });
});
$("addStop").addEventListener("click", function () {
var gap = 0, at = 50, i;
for (i = 0; i < state.stops.length - 1; i++) {
var d = state.stops[i + 1].pos - state.stops[i].pos;
if (d > gap) { gap = d; at = state.stops[i].pos + d / 2; }
}
var st = addStopAt(at);
if (!st) return;
var el = handleEls[st.id];
if (el) el.focus();
});
$("delStop").addEventListener("click", function () { removeStop(state.sel); });
$("spread").addEventListener("click", function () {
var n = state.stops.length;
state.stops.forEach(function (s, i) { s.pos = n === 1 ? 50 : Math.round(i / (n - 1) * 1000) / 10; });
render(); say("Stops spaced evenly.");
});
$("reverse").addEventListener("click", function () {
var cols = state.stops.map(function (s) { return { h: s.h, s: s.s, l: s.l, a: s.a }; }).reverse();
state.stops.forEach(function (s, i) { s.h = cols[i].h; s.s = cols[i].s; s.l = cols[i].l; s.a = cols[i].a; });
render(); say("Colors reversed.");
});
/* ---------- surprise me ---------- */
function rnd(a, b) { return a + Math.random() * (b - a); }
$("shuffle").addEventListener("click", function () {
var base = rnd(0, 360);
var spreadH = [18, 40, 70, 150, 180][Math.floor(rnd(0, 5))];
var dir = Math.random() < 0.5 ? -1 : 1;
var n = Math.random() < 0.45 ? 2 : 3;
var stops = [], i;
for (i = 0; i < n; i++) {
var t = n === 1 ? 0 : i / (n - 1);
var s = {
id: "s" + (++uid),
h: (((base + dir * spreadH * t) % 360) + 360) % 360,
s: rnd(52, 88),
l: rnd(70, 78) - t * rnd(22, 44),
a: 1,
pos: Math.round(t * 100)
};
stops.push(s);
}
state.stops = stops;
state.sel = stops[0].id;
var roll = Math.random();
state.type = roll < 0.68 ? "linear" : roll < 0.9 ? "radial" : "conic";
state.angle = (Math.round(rnd(0, 24)) * 15) % 360;
state.from = (Math.round(rnd(0, 24)) * 15) % 360;
state.cx = Math.round(rnd(25, 75)); state.cy = Math.round(rnd(25, 75));
state.repeat = false;
syncControls();
render();
say("New gradient rolled: " + state.type + ", " + n + " stops.");
});
/* ---------- type tabs ---------- */
var tabs = Array.prototype.slice.call(document.querySelectorAll(".tab"));
function setType(t, focus) {
state.type = t;
syncControls();
render();
if (focus) {
var el = document.getElementById("tab-" + t);
if (el) el.focus();
}
say(t + " gradient");
}
tabs.forEach(function (tab, i) {
tab.addEventListener("click", function () { setType(tab.dataset.type, false); });
tab.addEventListener("keydown", function (e) {
var k = e.key, j = -1;
if (k === "ArrowRight") j = (i + 1) % tabs.length;
else if (k === "ArrowLeft") j = (i - 1 + tabs.length) % tabs.length;
else if (k === "Home") j = 0;
else if (k === "End") j = tabs.length - 1;
if (j >= 0) { e.preventDefault(); setType(tabs[j].dataset.type, true); }
});
});
/* ---------- dial ---------- */
function dialValue() { return state.type === "conic" ? state.from : state.angle; }
function setDial(v, snap) {
v = ((Math.round(v) % 360) + 360) % 360;
if (snap) v = (Math.round(v / 15) * 15) % 360;
if (state.type === "conic") state.from = v; else state.angle = v;
render();
}
function dialFromPoint(cx0, cy0) {
var r = dial.getBoundingClientRect();
var dx = cx0 - (r.left + r.width / 2);
var dy = cy0 - (r.top + r.height / 2);
return (Math.atan2(dx, -dy) * 180 / Math.PI + 360) % 360;
}
var dialDrag = false;
dial.addEventListener("pointerdown", function (e) {
e.preventDefault(); dialDrag = true;
try { dial.setPointerCapture(e.pointerId); } catch (_) {}
dial.focus();
setDial(dialFromPoint(e.clientX, e.clientY), e.shiftKey);
});
dial.addEventListener("pointermove", function (e) {
if (!dialDrag) return;
setDial(dialFromPoint(e.clientX, e.clientY), e.shiftKey);
});
function dialEnd(e) {
if (!dialDrag) return;
dialDrag = false;
try { dial.releasePointerCapture(e.pointerId); } catch (_) {}
}
dial.addEventListener("pointerup", dialEnd);
dial.addEventListener("pointercancel", dialEnd);
dial.addEventListener("keydown", function (e) {
var step = e.shiftKey ? 15 : 1, k = e.key, hit = true;
if (k === "ArrowLeft" || k === "ArrowDown") setDial(dialValue() - step, false);
else if (k === "ArrowRight" || k === "ArrowUp") setDial(dialValue() + step, false);
else if (k === "Home") setDial(0, false);
else if (k === "PageUp") setDial(dialValue() + 45, false);
else if (k === "PageDown") setDial(dialValue() - 45, false);
else hit = false;
if (hit) e.preventDefault();
});
angleNum.addEventListener("input", function () {
var v = parseFloat(this.value);
if (isNaN(v)) return;
if (state.type === "conic") state.from = ((Math.round(v) % 360) + 360) % 360;
else state.angle = ((Math.round(v) % 360) + 360) % 360;
render();
});
[0, 45, 90, 135, 180, 270].forEach(function (a) {
var b = document.createElement("button");
b.className = "btn tiny"; b.type = "button"; b.textContent = a + "°";
b.addEventListener("click", function () { setDial(a, false); });
$("angleChips").appendChild(b);
});
function renderDial() {
var v = dialValue();
var rad = v * Math.PI / 180;
var kx = 50 + 32 * Math.sin(rad), ky = 50 - 32 * Math.cos(rad);
needle.setAttribute("x2", kx.toFixed(2));
needle.setAttribute("y2", ky.toFixed(2));
knob.setAttribute("cx", kx.toFixed(2));
knob.setAttribute("cy", ky.toFixed(2));
dial.setAttribute("aria-valuenow", String(Math.round(v)));
dial.setAttribute("aria-valuetext", Math.round(v) + " degrees");
if (document.activeElement !== angleNum) angleNum.value = String(Math.round(v));
}
/* ---------- center pad on the preview ---------- */
function setCenterFromPoint(clientX, clientY) {
var r = stage.getBoundingClientRect();
if (!r.width || !r.height) return;
state.cx = clamp(Math.round((clientX - r.left) / r.width * 100), 0, 100);
state.cy = clamp(Math.round((clientY - r.top) / r.height * 100), 0, 100);
render();
}
var centerDrag = false;
function startCenter(e) {
if (state.type === "linear") return;
e.preventDefault();
centerDrag = true;
try { stage.setPointerCapture(e.pointerId); } catch (_) {}
setCenterFromPoint(e.clientX, e.clientY);
}
stage.addEventListener("pointerdown", startCenter);
stage.addEventListener("pointermove", function (e) {
if (!centerDrag) return;
setCenterFromPoint(e.clientX, e.clientY);
});
function centerEnd(e) {
if (!centerDrag) return;
centerDrag = false;
try { stage.releasePointerCapture(e.pointerId); } catch (_) {}
say("Center at " + state.cx + ", " + state.cy + " percent.");
}
stage.addEventListener("pointerup", centerEnd);
stage.addEventListener("pointercancel", centerEnd);
centerDot.addEventListener("keydown", function (e) {
var step = e.shiftKey ? 10 : 1, k = e.key, hit = true;
if (k === "ArrowLeft") state.cx = clamp(state.cx - step, 0, 100);
else if (k === "ArrowRight") state.cx = clamp(state.cx + step, 0, 100);
else if (k === "ArrowUp") state.cy = clamp(state.cy - step, 0, 100);
else if (k === "ArrowDown") state.cy = clamp(state.cy + step, 0, 100);
else if (k === "Home") { state.cx = 50; state.cy = 50; }
else hit = false;
if (hit) { e.preventDefault(); render(); }
});
$("cxNum").addEventListener("input", function () {
var v = parseFloat(this.value); if (isNaN(v)) return;
state.cx = clamp(Math.round(v), 0, 100); render();
});
$("cyNum").addEventListener("input", function () {
var v = parseFloat(this.value); if (isNaN(v)) return;
state.cy = clamp(Math.round(v), 0, 100); render();
});
$("recenter").addEventListener("click", function () {
state.cx = 50; state.cy = 50; render(); say("Center reset to the middle.");
});
/* ---------- shape / size / blending segments ---------- */
function bindSeg(wrapId, key, attr, after) {
var wrap = $(wrapId);
wrap.addEventListener("click", function (e) {
var b = e.target.closest("button");
if (!b || !b.dataset[attr]) return;
state[key] = b.dataset[attr];
syncControls();
render();
if (after) after();
});
}
bindSeg("shapeSeg", "shape", "shape");
bindSeg("sizeSeg", "size", "size");
var INTERP_HINT = {
srgb: "Plain sRGB blending — what a browser does by default.",
oklab: "Perceptual blending: no muddy grey band in the middle.",
oklch: "Perceptual, and it travels around the hue wheel."
};
bindSeg("interpSeg", "interp", "interp", function () {
say("Blending set to " + state.interp);
});
$("repeat").addEventListener("change", function () {
state.repeat = this.checked;
syncControls();
render();
say(state.repeat ? "Ramp repeats every " + state.band + " percent." : "Repeat off.");
});
$("band").addEventListener("input", function () {
state.band = clamp(parseInt(this.value, 10) || 25, 4, 100);
render();
});
/* ---------- copy ---------- */
var copyTimer = null;
function copyResult(good) {
clearTimeout(copyTimer);
copyBtn.classList.toggle("ok", good);
copyBtn.textContent = good ? "copied ✓" : "select + copy";
say(good ? "CSS copied to the clipboard." : "Clipboard is blocked here — the code is selected, press control or command C.");
copyTimer = setTimeout(function () {
copyBtn.classList.remove("ok");
copyBtn.textContent = "copy";
}, 1800);
}
function selectCode() {
try {
var sel = window.getSelection();
var range = document.createRange();
range.selectNodeContents(code);
sel.removeAllRanges();
sel.addRange(range);
} catch (_) {}
}
function fallbackCopy() {
var good = false;
try {
var ta = document.createElement("textarea");
ta.value = lastCode;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.top = "-1000px";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
good = document.execCommand("copy");
document.body.removeChild(ta);
} catch (_) { good = false; }
if (!good) selectCode();
copyResult(!!good);
}
copyBtn.addEventListener("click", function () {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(lastCode).then(function () { copyResult(true); }, fallbackCopy);
return;
}
} catch (_) {}
fallbackCopy();
});
/* ---------- code block ---------- */
var TOK = /(#[0-9a-fA-F]{6}\b|rgb\([^)]*\)|-?\d+(?:\.\d+)?(?:deg|%)|\b(?:repeating-)?(?:linear|radial|conic)-gradient\b|\bbackground\b)/g;
function renderCode() {
var txt = codeText();
lastCode = txt;
while (code.firstChild) code.removeChild(code.firstChild);
var i = 0, m;
TOK.lastIndex = 0;
while ((m = TOK.exec(txt)) !== null) {
if (m.index > i) code.appendChild(document.createTextNode(txt.slice(i, m.index)));
var t = m[0], sp = document.createElement("span");
if (t.charAt(0) === "#" || t.indexOf("rgb(") === 0) {
sp.className = "t-col";
var dot = document.createElement("i");
dot.className = "dot";
dot.style.background = t;
sp.appendChild(dot);
sp.appendChild(document.createTextNode(t));
} else if (/gradient$/.test(t)) { sp.className = "t-fn"; sp.textContent = t; }
else if (t === "background") { sp.className = "t-prop"; sp.textContent = t; }
else { sp.className = "t-num"; sp.textContent = t; }
code.appendChild(sp);
i = m.index + t.length;
}
if (i < txt.length) code.appendChild(document.createTextNode(txt.slice(i)));
}
/* ---------- control visibility ---------- */
function syncControls() {
tabs.forEach(function (t) {
var on = t.dataset.type === state.type;
t.setAttribute("aria-selected", on ? "true" : "false");
t.tabIndex = on ? 0 : -1;
});
$("geo").setAttribute("aria-labelledby", "tab-" + state.type);
var isLinear = state.type === "linear";
var isRadial = state.type === "radial";
var isConic = state.type === "conic";
$("angleField").hidden = isRadial;
$("angleLbl").textContent = isConic ? "Start angle" : "Direction";
$("shapeField").hidden = !isRadial;
$("sizeField").hidden = !isRadial;
$("centerField").hidden = isLinear;
$("bandRow").hidden = !state.repeat;
$("repeat").checked = state.repeat;
$("band").value = String(state.band);
$("oBand").textContent = state.band + "%";
Array.prototype.forEach.call($("shapeSeg").children, function (b) {
b.setAttribute("aria-pressed", b.dataset.shape === state.shape ? "true" : "false");
});
Array.prototype.forEach.call($("sizeSeg").children, function (b) {
b.setAttribute("aria-pressed", b.dataset.size === state.size ? "true" : "false");
});
Array.prototype.forEach.call($("interpSeg").children, function (b) {
b.setAttribute("aria-pressed", b.dataset.interp === state.interp ? "true" : "false");
});
$("interpHint").textContent = INTERP_HINT[state.interp] || INTERP_HINT.srgb;
centerDot.hidden = isLinear;
stage.classList.toggle("aim", !isLinear);
centerDot.tabIndex = isLinear ? -1 : 0;
$("stageHint").textContent = isLinear
? "Turn the dial to change the direction. Click the bar below to drop in another color stop."
: "Drag anywhere on the panel to move the center, or focus the ring and use the arrow keys.";
/* If the control that had keyboard focus just got hidden (e.g. the dial when
switching to radial), park focus on the active tab instead of dropping it
on <body>. */
var act = document.activeElement;
if (act && act.closest && act.closest("#geo") && act.closest("[hidden]")) {
var t = document.getElementById("tab-" + state.type);
if (t) t.focus();
}
}
/* ---------- master render ---------- */
function render() {
var g = gradientCss();
paint.style.backgroundImage = g;
ramp.style.backgroundImage = rampCss();
renderHandles();
renderEditor();
renderDial();
renderCode();
centerDot.style.left = state.cx + "%";
centerDot.style.top = state.cy + "%";
if (document.activeElement !== $("cxNum")) $("cxNum").value = String(state.cx);
if (document.activeElement !== $("cyNum")) $("cyNum").value = String(state.cy);
$("typeTag").textContent = (state.repeat ? "repeating " : "") + state.type;
stageOut.textContent = state.type === "linear"
? Math.round(state.angle) + "deg"
: state.type === "conic"
? "from " + Math.round(state.from) + "deg · " + state.cx + "% " + state.cy + "%"
: state.shape + " · " + state.cx + "% " + state.cy + "%";
$("oBand").textContent = state.band + "%";
}
/* ---------- presets ui ----------
The thumbnail paints the preset's REAL gradient function, not a linear
stand-in, so a radial or conic chip previews what clicking it will do. */
function presetCss(p) {
var num = function (v, d) { return v != null ? v : d; };
var head;
if (p.type === "linear") head = num(p.angle, 135) + "deg";
else if (p.type === "radial") {
head = (p.shape || "circle") + " " + (p.size || "farthest-corner") +
" at " + num(p.cx, 50) + "% " + num(p.cy, 50) + "%";
} else {
head = "from " + num(p.from, 0) + "deg at " + num(p.cx, 50) + "% " + num(p.cy, 50) + "%";
}
var sc = p.repeat ? num(p.band, 25) / 100 : 1;
var list = p.stops.map(function (t) {
var rgb = parseHex(t[0]) || [0, 0, 0];
var a = t[2] == null ? 1 : t[2];
var col = a >= 1 ? t[0] : "rgb(" + rgb[0] + " " + rgb[1] + " " + rgb[2] + " / " + Math.round(a * 100) + "%)";
return col + " " + fnum(t[1] * sc) + "%";
});
return (p.repeat ? "repeating-" : "") + p.type + "-gradient(" +
[head].concat(list).join(", ") + ")";
}
PRESETS.forEach(function (p) {
var b = document.createElement("button");
b.className = "chip"; b.type = "button";
var th = document.createElement("span");
th.className = "thumb checkerbg checkersm";
var thi = document.createElement("i");
thi.style.backgroundImage = presetCss(p);
th.appendChild(thi);
var nm = document.createElement("span");
nm.className = "nm";
nm.textContent = p.name;
b.appendChild(th); b.appendChild(nm);
b.addEventListener("click", function () { applyPreset(p); say(p.name + " loaded."); });
$("chips").appendChild(b);
});
/* ---------- blending support probe ----------
Only offer the blending row if every shape we can generate accepts the
`in <space>` keyword, so no combination of controls can produce a line the
browser silently drops. */
function okGrad(s) {
return !!(window.CSS && CSS.supports && CSS.supports("background-image", s));
}
var canInterp =
okGrad("linear-gradient(90deg in oklab, red, blue)") &&
okGrad("radial-gradient(circle farthest-corner at 50% 50% in oklab, red, blue)") &&
okGrad("conic-gradient(from 0deg at 50% 50% in oklab, red, blue)") &&
okGrad("repeating-linear-gradient(90deg in oklch, red, blue 25%)");
if (canInterp) $("interpField").hidden = false;
/* ---------- boot ---------- */
applyPreset(PRESETS[0]);
window.addEventListener("resize", function () { /* percentages handle it; keep readouts fresh */ renderHandles(); });
})();
</script>
</body>
</html>
source-visible by construction · nothing is published here without its code
Comments
0 totalNo comments yet. If you ran it, say what happened.