1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

この記事について

この記事では、shadcn/uiのダイアログコンポーネントをHTMLのネイティブdialog要素に置き換える実装について解説します。

対象読者

背景

shadcn/uiのダイアログは非常に便利ですが、以下のような課題を感じることがありました

  • 状態管理のためのuseStateが必要
  • 複数のダイアログがある場合の状態管理が複雑
  • フォークしたら、useRefのエラーでがち

そこで、HTMLネイティブのdialog要素を使用してシンプルなダイアログコンポーネントを作成してみました。

サンプル

スクリーンショット 2025-06-27 16.30.49.png

HTMLネイティブdialogのメリット

  • シンプルな開閉制御: HTMLのIDベースで指定して開閉できるため、useStateが不要
  • ネイティブサポート: ブラウザの標準機能なので軽量
  • 依存関係の削減: Radix UIなどの外部ライブラリに依存しない
  • フォーム処理の簡単さ: method="dialog"でフォームデータを簡単に取得可能
  • アクセシビリティ: フォーカストラップやESCキーでの閉じる機能が標準で提供
  • バックドロップ機能: ::backdrop疑似要素でオーバーレイを簡単に実装

実装

基本的な構成

native-dialog.tsx
"use client";

import { Cross2Icon } from "@radix-ui/react-icons";
import { type ComponentPropsWithoutRef, cloneElement, isValidElement, type MouseEvent, type ReactNode } from "react";
import { cn } from "@/lib/utils";

/**
 * HTML の dialog 要素を使用したダイアログコンポーネント
 *
 * 特徴: native-dialog.tsx は、htmlのdialog要素を使用して実装されたダイアログです。
 * 参考: https://developer.mozilla.org/ja/docs/Web/HTML/Reference/Elements/dialog
 * メリット: idで開閉します(useStateではなく、HTMLのdialog要素を使用するため、ダイアログの開閉がシンプルになります)
 */

ダイアログの開閉制御

native-dialog.tsx

// ダイアログを開く関数
export const openDialog = (id: string) => {
  const dialog = document.getElementById(id) as HTMLDialogElement | null;
  dialog?.showModal();
};

// ダイアログを閉じる関数
export const closeDialog = (id: string) => {
  const dialog = document.getElementById(id) as HTMLDialogElement | null;
  dialog?.close();
};

// ダイアログを切り替える関数
export const toggleDialog = (id: string) => {
  const dialog = document.getElementById(id) as HTMLDialogElement | null;
  if (dialog) {
    dialog.open ? dialog.close() : dialog.showModal();
  }
};

メインのダイアログコンポーネント

native-dialog.tsx
// ダイアログのベースコンポーネント
interface NativeDialogProps extends ComponentPropsWithoutRef<"dialog"> {
  children: ReactNode;
  onClose?: () => void;
}

const NativeDialog = ({ className, children, onClose, ...props }: NativeDialogProps) => {
  return (
    <dialog
      className={cn(
        // ネイティブdialogの位置(画面の中央位置に表示)
        "open:fixed top-[40%] left-[0%]",
        // ネイティブdialogのbackdropスタイル
        "backdrop:bg-black/80 backdrop:backdrop-blur-sm",
        // アニメーション(openの時)
        "open:animate-in open:fade-in-0 open:duration-200",
        // shadcn ライクなダイアログのスタイル
        "w-full max-w-lg h-fit bg-background rounded-lg p-6 space-y-4 text-foreground shadow-xl border",
        className,
      )}
      {...props}
    >
      {children}
    </dialog>
  );
};

NativeDialog.displayName = "NativeDialog";

各種サブコンポーネント

native-dialog.tsx
// ダイアログのクローズボタン
interface NativeDialogCloseProps extends ComponentPropsWithoutRef<"button"> {
  dialogId: string;
}

const NativeDialogClose = ({ className, dialogId, ...props }: NativeDialogCloseProps) => (
  <button
    type="button"
    onClick={() => closeDialog(dialogId)}
    className={cn(
      "absolute right-4 top-4 z-10 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none",
      "hover:bg-accent text-foreground",
      "h-8 w-8 flex items-center justify-center",
      className,
    )}
    {...props}
  >
    <Cross2Icon className="h-4 w-4" />
    <span className="sr-only">Close</span>
  </button>
);

NativeDialogClose.displayName = "NativeDialogClose";

// ダイアログのヘッダー
const NativeDialogHeader = ({ className, ...props }: ComponentPropsWithoutRef<"div">) => (
  <div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
);

NativeDialogHeader.displayName = "NativeDialogHeader";

// ダイアログのフッター
const NativeDialogFooter = ({ className, ...props }: ComponentPropsWithoutRef<"div">) => (
  <div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
);

NativeDialogFooter.displayName = "NativeDialogFooter";

// ダイアログのタイトル
const NativeDialogTitle = ({ className, ...props }: ComponentPropsWithoutRef<"h2">) => (
  <h2 className={cn("text-lg font-semibold leading-none tracking-tight", className)} {...props} />
);

NativeDialogTitle.displayName = "NativeDialogTitle";

// ダイアログの説明
const NativeDialogDescription = ({ className, ...props }: ComponentPropsWithoutRef<"p">) => (
  <p className={cn("text-sm text-muted-foreground", className)} {...props} />
);

NativeDialogDescription.displayName = "NativeDialogDescription";

// トリガーボタン
interface NativeDialogTriggerProps extends ComponentPropsWithoutRef<"button"> {
  dialogId: string;
  asChild?: boolean;
}

const NativeDialogTrigger = ({ dialogId, onClick, asChild, children, ...props }: NativeDialogTriggerProps) => {
  const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
    openDialog(dialogId);
    onClick?.(e);
  };

  if (asChild && isValidElement(children)) {
    return cloneElement(children, {
      ...children.props,
      onClick: handleClick,
    });
  }

  return (
    <button type="button" onClick={handleClick} {...props}>
      {children}
    </button>
  );
};

NativeDialogTrigger.displayName = "NativeDialogTrigger";

export {
  NativeDialog,
  NativeDialogClose,
  NativeDialogHeader,
  NativeDialogFooter,
  NativeDialogTitle,
  NativeDialogDescription,
  NativeDialogTrigger,
};
native-dialog.tsx 全体コード
native-dialog.tsx
"use client";

import { Cross2Icon } from "@radix-ui/react-icons";
import { type ComponentPropsWithoutRef, cloneElement, isValidElement, type MouseEvent, type ReactNode } from "react";
import { cn } from "@/lib/utils";

/**
 * ネイティブダイアログのコンポーネント
 *
 * 特徴: ネイティブダイアログは、htmlのdialogを使用して実装されたダイアログです。
 * 参考: https://developer.mozilla.org/ja/docs/Web/HTML/Reference/Elements/dialog
 * メリット: idで開閉します(useStateではなく、ネイティブのdialogを使用するため、ダイアログの開閉がシンプルになります)
 * 使い方: ダイアログを拡張して使用するときは、native-dialog.tsxを拡張して使用してください。
 */

// ダイアログを開く関数
export const openDialog = (id: string) => {
  const dialog = document.getElementById(id) as HTMLDialogElement | null;
  dialog?.showModal();
};

// ダイアログを閉じる関数
export const closeDialog = (id: string) => {
  const dialog = document.getElementById(id) as HTMLDialogElement | null;
  dialog?.close();
};

// ダイアログを切り替える関数
export const toggleDialog = (id: string) => {
  const dialog = document.getElementById(id) as HTMLDialogElement | null;
  if (dialog) {
    dialog.open ? dialog.close() : dialog.showModal();
  }
};

// ダイアログのベースコンポーネント
interface NativeDialogProps extends ComponentPropsWithoutRef<"dialog"> {
  children: ReactNode;
  onClose?: () => void;
}

const NativeDialog = ({ className, children, onClose, ...props }: NativeDialogProps) => {
  return (
    <dialog
      className={cn(
        // ネイティブdialogの位置(画面の中央位置に表示)
        "open:fixed top-[40%] left-[0%]",
        // ネイティブdialogのbackdropスタイル
        "backdrop:bg-black/80 backdrop:backdrop-blur-sm",
        // アニメーション(openの時)
        "open:animate-in open:fade-in-0 open:duration-200",
        // shadcn ライクなダイアログのスタイル
        "w-full max-w-lg bg-background rounded-lg p-6 space-y-4 text-foreground shadow-xl border",
        className,
      )}
      {...props}
    >
      {children}
    </dialog>
  );
};

NativeDialog.displayName = "NativeDialog";

// ダイアログのクローズボタン
interface NativeDialogCloseProps extends ComponentPropsWithoutRef<"button"> {
  dialogId: string;
}

const NativeDialogClose = ({ className, dialogId, ...props }: NativeDialogCloseProps) => (
  <button
    type="button"
    onClick={() => closeDialog(dialogId)}
    className={cn(
      "absolute right-4 top-4 z-10 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none",
      "hover:bg-accent text-foreground",
      "h-8 w-8 flex items-center justify-center",
      className,
    )}
    {...props}
  >
    <Cross2Icon className="h-4 w-4" />
    <span className="sr-only">Close</span>
  </button>
);

NativeDialogClose.displayName = "NativeDialogClose";

// ダイアログのヘッダー
const NativeDialogHeader = ({ className, ...props }: ComponentPropsWithoutRef<"div">) => (
  <div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
);

NativeDialogHeader.displayName = "NativeDialogHeader";

// ダイアログのフッター
const NativeDialogFooter = ({ className, ...props }: ComponentPropsWithoutRef<"div">) => (
  <div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
);

NativeDialogFooter.displayName = "NativeDialogFooter";

// ダイアログのタイトル
const NativeDialogTitle = ({ className, ...props }: ComponentPropsWithoutRef<"h2">) => (
  <h2 className={cn("text-lg font-semibold leading-none tracking-tight", className)} {...props} />
);

NativeDialogTitle.displayName = "NativeDialogTitle";

// ダイアログの説明
const NativeDialogDescription = ({ className, ...props }: ComponentPropsWithoutRef<"p">) => (
  <p className={cn("text-sm text-muted-foreground", className)} {...props} />
);

NativeDialogDescription.displayName = "NativeDialogDescription";

// トリガーボタン
interface NativeDialogTriggerProps extends ComponentPropsWithoutRef<"button"> {
  dialogId: string;
  asChild?: boolean;
}

const NativeDialogTrigger = ({ dialogId, onClick, asChild, children, ...props }: NativeDialogTriggerProps) => {
  const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
    openDialog(dialogId);
    onClick?.(e);
  };

  if (asChild && isValidElement(children)) {
    return cloneElement(children, {
      ...children.props,
      onClick: handleClick,
    });
  }

  return (
    <button type="button" onClick={handleClick} {...props}>
      {children}
    </button>
  );
};

NativeDialogTrigger.displayName = "NativeDialogTrigger";

export {
  NativeDialog,
  NativeDialogClose,
  NativeDialogHeader,
  NativeDialogFooter,
  NativeDialogTitle,
  NativeDialogDescription,
  NativeDialogTrigger,
};

使用例

example.tsx
import {
  NativeDialog,
  NativeDialogClose,
  NativeDialogHeader,
  NativeDialogFooter,
  NativeDialogTitle,
  NativeDialogDescription,
  NativeDialogTrigger,
} from "@/components/native-dialog";

export default function Example() {
  const dialogId = "example-dialog";

  return (
    <div>
      <NativeDialogTrigger dialogId={dialogId}>
        ダイアログを開く
      </NativeDialogTrigger>

      <NativeDialog id={dialogId}>
        <NativeDialogClose dialogId={dialogId} />
        <NativeDialogHeader>
          <NativeDialogTitle>確認</NativeDialogTitle>
          <NativeDialogDescription>
            この操作を実行してもよろしいですか?
          </NativeDialogDescription>
        </NativeDialogHeader>
        <NativeDialogFooter>
          <button onClick={() => closeDialog(dialogId)}>
            キャンセル
          </button>
          <button onClick={() => {
            // 何かの処理
            closeDialog(dialogId);
          }}>
            実行
          </button>
        </NativeDialogFooter>
      </NativeDialog>
    </div>
  );
}

※この記事では割愛しますが、ダイアログにセットされたフォームのデータを取得する方法については、以下の記事をご覧ください。
必須フォーム入力付きのダイアログを閉じる

実装のポイント

1. IDベースの開閉制御

従来のshadcnダイアログ:

const [open, setOpen] = useState(false);

ネイティブダイアログ:

const dialogId = "my-dialog";
openDialog(dialogId); // 開く
closeDialog(dialogId); // 閉じる

2. CSSでのスタイリング

ネイティブdialog要素の特徴的なスタイリングポイント:

  • open:修飾子:ダイアログが開いている時のスタイル
  • backdrop:疑似要素:オーバーレイのスタイル
  • fixedポジション:モーダル表示の位置制御
.dialog {
  /* ダイアログが開いている時のみ適用 */
  @apply open:fixed open:animate-in open:fade-in-0;

  /* バックドロップのスタイル */
  @apply backdrop:bg-black/80 backdrop:backdrop-blur-sm;
}

shadcn/uiとの比較

項目 shadcn/ui ネイティブdialog
状態管理 useState必要 ID指定のみ
依存関係 Radix UI必要 ネイティブのみ
フォーム処理 手動実装 method="dialog"で簡単
バンドルサイズ 大きめ 軽量
アクセシビリティ 手動実装 ネイティブサポート
ブラウザサポート 全て モダンブラウザのみ

注意点

ブラウザサポート

HTMLのdialog要素は比較的新しい仕様ですが、だいたいのブラウザでサポートされたようです。

IE11などの古いブラウザをサポートする必要がある場合は、ポリフィルを使用するか、従来のshadcnダイアログを使用することをおすすめします。

まとめ

HTMLネイティブのdialog要素を使用することで、以下のメリットが得られました:

  • シンプルな状態管理: useStateが不要でIDベースの開閉制御
  • 軽量な実装: ネイティブ機能を活用してバンドルサイズを削減
  • 依存関係の削減: Radix UIなどの外部ライブラリが不要
  • shadcnライクなAPI: 既存のコードからの移行が容易

モダンブラウザ環境であれば、ネイティブdialogを活用することで、よりシンプルで軽量なダイアログ実装が可能になります。
特に複数のダイアログを扱うアプリケーションでは、状態管理の複雑さを大幅に軽減できるのでおすすめです。

現場からは以上です! :santa:

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?