Web Componentsで作る、Radix UIライクなDialogコンポーネント

2025/02/11に公開

はじめに

Web Componentsで作る、Radix UIライクなAccordionコンポーネントに続き、今回はRadix UIライクなDialogを作成していきます。
Radix UIでは独自のdialogコンポーネントを実装していますが、独自実装よりも多くのメリットを享受できるため、今回は<dialog>要素を拡張する<ui-dialog>カスタムコンポーネントを作成します。

作成する<ui-dialog>の仕様

今回作成する<ui-dialog>(カスタムコンポーネント)は、以下の仕様とします。

  • WAI-ARIAに準拠したアクセシビリティ対応。
  • モーダルダイアログとして機能する。
  • open属性で開閉状態を制御する(boolean)。
  • modal属性でモーダルか(デフォルト)、モードレスかを選択可能。
  • closedby属性で、ダイアログ外をクリックした時に閉じるかどうかを制御。デフォルトはauto(モーダルの場合閉じ、モードレスの場合は閉じない)。他、any(常に閉じる)、none(閉じない)、closerequestdialogのclose requestでのみ閉じる)が指定可能。
  • onOpenChangeカスタムイベントで、開閉状態の変化を外部に通知。
  • Escキーでダイアログを閉じることができる(closedby="none"の場合は除く)。
  • <ui-dialog>外にトリガーを設置可能
  • <ui-dialog-trigger>でダイアログを開くトリガーとなる要素を指定できる。
  • <ui-dialog-close>でダイアログを閉じるトリガーとなる要素を指定できる。
  • <ui-dialog-title>でダイアログのタイトルを指定できる。
  • <ui-dialog-description>でダイアログの説明文を指定できる。

<ui-dialog>のメリット

<dialog>を直接利用する場合、dialogを開くためのJS処理の作成が必須になりますが、<ui-dialog>カスタムコンポーネントを使うことで、htmlの記述だけで<dialog>の実装が可能となります。

実装サンプル

開発環境

TypeScriptとzustandをインストールした環境を用意してください。
サンプルコードはCodepenなどでもJSの設定JavaScript PreprocessorTypeScriptを選択することで動作が可能です。

Dialogコンポーネントの実装

1. 状態管理ストア (Zustand)の準備

まず、Dialogの状態を管理するために、Zustandという軽量な状態管理ライブラリを使用します。
Zustandは主にReactで利用されるライブラリですが、vanillajsでも利用が可能です。
ここでは、Zustandのインポート、属性設定を簡略化するユーティリティ関数を準備します。

import { createStore } from "zustand/vanilla";
import { subscribeWithSelector } from "zustand/middleware";

// ユーティリティ関数:属性の一括設定
const setAttrsElement = (
  element: HTMLElement | null,
  attributes: { [key: string]: string | undefined }
) => {
  for (const [key, value] of Object.entries(attributes)) {
    if (value === undefined) {
      element?.removeAttribute(key);
    } else {
      element?.setAttribute(key, value);
    }
  }
};

2. UiDialog (Dialog Rootコンポーネント)

Dialog全体の動作を制御するコンポーネントです。
modalclosedbyの状態を管理し、状態の変更を伝播させます。Zustandのsubscribeを使うことで、状態の変更を監視し、リアクティブにUIを更新します。これにより、命令的にDOMを操作するのではなく、宣言的なスタイルでUIを構築できます。
reactのuseEffectでUIの更新を行なっているイメージです。

開いてUiDialogの実装を確認する
export type DialogClosedby = 'any' | 'closerequest' | 'none';
export type DialogClosedbyDefault = 'auto';

const getClosedby = (value: string | null): DialogClosedby | DialogClosedbyDefault => {
  if (value === 'any' || value === 'closerequest' || value === 'none') {
    return value as DialogClosedby;
  }
  return 'auto';
};

interface DialogStoreState {
  open: boolean;
  modal: boolean;
  closedby: DialogClosedby | DialogClosedbyDefault;
  dialogId: string;
  titleId: string;
  descriptionId: string;
}

export class UiDialog extends HTMLElement {
  private $dialog: HTMLDialogElement | null = null;

  unsubscribe: (() => void) | undefined = undefined;
  useRootStore = createStore(
    subscribeWithSelector<DialogStoreState>((set) => ({
      open: false,
      modal: false,
      closedby: 'auto',
      dialogId: `dialog-${Math.random().toString(36).slice(2)}`,
      titleId: `dialog-title-${Math.random().toString(36).slice(2)}`,
      descriptionId: `dialog-description-${Math.random().toString(36).slice(2)}`,
    })),
  );

  static get observedAttributes() {
    return ['open', 'modal', 'closedby'];
  }

  constructor() {
    super();
  }

  connectedCallback(): void {
    const open = this.hasAttribute('open');
    const modal = this.getAttribute('modal') !== 'false'; // 'false'の場合のみfalse
    const closedby = getClosedby(this.getAttribute('closedby'));
    const $title = this.querySelector('ui-dialog-title:not(:scope ui-dialog *)');
    const $description = this.querySelector('ui-dialog-description:not(:scope ui-dialog *)');
    this.$dialog = this.querySelector('dialog:not(:scope ui-dialog *)');
    const dialogId = this.$dialog?.getAttribute('id') ?? this.useRootStore.getState().dialogId;
    const titleId = $title?.getAttribute('id') ?? this.useRootStore.getState().titleId;
    const descriptionId = $description?.getAttribute('id') ?? this.useRootStore.getState().descriptionId;

    // 初期状態を store に反映
    this.useRootStore.setState({
      open,
      modal,
      closedby,
      dialogId,
      titleId,
      descriptionId,
    });

    // data-state設定
    setAttrsElement(this, {
      'data-state': open ? 'open' : 'closed',
    });

    this.unsubscribe = this.useRootStore.subscribe(
      (state) => state.open,
      (open) => {
        setAttrsElement(this, {
          'data-state': open ? 'open' : 'closed',
        });
        // 外部に通知するカスタムイベントを発火
        this.dispatchEvent(
          new CustomEvent('onOpenChange', {
            detail: { open: open, target: this },
          }),
        );
      },
    );
  }

  disconnectedCallback(): void {}

  attributeChangedCallback(property: string, oldValue: string | null, newValue: string | null) {
    // openの変更で表示を切り替え
    if (property === 'open' && newValue !== oldValue) {
      if (newValue === null) {
        this.close();
      } else {
        const { modal } = this.useRootStore.getState();
        if (modal) {
          this.showModal();
        } else {
          this.show();
        }
      }
      this.useRootStore.setState({
        open: newValue === null,
      });
    }
    // modal, closedbyの変更をstoreに反映
    if (property === 'modal' && newValue !== oldValue) {
      this.useRootStore.setState({
        modal: this.getAttribute('modal') !== 'false', // 'false'の場合のみfalse
      });
    }
    if (property === 'closedby' && newValue !== oldValue) {
      this.useRootStore.setState({
        closedby: getClosedby(newValue),
      });
    }
  }

  showModal(): void {
    this.$dialog?.showModal();
    this.useRootStore.setState({
      open: true,
    });
  }
  close(): void {
    this.$dialog?.close();
    this.useRootStore.setState({
      open: false,
    });
  }
  show(): void {
    this.$dialog?.show();
    this.useRootStore.setState({
      open: true,
    });
  }
}

Prop

プロパティ名 説明 デフォルト値 備考
open ダイアログの開閉状態。trueで開く、falseで閉じる。 false 外部から開閉状態を制御する場合に使用。
modal ダイアログをモーダル表示するかどうか。trueでモーダル、falseでモードレス。 true
closedby ダイアログを閉じる方法。
auto(モーダルの場合は外側クリックとEscで閉じ、モードレスの場合は閉じない)
any(常に外側クリックとEscで閉じる)
none(閉じない)
closerequest(dialogのclose requestイベントでのみ閉じる)
auto

Attribute

属性名 説明 デフォルト値 備考
data-state open | closed -

onOpenChangeカスタムイベントの使い方

UiDialogコンポーネントは、開閉状態が変更されたときにonOpenChangeカスタムイベントを発火します。
このイベントをリッスンすることで、Dialogの状態変化を外部から検知し、他の処理と連携できます。

<ui-dialog id="my-dialog">
~~~省略~~~
</ui-dialog>

<script>
  const dialog = document.getElementById("my-dialog");
  dialog.addEventListener("onOpenChange", (event) => {
    console.log("Dialog open state changed:", event.detail.open);
    // ここで、event.detail.open を使って何か処理を行う
  });
</script>
  • event.detail.open: ダイアログの開閉状態(true or false)。

3. UiDialogTrigger (Dialogを開くトリガー)

Dialogを開くためのトリガーとなるコンポーネントです。
<ui-dialog-trigger>タグでラップした<button>要素のclickイベントをリッスンし、Dialogのopen状態をtrueに更新します。(正確には、modal属性に応じてshowModal()またはshow()を呼び出します。)

アクセシビリティ属性

<button>要素には、アクセシビリティをのため、以下の属性が付与されます。

属性名 説明
aria-haspopup="dialog" この属性は、ボタンがダイアログを開くトリガーであることを示します。
aria-expanded="false" この属性は、ダイアログが現在閉じていることを示します。ダイアログが開かれるとtrueに更新されます。
aria-controls="dialog-xxxxx" この属性は、ボタンが制御するダイアログのIDを指定します。これにより、スクリーンリーダーはどの要素が関連しているかを理解できます。
<ui-dialog-trigger>
<button type="button" aria-haspopup="dialog" aria-expanded="false" aria-controls="dialog-xxxxx" data-state="closed">open</button>
</ui-dialog-trigger>
開いてUiDialogTriggerの実装を確認する
export class UiDialogTrigger extends HTMLElement {
  private $root: UiDialog | null = null;
  private $button: HTMLButtonElement | null = null;

  constructor() {
    super();
  }

  connectedCallback(): void {
    this.$root = this.closest('ui-dialog');
    if (!this.$root) {
      console.error('ui-dialog-trigger must be child of ui-dialog');
      return;
    }

    this.$button = this.querySelector('button:not(:scope ui-dialog *)');
    const { dialogId, open } = this.$root.useRootStore.getState();

    setAttrsElement(this.$button, {
      type: 'button',
      'aria-haspopup': 'dialog',
      'aria-expanded': open ? 'true' : 'false',
      'aria-controls': dialogId,
      'data-state': open ? 'open' : 'closed',
    });

    this.$button?.addEventListener('click', this.handleClick);

    this.$root.useRootStore.subscribe(
      (state) => state.open,
      (open) => {
        setAttrsElement(this, {
          'data-state': open ? 'open' : 'closed',
        });
        setAttrsElement(this.$button, {
          'aria-expanded': open ? 'true' : 'false',
          'data-state': open ? 'open' : 'closed',
        });
      },
    );
  }

  disconnectedCallback(): void {
    this.$button?.removeEventListener('click', this.handleClick);
  }

  private handleClick = (): void => {
    if (!this.$root) return;

    const { modal } = this.$root.useRootStore.getState();
    if (modal) {
      this.$root.showModal();
    } else {
      this.$root.show();
    }
  };
}

Attribute

属性名 説明 デフォルト値 備考
data-state open | closed -

4. UiDialogContent (Dialogのコンテンツ)

Dialogのコンテンツ部分を表すコンポーネントです。
基本的にはネイティブの<dialog>の機能を利用しますが、closedby属性についてはまだ利用できるブラウザが限られるため独自に実装します。
具体的には<dialog>をクリックした時にborderの外側であればcloseイベントを発火するようにしています。

開いてUiDialogContentの実装を確認する
export class UiDialogContent extends HTMLElement {
  private $root: UiDialog | null = null;
  private $dialog: HTMLDialogElement | null = null;
  private unsubscribe: (() => void) | undefined = undefined;
  private observer: MutationObserver | null = null;

  connectedCallback(): void {
    this.$root = this.closest('ui-dialog');
    if (!this.$root) {
      console.error('ui-accordion-content must be child of ui-dialog');
      return;
    }
    this.$dialog = this.$root.querySelector('dialog:not(:scope ui-dialog *)');
    if (!this.$dialog || this.$dialog.tagName !== 'DIALOG') {
      console.error('<dialog> is required as a child element of ui-dialog-content');
      return;
    }

    const { open, modal, dialogId, titleId, descriptionId } = this.$root.useRootStore.getState();

    setAttrsElement(this, {
      'data-state': open ? 'open' : 'closed',
    });
    setAttrsElement(this.$dialog, {
      id: dialogId,
      'aria-labelledby': titleId,
      'aria-describedby': descriptionId,
      'data-state': open ? 'open' : 'closed',
    });

    // 初期状態がopenならshowModal()
    if (open) {
      if (modal) {
        this.$dialog?.showModal();
      } else {
        this.$dialog?.show();
      }
    }

    this.unsubscribe = this.$root.useRootStore.subscribe(
      (state) => state.open,
      (open) => {
        if (open) {
          this.$dialog?.addEventListener('keydown', this.handleStopPropagationEscape);
          this.$dialog?.addEventListener('click', this.handleLightDismiss);
        } else {
          this.$dialog?.removeEventListener('keydown', this.handleStopPropagationEscape);
          this.$dialog?.removeEventListener('click', this.handleLightDismiss);
        }
        setAttrsElement(this, {
          'data-state': open ? 'open' : 'closed',
        });
        setAttrsElement(this.$dialog, {
          'data-state': open ? 'open' : 'closed',
        });
      },
    );
  }
  disconnectedCallback(): void {
    if (this.unsubscribe) this.unsubscribe();

    // observerを停止
    this.observer?.disconnect();
    this.observer = null;
  }

  private handleStopPropagationEscape = (event: Event): void => {
    const state = this.$root?.useRootStore.getState();
    // closedby='none'の場合、Escapeキー(Close request)イベントを止める
    // https://html.spec.whatwg.org/#close-request
    if (state?.open && state?.closedby === 'none' && event instanceof KeyboardEvent && event.key === 'Escape') {
      event.stopImmediatePropagation();
      event.preventDefault();
    }
  };

  private handleLightDismiss = (event: MouseEvent): void => {
    const state = this.$root?.useRootStore.getState();
    const target = event.target as Element;
    // closedby='any'の場合、dialog背景クリックで閉じる
    if (state?.open && state?.closedby === 'any' && this.$dialog && target) {
      if (target.nodeName === 'DIALOG') {
        const rect = target.getBoundingClientRect();

        // クリック座標 (ビューポート基準)
        const clickX = event.clientX;
        const clickY = event.clientY;

        // 要素内かどうかを判定
        const isInside = clickX >= rect.left && clickX <= rect.right && clickY >= rect.top && clickY <= rect.bottom;

        if (!isInside) {
          this.$dialog.close('dismiss');
        }
      }
    }
  };
}

Attribute

属性名 説明 デフォルト値 備考
data-state open | closed -

5. UiDialogClose (Dialogを閉じるトリガー)

Dialogを閉じるためのトリガーとなるコンポーネントです。
<ui-dialog-close>タグでラップしたボタン要素のclickイベントをリッスンし、Dialogを閉じます。
サンプルのhtmlではこのボタン要素にautofocus属性を付与しています。

<dialog>: ダイアログ要素 - HTML: ハイパーテキストマークアップ言語 | MDN

autofocus 属性を使用して初期フォーカスの配置を明確に示すと、特定のダイアログに対して最適な初期フォーカスの配置とみなされる要素に初期フォーカスが設定するのに役立ちます。ダイアログの初期フォーカスがどこに設定されるか常にわからない場合、特にダイアログのコンテンツが呼び出されたときに動的に描画される場合、必要であれば <dialog> 要素そのものにフォーカスを当てることが、初期フォーカスの配置として最適と判断されるかもしれません。

開いてUiDialogCloseの実装を確認する
export class UiDialogClose extends HTMLElement {
  private $root: UiDialog | null = null;
  private $button: HTMLButtonElement | null = null;

  constructor() {
    super();
  }

  connectedCallback(): void {
    this.$root = this.closest('ui-dialog');
    if (!this.$root) {
      console.error('ui-dialog-close must be child of ui-dialog');
      return;
    }

    this.$button = this.querySelector('button:not(:scope ui-dialog *)');

    setAttrsElement(this.$button, {
      type: 'button',
    });

    this.$button?.addEventListener('click', this.handleClick);
  }

  disconnectedCallback(): void {
    this.$button?.removeEventListener('click', this.handleClick);
  }

  // $rootのclose関数を実行しダイアログを閉じる
  private handleClick = (): void => {
    this.$root?.close();
  };
}

6. UiDialogTitle (Dialogのタイトル)

Dialogのタイトルを表すコンポーネントです。
id属性を持たない場合、自動的に付与し、aria-labelledby属性でDialog Contentと関連付けます。

開いてUiDialogTitleの実装を確認する
export class UiDialogTitle extends HTMLElement {
  private $root: UiDialog | null = null;
  connectedCallback(): void {
    this.$root = this.closest('ui-dialog');
    if (!this.$root) {
      console.error('ui-accordion-title must be child of ui-dialog-content');
      return;
    }
    const { titleId } = this.$root.useRootStore.getState();
    setAttrsElement(this, {
      id: titleId,
    });
  }
  disconnectedCallback(): void {}
}

7. UiDialogDescription (Dialogの説明)

Dialogの説明を表すコンポーネントです。
id属性を持たない場合、自動的に付与し、aria-describedby属性でDialog Contentと関連付けます。

開いてUiDialogDescriptionの実装を確認する
export class UiDialogDescription extends HTMLElement {
  private $root: UiDialog | null = null;
  connectedCallback(): void {
    this.$root = this.closest('ui-dialog');
    if (!this.$root) {
      console.error('ui-accordion-description must be child of ui-dialog-content');
      return;
    }

    const { descriptionId } = this.$root.useRootStore.getState();
    setAttrsElement(this, {
      id: descriptionId,
    });
  }
  disconnectedCallback(): void {}
}

8. UiDialogOutsideTrigger(Dialog外のトリガー)

<ui-dialog>外に設置するトリガーです。data-target属性に<ui-dialog>を取得できるCSS Selectorを指定することで、対象のダイアログを開きます。
<ui-dialog>とトリガーボタンを分けて配置したい場合などに利用します。

一般的なHTMLでのWeb制作においては、トリガーボタンとDialog要素を別々の場所に配置することで、コードの見通しが向上します。そのため、Radix UIには存在しないコンポーネントを独自に用意しました。

開いてUiDialogOutsideTriggerの実装を確認する
export class UiDialogOutsideTrigger extends HTMLElement {
  private $root: UiDialog | null = null;
  private $button: HTMLButtonElement | null = null;

  constructor() {
    super();
  }

  connectedCallback(): void {
    const target = this.getAttribute('data-target') || '';
    const $targetRoot = document.querySelector(target);
    if (!$targetRoot) {
      console.error('Target ui-dialog is not found');
      return;
    }
    if ($targetRoot.tagName !== 'UI-DIALOG') {
      console.error('The target is not <ui-dialog>');
      return;
    }
    this.$root = $targetRoot as UiDialog;
    this.$button = this.querySelector('button');
    const { dialogId, open } = this.$root.useRootStore.getState();

    setAttrsElement(this.$button, {
      type: 'button',
      'aria-haspopup': 'dialog',
      'aria-expanded': open ? 'true' : 'false',
      'aria-controls': dialogId,
      'data-state': open ? 'open' : 'closed',
    });

    this.$button?.addEventListener('click', this.handleClick);

    this.$root.useRootStore.subscribe(
      (state) => state.open,
      (open) => {
        setAttrsElement(this, {
          'data-state': open ? 'open' : 'closed',
        });
        setAttrsElement(this.$button, {
          'aria-expanded': open ? 'true' : 'false',
          'data-state': open ? 'open' : 'closed',
        });
      },
    );
  }

  disconnectedCallback(): void {
    this.$button?.removeEventListener('click', this.handleClick);
  }

  private handleClick = (): void => {
    if (!this.$root) return;

    const { modal } = this.$root.useRootStore.getState();
    if (modal) {
      this.$root.showModal();
    } else {
      this.$root.show();
    }
  };
}

9. カスタム要素の定義

作成したWeb Componentsをカスタム要素として登録します。

customElements.define('ui-dialog', UiDialog);
customElements.define('ui-dialog-trigger', UiDialogTrigger);
customElements.define('ui-dialog-close', UiDialogClose);
customElements.define('ui-dialog-content', UiDialogContent);
customElements.define('ui-dialog-title', UiDialogTitle);
customElements.define('ui-dialog-description', UiDialogDescription);
customElements.define('ui-dialog-outside-trigger', UiDialogOutsideTrigger);

また、グローバルに型を宣言します。

declare global {
 interface HTMLElementTagNameMap {
   'ui-dialog': UiDialog;
   'ui-dialog-trigger': UiDialogTrigger;
   'ui-dialog-close': UiDialogClose;
   'ui-dialog-content': UiDialogContent;
   'ui-dialog-title': UiDialogTitle;
   'ui-dialog-description': UiDialogDescription;
   'ui-dialog-outside-trigger': UiDialogOutsideTrigger;
 }
}

HTMLでの使用例

HTML

<ui-dialog>
  <ui-dialog-trigger><button>open</button></ui-dialog-trigger>
  <ui-dialog-content>
    <dialog>
      <ui-dialog-close><button autofocus>close</button></ui-dialog-close>
      <ui-dialog-title><h2>タイトル</h2></ui-dialog-title>
      <ui-dialog-description><p>説明</p></ui-dialog-description>
      <p>コンテンツ</p>
    </dialog>
  </ui-dialog-content>
</ui-dialog>

CSS

任意の方法でスタイリングします。
dialogの開閉アニメーションはMDNで紹介されている@starting-styleによるtransitionを付与しています。

<dialog>: ダイアログ要素 - HTML: ハイパーテキストマークアップ言語 | MDN

CSS では、@starting-style ブロックを記述して、opacity および transform プロパティのトランジション開始時のスタイル、dialog[open] 状態のトランジション終了時のスタイル、<dialog> が表示された後に元の状態に戻る際の既定の dialog 状態のスタイルを定義します。注意してほしいのは、 <dialog>transition リストには、これらのプロパティだけでなく、displayoverlay プロパティも含まれ、それぞれに allow-discrete が設定されていることです。

また、開いたときに現れる <dialog> の背後に現れる ::backdropbackground-color プロパティに開始時のスタイル値を設定し、素敵な暗転アニメーションを指定しました。 dialog[open]::backdrop セレクターは、ダイアログが開いているときに、<dialog> 要素の背景のみを選択します。

開いてCSSを確認する
:where:is(ui-dialog, ui-dialog-trigger, ui-dialog-content, ui-dialog-close, ui-dialog-title, ui-dialog-description) {
  display: block;
}
ui-dialog-outside-trigger button,
ui-dialog-trigger button {
  text-align: left;
  padding: 10px;
  background-color: #eee;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
ui-dialog-content dialog {
  border: 1px solid #ccc;
  border-radius: 4px;
  padding: 10px;
  width: 100%;
  max-width: 500px;
  position: relative;
}
ui-dialog-close button {
  font-size: 0.8rem;
  position: absolute;
  top: 10px;
  right: 10px;
}
ui-dialog-title h2 {
  font-weight: bold;
  font-size: 1.4rem;
}
dialog::backdrop {
  background-color: rgba(0, 0, 0, 0.5);
}



/*   開いた状態のダイアログ  */
ui-dialog-content dialog[open] {
  opacity: 1;
}

/* ※safari対策 */
ui-dialog:not([modal="false"]) ui-dialog-content dialog {
  position: fixed;
  inset-block: 0;
}

/*   閉じた状態のダイアログ   */
ui-dialog-content dialog {
  opacity: 0;
  transition:
    opacity 0.15s ease-out,
    overlay 0.15s ease-out allow-discrete,
    display 0.15s ease-out allow-discrete;
  /* transition: all 0.15s allow-discrete;
と等しい*/
}

/*   開く前の状態  */
/* 詳細度が同じであるため、前の dialog[open] ルールの後に置かなければ効果がありません */
@starting-style {
  ui-dialog-content dialog[open] {
    opacity: 0;
  }
}

/* ダイアログがモーダルで最上位に来た場合に :backdrop をトランジションする */
ui-dialog-content dialog::backdrop {
  background-color: rgb(0 0 0 / 0%);
  transition:
    display 0.15s allow-discrete,
    overlay 0.15s allow-discrete,
    background-color 0.15s;
  /* transition: all 0.15s allow-discrete;
と等しい */
}

ui-dialog-content dialog[open]::backdrop {
  background-color: rgb(0 0 0 / 50%);
}

/* この開始スタイル設定ルールは、上記のセレクター内にネストすることができません。
入れ子セレクターは擬似要素を表すことができないからです。 */

@starting-style {
  ui-dialog-content dialog[open]::backdrop {
    background-color: rgb(0 0 0 / 0%);
  }
}

まとめ

この記事では、Web Componentsを使って、Radix UIライクなDialogコンポーネントを実装する方法を解説しました。
Web Componentsの主要な概念(Custom Elements)と、Zustandによる状態管理を組み合わせることで、
フレームワークに依存しない、再利用可能で保守性の高いコンポーネントを作成できることができます。

特にコーポレートweb開発のようなページごとに個別のhtmlやcss、jsが利用され、複数の作業者がいるような環境では便利に使えるのではないでしょうか。

次はTooltipとTabsの作成を予定しています。

以上です。

Discussion