Open17

Radix UI覚書

koyama shigehitokoyama shigehito

Introduction

https://www.radix-ui.com/docs/primitives/overview/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 に準拠しているとのこと。
https://www.radix-ui.com/docs/primitives/overview/accessibility

Unstyled

  • 標準でコンポーネントにはスタイルが付与されていない
  • CSS-in-JS libraries、CSS preprocessors、vanilla CSSとの互換性

https://www.radix-ui.com/docs/primitives/overview/styling

Opened

各コンポーネントはカスタマイズできるように設計されている。

Incremental adoption

  • 各プリミティブは個別にインストールできる
  • プリミティブは独立してバージョン管理されている
# 例
npm install @radix-ui/react-dialog
npm install @radix-ui/react-dropdown-menu
npm install @radix-ui/react-tooltip

Community

koyama shigehitokoyama shigehito

サンプル用の環境構築

サンプル用の開発環境をViteで作成

https://vitejs.dev/guide/

$ 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ライブラリを使用しているのでインストールする
https://stitches.dev/

$ npm install @stitches/react

componentsディレクトリを作成してPopoverコンポーネントを作成する

Popover.tsx
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",
  ...
});

これを親コンポーネントでインポートして使用する

App.tsx
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>
koyama shigehitokoyama shigehito

Styling

https://www.radix-ui.com/docs/primitives/overview/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プロパティを持っているコンポーネントは、アクセシビリティや機能的な要件を独自の要素に付加することができる

koyama shigehitokoyama shigehito

CSS-in-JSとの相性が良さそうな印象(特にStitchesはResources扱いなので、これを使うのが一番良いのかも)。

素のCSSでスタイルを追加したコンポーネントを作成するには、毎回同じような記述でしんどそう。以下記事に高次コンポーネント(Higher-Order Component (HOC))を実装して利用する方法が書かれている。
https://samuelkraft.com/blog/radix-ui-styling-with-css

withClassName.ts
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を併用した例)。

Accordion.tsx
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
);

親コンポーネントから利用する。

App.tsx
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;

koyama shigehitokoyama shigehito

Tailwind CSSで使う

以下の記事にTailwind CSSでRadix UIのdata-state属性を使えるようにする方法が書かれている。
https://blog.makerx.com.au/styling-radix-ui-components-using-tailwind-css/

以下のようにtailwind.configファイルでプラグインを作成し、data-state属性にTailwind CSSのスタイルを適用する。(例としてopenを追加)

tailwind.config.cjs
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'
/>
koyama shigehitokoyama shigehito

その他のRadix UIデータステート属性への対応

tailwind.config.cjs
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}`,
      )}`
    })
  })
}
koyama shigehitokoyama shigehito

Animation

https://www.radix-ui.com/docs/primitives/overview/animation

  • CSSのkeyframesでanimationを利用できる
  • マウントとアンマウントの両方の段階をアニメーション化することができる
Dialog.module.css
@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コンポーネントで使用してみる

Dialog.tsx
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>
);

アニメーションなし

アニメーションあり

koyama shigehitokoyama shigehito

Accessibility

https://www.radix-ui.com/docs/primitives/overview/accessibility

  • WAI-ARIAオーサリングプラクティスガイドラインに従った実装となっている
  • aria属性やrole属性、フォーカス管理、キーボードナビゲーションなど
  • コントロールのラベル付けを簡単にするためにLabel primitiveが用意されている

https://www.w3.org/TR/wai-aria-1.2/
https://www.w3.org/WAI/ARIA/apg/
https://www.w3.org/TR/wai-aria-1.2/#namecalculation

koyama shigehitokoyama shigehito

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
koyama shigehitokoyama shigehito

Accordion

https://www.radix-ui.com/docs/primitives/components/accordion

【基本の例】Accordion.tsx
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.RootcollapsiblePropsを渡すとすべて閉じることができる
  • 複数要素を開くことができいるようにするにはAccordion.RoottypePropsをmultipleとする

開閉のアニメーションをつける

Accordion.module.css
@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で取得できる

koyama shigehitokoyama shigehito

Alert Dialog

https://www.radix-ui.com/docs/primitives/components/alert-dialog

【基本の例】AlertDialog.tsx
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.RootopenPropで開閉状態をonOpenChangePropで開閉状態が変化したときに呼び出されるイベントハンドラを渡せる
  • AlertDialog.PortalcontainerPropでアラートダイアログがポータルする要素をカスタマイズできる
koyama shigehitokoyama shigehito

Checkbox

https://www.radix-ui.com/docs/primitives/components/checkbox

【基本の例】Checkbox.tsx
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.RootcheckedPropの値をindeterminateとすると、開閉状態をonCheckedChangePropに渡したイベントハンドラで制御できる
koyama shigehitokoyama shigehito

Collapsible

https://www.radix-ui.com/docs/primitives/components/collapsible

【基本の例】Cllapsible.tsx
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で取得できる
koyama shigehitokoyama shigehito

Dialog

https://www.radix-ui.com/docs/primitives/components/dialog

【基本の例】Dialog.tsx
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.RootmodalPropsをtrueに設定すると、外部要素とのインタラクションが無効になり、ダイアログの内容のみがスクリーンリーダに見えるようになる
  • AlertDialogとの違いとしてContetの外部をクリックで閉じることができる(どちらもEscキーで閉じれる)