この記事について
この記事では、shadcn/uiのダイアログコンポーネントをHTMLのネイティブdialog
要素に置き換える実装について解説します。
対象読者
- shadcn/uiのダイアログを使用したことがある方
- HTMLのダイアログに興味がある方
背景
shadcn/uiのダイアログは非常に便利ですが、以下のような課題を感じることがありました
- 状態管理のためのuseStateが必要
- 複数のダイアログがある場合の状態管理が複雑
- フォークしたら、useRefのエラーでがち
そこで、HTMLネイティブのdialog
要素を使用してシンプルなダイアログコンポーネントを作成してみました。
サンプル
HTMLネイティブdialogのメリット
- シンプルな開閉制御: HTMLのIDベースで指定して開閉できるため、useStateが不要
- ネイティブサポート: ブラウザの標準機能なので軽量
- 依存関係の削減: Radix UIなどの外部ライブラリに依存しない
-
フォーム処理の簡単さ:
method="dialog"
でフォームデータを簡単に取得可能 - アクセシビリティ: フォーカストラップやESCキーでの閉じる機能が標準で提供
-
バックドロップ機能:
::backdrop
疑似要素でオーバーレイを簡単に実装
実装
基本的な構成
"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要素を使用するため、ダイアログの開閉がシンプルになります)
*/
ダイアログの開閉制御
// ダイアログを開く関数
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 h-fit 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,
};
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,
};
使用例
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
を活用することで、よりシンプルで軽量なダイアログ実装が可能になります。
特に複数のダイアログを扱うアプリケーションでは、状態管理の複雑さを大幅に軽減できるのでおすすめです。
現場からは以上です!