🍩

conical-gradients を利用してドーナツ型のノブ型 UI を実装する

2023/05/31に公開

初めに

以前出場したハッカソンでノブ型 UI をフルスクラッチで作りました。そこで、簡単な作り方をまとめながらハッカソン中には実装しきれなかった機能も追加してみたいと思います。

完成品は以下になります。(スマホの場合は、ドーナツ上でスクロールしてください)

1. ドーナツを作る

ひとまずドーナツを作成してみます。ドーナツは色々な実装方法があるかと思いますが、今回はradial-gradientを利用して実装します。

ドーナツ

index.tsx
const Home = () => {
  return (
    <div className="page">
      <div className="circle">
          100%
      </div>
    </div>
  )
}
style.css
:root {
  --knob-size: 128px; /* 後から利用するためCSS変数に切り出し */
}

.circle {
  background: radial-gradient(circle, transparent 40%, pink 40%);
  width: var(--knob-size);
  height: var(--knob-size);
  border-radius: 100%;
  display: grid;
  place-items: center;
}

.page {
  display: flex;
  padding: 4rem;
}

2. パーセントに応じてドーナツを欠けさせる

ハッカソン中は、beforeafterの擬似要素を使って円の一部を隠すことで対応していました。しかし、conical-gradientsという円錐グラデーションを用いると簡単に実装できますので、そちらをご紹介します。

ドーナツを欠けさせる

style.css
:root {
  --knob-size: 128px;
  --knob-occupied-color: #ffc0cb;
  --knob-bg-color: #f0f0f0;
  --knob-amount: 0.4turn;
}

.circle {
  background: radial-gradient(circle, #ffffff 40%, transparent 40%),
    conic-gradient(
      var(--knob-occupied-color) var(--knob-amount),
      var(--knob-bg-color) var(--knob-amount)
    );

  /* ... */
}

供養のために、ハッカソン時の実装も載せておきます。

ハッカソン中の実装イメージ(供養)

0~180 度までは、before 要素で左半分、after 要素で左半分を隠し、角度に応じて after を回転させることで対応しました。

回転のイメージ

180 度以降は、before 要素で右側の円グラフを表示し、after 要素を回転させました。
50%以降の図

conical-gradients

円錐グラデーションを適用する関数です。
radial-gradient は円の中心から外側に向かう方向にグラデーションですが、conical-gradients は円周の方向に向かうグラデーションを定義できます。

この 2 つのグラデーションを組み合わせることで、ドーナツ型の円グラフを実現することができます。

turn

角度を指定する単位としては deg, rad などがありますが、turn は一周を 1 とする単位です。

3. パララックスの実装

次にパララックスを実装します。円グラフをホバーした状態でスクロールすると、円グラフの値が増減するような実装を行います。

3-1. スクロール領域の確保

まずは円グラフ(div.circle)をdiv.scrollable-areaで囲み、さらにdiv.parallax-windowで囲みます。
div.parallax-windowは円グラフと同じ width と height、div.scrollable-areaは大きめの height を持たせます。こうすることで、div.parallax-windowのなかで、div.scrollable-areaがスクロールされます。

{
  /* ↓ height,widthは円グラフ(.circle)と同じ */
}
<div className="parallax-window">
  {/* ↓ heightはかなり大きくしておく */}
  <div className="scrollable-area">
    <div className="circle">100%</div>
  </div>
</div>;

以下は分かりやすさのためにdiv.scrollable-areaの背景にグラデーションを設定しています。
スクロールのイメージ

index.tsx
export default function App() {
  return (
    <div className="page">
      <div className="parallax-window scrollbar-none">
        <div className="circle">100%</div>
        <div className="scrollable-area" />
      </div>
    </div>
  );
}
style.css
:root {
  --knob-size: 128px;
  --knob-occupied-color: #ffc0cb;
  --knob-bg-color: #f0f0f0;
  --knob-amount: 0.4turn;
  --knob-scroll-size: 500px;
}

.parallax-window {
  width: var(--knob-size);
  height: var(--knob-size);
  overflow-y: scroll;
}

.scrollable-area {
  height: var(--knob-scroll-size);
}

3-2. スクロール量の取得

次にスクロール量を取得し、今まで「100%」と表示していた部分をスクロール量に応じて変化させます。
以下のイメージでは、スクロール部分にグラデーションを設定して分かりやすくしています。また、ボックス上部のパーセンテージ表記も加えています。

スクロールのイメージ

index.tsx
import throttle from 'lodash.throttle'
import { useRef, useState } from 'react'

const Home = () => {
  const [scroll, setScroll] = useState(0)
  const scrollRef = useRef<HTMLDivElement>(null)

  const handleScroll = throttle(() => {
    if (scrollRef.current == null) return;
    const scrollRate =
      scrollRef.current.scrollTop /
      (scrollRef.current.scrollHeight - scrollRef.current.clientHeight);

    // 少数第一位までで四捨五入
    setScroll(Math.round(scrollRate * 1000) / 1000);
  }, 30);

  const roundedScroll = Math.round(scroll * 100);

  return (
    <div className="page">
      <div className="parallax-window" ref={scrollRef} onScroll={handleScroll}>
       <div className="circle">
          {scroll}%
        </div>
        <div className="scrollable-area" />
      </div>
    </div>
  )
}
handleScroll

簡単に handleScroll 関数の説明です。

const handleScroll = throttle(() => {
  if (scrollRef.current == null) return;
  const scrollRate =
    scrollRef.current.scrollTop /
    (scrollRef.current.scrollHeight - scrollRef.current.clientHeight);

  // 少数第一位までで四捨五入
  setScroll(Math.round(scrollRate * 1000) / 1000);
}, 100);

onScroll イベントは発生頻度が高く、パフォーマンス上問題となる場合があるので、loadsh の throttle 関数を用いて関数の実行を間引きます。スクロール量はscrollRef.current.scrollTop、スクロール可能な高さはscrollRef.current.scrollHeight - scrollRef.current.clientHeightで取得できるので、これらを利用して 0~100 の範囲で正規化しています。

3-3. 円グラフがスクロールされないようにする

これまでの実装では、スクロール量は取得できるものの、スクロールに応じて円グラフが隠れてしまいます。そこで、円グラフにposition: sticky;を追加し、スクロールの影響を受けないように対策します。

style.css
  .circle {
    ...
+   // スクロールエリアの上部に固定
+   position: sticky;
+   top: 0;
   }

4. スクロール量をドーナツに反映する

最後にスクロール量をドーナツの円グラフに反映させていきます。まだ単位付き attr がサポートされていないため、style 属性で CSS 変数の値を設定することで、スクロール量を CSS に渡します。

index.tsx
  return (
    <div className="page flex flex-col">
      <div className="parallax-window" ref={scrollRef} onScroll={handleScroll}>
-       <div className="circle">
+       <div className="circle" style={{ '--scroll--amount': `${scroll}turn` }}>
          {scroll}%
        </div>
        <div className="scrollable-area" />
      </div>
    </div>
  )

完成

5. スタイルの整形

最後に、スクロールバーを非表示にします。また、数字部分を monospace とし、表示する値によって文字幅が変化しないようにします。これらのスタイルを適用して完成です!

style.css
.circle {
  /* ... */
  font-variant-numeric: tabular-nums; /* 数字の文字幅を一定に */
  cursor: pointer;
}

/* スクロールバーを非表示に */
.scrollbar-none {
  overscroll-behavior: none;
  scrollbar-width: none;
}
.scrollbar-none::-webkit-scrollbar {
  display: none;
}

ブラッシュアップ

6. 完成品

Discussion