0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CSS グラデーションを GUI で作るツールを書いた — 複数ストップ + 即プレビュー + コピー

0
Posted at

きっかけ

CSS の linear-gradient を手で書くと、だいたい linear-gradient(135deg, #ff6b6b, #f5c26b, #c5a3ff) みたいな字面になります。角度と色を入れて調整して、保存してブラウザリロードして、「もうちょっと右寄りに」と調整して、また保存して...

これを ビジュアルで直接いじれる GUI が欲しい。色ピッカー、スライダでのストップ位置調整、プリセットからの出発点、即プレビュー。そしてコピー可能な CSS 文字列を生成。

既存の gradient.io みたいなサービスもありますが、自分で書くと仕様がわかるし、200 行以内に収まることを確認できた。

作ったもの

Gradient Designerhttps://sen.ltd/portfolio/gradient-designer/

スクリーンショット

  • linear / radial の切替
  • 角度 (linear) または 形状 (circle / ellipse, radial)
  • 複数カラーストップ — 追加・削除・色ピッカー・位置スライダ
  • 即時プレビュー — 大きな色面でリアルタイム確認
  • コピーボタン付き CSS 出力
  • 5 プリセット: Sunset / Ocean / Forest / Spotlight / Mono

vanilla JS + HTML + CSS、ゼロ依存、ビルドツール不要。ロジックは 90 行、プリセット 60 行。node --test で 10 ケース。

buildGradient(config) は 15 行

構造化された config から CSS 文字列を組み立てる関数:

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})`
}

ポイント:

  1. .slice().sort() — 元の stops 配列を変更しないよう copy してから並べる。UI 側は position を自由に動かせて、出力時に正規化。
  2. ストップが 2 つ未満なら none — グラデーションにならないので無効値として扱う
  3. 角度のデフォルトは 90° — 一般的な「左から右」方向

たったこれだけで linear / radial 両方対応。CSS の文法がシンプルなので、ビルダーもシンプル。cron と同じく、「構築は簡単、パースが難しい」 の典型例。

ストップを sort してから出力するのが正解

UI で色ストップを追加するとき、位置を 60% にしたり 20% にしたりバラバラにドラッグできるようにしたい。でも CSS 側には位置順にソート済みの状態で渡さないと見た目が崩れます。

config.stops
  .slice()
  .sort((a, b) => a.position - b.position)

UI 内部は「ストップが追加された順」を保っていて、出力直前にだけソートします。なぜなら:

  • ストップの追加・削除を UI でやるとき、挿入順を維持したほうが操作が自然
  • CSS 出力は位置順でないと破綻する
  • 2 つの状態を独立に持つことで、UI のドラッグ中にフリッカーしない

内部状態と出力状態を分離する」のは小さな UI を書くときの王道パターン。

即プレビューは textarea への代入

プレビューを描画するには、background プロパティを即座に更新するだけ:

function update() {
  const css = buildGradient(state.config)
  $('preview').style.background = css
  $('output').textContent = `background: ${css};`
}

各 UI イベント(色変更・位置変更・角度変更等)から update() を呼ぶだけで、プレビューとコード出力の両方が更新される。即時フィードバックの UX は vanilla JS でもこれで十分作れる。

React や Vue を持ってきたくなる誘惑がありますが、プレビュー更新が 1 つの要素の style.background を書き換えるだけなら、フレームワークのオーバーヘッドはノイズ。

プリセットは「いい感じの出発点」の提供

5 つのプリセットを用意:

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
]

Sunset / Ocean / Forest はよくあるグラデーション、Spotlight は radial の例、Mono はモノクロ。空の状態からはユーザーが何を触ればいいか分からないので、即座に「こういう形ができるよ」を見せてからカスタマイズさせる、というのがプリセットの役割。

プリセットクリックで state.config = structuredClone(preset.config) して re-render、そこから自由にいじれる。「プリセットをそのまま使う」より「プリセットから始めていじる」のほうが圧倒的に多いので、編集可能なプリセットとして機能する。

コピーボタン

出力 CSS をワンクリックでクリップボードへ:

$('copy').addEventListener('click', async () => {
  const css = buildGradient(state.config)
  await navigator.clipboard.writeText(`background: ${css};`)
  showToast('CSS copied')
})

プレフィックス background: 込みでコピーするのは、VSCode / CodePen に貼り付けてすぐ動くため。background: だけ足す手間を消す、という細かい UX。

テスト

node --test で 10 ケース:

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 },  // 先に 100%
      { color: '#000', position: 0 },    // 後に 0%
    ],
  })
  // should output in order 0% → 100%
  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')
})

ストップの順序テストは indexOf 比較で書くのがコツ。文字列の部分一致で順序を保証できます。=== で完全一致を要求するより、壊れにくい。

おわりに

SEN 合同会社の ポートフォリオシリーズ 100+ の 20 件目です。

conic-gradient 対応は次の改修候補です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?