← Blog
CSSUIAccessibilityInteractive

CSSだけで作るインタラクティブUI — transform, transition, custom propertiesの実践

6 分で読める

このトピックを深掘りする

Canvas vs DOM・インタラクティブUI

CanvasとDOMの使い分け・CSSインタラクション・カスタム物理エンジン・ポートフォリオ設計

全5本中 3 本目。

このトピックの記事一覧

シリーズ全体の流れを見ながら、次に読む記事へ進めます。 初めての方はホームへ →

Canvas API vs DOM の判断基準

sakimytocomでは描画方法を2つに分けている:

条件 選択 理由
要素数100+ Canvas DOMリフローが重い
テキスト・フォーカス必要 DOM アクセシビリティ
回転・拡縮が主体 DOM + CSS Transform GPU合成
ピクセル単位の描画 Canvas 自由度が必要

FidgetSpinner、InfiniteKnob、ToggleSwitchはすべてDOM + CSS Transformを採用している。

CSS Transformの物理的な回転

FidgetSpinnerは指でフリックして回転する。回転角度はJSで計算し、CSS transform: rotate() で反映する:

const [angle, setAngle] = useState(0)
const velocityRef = useRef(0)

// ドラッグ操作から角速度を計算
function onPointerMove(e: PointerEvent) {
  const dx = e.clientX - centerX
  const dy = e.clientY - centerY
  const currentAngle = Math.atan2(dy, dx)
  const delta = currentAngle - lastAngle.current
  velocityRef.current = delta * 60 // rad/frame → rad/s
}
.spinner {
  transform: rotate(var(--angle));
  will-change: transform;
}

will-change: transform で要素を合成レイヤーに昇格させる。これにより回転がGPUで処理され、メインスレッドをブロックしない。

CSS Custom Propertiesによるリアルタイム制御

JSから直接 style.transform を書くよりも、CSS Custom Propertiesを介したほうが柔軟:

element.style.setProperty('--angle', `\${angle}deg`)
element.style.setProperty('--scale', String(scale))
.knob {
  transform: rotate(var(--angle)) scale(var(--scale));
  transition: transform 0.05s ease-out;
}

利点は3つ:

  1. CSSメディアクエリで変数を上書きできる
  2. transition との組み合わせが自然
  3. DevToolsでリアルタイムにデバッグ可能

InfiniteKnobの無限回転

InfiniteKnobは360度を超えて回転し続ける。CSSの rotate() は0-360の範囲しか見た目に意味がないが、内部の角度値は累積させる:

const totalAngle = useRef(0)

function onRotate(delta: number) {
  totalAngle.current += delta

  // 表示用の角度は0-360に正規化
  const displayAngle = ((totalAngle.current % 360) + 360) % 360
  element.style.setProperty('--angle', `\${displayAngle}deg`)

  // 出力値は累積角度から計算
  const value = totalAngle.current / 360
  onChange(value)
}

((x % 360) + 360) % 360 は負の角度にも対応する正規化パターン。

CSS Transitionの減衰アニメーション

FidgetSpinnerの慣性回転は、JSの requestAnimationFrame で減衰計算する:

function decayLoop() {
  if (Math.abs(velocityRef.current) < 0.01) return

  velocityRef.current *= 0.98 // 摩擦
  setAngle(prev => prev + velocityRef.current)
  requestAnimationFrame(decayLoop)
}

CSS Transitionだけでは指数関数的な減衰を表現できない。transition はAからBへの線形補間が得意で、物理的な減衰はJSで行い、CSSは描画だけを担当する。

ToggleSwitchのアクセシビリティ

トグルスイッチは <button role="switch"> で実装する:

<button
  role="switch"
  aria-checked={isOn}
  onClick={() => setIsOn(!isOn)}
  className="toggle-track"
>
  <span className="toggle-thumb" />
</button>
.toggle-track {
  width: 48px;
  height: 24px;
  border-radius: 12px;
  background: var(--track-color);
  transition: background 0.2s;
}

.toggle-thumb {
  width: 20px;
  height: 20px;
  border-radius: 50%;
  transform: translateX(var(--thumb-x));
  transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}

role="switch"aria-checked でスクリーンリーダーが「オン/オフ」を読み上げる。見た目のアニメーションとアクセシビリティは両立する。

Tailwind CSS v4との連携

Tailwind v4では @custom-variant でダークモード切り替えを定義している:

@custom-variant dark (&:where(.dark, .dark *));
@custom-variant light (&:where(.light, .light *));

インタラクティブコンポーネントではダーク/ライト両方で視認性を確保する必要がある。CSS Custom Propertiesでテーマカラーを定義しておけば、切り替え時のちらつきもない。

まとめ:CSSインタラクティブUIの道具たち

CSSの設計パターンを体系的に学ぶなら、道具棚の書籍が参考になる。とくにグリッドとカスタムプロパティの組み合わせは、レイアウトの柔軟性を大きく高める。