DEV Community

SEN LLC
SEN LLC

Posted on

A Tiny CSS Gradient Designer in 200 Lines — And Why Sorting Happens at Output Time

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

Screenshot

  • 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})`
}
Enter fullscreen mode Exit fullscreen mode

Three things worth noting:

  1. .slice() before .sort() — never mutate the input array. The UI owns config.stops and needs to preserve insertion order; this function only sorts for the purpose of output.
  2. 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.
  3. 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};`
}
Enter fullscreen mode Exit fullscreen mode

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
]
Enter fullscreen mode Exit fullscreen mode

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')
})
Enter fullscreen mode Exit fullscreen mode

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')
})
Enter fullscreen mode Exit fullscreen mode

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.

Conic-gradient support is the obvious next feature. Feedback welcome.

Top comments (0)