一歩先へ — CSSを超えて

私たちは細部の仕上げが大好きです。この記事では、ウェブとWindowsユーザーに卓越した体験を届けるために一歩踏み込んだいくつかの事例を紹介します。一見気づかれにくくても、こうした細やかな工夫が大きな違いを生み出します。

公開日
9/5/2024
Building Craft
一歩先へ — CSSを超えて

ここCraftでは、美しく磨き上げられた体験を作ることを誇りにしています。その実績として、MacアプリでAppleのMac App of the Yearを受賞しましたが、それだけにとどまらず、独自の課題を持つウェブにおいても同じようにこだわり抜き、Webby Awardを獲得しました。

まずは基本を押さえる

ウェブの世界でデバイスをサポートすることは複雑です。対応すべきプラットフォームが多く(Windows、Mac、iOS、Androidなど)、それぞれのプラットフォームで複数のブラウザ(Chrome、Safari、Firefox)をサポートする必要があります。どこでも美しく見えるピクセルパーフェクトな実装を作るのは本当に難しいことで、私たちにとってのそんなケースがチェックボックスのリデザインでした。

最初、タスクはかなり単純に見えました。新しいデザインを実装するだけです。Figmaファイルを受け取り、すぐにコンポーネントライブラリに組み込み始めました。

CleanShot 2024-08-13 at 13.52.14@2x.png

外側のボックスはCSSで作り、チェックマークのアイコンをSVGとしてエクスポートし、コードに組み込みました。アニメーションはstroke-dasharrayプロパティを使って実装しました。

結果に満足し、週次の全社デモセッションでプレゼンする準備を張り切って進めました。

一目見て気づいた問題

プレゼンしたところ…記事にしているくらいですから、スムーズには行かなかったわけです。CEOで熱狂的なSafariユーザーのBálintが、チェックボックスが少し位置ずれしているように感じると気づきました。本当に少しだけですが。

image-1.png

そこで前述の通り、一歩踏み込んで取り組みました。ユーザーが使っているブラウザに応じて微調整を試みましたが、ブラウザのバリエーションが非常に多く、さらに画面の違いも加わって—高DPIモニターでは低DPIモニターと全く異なる挙動をするため—うまくいきませんでした。

image.png CleanShot 2024-08-13 at 13.30.49@2x.png
image0.png
デザイナーたちは本当に細部への目を持っています。

根本的な原因は、ブラウザのレンダリングエンジンの違いにありました。ChromeのChromium、FirefoxのBlink、SafariのWebkitはそれぞれ微妙に異なるラスタリゼーションアルゴリズムを持っており、私たちにはほとんど手が出せないものでした。

ラスタリゼーションとは、ベクター形式の画像やオブジェクトをラスター(ビットマップ)形式に変換するプロセスを指します。図形や線の数学的な記述をピクセルのグリッドに変換します。

「ラスタリゼーションとは?」by Lenovo

車輪を再発明しない

次のアイデアは、外部アプリ(例:Figma)を使って一度だけ画像をラスタリゼーションし、コード内でそのラスタリゼーション済みのPNGを使うことでした。Macアプリでは以前からこの方法を使っていました。しかし残念ながら、このアプローチはここでは機能しませんでした。

目標は単に新しい見た目を加えるだけでなく、タスクを完了したときに達成感を感じさせる遊び心のあるアニメーションを追加することでもありました。

では、どうするか?

険しい道を進む

以前からキャンバスを使ったレンダリングの経験がありました。そこではレンダリングエンジン間の差異がはるかに小さいことがわかっていました。昔ながらのSohCahToaの原則を引っ張り出し、三角法を使ってアニメーション中の線の描き方を計算しました。

Image.jpeg

数学はフロントエンド開発者に役立たないなんて誰が言いましたか?

幸いにも、チェックボックスはとても描きやすい形でした。必要な線はわずか2本です。

image1.jpeg

チェックボックスの3つの端点がわかれば、静的に描くのは簡単でした。

const $canvas = canvasRef.current
const ctx = $canvas.getContext('2d')
ctx.moveTo(x1, y1)
ctx.lineTo(x2, y2)
ctx.lineTo(x3, y3)

難しかったのはアニメーションでした。より正確に言うと、特定の時点での線の端点をどう計算するかという問題です。

別の視点から考えてみましょう。チェックボックスは特定の角度で引かれた2本の線です。

アニメーションのように描くためには、次の手順が必要です。

  1. 1本目の線の先端から始める。
  2. 経過時間に比例した角度で描き始める。
  3. 全部描き終えるまで続ける。
  4. 2本目の線の起点から始める。
  5. 経過時間に比例した角度で描き始める。
  6. 全部描き終えるまで続ける。
  7. アニメーション完了!

数学の力を借りる

角度があれば三角法から逃れられません!

bigger.jpeg

まず、線を描くべき角度を計算しましょう。SohCahToaの公式を使い、2点間のxyの差とその逆正接から計算できます。

image-3.jpeg

次に、ピタゴラスの定理を使って線の長さそのものを計算します。

image11.jpeg

これがわかれば、経過時間(進捗)を単純に掛け合わせることで、その時点での線の長さを計算できます。

prog.jpeg d-pr.jpeg

角度、起点、最終的な長さ、進捗がわかったので、起点と現在の線の端を結ぶ線を描けるように、それを座標に変換し直す必要があります。

ctx.moveTo(startX, startY)
ctx.lineTo(endX, endY)

ここでも三角法が助けに来てくれます。サインとコサインを使えば、隣辺(x)と対辺(y)の長さを計算できます。

IMG_0378.jpeg

IMG_0379.jpeg すべてをまとめる

最後のステップは、これまでの計算をすべて組み合わせることです。

  1. キャンバスをクリアする。
  2. 線を描くべき角度と長さを計算する。
  3. 時間の比率を計算する。
  4. progressの値(0から1)に基づいて線#1を描く。
  5. progressの値(0から1)に基づいて線#2を描く。
  6. requestAnimationFrameを使って次のフレームまで待つ。
  7. 完了するまで1に戻る。
function getLineInfo({ start, end }) {
  const dx = end.x - start.x
  const dy = end.y - start.y
  const angle = Math.atan2(dy, dx)
  const d = Math.sqrt(dx ** 2 + dy ** 2)
  return { d, angle }
}
function drawLine(ctx, line, angle, d, progress) {
  const { start } = line
  ctx.moveTo(start.x, start.y)
  const xd = Math.cos(angle) * d * progress
  const yd = Math.sin(angle) * d * progress
  ctx.lineTo(start.x + xd, start.y + yd)
}
function draw(progress, ctx) {
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height)
  const line1Info = getLineInfo(checkLine1)
  const line2Info = getLineInfo(checkLine2)
  const line1DurationRatio = line1Info.d / (line1Info.d + line2Info.d)
  const line2DurationRatio = line2Info.d / (line1Info.d + line2Info.d)
  const line1Progress = clampTo(progress / line1DurationRatio, 0, 1)
  const line2Progress = clampTo((progress - line1DurationRatio) / line2DurationRatio, 0, 1)
  ctx.beginPath()
  drawLine(ctx, checkLine1, line1Info.angle, line1Info.d, line1Progress)
  drawLine(ctx, checkLine2, line2Info.angle, line2Info.d, line2Progress)
  ctx.stroke()
}
function animate() {
  function frame(timestamp) {
    const dt = Math.min(duration, timestamp - animationStart)
    const progress = dt / duration
    draw(easingFunction(progress), ctx, icon)
    if (dt < duration) {
      requestAnimationFrame(frame)
    }
  }
  requestAnimationFrame(frame)
}

下のインタラクティブな操作環境で、アニメーションの進捗、点の位置、数式の値を自由に試せます。

完成!

これでアニメーションが完成しました!いくつかの追加調整を経て、クロスプラットフォーム・クロスブラウザ対応のチェックボックス実装が完成しました。

ボタン その2

Apple - WWDC 2024 — June 10 Apple [RXeOiIDNNek - 1541x867 - 1h28m02s].png

前回のWWDCは私たちにとって素晴らしいものでした。基調講演で取り上げてもらえただけでなく、AppleがメールアプリにFluid Segmented Controlと私たちが呼ぶ新しいコンポーネントを導入したことに大きくインスピレーションを受けました。

Apple - WWDC 2024 — June 10 Apple [RXeOiIDNNek - 1541x867 - 23m11s].png

インスピレーションを得ることはクリエイティブワークの重要な部分です。私たちはインスピレーションを得ることが大好きです。特に一流のものから!

サイドバーのいくつかの要素に手を加える作業をすでに始めていたところで、これが私たちのユースケースにぴったりに見えました。デザイナーの一人、Daniela Muntianがそれをigmaで再現し、どのように動作すべきかを簡単に説明してくれました。

要素の一つが選択されると、他の要素を押しのけてスペースを作る。

シンプルに聞こえましたが、実際はそう簡単ではありませんでした。

押しのける とはどういう意味か?

最初の課題は、選択中のタブが変わったときに要素がどのように動くべきかを考えることでした。

スペースが十分にある場合は簡単です。選択されていない要素をできるだけ小さくし、選択された要素で残りのスペースを埋める。

難しくなるのは、選択された要素全体とすべてのアイコン要素を同時に収めるスペースが不足する場合です。

解決策は、コンポーネント全体のコンテナ幅を積極的に計算し、内部の各要素の幅も計算することでした。

その計算に基づいて、要素が収まるかどうかを確認します。収まらない場合、左側の要素を指定量(私たちの場合は8ピクセル)重ね合わせます。

CleanShot 2024-08-19 at 11.19.32@2x.png

それでも収まらない?今度は右側の要素を重ね合わせます。

CleanShot 2024-08-19 at 11.19.38@2x.png

まだスペースが足りない?不足しているスペース量を計算し始めます。その量を周囲のすべての要素に分配して、さらに重ね合わせます。

CleanShot 2024-08-19 at 11.19.48@2x.png

それでも重ね合わせた状態でアイコンを表示するスペースが足りない場合、選択された要素を縮小させ、テキストを省略記号で切り取ることを許可します。

CleanShot 2024-08-19 at 11.19.52@2x.png

すべての動作をここで確認できます!

さまざまなユースケースへの対応

どのように動作すべきかを定義することと、実際に動作させることは別の話です。これはコンポーネントライブラリに組み込まれているため、あらゆるケース—画面サイズ、解像度、DPI—に対応する必要があります。また、さまざまな使用方法—アイコンあり、アイコンなし、通常の1:1アスペクト比に収まらないカスタムアイコンもあるかもしれない—にも対応が必要です。

幅を正確に計算するために、まず要素を計算する必要があります。各要素を調べてアイコンの幅、テキストの幅、それらの間のギャップを取得します。これをもとに、アイコンありとアイコンなしのすべての組み合わせのサイズを取得できます。

テキストの切り詰め処理

仕上げとして実装したかったのが、省略記号によるテキストの切り詰めです。聞こえは簡単で、実際に簡単です—要素にtext-overflow: ellipsisoverflow: hiddenを適用すれば、要素が小さすぎる場合に自動的に適用されます。

しかし、このアプローチは要素が選択されるアニメーション中に問題を引き起こしました。が一瞬チラつき、とても目立って意図しない効果になってしまいました。

これを解消するために、本当に必要な要素にのみtext-overflowを適用しました。周囲の要素にオーバーフローを適用しても収まらない選択された要素のみです。

重なりの見た目を完璧に仕上げる

しかし、最大の問題は最後にやってきました。ご覧のように、要素が重なるとき、その間のスペースは切り取られ、背景が透けて見えるようにする必要があります。

最初は要素の周りに単純な白いボーダーを使って実装しました。Storybookでは見栄えが良かったのですが、アプリで使うと…どこでも単色の背景を使っているわけではないため、違和感がありました。

CleanShot 2024-08-19 at 11.31.22@2x.png

幸いにも、clip-pathプロパティが救いの手を差し伸べてくれました。これにより要素にクリッピングマスクを適用できます。難しかったのは、CSSプロパティではなくSVGパスを作成する必要があるため、どのようにクリッピングマスクを作るかという点でした。

clip-pathプロパティにより、要素のどの部分が表示されるかを決定するクリッピング領域を定義できます。これは単純なCSSプロパティでは不可能な複雑な形状や効果を作る際に特に役立ちます。

SVGパスの簡単な入門

FigmaやAdobe Illustratorなどのツールからエクスポートされたパスの定義を見ると、とても難しそうに見えるかもしれません。

<path
  d="
    M14.581 3
    c1.527 0 2.885.957 3.384 2.368
    a.582.582 0 0 1-.369.75.595.595 0 0 1-.759-.355 2.392 2.392 0 0 0-2.086-1.579
    H6.594
    c-1.278 0-2.316.967-2.396 2.2
    v8.063
    c0 1.254.978 2.28 2.226 2.359
    l.17.01
    h7.987
    a2.401 2.401 0 0 0 2.256-1.58.587.587 0 0 1 .76-.354
    c.319.108.478.444.368.75
    a3.601 3.601 0 0 1-3.194 2.358
    l-.19.01
    H6.594
    C4.667 18 3.1 16.51 3 14.635
    V6.553
    C3 4.648 4.498 3.099 6.394 3
    h8.187
    Z
  "
  fill="#000"
/>

しかし実際には、基本的な命令のリストに過ぎません。

  • 移動: M/m(絶対座標 / 相対座標)
  • 直線: L/l
  • 水平線: H/h
  • 垂直線: V/v
  • 三次ベジェ曲線: C/c
  • 滑らかな三次ベジェ曲線: S/s
  • 二次ベジェ曲線: Q/q
  • 滑らかな二次ベジェ曲線: T/t
  • 円弧: A/a
  • パスを閉じる: Z/z

こうしたパスをより簡単に読んで構築できるよう、人間が読みやすいコマンドで操作できるシンプルなヘルパークラスを作成しました。

class ClipPath {
  commands: string[] = []

  absoluteMoveTo(x: number, y: number) {
    this.commands.push(`M ${x} ${y}`)
    return this
  }

  relativeMoveTo(x: number, y: number) {
    this.commands.push(`m ${x} ${y}`)
    return this
  }

  // ...

  closePath() {
    this.commands.push('Z')
    return this
  }

  build() {
    return this.commands.join(' ')
  }
}

あとは、適切なマスクを作るためにどのようなステップを踏むべきかを考えるだけでした。

Frame 1.png
function buildPath(borderRadius: number, fullyVisibleWidth: number, height: number) {
  return ClipPath.start()
    .absoluteMoveTo(0, 0) // (1)
    .absoluteHorizontalLineTo(fullyVisibleWidth) // (2)
    .relativeHorizontalLineTo(borderRadius) // (3)
    .relativeQuadraticCurveTo(-borderRadius, 0, -borderRadius, borderRadius) // (4)
    .absoluteVerticalLineTo(height - borderRadius) // (5)
    .relativeQuadraticCurveTo(0, borderRadius, borderRadius, borderRadius) // (6)
    .absoluteHorizontalLineTo(0) // (7)
    .absoluteLineTo(0, 0) // (8)

    .closePath()
    .build()
}

これは以下のような結果になります。

M 0 0
H 30
h 10
q -10 0 -10 10
V 24
q 0 10 10 10
H 0
L 0 0
Z

これを構築することで、clip-pathプロパティを使って重なっている要素にマスクを適用できます。背景が透けて見えるきれいなエフェクトが生まれます。また、要素をわずかに透明にして、背景色と色をブレンドさせることもできます。

CleanShot 2024-08-19 at 11.32.58@2x.png

これだけの苦労は何のために?

すべてのユーザーがその違いに気づくでしょうか?—おそらく気づかないでしょう。それでも価値はあったでしょうか?—絶対に。こうした細かな部分は、存在していると気づかれないことが多い一方、なければすぐに目立ってしまいます。

私たちはそうした細部をできるだけ多く詰め込み、体験を素晴らしいものにしながら、同時にシームレスにすることを目指しています。

ブログをもっと読む