Variants

Learn how to build and customize modal variants

Variants

Variants are the shells that render your modal content.

Key ideas

  • Variants are just components: they receive your modal props and children, and they render whatever UI you want.
  • You can fully customize the layout, transitions, and interaction model for each variant.
  • Helper hooks such as useModal and useModalStore give you access to the active modal's metadata and lifecycle helpers inside the variant body.
  • Any component library works for variants—compose it with Better Modal's hooks to control open/close state.

Hooks

  • useModal() returns the active modal instance
  • useModalStore() exposes helpers like dismiss and remove to orchestrate closing and cleanup.
const modal = useModal();
const store = useModalStore();

store.dismiss(modal.id); // Close the modal
store.remove(modal.id); // Remove once your exit animation completes

Registering variants

Once you have a variant component, register it when creating your modal collection:

import { betterModal } from "better-modal";
import { ShadcnDialog } from "@/modals/variants/shadcn-dialog";

const m = betterModal({
  variants: {
    dialog: ShadcnDialog,
  },
});

Every modal you create can target any registered variant, letting you reuse variant shells across different pieces of modal content.

Examples

Shadcn

Dialog

Basic

"use client";

import { useModal, useModalStore } from "better-modal/react";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";

type Props = {
  title: string;
  description: string;
};

export function ShadcnDialog({
  children,
  title,
  description,
}: { children: React.ReactNode } & Props) {
  const modal = useModal();
  const store = useModalStore();

  return (
    <Dialog
      open={modal.open}
      onOpenChange={(v) => !v && store.dismiss(modal.id)}
    >
      <DialogContent
        onAnimationEnd={() => {
          if (!modal.open) store.remove(modal.id);
        }}
      >
        <DialogHeader>
          <DialogTitle>{title}</DialogTitle>
          <DialogDescription>{description}</DialogDescription>
        </DialogHeader>
        {children}
      </DialogContent>
    </Dialog>
  );
}

With Sizes

"use client";

import { useModal, useModalStore } from "better-modal/react";
import { cva, type VariantProps } from "class-variance-authority";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";

const dialogVariants = cva("", {
  variants: {
    size: {
      sm: "sm:max-w-sm",
      md: "sm:max-w-md",
      lg: "max-w-lg",
      xl: "sm:max-w-xl",
      "2xl": "sm:max-w-2xl",
      "3xl": "sm:max-w-3xl",
      "4xl": "sm:max-w-4xl",
      "5xl": "sm:max-w-5xl",
      "6xl": "sm:max-w-6xl",
      "7xl": "sm:max-w-7xl",
      "8xl": "sm:max-w-8xl",
      "9xl": "sm:max-w-9xl",
      full: "sm:max-w-full h-full",
    },
  },
});

type Props = {
  title: string;
  description: string;
} & VariantProps<typeof dialogVariants>;

export function ShadcnDialogSizes({
  children,
  title,
  description,
  size,
}: { children: React.ReactNode } & Props) {
  const modal = useModal();
  const store = useModalStore();

  return (
    <Dialog
      open={modal.open}
      onOpenChange={(v) => !v && store.dismiss(modal.id)}
    >
      <DialogContent
        className={dialogVariants({ size })}
        onAnimationEnd={() => {
          if (!modal.open) store.remove(modal.id);
        }}
      >
        <DialogHeader>
          <DialogTitle>{title}</DialogTitle>
          <DialogDescription>{description}</DialogDescription>
        </DialogHeader>
        {children}
      </DialogContent>
    </Dialog>
  );
}

Sheet

Basic

"use client";

import { useModal, useModalStore } from "better-modal/react";
import {
  Sheet,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
} from "@/components/ui/sheet";

type Props = {
  title: string;
  description: string;
};

export function ShadcnSheet({
  children,
  title,
  description,
}: { children: React.ReactNode } & Props) {
  const modal = useModal();
  const store = useModalStore();

  return (
    <Sheet
      open={modal.open}
      onOpenChange={(v) => !v && store.dismiss(modal.id)}
    >
      <SheetContent
        onAnimationEnd={() => {
          if (!modal.open) store.remove(modal.id);
        }}
      >
        <SheetHeader>
          <SheetTitle>{title}</SheetTitle>
          <SheetDescription>{description}</SheetDescription>
        </SheetHeader>
        {children}
      </SheetContent>
    </Sheet>
  );
}

With Sizes

"use client";

import { useModal, useModalStore } from "better-modal/react";
import { cva, type VariantProps } from "class-variance-authority";
import {
  Sheet,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
} from "@/components/ui/sheet";

const sheetVariants = cva("", {
  variants: {
    size: {
      sm: "sm:max-w-sm",
      md: "sm:max-w-md",
      lg: "max-w-lg",
      xl: "sm:max-w-xl",
      "2xl": "sm:max-w-2xl",
      "3xl": "sm:max-w-3xl",
      "4xl": "sm:max-w-4xl",
      "5xl": "sm:max-w-5xl",
      "6xl": "sm:max-w-6xl",
      "7xl": "sm:max-w-7xl",
      full: "w-full",
    },
  },
});

type Props = {
  title: string;
  description: string;
  side?: "top" | "right" | "bottom" | "left";
} & VariantProps<typeof sheetVariants>;

export function ShadcnSheetSizes({
  children,
  title,
  description,
  size,
  side = "right",
}: { children: React.ReactNode } & Props) {
  const modal = useModal();
  const store = useModalStore();

  return (
    <Sheet
      open={modal.open}
      onOpenChange={(v) => !v && store.dismiss(modal.id)}
    >
      <SheetContent
        side={side}
        data-side={side}
        className={sheetVariants({ size })}
        onAnimationEnd={() => {
          if (!modal.open) store.remove(modal.id);
        }}
      >
        <SheetHeader>
          <SheetTitle>{title}</SheetTitle>
          <SheetDescription>{description}</SheetDescription>
        </SheetHeader>
        {children}
      </SheetContent>
    </Sheet>
  );
}

Alert Dialog

Basic

"use client";

import { useModal, useModalStore } from "better-modal/react";
import {
  AlertDialog,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogHeader,
  AlertDialogTitle,
} from "@/components/ui/alert-dialog";

type Props = {
  title: string;
  description: string;
};

export function ShadcnAlertDialog({
  children,
  title,
  description,
}: { children: React.ReactNode } & Props) {
  const modal = useModal();
  const store = useModalStore();

  return (
    <AlertDialog
      open={modal.open}
      onOpenChange={(v) => !v && store.dismiss(modal.id)}
    >
      <AlertDialogContent
        onAnimationEnd={() => {
          if (!modal.open) store.remove(modal.id);
        }}
      >
        <AlertDialogHeader>
          <AlertDialogTitle>{title}</AlertDialogTitle>
          <AlertDialogDescription>{description}</AlertDialogDescription>
        </AlertDialogHeader>
        {children}
      </AlertDialogContent>
    </AlertDialog>
  );
}

With Action

"use client";
import { useModal, useModalStore } from "better-modal/react";
import { useState, useTransition } from "react";
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from "@/components/ui/alert-dialog";

type Props = {
  title: string;
  description: string;
  confirmText?: string;
  cancelText?: string;
  onConfirm?: () => void | Promise<void>;
  onCancel?: () => void;
  onError?: (e: unknown) => void | Promise<void>;
  children: React.ReactNode;
};

export function ShadcnAlertDialogAction({
  title,
  description,
  children,
  onConfirm,
  onCancel,
  onError,
  confirmText = "Confirm",
  cancelText = "Cancel",
}: Props) {
  const modal = useModal();
  const store = useModalStore();

  const [isPending, startTransition] = useTransition();

  const handleConfirm = async (e: React.MouseEvent) => {
    if (!onConfirm) return;
    e.preventDefault();

    startTransition(async () => {
      try {
        const result = onConfirm();
        if (result instanceof Promise) {
          await result;
        }
        store.dismiss(modal.id);
      } catch (e) {
        onError?.(e);
      }
    });
  };

  return (
    <AlertDialog
      open={modal.open}
      onOpenChange={(v) => !v && store.dismiss(modal.id)}
    >
      <AlertDialogContent
        onAnimationEnd={() => {
          if (!modal.open) store.remove(modal.id);
        }}
      >
        <AlertDialogHeader>
          <AlertDialogTitle>{title}</AlertDialogTitle>
          <AlertDialogDescription>{description}</AlertDialogDescription>
        </AlertDialogHeader>
        {children}
        <AlertDialogFooter>
          <AlertDialogCancel
            onClick={() => {
              store.dismiss(modal.id);
              onCancel?.();
            }}
          >
            {cancelText}
          </AlertDialogCancel>
          <AlertDialogAction onClick={handleConfirm} disabled={isPending}>
            {confirmText}
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  );
}