Radix UIでコンポーネントを作成する時に意識したこと
microCMSでプロダクトエンジニアをしています。りゅーそうです。
microCMSでは、UIコンポーネントを作成するのにRadix UIを採用しています。
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">
コンポーネントを作成します。
<input type="range">
は音量など、あまり厳密な値の入力が求められないUIで使用されます。
max,minの値を設定することができ、指定された値の中で入力ができるので、特定の状況下であれば便利なUIです。
ただ、<input type="range">
をそのまま扱うと、ブラウザごとに見た目が異なるという問題点があります。
CSSで調整することも可能ですが、ブラウザごとにCSSを調整する必要があります。
(もう少し良く使うコンポーネントで紹介して...というツッコミがありそうですが、最近作ったコンポーネントがこれなのです....。他のコンポーネントでも生きる記事にはなっているはず(?)です
Radix UIについて
Radix UIの思想などについてはmicroCMSの記事にまとまっているので、そちらをご参照ください。
今回はその中のComponentのsliderを使用します。
内部実装的には、spanタグにroleを振るような形で実装されているので、自由にスタイルをつけられます。
Radix UIはComponentごとにインストールする必要があります。
npm install @radix-ui/react-slider
実装
汎用的に扱えるコンポーネントとして実装します。
完成イメージは以下のようになります。
今回実装するにあたり、以下のリポジトリを参考にしました(Radix UI公式のデザインシステムのサンプルです)
以下実装した際に意識したポイントを紹介します。
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だけを受け取るという設計もありだと思います)
スタイルを定義する
最初に述べた通り、スタイルは自由に定義することができます。
公式のサンプルを拡張すれば簡単に実装できると思います。
実装の例です。(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
を表示させたいという要件がありました。
このように動的なUIを作成するには、Floating UIを使用すると簡単に実装できます。
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);
}
参考情報
- MDN:
<input type="range" />
https://developer.mozilla.org/ja/docs/Web/HTML/Element/input/range - UIライブラリの「機能」は欲しいけど「見た目」はカスタマイズしたいを叶えるRadix UI
https://blog.microcms.io/radix-ui-headless-ui/ - Radix UI Slider
https://www.radix-ui.com/docs/primitives/components/slider - radix-ui/design-system
https://github.com/radix-ui/design-system - Floating UI
https://floating-ui.com/
Discussion