Radix UI覚書
React用のUI components、Radix UI のドキュメントを読んで試してみます。
Introduction
Headless UIのようなコンポーネントライブラリと思われるが、"headless"のような言葉は使われていないみたい。"low-level UI component library"と表現されている。
Radix Primitives is a low-level UI component library with a focus on accessibility, customization and developer experience. You can use these components either as the base layer of your design system, or adopt them incrementally.
Accessible
可能な限り WAI-ARIA design patterns に準拠しているとのこと。
Unstyled
- 標準でコンポーネントにはスタイルが付与されていない
- CSS-in-JS libraries、CSS preprocessors、vanilla CSSとの互換性
Opened
各コンポーネントはカスタマイズできるように設計されている。
Incremental adoption
- 各プリミティブは個別にインストールできる
- プリミティブは独立してバージョン管理されている
# 例
npm install @radix-ui/react-dialog
npm install @radix-ui/react-dropdown-menu
npm install @radix-ui/react-tooltip
Community
サンプル用の環境構築
サンプル用の開発環境をViteで作成
$ npm create vite@latest
React、TypeScriptを選択
√ Project name: ... radix-ui-example
√ Select a framework: » React
√ Select a variant: » TypeScript
Popover
コンポーネントを実装してみる
チュートリアルに従って使用するコンポーネントを個別にインストールする設計になっている。
$ npm install @radix-ui/react-popover@latest -E
スタイリングにStitchesというCSS-in-JSライブラリを使用しているのでインストールする
$ npm install @stitches/react
componentsディレクトリを作成してPopover
コンポーネントを作成する
import { styled } from "@stitches/react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
export const Popover = PopoverPrimitive.Root;
export const PopoverTrigger = styled(PopoverPrimitive.Trigger, {
// ここに好きなだけスタイルを記述する
padding: "8px 16px",
backgroundColor: "#44403c",
...
});
export const PopoverContent = styled(PopoverPrimitive.Content, {
// ここに好きなだけスタイルを記述する
marginTop: "16px",
padding: "8px 16px",
...
});
これを親コンポーネントでインポートして使用する
import { Popover, PopoverTrigger, PopoverContent } from "./components/Popover";
const App = () => {
return (
<Popover>
<PopoverTrigger>trigger</PopoverTrigger>
<PopoverContent>content</PopoverContent>
</Popover>
);
};
export default App;
動いた!
DevToolsで確認すると、ARIA属性等がよしなに付与されている
<body>
<span
data-radix-focus-guard=""
tabindex="0"
style="outline: none; opacity: 0; position: fixed; pointer-events: none"
></span>
<div id="root">
<div
style="
padding: 16px;
background-color: rgb(245, 245, 244);
min-height: 100vh;
"
>
<button
type="button"
aria-haspopup="dialog"
aria-expanded="true"
aria-controls="radix-:r1:"
data-state="open"
class="c-hfduQu"
>
trigger
</button>
<div
data-radix-popper-content-wrapper=""
style="
position: fixed;
left: 0px;
top: 0px;
transform: translate3d(15px, 50px, 0px);
min-width: max-content;
z-index: auto;
--radix-popper-transform-origin: 50% 0px;
"
>
<div
data-side="bottom"
data-align="center"
data-state="open"
role="dialog"
id="radix-:r1:"
class="c-heLEg"
tabindex="-1"
style="
--radix-popover-content-transform-origin: var(
--radix-popper-transform-origin
);
"
>
content
</div>
</div>
</div>
</div>
<script type="module" src="/src/main.tsx?t=1665410714596"></script>
<span
data-radix-focus-guard=""
tabindex="0"
style="outline: none; opacity: 0; position: fixed; pointer-events: none"
></span>
</body>
作業用リポジトリ
Styling
あらゆるスタイリングソリューションと互換性があり、スタイリングを自由にコントロールすることができる
- 機能的なスタイルも含め、スタイリングをコントロールすることができる
- すべてのコンポーネントとそのパーツは、
className
プロパティを受け取る - data属性によって状態ごとのスタイリングが可能
素のCSSでスタイリング
指定したclassNameをターゲットにして、コンポーネントパーツをスタイリングする。プリミティブをラップして、クラスを追加することができる
import * as React from 'react';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentProps<typeof AccordionPrimitive.Item>
>((props, forwardedRef) => {
const { className, ...itemProps } = props;
return (
<AccordionPrimitive.Item
{...itemProps}
ref={forwardedRef}
className={'accordion-item ' + className}
/>
);
});
.accordion-item {
border-bottom: 1px solid gainsboro;
}
/* data属性によるスタイリングが可能 */
.accordion-item[data-state='open'] {
border-bottom-width: 2px;
}
CSS-in-JSでのスタイリング
- 例としてStitchesが紹介されているが、他のCSS-in-JSも利用可能
-
styled
関数でコンポーネントをラップしてスタイリングする
import { styled } from '@stitches/react';
import * as Accordion from '@radix-ui/react-accordion';
const StyledItem = styled(Accordion.Item, {
borderBottom: '1px solid gainsboro',
});
const StyledPanel = styled(Accordion.Panel, {
padding: 10,
});
- data属性を利用した状態によるスタイリング
import { styled } from '@stitches/react';
import * as Accordion from '@radix-ui/react-accordion';
const StyledItem = styled(Accordion.Item, {
borderBottom: '1px solid gainsboro',
'&[data-state=open]': {
borderBottomWidth: '2px',
},
});
レンダリングされる要素を変更する
asChild
プロパティを持っているコンポーネントは、アクセシビリティや機能的な要件を独自の要素に付加することができる
CSS-in-JSとの相性が良さそうな印象(特にStitchesはResources扱いなので、これを使うのが一番良いのかも)。
素のCSSでスタイルを追加したコンポーネントを作成するには、毎回同じような記述でしんどそう。以下記事に高次コンポーネント(Higher-Order Component (HOC))を実装して利用する方法が書かれている。
import { ComponentProps, createElement, JSXElementConstructor } from 'react';
export const withClassName = <T extends keyof JSX.IntrinsicElements | JSXElementConstructor<any>>(
component: T,
...classes: string[]
) => {
const name = `${typeof component === 'string' ? component : ''}_${classes[0]}`;
return {
[name](props: ComponentProps<T>) {
return createElement(component, {
...props,
className: `${props.className} ${classes}`,
});
},
}[name];
};
これを以下のようにして使用できる(CSS moduleを併用した例)。
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { withClassName } from "../../utils/withClassName";
import styles from "./Accordion.module.css";
export const Accordion = withClassName(AccordionPrimitive.Root, styles.route);
export const AccordionItem = withClassName(
AccordionPrimitive.Item,
styles.item
);
export const AccordionHeader = withClassName(
AccordionPrimitive.Header,
styles.header
);
export const AccordionContent = withClassName(
AccordionPrimitive.Content,
styles.content
);
export const AccordionTrigger = withClassName(
AccordionPrimitive.Trigger,
styles.trigger
);
親コンポーネントから利用する。
import {
Accordion,
AccordionItem,
AccordionContent,
AccordionTrigger,
AccordionHeader,
} from "./components/Accordion/Accordion";
const App = () => {
return (
<div>
<h2>Accordion (CSS modules)</h2>
<Accordion type="single">
<AccordionItem value="item-1">
<AccordionHeader>
<AccordionTrigger>title-1</AccordionTrigger>
</AccordionHeader>
<AccordionContent>content-2</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionHeader>
<AccordionTrigger>title-2</AccordionTrigger>
</AccordionHeader>
<AccordionContent>content-2</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
};
export default App;
Tailwind CSSで使う
以下の記事にTailwind CSSでRadix UIのdata-state属性を使えるようにする方法が書かれている。
以下のようにtailwind.configファイルでプラグインを作成し、data-state属性にTailwind CSSのスタイルを適用する。(例としてopen
を追加)
let plugin = require('tailwindcss/plugin')
module.exports = {
// ...
plugins: [
plugin(function ({ addVariant, e }) {
addVariant('data-state-open', ({ modifySelectors, separator }) => {
modifySelectors({ className }) => {
return `.${e(`data-state-open${separator}${className}`)}[data-state='open']`
}
})
})
]
}
これでTailwind CSSのvariantとして利用できる。
<AccordionPrimitive.Item
{...itemProps}
className='data-state-open:border-green'
/>
その他のRadix UIデータステート属性への対応
tailwind.config.cjs
const plugin = require('tailwindcss/plugin')
module.exports = {
// ...
plugins: [
plugin(function (helpers) {
// variants that help styling Radix-UI components
dataStateVariant('open', helpers)
dataStateVariant('closed', helpers)
dataStateVariant('on', helpers)
dataStateVariant('checked', helpers)
dataStateVariant('unchecked', helpers)
}),
],
}
function dataStateVariant(state, {
addVariant, // for registering custom variants
e // for manually escaping strings meant to be used in class names
}) {
addVariant(`data-state-${state}`, ({ modifySelectors, separator }) => {
modifySelectors(({ className }) => {
return `.${e(`data-state-${state}${separator}${className}`)}[data-state='${state}']`
})
})
addVariant(`group-data-state-${state}`, ({ modifySelectors, separator }) => {
modifySelectors(({ className }) => {
return `.group[data-state='${state}'] .${e(
`group-data-state-${state}${separator}${className}`,
)}`
})
})
addVariant(`peer-data-state-${state}`, ({ modifySelectors, separator }) => {
modifySelectors(({ className }) => {
return `.peer[data-state='${state}'] ~ .${e(
`peer-data-state-${state}${separator}${className}`,
)}`
})
})
}
パッケージを作っている方がいた。後で試してみる
あまり色々入れるのも気がひけるので、Tailwind CSS使うなら素直にHeadless UIが良いのかもしれない。
Animation
- CSSの
keyframes
でanimationを利用できる - マウントとアンマウントの両方の段階をアニメーション化することができる
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.dialog-overlay[data-state='open'],
.dialog-content[data-state='open'] {
animation: fadeIn 300ms ease-out;
}
.dialog-overlay[data-state='closed'],
.dialog-content[data-state='closed'] {
animation: fadeOut 300ms ease-in;
}
このアニメーションをDialogコンポーネントで使用してみる
import styles from './Dialog.module.css';
import * as Dialog from '@radix-ui/react-dialog';
export default () => (
<Dialog.Root>
<Dialog.Trigger>
button
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay
className={styles['dialog-overlay']}
/>
<Dialog.Content
className={styles['dialog-content']}
>
// ...
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
アニメーションなし
アニメーションあり
Accessibility
- WAI-ARIAオーサリングプラクティスガイドラインに従った実装となっている
- aria属性やrole属性、フォーカス管理、キーボードナビゲーションなど
- コントロールのラベル付けを簡単にするために
Label primitive
が用意されている
Components
2022/10/15現在、トータル26コンポーネント、Coming soonが2コンポーネント
- Accordion
- Alert Dialog
- Aspect Ratio
- Avatar
- Carousel(Coming soon)
- Checkbox
- Collapsible
- Context Menu
- Dialog
- Dropdown Menu
- Hover Card
- Label
- Menubar(Coming soon)
- Navigation Menu
- Popover
- Progress
- Radio Group
- Scroll Area
- Select
- Separator
- Slider
- Switch
- Tabs
- Toast
- Toggle
- Toggle Group
- Toolbar
- Tooltip
Utilities
ユーティリティコンポーネントが5種類
- Accessible Icon
- Direction Provider
- Portal
- Slot
- Visually Hidden
Accordion
import * as Accordion from '@radix-ui/react-accordion';
type Item = {
title: string;
content: string;
};
type Props = {
data: Item[];
};
export default ({ data }: Props) => (
<Accordion.Root type='single'>
{data.map((item) => (
<Accordion.Item value={item.title} key={item.title}>
<Accordion.Header>
<Accordion.Trigger>{item.title}</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>{item.content}</Accordion.Content>
</Accordion.Item>
))}
</Accordion.Root>
);
簡単にスタイリングした例
- デフォルトではすべての要素を閉じることができないが、
Accordion.Root
にcollapsible
Propsを渡すとすべて閉じることができる - 複数要素を開くことができいるようにするには
Accordion.Root
のtype
Propsをmultiple
とする
開閉のアニメーションをつける
@keyframes open {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes close {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
.accordion-content[data-state='open'] {
animation: open 300ms ease-out;
}
.accordion-content[data-state='close'] {
animation: close 300ms ease-out;
}
※コンテンツが開閉するときの幅と高さはCSS変数として--radix-accordion-content-width
と--radix-accordion-content-height
で取得できる
Alert Dialog
import * as AlertDialog from '@radix-ui/react-alert-dialog';
const action = () => {
console.log('do some action');
};
export default () => (
<AlertDialog.Root>
<AlertDialog.Trigge>
open
</AlertDialog.Trigger>
<AlertDialog.Portal>
<AlertDialog.Overlay />
<AlertDialog.Content>
<AlertDialog.Title>title</AlertDialog.Title>
<AlertDialog.Description>descriptin</AlertDialog.Description>
<div>
<AlertDialog.Cancel>cancel</AlertDialog.Cancel>
<AlertDialog.Action onClick={action}>action</AlertDialog.Action>
</div>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
);
簡単にスタイリングした例
-
AlertDialog.Root
のopen
Propで開閉状態をonOpenChange
Propで開閉状態が変化したときに呼び出されるイベントハンドラを渡せる -
AlertDialog.Portal
のcontainer
Propでアラートダイアログがポータルする要素をカスタマイズできる
Checkbox
import * as Checkbox from '@radix-ui/react-checkbox';
import { CheckIcon } from '@radix-ui/react-icons';
export default () => (
<div>
<Checkbox.Root id='c1' defaultChecked>
<Checkbox.Indicator>
<CheckIcon />
</Checkbox.Indicator>
</Checkbox.Root>
<label htmlFor='c1'>check box test</label>
</div>
);
簡単にスタイリングした例
-
Checkbox.Root
のchecked
Propの値をindeterminate
とすると、開閉状態をonCheckedChange
Propに渡したイベントハンドラで制御できる
Collapsible
import * as Collapsible from '@radix-ui/react-collapsible';
import { TriangleRightIcon } from '@radix-ui/react-icons';
export default () => (
<Collapsible.Root>
<div>
<Collapsible.Trigger>
Collapsible
<TriangleRightIcon />
</Collapsible.Trigger>
</div>
<Collapsible.Content>
<div>Collapsible component.</div>
</Collapsible.Content>
</Collapsible.Root>
);
簡単にスタイリングした例
- accordionと似ているが、単体の開閉用?
- コンテンツが開閉するときの幅と高さはCSS変数として
--radix-collapsible-content-width
と--radix-collapsible-content-height
で取得できる
Dialog
import * as Dialog from '@radix-ui/react-dialog';
export default () => (
<Dialog.Root>
<div>
<Dialog.Trigger>open</Dialog.Trigger>
</div>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
<Dialog.Title>title</Dialog.Title>
<Dialog.Description>This is Dialog component.</Dialog.Description>
<div>
<Dialog.Close>close</Dialog.Close>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
簡単にスタイリングした例
-
Dialog.Root
のmodal
Propsをtrue
に設定すると、外部要素とのインタラクションが無効になり、ダイアログの内容のみがスクリーンリーダに見えるようになる - AlertDialogとの違いとしてContetの外部をクリックで閉じることができる(どちらもEscキーで閉じれる)