株式会社microCMS
🐈

Radix UIでコンポーネントを作成する時に意識したこと

2023/05/08に公開

microCMSでプロダクトエンジニアをしています。りゅーそうです。

microCMSでは、UIコンポーネントを作成するのにRadix UIを採用しています。
https://www.radix-ui.com/

Radix UIはいわゆるヘッドレスUIと呼ばれるUIライブラリで、UIの機能のみを提供します。
ChakraUIやMUIのようなUIライブラリの場合、既成のコンポーネントセットをそのままインポートして使うだけで良いケースも多いのですが、
Radix UIの場合、「Radix UIの機能」に加えて「自社サービスにあったデザイン」などを付与してコンポーネントを作成し使うことがほとんどです。

最近、私はSlider(Range)コンポーネントをRadix UIを使用して作成しました。
その際に意識したことなどをまとめました。

要約

  • Radix UIを最大限に活用するが、Radix UIの仕様を「なるべく意識しない」で使えるコンポーネントを作る
  • そのためにPropsはComponentProps型やTypeScriptのOmitを活用する
  • Radix UIでできないことはfloating UIにやらせましょう

作成するコンポーネント

<input type="range"> コンポーネントを作成します。
https://developer.mozilla.org/ja/docs/Web/HTML/Element/input/range

<input type="range"> は音量など、あまり厳密な値の入力が求められないUIで使用されます。
max,minの値を設定することができ、指定された値の中で入力ができるので、特定の状況下であれば便利なUIです。

ただ、<input type="range"> をそのまま扱うと、ブラウザごとに見た目が異なるという問題点があります。
CSSで調整することも可能ですが、ブラウザごとにCSSを調整する必要があります。

(もう少し良く使うコンポーネントで紹介して...というツッコミがありそうですが、最近作ったコンポーネントがこれなのです....。他のコンポーネントでも生きる記事にはなっているはず(?)です

Radix UIについて

Radix UIの思想などについてはmicroCMSの記事にまとまっているので、そちらをご参照ください。
https://blog.microcms.io/radix-ui-headless-ui/

今回はその中のComponentのsliderを使用します。
https://www.radix-ui.com/docs/primitives/components/slider

内部実装的には、spanタグにroleを振るような形で実装されているので、自由にスタイルをつけられます。
Radix UIはComponentごとにインストールする必要があります。

npm install @radix-ui/react-slider

実装

汎用的に扱えるコンポーネントとして実装します。
完成イメージは以下のようになります。
sliderコンポーネントの例

今回実装するにあたり、以下のリポジトリを参考にしました(Radix UI公式のデザインシステムのサンプルです)
https://github.com/radix-ui/design-system/blob/master/components/Slider.tsx

以下実装した際に意識したポイントを紹介します。

Ref の forwarding

コンポーネントを作成する上で、値を受け渡しするにはRefのforwardingが必要です。
Radix UIでデザインシステムを作成するには、ほとんどのパターンで必要になります。

import * as Slider from '@radix-ui/react-slider';
import { forwardRef } from 'react';

import styles from './range.module.css';

export type Props = React.ComponentProps<typeof Slider.Root> & {
  label: string;
};

export const Range = forwardRef<React.ElementRef<typeof Slider.Root>, Props>(
  (props, forwardedRef) => {

    return (
      <Slider.Root
        ref={forwardedRef}
        className={styles.sliderRoot}
        step={1}
        {...props}
      >
        <Slider.Track className={styles.sliderTrack}>
          <Slider.Range className={styles.sliderRange} />
        </Slider.Track>
        <Slider.Thumb
          className={styles.sliderThumb}
          aria-label={props.label}
        />
      </Slider.Root>
    );
  }
);

props定義

Reactの ComponentProps 型を活用しています。
これにより、Radix UIが持つPropsを受け継ぎRadix UIにスタイルを追加しただけというコンポーネントを定義できます。

上記のサンプルでは、

export type Props = React.ComponentProps<typeof Slider.Root> & {
  label: string;
};

というようにlabelを別で定義しています。aria-labelをそのまま使用しても良いのですが、
Slider.Thumb にlabelを必須にすることで、a11yを担保しています。

上記の形でも良いのですが、
このままでは、Rangeコンポーネントを使用する際に、Radix UIの知識が求められてしまうという問題があります。
ユースケースに応じてRadix UIのPropsを活用するケースもありますし、その際にRadix UIのドキュメントを参照するのは必要な作業です。

しかし、ある程度汎用的なケースでは、使用側ではこのコンポーネント定義を見るだけの形で大体のユースケースをイメージして使えるのが良さそうです。
このような場合は、TypeScriptの Omit を活用して、ユースケースを定義します。

import * as Slider from '@radix-ui/react-slider';
import { forwardRef } from 'react';

import styles from './range.module.css';

export type Props = Omit<
  React.ComponentProps<typeof Slider.Root>,
  'mix' | 'max' | 'value' | 'defaultValue' | "onValueChange"
> & {
  label: string;
  min: number;
  max: number;
  value?: number[];
  defaultValue?: number[];
  onValueChange?(value: number[]): void;
};

export const Range = forwardRef<React.ElementRef<typeof Slider.Root>, Props>(
  ({ label, max, min, value, defaultValue, onValueChange, ...props }, forwardedRef) => {

    const rootValue = value || defaultValue;

    return (
      <Slider.Root
        ref={forwardedRef}
        className={styles.sliderRoot}
        step={1}
        max={max}
        min={min}
        value={rootValue}
        onValueChange={onValueChange}
        {...props}
      >
        <Slider.Track className={styles.sliderTrack}>
          <Slider.Range className={styles.sliderRange} />
        </Slider.Track>
        <Slider.Thumb
          className={styles.sliderThumb}
          aria-label={label}
        />
      </Slider.Root>
    );
  }
);

どこまで汎用的なものとして、定義するのかはチームで相談をしながら作成するのが良いかと思います。
(コンポーネントの責務として、ComponentPropsを受け取るだけ、または必要なPropsだけを受け取るという設計もありだと思います)

スタイルを定義する

最初に述べた通り、スタイルは自由に定義することができます。
公式のサンプルを拡張すれば簡単に実装できると思います。
https://www.radix-ui.com/docs/primitives/components/slider

実装の例です。(paddingやcolorなどはよしなに定義してください)

.sliderRoot {
  position: relative;
  display: flex;
  align-items: center;
  padding: var(--spacing-medium) 0;
}

.sliderTrack {
  position: relative;
  background-color: var(--color-bg-purple);
  flex-grow: 1;
  border-radius: 9999px;
  height: 4px;
}

.sliderRange {
  position: absolute;
  background-color: var(--color-accent-purple);
  border-radius: 9999px;
  height: 100%;
}

.sliderThumb {
  display: block;
  width: 16px;
  height: 16px;
  background-color: var(--color-accent-purple);
  border-radius: var(--border-radius-large);

  &:focus {
    outline: 1px solid var(--color-accent-purple);
  }
}

focus時などのスタイルを当てることをおすすめします。

Appendix:現在の値を表示する

ここまで、基本的なRangeコンポーネントを作成できました。
ただRangeコンポーネントは正確な値の入力は求められないUIとはいえ、ある程度どのような値が入力されるのか分かると便利です。

現在の値は上記のコンポーネントの例である value,min, maxの値を活用するだけで表示できます。
私が実装したケースでは、以下のように点の上に value を表示させたいという要件がありました。
sliderコンポーネントの例

このように動的なUIを作成するには、Floating UIを使用すると簡単に実装できます。
https://floating-ui.com/

autoUpdate で自動でUIの位置が再計算されます。

import { useFloating, autoUpdate, offset } from '@floating-ui/react';
import * as Slider from '@radix-ui/react-slider';
import { forwardRef } from 'react';

import styles from './range.module.css';

export type Props = Omit<
  React.ComponentProps<typeof Slider.Root>,
  'mix' | 'max' | 'value' | 'defaultValue'
> & {
  label: string;
  min: number;
  max: number;
  value?: number[];
  defaultValue?: number[];
};

export const Range = forwardRef<React.ElementRef<typeof Slider.Root>, Props>(
  ({ label, max, min, value, defaultValue, ...props }, forwardedRef) => {
    const { refs, x, y, strategy } = useFloating({
      placement: 'top',
      middleware: [offset(4)],
      whileElementsMounted: autoUpdate,
    });
    const rootValue = value || defaultValue;

    return (
      <Slider.Root
        ref={forwardedRef}
        className={styles.sliderRoot}
        step={1}
        max={max}
        min={min}
        value={rootValue}
        {...props}
      >
        <span className={styles.sliderMin}>{min}</span>
        <Slider.Track className={styles.sliderTrack}>
          <Slider.Range className={styles.sliderRange} />
        </Slider.Track>
        <span className={styles.sliderMax}>{max}</span>
        <Slider.Thumb
          className={styles.sliderThumb}
          aria-label={label}
          ref={refs.setReference}
        />
        <span
          ref={refs.setFloating}
          style={{ position: strategy, left: x || 0, top: y || 0 }}
          className={styles.sliderValue}
        >
          {value}
        </span>
      </Slider.Root>
    );
  }
);

このようにRadix UIでは実装できない動的なUIを追加したい場合は、Floating UIを活用すると便利です。
ちなみにRadix UI自体もFloating UIに依存しています。
バンドルサイズ、ライブラリへの依存という観点では、すでにRadix UIを使用している場合デメリットは少ないと考えます。

まとめ

Radix UIは「機能」のみを提供しているため、スタイルの拡張がしやすい便利なUIライブラリです。
ただ、デザインシステムに組み込む場合、使用する側で本来のHTML以上の知識が必要になってしまうケースがあります。
Radix UIを生かしつつ、より扱いやすいコンポーネントとは何かを考える必要があると感じました。

この記事が少しでもその参考になれば嬉しいです。
参考までに最終的なコードを貼っておきます。

import { useFloating, autoUpdate, offset } from '@floating-ui/react';
import * as Slider from '@radix-ui/react-slider';
import { forwardRef } from 'react';
import styles from './range.module.css';
export type Props = Omit<
  React.ComponentProps<typeof Slider.Root>,
  'mix' | 'max' | 'value' | 'defaultValue' | 'onValueChange'
> & {
  label: string;
  min: number;
  max: number;
  value?: number[];
  defaultValue?: number[];
  onValueChange?(value: number[]): void;
};
export const Range = forwardRef<React.ElementRef<typeof Slider.Root>, Props>(
  (
    { label, max, min, value, defaultValue, onValueChange, ...props },
    forwardedRef
  ) => {
    const { refs, x, y, strategy } = useFloating({
      placement: 'top',
      middleware: [offset(4)],
      whileElementsMounted: autoUpdate,
    });
    const rootValue = value || defaultValue;
    return (
      <Slider.Root
        ref={forwardedRef}
        className={styles.sliderRoot}
        step={1}
        max={max}
        min={min}
        value={rootValue}
        onValueChange={onValueChange}
        {...props}
      >
        <span className={styles.sliderMin}>{min}</span>
        <Slider.Track className={styles.sliderTrack}>
          <Slider.Range className={styles.sliderRange} />
        </Slider.Track>
        <span className={styles.sliderMax}>{max}</span>
        <Slider.Thumb
          className={styles.sliderThumb}
          aria-label={label}
          ref={refs.setReference}
        />
        <span
          ref={refs.setFloating}
          style={{ position: strategy, left: x || 0, top: y || 0 }}
          className={styles.sliderValue}
        >
          {value}
        </span>
      </Slider.Root>
    );
  }
);
.sliderRoot {
  position: relative;
  display: flex;
  align-items: center;
  padding: var(--spacing-medium) 0;
}

.sliderTrack {
  position: relative;
  background-color: var(--color-bg-purple);
  flex-grow: 1;
  border-radius: 9999px;
  height: 4px;
}

.sliderRange {
  position: absolute;
  background-color: var(--color-accent-purple);
  border-radius: 9999px;
  height: 100%;
}

.sliderThumb {
  display: block;
  width: 16px;
  height: 16px;
  background-color: var(--color-accent-purple);
  border-radius: var(--border-radius-large);

  &:focus {
    outline: 1px solid var(--color-accent-purple);
  }
}

.sliderMin {
  position: absolute;
  bottom: -6px;
  color: var(--color-text-sub);
}

.sliderMax {
  position: absolute;
  bottom: -6px;
  right: 0;
  color: var(--color-text-sub);
}

.sliderValue {
  color: var(--color-text-sub);
}

参考情報

株式会社microCMS
株式会社microCMS

Discussion