CSSだけで作るインタラクティブUI — transform, transition, custom propertiesの実践
このトピックを深掘りする
Canvas vs DOM・インタラクティブUI
CanvasとDOMの使い分け・CSSインタラクション・カスタム物理エンジン・ポートフォリオ設計
全5本中 3 本目。
次に読む記事
自前物理エンジン180行の限界点 — Matter.jsを捨てた理由と、カバーできなかったこと
physics.tsの166行で2D物理エンジンを自作し、Matter.jsの300KBを4KBに削減した。重力・衝突・バウンドに絞り、拘束・摩擦・スタッキングを諦めたトレードオフを解説する。
ブラウザで動く物理エンジンを自前で書いた話
ポートフォリオサイトにMatter.jsを使わず約180行の軽量物理エンジンをTypeScriptで自作した経緯と設計判断。バンドルサイズ300KBを4KBに削減しつつ60fpsを維持した手法を解説。
Canvas vs DOM — 203個のコンポーネントで選んだ基準と実測データ
203個のインタラクティブコンポーネントをCanvas APIとDOMに振り分けた判断基準を、実例と実測データで解説する。
シリーズ全体の流れを見ながら、次に読む記事へ進めます。 初めての方はホームへ →
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つ:
- CSSメディアクエリで変数を上書きできる
transitionとの組み合わせが自然- 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の設計パターンを体系的に学ぶなら、道具棚の書籍が参考になる。とくにグリッドとカスタムプロパティの組み合わせは、レイアウトの柔軟性を大きく高める。