A Tiny CSS Gradient Designer in 200 Lines — And Why Sorting Happens at Output Time
Writing CSS gradients by hand is a tight feedback loop of "save, reload, tweak, save, reload." A visual editor collapses that loop to zero. I wrote a small one — linear and radial, multi-stop, drag-to-reorder, copy-paste-ready CSS — and the one small design decision worth writing about is when to sort the color stops.
Most CSS gradient designers are either bloated or locked behind a design-tool paywall. I wanted a tiny, self-contained one I could keep open in a tab. Linear and radial, multi-stop with drag handles, color picker per stop, instant preview, one-click copyable CSS.
🔗 Live demo: https://sen.ltd/portfolio/gradient-designer/
📦 GitHub: https://github.com/sen-ltd/gradient-designer
- Linear / radial toggle
- Angle (linear) or shape (circle / ellipse, radial)
- Multiple color stops, addable / removable / draggable
- Live preview on a large color surface
- Copy-to-clipboard CSS output
- Five starting presets (Sunset / Ocean / Forest / Spotlight / Mono)
Vanilla JS, zero deps, no build. Core logic is 90 lines; presets are another 60.
buildGradient(config) is 15 lines
export function buildGradient(config) {
if (!config || !Array.isArray(config.stops) || config.stops.length < 2) {
return 'none'
}
const stops = config.stops
.slice()
.sort((a, b) => a.position - b.position)
.map((s) => `${s.color} ${s.position}%`)
.join(', ')
if (config.type === 'radial') {
const shape = config.shape === 'circle' ? 'circle' : 'ellipse'
return `radial-gradient(${shape}, ${stops})`
}
const angle = Number.isFinite(config.angle) ? config.angle : 90
return `linear-gradient(${angle}deg, ${stops})`
}
Three things worth noting:
-
.slice()before.sort()— never mutate the input array. The UI ownsconfig.stopsand needs to preserve insertion order; this function only sorts for the purpose of output. -
Fewer than two stops returns
'none'— a single-color "gradient" is not a gradient, so output the CSS keyword that makes the preview go blank. - Default angle is 90deg (left-to-right) if none is provided, matching the most common user expectation.
And that's it. Building CSS gradients is trivial because the grammar is regular — the asymmetry with cron parsing (hard) vs. cron building (easy) shows up here too.
Sorting at output time, not on mutation
There's a subtle decision embedded in buildGradient: stops are sorted during output, not when they're added or moved in the UI.
The UI lets users drag stop handles around freely. If you sorted the array every time the user moved a handle, the array would reorder mid-drag and the handle the user is currently holding would get a new index. Drag-and-drop UX breaks immediately.
Solution: keep the internal state in insertion order, and only sort when generating the CSS string. The UI sees stable indices for drags; the output sees the correct positional order for CSS. Two independent representations of the same data, optimized for different operations.
This "different internal representation from output representation" pattern is one I keep reaching for in small UIs. Mutation-based reactive systems (Svelte, MobX, etc.) nudge you toward conflating these, and then you run into mysterious UI state bugs. Keeping a clean separation removes the class of bug entirely.
Live preview is one assignment
The entire preview mechanism:
function update() {
const css = buildGradient(state.config)
$('preview').style.background = css
$('output').textContent = `background: ${css};`
}
Every UI event (color change, position change, angle change, stop add/remove) calls update(). The preview div's style.background updates, the output textarea's content updates, the browser repaints. No virtual DOM, no reconciliation, no dependency tracking.
For this size of tool, a framework is overhead, not help. The single-function update pattern is perfectly maintainable at this scale and literally can't have a hydration bug.
Presets as editable starting points
The five presets are just pre-built configs:
export const PRESETS = [
{
name: 'Sunset',
config: {
type: 'linear',
angle: 135,
stops: [
{ color: '#ff6b6b', position: 0 },
{ color: '#f5c26b', position: 50 },
{ color: '#c5a3ff', position: 100 },
],
},
},
// Ocean / Forest / Spotlight / Mono
]
Clicking a preset does state.config = structuredClone(preset.config) and re-renders. The user can then tweak freely — the preset is the starting point, not the final product. This is a deliberate choice: most users want to start from "something that works" and tweak, not from an empty canvas.
Spotlight demonstrates radial gradients (without it, the radial option feels hidden); Mono gives a monochrome baseline so you can see what a grayscale gradient looks like.
Copy button with background: prefix baked in
$('copy').addEventListener('click', async () => {
const css = buildGradient(state.config)
await navigator.clipboard.writeText(`background: ${css};`)
showToast('CSS copied')
})
Note the clipboard contains background: linear-gradient(...);, not just linear-gradient(...). The expected paste destination is a CSS file or dev tools, and in both cases you want the complete property assignment. A small UX win — it saves one manual keystroke, and the paste is ready-to-run.
Tests
10 cases on node --test:
test('basic linear gradient', () => {
const css = buildGradient({
type: 'linear',
angle: 90,
stops: [
{ color: '#000', position: 0 },
{ color: '#fff', position: 100 },
],
})
assert.equal(css, 'linear-gradient(90deg, #000 0%, #fff 100%)')
})
test('radial circle', () => {
const css = buildGradient({
type: 'radial',
shape: 'circle',
stops: [
{ color: '#fff', position: 0 },
{ color: '#000', position: 100 },
],
})
assert.equal(css, 'radial-gradient(circle, #fff 0%, #000 100%)')
})
test('stops are sorted by position', () => {
const css = buildGradient({
type: 'linear',
angle: 0,
stops: [
{ color: '#fff', position: 100 },
{ color: '#000', position: 0 },
],
})
assert.ok(css.indexOf('#000 0%') < css.indexOf('#fff 100%'))
})
test('less than 2 stops returns none', () => {
const css = buildGradient({ type: 'linear', angle: 0, stops: [] })
assert.equal(css, 'none')
})
The "stops are sorted" test uses indexOf comparison rather than exact string equality, which means it survives any cosmetic changes to the output format (spacing, quoting) as long as the underlying order is correct.
Series
This is entry #20 in my 100+ public portfolio series.
- 📦 Repo: https://github.com/sen-ltd/gradient-designer
- 🌐 Live: https://sen.ltd/portfolio/gradient-designer/
- 🏢 Company: https://sen.ltd/
Conic-gradient support is the obvious next feature. Feedback welcome.

Top comments (0)